diff --git a/.github/workflows/build-plugins.yml b/.github/workflows/build-plugins.yml new file mode 100644 index 0000000..33ac0ae --- /dev/null +++ b/.github/workflows/build-plugins.yml @@ -0,0 +1,196 @@ +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: fix/gui-layout-and-alignment + +concurrency: + group: build-plugins-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # A1 uses the camera-profile and confirmed-readback host commands introduced + # by the paired augur-rs PR. Move this back to `main` after that PR lands. + AUGUR_RS_REF: ${{ inputs.augur_rs_ref || 'feat/plugin-apply-biases' }} + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + name: macOS (arm64) + bundle: macos-arm64 + - os: macos-13 + 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: 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: 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7edc25d..af41406 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,6 +137,18 @@ Plugins do not render `egui` directly. Instead, expose: The host owns rendering, export, caching, and window state for declared host views. +When a table dataset should participate in the linked investigation workspace, also populate the additive metadata the host can use: + +- `coordinate_space_2d` +- `coordinate_space_3d` +- `row_id_column` +- `time_column` +- `layer_id` +- `semantic_label` +- `HostDatasetDescriptor.display` + +Prefer structured datasets for selection/linking and use overlays only for supplemental 2D annotations or hit-testing. + ### 7. Write `plugin.toml` Use the runtime format: @@ -160,6 +172,14 @@ cp plugins/my-plugin/plugin.toml ~/.augur/plugins/my-plugin/ cp target/release/libaugur_plugin_my_plugin.dylib ~/.augur/plugins/my-plugin/ ``` +On macOS, either run `./scripts/install-built-plugins.sh --profile release` instead of the manual +copy steps or rewrite the installed dylib id yourself: + +```bash +install_name_tool -id "@loader_path/libaugur_plugin_my_plugin.dylib" \ + ~/.augur/plugins/my-plugin/libaugur_plugin_my_plugin.dylib +``` + Then open `augur-gui`, go to **Plugins**, click **Scan for New Plugins**, and enable the plugin. ## Migrating Older Plugins diff --git a/Cargo.toml b/Cargo.toml index 306f834..f33ef05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,13 @@ [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", "plugins/reconstruction", "plugins/focus-metrics", @@ -22,6 +30,9 @@ 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" +serialport = "4" +stage-a-plugin-contract = { path = "stage-a-plugin-contract" } diff --git a/README.md b/README.md index 40cb651..da10175 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,27 @@ Use this repository for the plugin implementations, template crate, and repo-loc ## Runtime Model - Each plugin ships as a `plugin.toml` manifest plus one platform library (`.dylib`, `.so`, or `.dll`). -- `augur-gui` discovers plugins from `~/.augur/plugins/`, loads the exported `augur_plugin_vtable`, and renders settings, status, and host views through the host. +- `augur-gui` discovers plugins from `~/.augur/plugins/`, loads the exported `augur_plugin_vtable`, and renders settings, status, and linked investigation datasets/views through the host. - Host-owned built-in tools stay in `augur-gui`; they are not runtime plugins in this repository. - Host-owned experiment settings such as pixel scale, sensor geometry, acquisition time, and EventStore budget are published to plugins as `GlobalSettings` on `augur.global_settings`. - Standard shared scientific payloads can also live in companion crates such as `augur-plugin-types`. +## Investigation Workspace Contract + +The host now owns a generic linked workspace across: + +- 2D preview +- 3D inspection +- host-rendered tables + +For plugins, that means: + +- structured datasets are the primary linking mechanism +- stable row ids should be provided when possible +- 2D/3D coordinate metadata should be declared when the plugin has it +- layer/display metadata should describe visibility, color, marker shape, and size +- overlays are supplemental annotations, not the primary integration surface + ## In-Tree Runtime Plugins (work in progress) The plugin crates under `plugins/` are under active development and not yet ready for external use. The template crate and documentation are stable references for writing your own plugins. @@ -37,16 +53,44 @@ The plugin crates under `plugins/` are under active development and not yet read | Plugin | Phase | Notes | |---|---|---| | `localization` | `RawEvents` | Wavelet/Gaussian SMLM localization and standard `LocalizationResults` output | -| `reconstruction` | `DerivedData` | Accumulated localization table plus host-rendered reconstruction windows | +| `reconstruction` | `DerivedData` | Accumulated localization dataset with stable ids, time metadata, density rendering, and 3D inspection | | `focus-metrics` | `DerivedData` | Focus metrics from localization results or FFT preview sharpness | -| `evesmlm-candidates` | `RawEvents` | Event-domain candidate clustering for eveSMLM | -| `evesmlm-fitting` | `DerivedData` | Candidate fitting plus EVE and compatibility localization outputs | -| `evesmlm-postproc` | `DerivedData` | Filtering, drift correction, evaluation, and the later EVE compact view provider | +| `evesmlm-candidates` | `RawEvents` | Event-domain candidate clustering plus accepted/rejected raw-event investigation layers | +| `evesmlm-fitting` | `DerivedData` | Candidate fitting plus shared current-localization datasets, stable ids, and linked 3D inspection | +| `evesmlm-postproc` | `DerivedData` | Filtering, drift correction, evaluation, and the later shared EVE current-localization provider | +| `stage-a-modulation` | control service | Sole owner of the Stage-A Teensy command port and ACKed modulation state | +| `stage-a-photodiode` | control service | Sole owner of the Stage-A stream port, PDA1 ingestion, and PDQ persistence | +| `stage-a-a1` | `RawEvents` + orchestration | A1 protocol/schedule validation, raw phase quicklooks, analysis core, and a safety-gated commissioning run through the two owner services | `plugin-template/` is the starting point for new plugin crates. ## 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 @@ -62,6 +106,8 @@ cp target/release/libaugur_plugin_localization.dylib ~/.augur/plugins/localizati ``` On Linux, copy the `.so`. On Windows, copy the `.dll`. +On macOS, prefer `./scripts/install-built-plugins.sh --profile release`; it rewrites the copied +plugin dylib id so Plugin Manager reloads do not keep pointing at Cargo's build tree. Then open `augur-gui`, go to **Plugins**, click **Scan for New Plugins**, and enable the plugin. @@ -101,7 +147,8 @@ The current authoring flow is: 2. export the vtable with `export_plugin!` 3. choose `input_kind()` and optional `PluginCapabilities` 4. use `HostContext` for shared payloads, companion crates such as `augur-plugin-types` for reusable payload types, and `CTX_GLOBAL_SETTINGS` for host-owned calibration/settings -5. declare host-rendered outputs with `host_views()` when needed +5. declare host-rendered outputs with `host_views()` when needed and populate stable-id / coordinate / layer metadata when the dataset should participate in linked investigation + - to expose interactive operations, append `HostActionDescriptor`s to `HostViewRegistry.actions` (scope `Dataset`/`Row`/`Cluster`, optional `param_schema`); consume requests from the persistent context key `CTX_INVESTIGATION_ACTION_REQUESTS` 6. build a `cdylib` 7. install `plugin.toml` plus the compiled library into `~/.augur/plugins//` @@ -123,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/005-investigation-workspace-datasets.md b/docs/adr/005-investigation-workspace-datasets.md new file mode 100644 index 0000000..03754e7 --- /dev/null +++ b/docs/adr/005-investigation-workspace-datasets.md @@ -0,0 +1,43 @@ +# ADR 005: Expose Generic Investigation Datasets From Plugins + +## Status + +Accepted + +## Context + +`augur-gui` now owns a generic linked investigation workspace across 2D preview, 3D inspection, and host-rendered tables. + +That host model depends on richer plugin-side dataset metadata than the older window-centric host-view integration used: + +- stable row ids +- optional 2D and 3D coordinates +- optional time columns +- layer ids and display metadata + +The eveSMLM pipeline also needs stage-local investigation surfaces for tuning, especially at the candidate-finding stage where researchers need to compare accepted and rejected raw events directly. + +## Decision + +Plugins in this repository will align to the investigation workspace through generic structured datasets. + +Rules: + +1. Use table datasets as the primary linking surface for inspectable scientific outputs. +2. Provide `row_id_column` when the plugin can produce stable ids. +3. Provide `coordinate_space_2d`, `coordinate_space_3d`, and `time_column` when the data supports linked 2D/3D inspection. +4. Use `layer_id` plus `HostDatasetDescriptor.display` for visibility and styling defaults. +5. Keep intentionally shared dataset/view ids byte-for-byte identical across providers. +6. Use overlays only for supplemental 2D annotation or hit-testing, not as the primary data contract. +7. When one stage needs multiple logical layers, publish separate datasets/layer ids instead of keying style by plugin name. +8. It is acceptable for multiple rows to share the same stable row id when the intended interaction is "select the whole cluster" rather than "select one raw sample". +9. Stable row keys are dataset-scoped in the current host, so matching row ids across different datasets do not create cross-dataset selection on their own. + +## Consequences + +- the host can keep selection, styling, and filtering generic +- candidate-finding can expose accepted and rejected raw events as separate investigation layers +- candidate centroid overlays can select whole raw-event clusters by reusing `cluster_id` as the stable row key for accepted events +- fitting and post-processing can safely reuse the same current-localization ids without breaking host linking +- fitting can expose rejected fits as a first-class investigation dataset instead of hiding them behind aggregate counters +- plugins carry a little more schema metadata, but avoid plugin-specific host hooks diff --git a/docs/adr/005-stage-a-device-ownership.md b/docs/adr/005-stage-a-device-ownership.md new file mode 100644 index 0000000..fe7588b --- /dev/null +++ b/docs/adr/005-stage-a-device-ownership.md @@ -0,0 +1,47 @@ +# ADR 005 — Stage-A device ownership and the `stage-a-io` boundary + +- **Status:** Accepted +- **Date:** 2026-07-13 +- **Amended by:** ADR 006 and ADR 007 + +## Context + +The Stage-A camera calibrations (A1–A3) drive a Teensy stimulus/DAQ +controller over USB serial while recording the event camera. Someone has +to own the serial port, the experiment state machines, and the safety +rules. The knowledge-base control-software spec fixes the boundary: +AugurRs stays a generic camera recorder and plugin host and must not gain +laboratory-instrument abstractions. + +## Decision + +1. **Device control lives in removable protocol plugins.** The permanent + command-port and stream-port owners are now `stage-a-modulation` and + `stage-a-photodiode` (ADR 006/007). Experiment workflows such as A1/A2/A3 + orchestrate those owners through the host service plane and do not open the + ports themselves. +2. **A shared plain-Rust library `stage-a-io`** (this repo, not a plugin) + owns everything protocol-shaped: PDA1 framing + CRC resync, the ASCII + command grammar with idempotent sequence retries, the bounded I/O + worker, `.pdq` persistence, the run sidecar, and the calibrated optical + contrast estimator. It contains **no experiment policy** (sweeps, + bisection, fits stay in the plugins) and **no augur types** (testable + without a host). +3. **Effects are gated by the host's execution context** (plugin ABI v5): + plugins fail closed unless `LiveCapture && effects_allowed`. Hardware + commands are host actions, never persistent settings. +4. **Wire compatibility is anchored to the firmware header** + (`stage-a-controller/include/wire_protocol.h`); `stage-a-io` mirrors it + with layout tests, and the mock controller implements the same + idempotency contract the firmware promises. + +## Consequences + +- A1/A2/A3 reuse the owner services and serde-only contracts; they may reuse + hardware-free `stage-a-io` parsing/analysis but not its serial transports. +- The GUI knows nothing about Teensys; removing the three plugins removes + every trace of lab hardware from the product. +- Protocol changes must land in the firmware header first, then in + `stage-a-io`, keeping a single source of truth for the wire format. +- Plugins depend on `stage-a-io` by path; it is versioned with the + workspace and its API may still move until A2/A3 land. diff --git a/docs/adr/006-stage-a-two-plugin-split.md b/docs/adr/006-stage-a-two-plugin-split.md new file mode 100644 index 0000000..1ba4f93 --- /dev/null +++ b/docs/adr/006-stage-a-two-plugin-split.md @@ -0,0 +1,52 @@ +# ADR 006 — Stage-A simplification: two plugins, one serial port each + +- **Status:** Accepted +- **Date:** 2026-07-15 +- **Amends:** ADR 005 (Stage-A device ownership) +- **Amended by:** ADR 007 (persistent owners with host-routed orchestration) + +## Context + +The three commissioning plugins (`stage-a-monitor`, `stage-a-funcgen`, `stage-a-a1`, ~3100 lines) +bundled experiment state machines, contrast estimation, and drive control into UIs that were too +complex and opaque for the current bench stage. What the bench actually needs now is: + +1. direct, immediate control of the laser modulation output (capped power slider, + constant/sine/square with frequency), and +2. a plain readout of the photodiode (raw, or inverted to excitation power). + +Both need the same Teensy, but ADR 005 fixes one owner per serial port — and a context-bus +coupling (one plugin republishing data for the other) would make the readout depend on the +control plugin's connection. + +## Decision + +1. **The firmware enumerates two USB CDC ports** (`USB_DUAL_SERIAL`, `stage-a-controller` + ADR 002): port 1 keeps the v1 command protocol; port 2 free-runs the PDA1 photodiode + stream. ADR 005's rule is unchanged — one owner per port — there are simply two ports now. +2. **Two minimal plugins replace the three commissioning plugins** (deleted 2026-07-15, retained + in git history): + - `stage-a-modulation` owns the command port (`docs/features/stage-a-modulation.md`); + - `stage-a-photodiode` owns the stream port (`docs/features/stage-a-photodiode.md`). +3. **`stage-a-io` stays** as the protocol library (wire format, client, worker, firmware-faithful + mock — the mock now models firmware 0.3.0's `MOD` verb). Owner plugins use its transport/PDQ + pieces. A1/A2/A3 do not open transports; they use the host-routed owner contract (ADR 007) + and may use hardware-free parsing/analysis helpers. +4. **Immediate transfer replaces the Apply-action pattern**, and **all device control is + settings-driven** (connect checkbox, slider changes sent as they happen). Host actions and + the per-frame effects gate are unsuitable here: the host only runs `process_frame()` while + camera frames flow, but the bench must work with no camera attached (amended 2026-07-16). + Replay mode still disconnects the modulation plugin defensively. The firmware output is + set-and-hold. ADR 008's 2026-07-23 amendment separates Manual/Calibrated drive method from + waveform mode and makes `max_level` the universal DAC ceiling. + +## Consequences + +- Each owner plugin has a single hardware concern; the photodiode plugin uses + `stage-a-io`'s PDA1 parser and PDQ persistence without taking command-port ownership. +- Both plugins work independently — either can connect, disconnect, or crash without affecting + the other. +- Wire-protocol changes still land firmware-first (`stage-a-controller/include/wire_protocol.h` + and command grammar), then in `stage-a-io`'s client/mock. +- The A1 min-depth workflow is rebuilt as an orchestrator on this stable two-owner + stack; it never becomes a third Teensy owner. diff --git a/docs/adr/007-stage-a-owner-orchestration.md b/docs/adr/007-stage-a-owner-orchestration.md new file mode 100644 index 0000000..add704a --- /dev/null +++ b/docs/adr/007-stage-a-owner-orchestration.md @@ -0,0 +1,68 @@ +# ADR 007 — Persistent Stage-A owners with host-routed orchestration + +- **Status:** Superseded in part (2026-07-20) — the persistent two-owner model and + host-routed control plane still hold, but `stage-a-a1` no longer orchestrates the + A1 acquisition. It was reduced to a read-only live-analysis plugin (two + phase-folded quicklooks + the photodiode-measured `a`); leases, recordings, + protocol/schedule freezing, references/epochs, and the minimum-depth logistic fit + were removed. See [Stage-A A1 Analysis](../features/stage-a-a1.md). +- **Date:** 2026-07-20 +- **Amends:** ADR 005 and ADR 006 + +## Context + +The A1 workflow must coordinate laser modulation, high-rate photodiode capture, +and camera recording. The earlier handoff proposed that `stage-a-a1` open both +Teensy ports while armed. That would create a third hardware owner and duplicate +the control/readout logic already maintained by the two manual plugins. + +The existing Augur frame context cannot solve this safely: it is available only +inside `process_frame`, while device control and reference acquisition must also +progress without camera frames. Augur also loads a GUI mirror and a live-worker +instance of each plugin, so an effectful setting copied between both instances +can make them compete for the same port. + +## Decision + +1. `stage-a-modulation` is permanently the sole command-port owner and source of + truth for requested and controller-ACKed modulation state. +2. `stage-a-photodiode` is permanently the sole stream-port owner and source of + truth for PDA1 ingestion, integrity accounting, and PDQ persistence. +3. `stage-a-a1` is an orchestrator and camera-analysis plugin. It never opens a + Teensy port and never creates a `PdqWriter`. +4. Coordination uses Augur's frame-independent, worker-owned plugin service + plane. Requests are atomic semantic operations with stable plugin IDs, + request IDs, leases, run IDs, expected revisions, explicit success/rejection, + and bounded versioned snapshots. The host routes messages but contains no + Stage-A logic. +5. Manual controls and automation share the same owner-side validation. A held + automation lease prevents competing manual mutations; a deliberate manual + override revokes the lease, becomes a visible workflow fault, and commands + output-off where safe. +6. Camera start/finalize uses the allow-listed plugin-to-host recording command + contract. RAW and PDQ receipts are correlated by immutable run ID and actual + finalized paths; the workflow never claims filesystem atomicity. +7. Raw photodiode arrays do not cross JSON. A1 consumes small live summaries and + parses finalized PDQ data for replayable scientific results. + +## Safety and synchronization + +- Only the canonical live-worker instances may effect hardware. GUI mirrors, + replay, and offline instances are fail-closed. +- Duplicate request IDs return the original terminal response without repeating + an effect. +- Lease expiry, replay transition, plugin disable, worker shutdown, or hard fault + revokes control and requests output-off/finalization. +- Current PDA1 frames do not carry a shared modulation/configuration revision. + Ordered ACKs establish operational order, but scientific cross-port identity is + reported as `UNSYNCED` until firmware supplies a common epoch or marker. + +## Consequences + +- A1/A2/A3 can reuse the same owner services without duplicating serial code. +- The manual plugins remain independently useful and testable. +- ADR 005's statement that each experiment plugin owns the serial port no longer + applies to A1/A2/A3; exclusive ownership now belongs to the two device plugins. +- ADR 006's two-port/two-owner split becomes the stable architecture instead of a + temporary commissioning simplification. + diff --git a/docs/adr/008-stage-a-optical-waveform-inversion.md b/docs/adr/008-stage-a-optical-waveform-inversion.md new file mode 100644 index 0000000..8b84a65 --- /dev/null +++ b/docs/adr/008-stage-a-optical-waveform-inversion.md @@ -0,0 +1,85 @@ +# ADR 008 — Optical waveform inversion for the Stage-A modulator + +- **Status:** Accepted +- **Date:** 2026-07-20 +- **Relates to:** ADR 006 (two-plugin split), `stage-a-controller` waveform drive + +## Context + +The Pockels/PBS amplitude modulator has a `sin²` voltage→transmission transfer. +A pure DAC sine (`DAC_SINE`) therefore produces a distorted optical waveform, +and a 50 % bias only approximately linearises the small signal. The A1 +measurement wants a clean optical target — ideally a **log-intensity** sine, +because the event camera responds to changes in `ln I`. + +Producing that target requires driving the DAC with the *inverse* of the `sin²` +lobe, `V(u) = V_null + (2Vπ/π)·arcsin√u`, which is not a sinusoid. The existing +firmware only synthesises a pure sine from a fixed 256-entry table scaled between +`min`/`level`, so it cannot emit the warped shape as-is. The command line is also +capped at 192 bytes, too small to upload a 256-code table inline. + +## Decision + +1. **Own the inversion in the modulation plugin.** `waveform.rs` computes a + 256-entry DAC warp table from an `OpticalTarget` (`LogSine`/`LinearSine`), the + requested depth `a`, and a `LobeInversion { v_null_dac, v_pi_dac }` (built from + the two observed endpoint codes since + [ADR 016](016-stage-a-lobe-endpoints-not-a-distance.md)). It refuses + (never clamps) a drive whose codes leave `0..4095`. +2. **Keep the measured lobe endpoints settable.** `V_null` and `V_peak` are + entered as absolute DAC codes and the internal `Vπ` span is derived from + them (ADR 016); no measurement rig is required to start. The scientifically clean + **measured LUT** (sweep constant codes, log the photodiode, freeze the table) + is a documented follow-up that drops in behind the same `warp_table` interface. +3. **Send parameters, not the table, over the wire.** The compact + `MOD wave=WARP freq_mhz=… target=… a_milli=… v_null=… v_pi=…` command fits the + 192-byte limit; the firmware rebuilds the identical table with the same formula + (`stimulus_mod::normalisedIntensity` + `dacForU`) and plays it back through a + `warpIsr`. A chunked table-upload command is the future path for the measured + LUT, which cannot be parameterised. +4. **Preserve `DAC_SINE`.** The pure DAC sine (firmware `SINE`) is unchanged and + remains the default for non-optical work. +5. **Separate drive from measurement.** The requested `a` is only a drive target. + The realised optical depth is always the photodiode-measured `a` + (rejected-complement corrected in the `stage-a-io` estimator), never the + commanded value. +6. **Keep drive method orthogonal to waveform mode (2026-07-23 amendment).** + `MANUAL` defines a DAC band from Power + Min threshold; `CALIBRATED` derives + one from `V_null`, `Vπ`, normalized `u`, and `a`. All five waveform modes remain + available with both methods. Manual optical modes pass their DAC endpoints + through the forward `sin²` transfer to derive `(u, a)`, then reuse the same + inversion path. The separate `max_level` setting is the hard ceiling for + every drive; it is no longer merely the upper bound of the Power slider. +7. **Treat constant hold separately from modulation headroom (2026-07-23 + amendment).** `CONST` maps `u` directly through the inverse lobe and ignores + `a`; periodic modes retain target-specific headroom. A rejected + calibrated setting is rolled back so displayed settings always describe the + command that can actually be sent. +8. **Separate normalized transfer coordinate from physical flux and preserve + its mean (2026-07-28 amendment).** The UI setting is the normalized + cycle mean `ū`; it is never called the physical A1 flux `I_k`. Log-sine + generation derives `u_g=ū/I_0(a/2)` before sending the existing WARP + parameters. The internal value is quantized to the existing `u_k_milli` + wire field, and the acknowledged state publishes both requested and resolved + means, so sweeping `a` removes the analytic mean shift and makes the small + quantization residual explicit. Finite `I_floor` + still makes physical contrast smaller than the requested floor-subtracted + contrast. A1 therefore records a separate physical `flux_point_id`, + measured photodiode `a` remains authoritative, and the measured finite-floor + LUT remains required when the analytic residual exceeds the error budget. + `ModulationStateV1.optical_drive` publishes requested and resolved `ū`, + internal `u_g`/`u_c`, requested `a`, target and lobe codes as an additive V1 + provenance field. + +## Consequences + +- The plugin, the `stage-a-io` mock, and the firmware share one small parameter + contract and one formula; the inversion math is duplicated in Rust and C++ but + covered by the Rust round-trip tests (`sin²(warp) ≈ target`). +- Method changes only the operating-band source; mode remains a pure waveform + choice. The UI can therefore hide inactive parameters without filtering modes. +- Real optical output on hardware depends on firmware that supports the `WARP` + command; until flashed, the mode is exercisable only against the in-process + mock and the unit tests. +- The measured-LUT upgrade and the eventual `EXT_TRIGGER` camera marker (see the + A1 analysis brief) remain the two open scientific accuracy items. diff --git a/docs/adr/009-stage-a-a1-recording-coordinator.md b/docs/adr/009-stage-a-a1-recording-coordinator.md new file mode 100644 index 0000000..3b446f1 --- /dev/null +++ b/docs/adr/009-stage-a-a1-recording-coordinator.md @@ -0,0 +1,111 @@ +# ADR 009 — Stage-A A1 as a focused recording coordinator + +- **Status:** Accepted — decision 3 revised by + [ADR 015](015-stage-a-a1-recording-robustness.md), which makes A1's output + folder authoritative and gathers the RAW/PDQ into it after finalization +- **Date:** 2026-07-23 +- **Relates to:** ADR 005 (device ownership), ADR 006 (two-plugin split), + ADR 007 (owner orchestration — the earlier, broader orchestrator), + [Stage-A A1 Analysis](../features/stage-a-a1.md), + [Stage-A A1 Automation](../features/stage-a-a1-automation.md) + +## Context + +The A1 measurement records, for one illumination `I_k` and frequency `f`, several +runs while sweeping the modulation depth `a`. Each run must persist the camera +**RAW** stream, the photodiode **PDQ** stream, and enough configuration to +reproduce and analyse it offline — named consistently so repeats of an `(I_k, f)` +pair stay grouped. + +The previous A1 plugin (ADR 007, then the live-analysis MVP that superseded it) +was a *read-only* surface: it folded events into quicklooks and offered a manual +response-curve, but **recorded nothing**. Operators had to start/stop the camera +and photodiode recordings separately, with no shared naming and no single place +capturing the modulation settings and measured `a`. Its controls had also drifted +away from the real workflow: a "Capture camera events" toggle that recorded +nothing, an obsolete fallback frequency and phase-bin width, and interim +phase-anchoring knobs (event latency, self-align) that the now-reliable +`EXT_TRIGGER` makes unnecessary. + +## Decision + +1. **A1 becomes a focused recording coordinator.** One *Start recording* button, + a chosen **folder**, a per-`(I_k, f)` **measurement id** (auto-default, + regenerate, or edit), and a **duration** drive a small ordered state machine: + start and acknowledge the host camera recorder; connect and lease the + photodiode; open and acknowledge the PDQ; run for the requested duration; + atomically finalize the PDQ and release its lease; stop and acknowledge the + camera; then write an A1 config sidecar. This order keeps PDQ cleanup inside + the live-effects window and starts the timer only after both streams exist. + It deliberately **re-adds** recording orchestration that the + live-analysis MVP had dropped — in a narrow form: only camera + photodiode + recording, no drive/lease of the modulation device. + +2. **A1 never drives the Teensy.** The optical drive is armed in the modulation + plugin. A1 only *reads* the published `ModulationStateV1` snapshot into the + sidecar. Reintroducing the modulation drive (settle detection, amplitude + sweep) stays on the [automation roadmap](../features/stage-a-a1-automation.md). + +3. **Consistent naming, recorder-owned directories.** Files share an + `_` stem under an `/` subfolder. The camera RAW path is + relative to the **host output root** and the PDQ path relative to the + **photodiode data root** — each recorder confines its own writes, so A1 cannot + force a single absolute directory. The A1 config sidecar is written under + `//` and records the *resolved* paths of both files, so the + set is linked regardless; pointing all roots at the same experiment directory + co-locates everything physically. + +4. **Camera biases stay owned by the host recorder.** The host writes a companion + `.toml` next to the RAW containing the camera config (biases, ROI). A1 + cannot read biases itself; its sidecar cross-references that file and also + passes the key parameters as recording metadata, which the host and photodiode + embed in their own sidecars. + +5. **Two live quicklooks, clearly scoped.** Keep the **rolling half-period + response** `S_p(t)` (live sanity: are events appearing, is ON/OFF timing sane?) + and the **response probability** `q_p` (binary pixel-cycle statistic vs measured + `a`). Drop the phase-bin rate plot. The authoritative `q_p(a, f)` fit is an + **offline** computation over the recordings; the live `q_p` is a quicklook. + + **`q_p` windows: auto by default, pilot-frozen per row.** Because the + `EXT_TRIGGER` fixes the phase, ON and OFF fall in opposite half-cycles, so the + windows are found directly from the current fold — each anchored on its + histogram peak and grown outward until it drops below a floor (default 10 % of + the peak) or the opposite polarity dominates. This replaces the old + manual-pilot *button* and its window-threshold / self-align knobs. + + The window phase depends on the event latency, which is a *phase* shift `τ·f` + (negligible at low `f`, up to a full cycle at high `f`) and drifts with `I_k`, + so windows must be fixed **per `(I_k, f)` row** and held across the `a`-sweep. + A **Record pilot** action therefore freezes the auto-windows for the row and + writes them into the pilot recording's sidecar; **Record background** captures + the floor `q0`. Both are keyed to the measurement id (one id = one row) and are + auto-reloaded by scanning the measurement folder, so returning to a row reuses + its frozen windows. The live `q_p` remains a quicklook — the authoritative fit + still freezes windows offline from the brightest run. + +6. **Lean the trigger surface.** With `EXT_TRIGGER` now reliable, remove the + fallback frequency, the phase-bin width, the event-latency shift, and the + response-curve self-align/threshold knobs. The trigger marker spacing *defines* + `T`; the modulation acknowledged waveform is the only fallback. + +## Consequences + +- A1 now declares `host_commands = ["start_recording", "stop_recording"]` in its + manifest and holds a photodiode lease while recording (the photodiode's manual + recording UI is locked during that window). The first host-command use triggers + a one-time GUI consent prompt. +- A1 writes one file itself (the `.toml` sidecar) via `std::fs` — a small, bounded + write, not a PDQ/serial writer; hardware ownership is unchanged. +- A recording reports success only after complete host finalization and a valid + typed PDQ finalization receipt. The UI keeps one concise phase/result message, + not a rolling internal log. +- The host returns to Preview before delivering its final receipt, which keeps + repeated recordings and automated sweeps live without an extra operator step. +- ~~True single-directory co-location is a **configuration** convention (align the + recorder roots), not something A1 enforces. Enforcing it would require host and + photodiode path changes and is out of scope.~~ **Revised by ADR 015:** A1 moves + the finalized files into its own measurement folder, which needs no host or + photodiode path changes because both files are already closed and hashed. +- The contract and ABI are unchanged: every message used already exists + (`HostCommand`, `PhotodiodeCommandV1` lease/begin/finalize). diff --git a/docs/adr/010-stage-a-a1-amplitude-sweep.md b/docs/adr/010-stage-a-a1-amplitude-sweep.md new file mode 100644 index 0000000..97edfea --- /dev/null +++ b/docs/adr/010-stage-a-a1-amplitude-sweep.md @@ -0,0 +1,84 @@ +# ADR 010 — Stage-A A1 amplitude sweep via leased optical-depth retargeting + +- **Status:** accepted (2026-07-23) +- **Relates to:** ADR 007 (owner orchestration), ADR 009 (recording + coordinator), [Stage-A A1 Automation](../features/stage-a-a1-automation.md) + +## Context + +The A1 workflow records a response curve `q_p(a, f)`: several recordings at +different modulation depths `a` for one `(I_k, f)` row. With the manual +coordinator (ADR 009) the operator had to retarget the drive in the modulation +plugin and press *Start recording* once per amplitude. The automation roadmap +(§1–§4 of the automation brief) calls for a scoped control path: sweep only +`a`, never the rest of the drive. + +Two structural gaps blocked this: + +1. **No semantic "set depth" command.** The modulation service only exposed + `SetWaveform` (raw DAC band) and `PrepareA1`. Sweeping `a` through raw DAC + values would duplicate the optical-inversion math (ADR 008) and the + calibration state (`V_null`, `Vπ`, requested mean `ū`) outside their owner. +2. **Momentary buttons never reached the live worker.** The host runs a UI + mirror and a live worker per plugin; button presses land on the mirror via + `set_setting(key, true)`, while the worker only receives the settings + snapshot built from `get_setting`. Buttons that returned `false` lost every + press (the root cause of the dead record buttons). + +## Decision + +**1. `ModulationCommandV1::SetOpticalDepth { depth_a_milli }`** (contract +addition, additive to V1). Under an automation lease the modulation owner +re-derives its armed drive with the new depth through the same +`drive_command()` builder the operator path uses; everything else (waveform +shape, frequency, requested `ū`, calibration, power cap) stays as armed. For +each depth, the owner derives and wire-quantizes the internal +`u_g=ū/I_0(a/2)`. The owner rejects the command when no device link is open, +when the armed drive is not calibrated `OPTICAL_LOG_SINE`, or when the derived +drive violates its own safety validation. The command is applied immediately +(`Applied`), not revision-tracked: the sweep's ground truth for "the drive is +really there" is the photodiode-measured `a`, not a firmware ACK. + +**2. The sweep lives in A1** as a small state machine layered *on top of* the +ADR 009 coordinator: `AcquiringLease → (per point) SettingDepth → Settling → +Recording → …release`. Per point it renews the modulation lease, retargets the +depth, waits until the photodiode-measured `a` holds the target tolerance +(±10 %, at least ±0.05) for the configured dwell and hands off to the unchanged +recording coordinator (`…_pNN` stem tag, `sweep.requested_a` / `point_index` / +`point_total` in the sidecar). Any rejection, timeout, or failed point aborts +the sweep and releases the lease (`safe_off = false` — the drive holds; safety +remains the owner's lease-expiry job). + +**2026-07-28 amendment:** `SetOpticalDepth` is accepted only for an applied +measured calibration and `OPTICAL_LOG_SINE`; accepting DAC, square, linear, or +unidentified hand-entered lobe parameters under the same semantic command made +`a` ambiguous. A1 also requires a connected, fresh photodiode optical summary +from complete marker-bounded cycles and a confirmed named `I_tot` anchor. +Failure to settle before the deadline now aborts the sweep instead of recording +an invalid point. A required `flux_point_id` carries the physical cycle-mean +`I_k` provenance separately from the Bessel-normalized modulation mean `ū`; +the config sidecar records requested/resolved `ū`, quantized internal `u_g`, +requested `a`, target, `V_null`, `Vπ`, and transfer calibration ID from the +additive modulation snapshot. + +**3. Press counters for momentary buttons.** Every A1 button exports a +monotonic press counter from `get_setting`; `set_setting` interprets `true` as +a local click and a counter advance as one forwarded press edge, adopting the +first-seen value silently (reloads must not replay presses). The plugin-API +`Button` doc now records this idiom, and `SettingKind::Button` gained an +`enabled` flag (serde-default `true`, backward compatible in both directions) +so prerequisite-less presses can be prevented in the UI instead of rejected +after the fact. + +## Consequences + +- A1 now drives exactly one modulation parameter, under a lease, through the + contract — the "A1 owns no hardware" boundary narrows to "A1 may retarget + the armed drive's depth while leased" (the focused re-introduction ADR 007 + anticipated). +- Manual modulation settings stay locked during a sweep (lease lock), and the + operator's own `depth a` re-applies on the next modulation settings sync + after release. +- The press-counter idiom is the sanctioned pattern for momentary controls in + dual-instance plugins; requested-state booleans (`connect`, `record`, + `protocol_run`) remain correct as-is. diff --git a/docs/adr/011-stage-a-pockels-transfer-calibration.md b/docs/adr/011-stage-a-pockels-transfer-calibration.md new file mode 100644 index 0000000..de95420 --- /dev/null +++ b/docs/adr/011-stage-a-pockels-transfer-calibration.md @@ -0,0 +1,160 @@ +# ADR 011 — Measured Pockels transfer calibration in the modulation plugin + +- **Status:** Accepted +- **Date:** 2026-07-25 +- **Relates to:** ADR 006 (two-plugin split), ADR 008 (optical waveform + inversion), ADR 010 (amplitude sweep / press-counter idiom), + [Stage-A Pockels Transfer Calibration](../features/stage-a-pockels-calibration.md) + +## Context + +ADR 008 made `V_null`/`Vπ` settable and named the **measured LUT** as the +follow-up. In practice they stayed two bare number fields whose tooltip told the +operator to measure them while the software offered no way to do so. Nothing +related a DAC code to an observed photodiode value, so the whole calibrated +drive rested on numbers typed in from a nominal datasheet — exactly what the +knowledge base warns against (`methodology/pockels-waveform-linearisation.md` +§1: "Do not use nominal `Vπ` as the measurement calibration"). + +## Decision + +### 1. The modulation plugin owns the calibration + +It already owns `V_null`/`Vπ` and the DAC. It reads photodiode levels **read +only** from the control-snapshot broadcast (the same bus A1 reads for the +measured `a`), so no lease, no service command, no coordinating plugin, and no +PDQ recording are involved. The alternative — a lease-based cross-plugin +protocol like ADR 010's sweep — would have moved the calibration state away from +the parameters it calibrates for no gain. + +### 2. `PhotodiodeStreamV1.level` — one additive V1 field + +`PhotodiodeLevelV1 { mean_volts, peak_to_peak_volts, sample_count, +end_sample_index, clipped }`, `#[serde(default)]`. + +`PhotodiodeOpticalSummaryV1` could not serve: it reports *contrast* not level, +applies the geometry transform (which needs an anchor this reading must not +depend on), and **refuses** on clipping or missing headroom — precisely at +`V_null`, where the reject-port detector is brightest. The level is deliberately +fail-open where the optical summary is fail-closed, and always **raw** detector +volts, never the plugin's RAW/EXCITATION display transform. + +`end_sample_index` makes settling *provable*: a point is accepted only from a +window that began after its code was commanded plus a settle margin, on the +device sample clock. No shared wall clock, no sleeps, immune to tick jitter. + +### 3. The detector geometry is an input, not an inference + +The initial design assumed a free-signed amplitude would let the fit *identify* +the port. It cannot. Since `sin²` is symmetric about its peak, +`(v, p₀, p₁)` and `(v + Vπ, p₀ + p₁, −p₁)` describe the measured curve +*identically* — the data cannot say which extremum is zero excitation. This is a +fact about the optics, so it is asked (`Detector port`, default `REJECT PORT`, +which `setup/optical-path.md` settles by construction) and the fit selects the +matching representation. Guessing would place `V_null` one half-wave-voltage span off and +silently run the drive on the inverted branch. + +### 4. One-dimensional harmonic fit, not a nonlinear solve + +`sin²(x) = (1 − cos 2x)/2` makes the model a constant plus one sinusoid of +period `2Vπ`, which is linear in its quadrature components. For each candidate +`Vπ`, the phase (hence `V_null`) and both amplitudes come from a 3×3 solve, so +only `Vπ` is searched — a log-spaced scan plus a golden-section refine. + +The rejected alternative, seeding the period from the measured extrema, breaks +on the sweeps that matter: at a realistic `Vπ ≈ 860` the DAC range holds ~2.4 +lobes and the global extrema can sit whole periods apart. + +Where several nulls are valid, the **lowest** in-range one wins: least voltage +across the crystal, most headroom, and predictable for the operator. + +### 5. `enabled` is computed from mirrored settings only + +`settings_schema()` is rendered by the **UI mirror**, which by construction +never owns the device link, a lease, a running sweep, or a fit — all of that +lives on the live worker. A first cut gated the calibration buttons on +`calibration_blocker()` and `fit.is_some()`, which disabled them *permanently*: +the mirror can never satisfy either. The buttons now gate on the one +prerequisite the mirror does know (the operator asked to connect), and the +authoritative interlocks stay worker-side, reported through the status entries +the host already takes from the worker. + +**Rule for this repo:** a `SettingKind::Button { enabled }` may only depend on +state that is itself a setting. Anything else is invisible to the instance that +renders it. The same trap bit the press counters — a baseline folded into the +counter made a fresh worker swallow the operator's first press, so the modulation +plugin now uses A1's `PressLatch` (separate `counter` and `seen`) verbatim. + +### 6. The sweep owns the DAC, so `send_modulation` is silent while it runs + +`apply_live_plugin_snapshot` writes **every** settings key to the worker on +every sync, and most of this plugin's drive handlers call `send_modulation()` +unconditionally rather than on change. Each sync therefore re-armed the +operator's waveform on top of the code the sweep had just commanded: the board +spent the sweep playing the armed drive, every point measured the same +waveform-averaged level, and the fit correctly reported `NoModulation` on a +bench where the light was plainly modulating. + +`send_modulation` now returns early while a sweep is in flight, the same shape +as the existing automation-lease guard — a sweep is simply another owner of the +DAC. Settings changed mid-sweep are withheld rather than rejected, and land on +the board when the sweep finishes: the restore prefers the *current* drive and +falls back to the command captured at sweep start. + +### 7. Robust refit, and fit quality warns rather than blocks + +The first cut refused to apply a fit whose residual exceeded 2 % of the detector +span. On the bench that gate fired at 20.8 % on a sweep whose plot looked +correct, and withheld a usable calibration. + +Synthetic failure-mode sweeps show the distinction: 5 mV of injected noise +gives 3.1 %, drift 3.2 %, hysteresis 5.5 % — but a **single stray point gives +9.9 % while leaving `Vπ` accurate to three codes**. These are test-model +outputs, not bench measurements. Residual and correctness are not the same +axis, so a residual threshold is the wrong thing to block on. (A compressed +synthetic waveform gives 15.2 % *and* a `Vπ` off by 250 codes, which the plot +shows plainly.) + +Two changes follow. The fit now runs twice, dropping points beyond `6 × median` +absolute residual before refitting — a median cut, because mean and standard +deviation are themselves inflated by the points being sought. And every quality +measure became a warning; the only meaningless case, no full lobe inside the +commandable range, is already refused inside `fit_transfer`, so the separate +coverage gate was dead code and was removed rather than kept. + +Applying still re-validates the resulting drive and rolls back if it cannot be +armed. The sweep restores the pre-sweep drive on every exit path, and refuses to +run while a lease or protocol owns the DAC. + +The original fit also rejected every detector swing below a fixed 10 mV. That +is incompatible with the observed Stage-A operating range of roughly +0.5–15 mV and confuses small absolute scale with absence of information. The +absolute threshold is removed. The only signal gate is now relative: the +between-code sweep span and the fitted lobe span must exceed the median raw +peak-to-peak excursion measured inside the settled CONST windows. This accepts +repeatable millivolt-scale transfers while still refusing structure that is no +larger than the acquisition noise witness. + +### 8. `ModulationStateV1.calibration_id` — one additive V1 field + +Set when a measured fit is applied, `None` when the lobe was typed in by hand, +so a consumer's sidecar can cite which inversion produced a run's optical depth. +Previously unrecoverable. + +## Consequences + +- The reported detector level at the null is a **lower bound** on the + total-power anchor `I_tot`, not the anchor: on the reject port the residual + transmitted floor is not separable from it (knowledge base §4.4). The plugin + labels it as such and derives no maximum achievable `a` from it. Freezing a + real anchor still needs a transmitted-port power measurement. +- `V_null`/`Vπ` need neither a dark measurement nor an anchor, because the + fitted offset and amplitude absorb both. That is what keeps this one button + instead of a protocol. +- Ascending and descending passes are both recorded, so the hysteresis figure + the knowledge base's acceptance test 1 asks for comes out of the normal run. +- Still an analytic `sin²` inversion, not a measured LUT. The archived record + stores the points a LUT would need, so ADR 008's follow-up remains open behind + the same `warp_table` interface. +- A static calibration must never be used to correct dynamic roll-off; doing so + would manufacture the Bode curve A1 exists to measure. diff --git a/docs/adr/012-stage-a-contrast-geometry-is-bench-not-display.md b/docs/adr/012-stage-a-contrast-geometry-is-bench-not-display.md new file mode 100644 index 0000000..7a39528 --- /dev/null +++ b/docs/adr/012-stage-a-contrast-geometry-is-bench-not-display.md @@ -0,0 +1,82 @@ +# ADR 012 — The contrast geometry follows the bench, not the display mode + +- **Status:** Accepted +- **Date:** 2026-07-27 +- **Relates to:** ADR 006 (two-plugin split), ADR 008 (optical waveform + inversion), ADR 010 (amplitude sweep), ADR 011 (Pockels transfer + calibration), + [Stage-A Photodiode](../features/stage-a-photodiode.md), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +The photodiode plugin has a display toggle: **RAW** plots the detector volts as +measured, **EXCITATION** plots `I_tot − I_pd`. `optical_summary` picked the +estimator's [`ContrastGeometry`] from that toggle — `Direct` under RAW, +`RejectedComplement` under EXCITATION — and published the result as +`PhotodiodeOpticalSummaryV1::measured_log_contrast`. + +That made a *published scientific quantity* depend on what the operator +happened to be looking at. It is wrong on the physics and it breaks A1: + +- On this bench the detector sits behind the PBS reject port and measures the + complement `I_pd = I_tot − I_exc`. That is settled by construction + (`knowledge base: setup/optical-path.md`), not a display choice. Under RAW the + published value was `ln(I_pd,max / I_pd,min)` — the *detector* contrast, not + the excitation contrast `a` that every A1 estimand is defined against. +- RAW is the default. A1's amplitude sweep settles `measured_a` against a target + `a` (`drive_sweep`): with the display left on its default the sweep compares + the wrong quantity, never settles, times out at 30 s per point, and writes a + wrong `measured_a` into every sweep sidecar. + +The same function also passed `dark_volts: 0.0` and a raw `reference_volts` +anchor, i.e. it dark-corrected one side of the complement and not the other. + +## Decision + +### 1. Geometry is a property of the optical configuration + +`optical_summary` always uses `ContrastGeometry::RejectedComplement`, anchored on +`reference_volts`. `measured_log_contrast` is always the excitation contrast. +The display `Mode` is presentational and never reaches the estimator; the status +readout is labelled `a (excitation)` unconditionally. + +If a future bench puts the detector in the excitation path, that is a new +optical configuration ID and a code change here — not a UI toggle. + +### 2. The dark level is measured, and applied to both sides + +`dark_volts` is a plugin setting with a **Capture dark** action (block the beam, +press; the mean of the current ring becomes the dark level, refused if it is not +below the `I_tot` reference). It is applied to the detector samples *and* +subtracted from the `reference_volts` anchor. + +Applied consistently, the DC dark term **cancels** out of the complement — the +excitation is a difference of two readings from the same DC-coupled detector, so +a common offset drops out. Correcting only one side is what would bias `a`, and +that is what the code did. `dark_id` reports `dark-measured` or `dark-none` so a +consumer can tell a real dark measurement from the un-measured default. + +### 3. A withheld `a` states its reason + +The estimator is deliberately fail-closed (clipping, no headroom, anchor below +signal). Those refusals now surface in the status readout as +`a unavailable: ` instead of the row silently disappearing. This matters +more under the new geometry: with an un-measured anchor left at ADC full scale, +`TotalPowerBelowSignal` is the expected first-run outcome, and the operator has +to be told to set `reference_volts`. + +## Consequences + +- `measured_log_contrast` is comparable across runs and independent of operator + UI state; A1's sweep settles against the quantity it targets. +- Runs recorded before this change that were taken with the display on RAW + carry a detector contrast in `measured_a`. They are distinguishable: their + sidecar has `anchor_id: "detector-direct"`. Those points must not be mixed + with `reference-volts` points. +- `excitation_headroom_volts` is, by construction, equal to + `excitation_min_volts` (both geometries are dark-referenced). The field is + kept because the contract publishes it, and is now documented as redundant + rather than silently duplicated. +- First use on a fresh bench requires setting `reference_volts` before any `a` + is published at all. This is intended: a wrong `a` is worse than no `a`. diff --git a/docs/adr/013-stage-a-a1-event-count-depth-lock.md b/docs/adr/013-stage-a-a1-event-count-depth-lock.md new file mode 100644 index 0000000..7ee1c2e --- /dev/null +++ b/docs/adr/013-stage-a-a1-event-count-depth-lock.md @@ -0,0 +1,120 @@ +# ADR 013 — Stage-A A1 exact-event-count depth lock (`a₀`) + +- **Status:** accepted (2026-07-25) +- **Relates to:** ADR 009 (recording coordinator), ADR 010 (amplitude sweep via + leased `SetOpticalDepth`), ADR 011 (measured Pockels transfer calibration), + ADR 012 (the contrast geometry the measured `a` comes from), + [Stage-A A1 Exact Event Count](../features/stage-a-a1-event-count.md) + +## Context + +The minimum-depth workflow sweeps the depth `a` at one frequency and fits `a50`. +The **exact-event-count** workflow is the complement: freeze **one** depth + +```math +a_0=\ln\!\left(\frac{I_{\mathrm{exc,max}}}{I_{\mathrm{exc,min}}}\right), +\qquad I_\mathrm{exc}=I_\mathrm{tot}-I_\mathrm{pd} +``` + +and hold **that measured value** constant while the frequency varies, so the +event count per half-cycle is compared across `f` at equal optical contrast. + +`a₀` is defined on the **photodiode-measured** log contrast, never on a DAC +excursion. That is exactly where the existing sweep path stops short: ADR 010 +drives `SetOpticalDepth { depth_a_milli }` **open-loop**, trusting the measured +Pockels inversion (ADR 011) to turn a commanded depth into an optical one, and +then only *waits* for the measured `a` to arrive. The inversion is static, so as +`f` rises the drive electronics and the crystal response roll off and the +delivered depth falls short of the commanded one. Waiting cannot fix a +systematic gain error: the sweep would hit its 30 s settle cap and record a +point at the wrong depth (with the measured value honestly in the sidecar, but +the run wasted). + +A second, subtler problem: ADR 010 already notes that "the operator's own +`depth a` re-applies on the next modulation settings sync after release". A +depth found in one operator action and recorded in a *later* one can therefore be +silently overwritten between the two. + +## Decision + +**1. A closed-loop `a₀` lock in A1, separate from recording.** A *Find a₀* +button runs a small state machine — `AcquiringLease → (per trial) SettingDepth → +Measuring → …release` — that iterates + +```math +a_\text{cmd} \leftarrow a_\text{cmd}\cdot\frac{a_0}{a_\text{measured}} +``` + +until the photodiode-measured `a` is within an absolute tolerance of `a₀` +(default ±0.02), at most 8 trials, each correction capped at ×2/÷2 and clamped +to the owner's `0.01..=6.0`. The delivered depth is proportional to the commanded +one to first order, so this converges in two or three trials while absorbing +whatever roll-off the frequency introduces. It reuses the ADR 010 contract +command unchanged — no new modulation command, no optical math outside its owner. + +The lock **records nothing** and releases the lease with `safe_off = false`, so +the drive stays exactly where the lock left it. + +Measurement hygiene: readings are only taken after the operator's settle dwell +has passed, and one reading per **fresh** photodiode `service_revision` (three +per trial), so a slow publisher is not averaged once per control tick. A +measured `a ≤ 0`, a missing optical summary, or an owner rejection ends the lock +with the owner's own wording — a refused depth *is* the "`a₀` unreachable at this +operating point" answer. + +**2. The result is data, not a transient.** Each finished lock is stored as one +row per frequency — `frequency_hz`, `target_a`, `commanded_a`, `measured_a`, +`trials`, `converged`, clip fractions, timestamp — replacing any earlier row +within 1 % of the same frequency, shown in an `a₀ lock table` host view, and +mirrored to `a0_locks.json` in the output folder so the found depths survive a +restart and can be cited offline. Non-converged attempts are kept for the record +but never arm a recording. + +**3. Recording replays the locked depth under the lease.** *Record a₀ point* +does **not** simply record at whatever the drive currently is. It runs the ADR 010 +sweep machinery as a **one-point sweep of a new kind**: lease → command the +locked `a_cmd` → confirm the measured `a` holds `a₀` within the lock tolerance → +record through the unchanged coordinator → release. This gives three things at +once: the depth is re-asserted (immune to an intervening settings sync), the +lease locks the operator's modulation settings out for the whole point, so +"never change amplitude during the recorded interval" is enforced rather than +trusted, and the point is one button press. + +To express this, a sweep point became a pair — what the drive is **commanded** +to, and the depth it is **expected to measure**. The amplitude sweep sets both +equal (it trusts the calibration); an event-count point deliberately does not, +and the difference *is* the absorbed roll-off. + +**4. Naming and provenance.** Event-count points take the role suffix `_ec` and +carry their **frequency** in the stem (`…_ec_f50Hz`, `…_ec_f0p5Hz`) instead of a +sweep-point index, because one measurement id spans the whole frequency sweep at +the single frozen depth. The sidecar gains `sweep.commanded_a` and an +`[a0_lock]` section (target, commanded, measured-at-lock, frequency-at-lock, +trials, converged, locked-at), and both recorders' own sidecars carry the same +values as string metadata. + +**5. What stays the operator's.** The flux point, camera configuration, ROI/mask, +pedestal, bias set, gates, reference epoch, the frequency itself, the +`I_tot` anchor, the zero-depth background and the pilot (already separate +buttons), the randomised frequency order, the interleaved low-frequency +reference, and the repeated blocks. A1 adds exactly two buttons per frequency — +*Find a₀* and *Record a₀ point* — because the protocol's ordering and +randomisation decisions are scientific, not mechanical. + +## Consequences + +- A1's scoped hardware reach is unchanged in kind (still only the armed drive's + depth, still only while leased) but now closed-loop: it reads the photodiode + to decide what to command. +- The recorded amplitude is provably the measured `a₀`, not a calibrated guess, + at every frequency — including frequencies where the static Pockels inversion + is no longer accurate. +- `a₀` itself is **not** frozen numerically in this repository: it is an operator + input, to be chosen from the low-frequency scout (several events per + pixel-half-cycle, still proportional, refractory-safe at the top frequency). + The plugin default is a placeholder. +- The refractory condition `2 f a₀/C ≪ 1/τ_refr` is *not* checked in the plugin; + it is a choice made once when `a₀` is picked, and stays with the operator. +- Re-locking after changing the flux point, the calibration or `a₀` is required: + a stored lock is only armed for a matching frequency **and** a matching `a₀`, + and *Clear a₀ lock table* exists for the rest. diff --git a/docs/adr/014-stage-a-a1-frequency-ladder.md b/docs/adr/014-stage-a-a1-frequency-ladder.md new file mode 100644 index 0000000..3b1f99e --- /dev/null +++ b/docs/adr/014-stage-a-a1-frequency-ladder.md @@ -0,0 +1,109 @@ +# ADR 014 — Stage-A A1 unattended frequency ladder + +- **Status:** accepted (2026-07-27) +- **Relates to:** ADR 009 (recording coordinator), ADR 010 (amplitude sweep via + leased `SetOpticalDepth`), ADR 012 (the contrast geometry the measured `a` is + defined in), ADR 013 (the per-frequency `a₀` lock), + [Stage-A A1 Exact Event Count](../features/stage-a-a1-event-count.md) + +## Context + +ADR 013 gave the operator two buttons per frequency — *Find a₀* and *Record a₀ +point* — and deliberately left the ladder manual, because "the protocol's +ordering and randomisation decisions are scientific, not mechanical". + +In practice an A1 event-count block is 7–12 frequencies over two or three +decades, each one a lock plus a recording, repeated over three independent +blocks. That is an hour of pressing two buttons in the right order while +watching a status line — and every gap between the two presses is a gap in which +a modulation settings sync can re-apply the operator's own `depth a` on top of +the found one (ADR 013 §3 exists precisely because of this hazard, and only +closes it *within* one point). + +The ordering decisions are scientific, but they are also **expressible**: a +seeded schedule and an interleaved reference cadence are exactly what the A1 +checklist asks to be frozen in the session plan before the block starts. Freezing +them as settings and recording them per point is stronger than leaving them to +be executed by hand and written down afterwards. + +Three things blocked automation: + +1. **A1 could not change the frequency.** The contract exposed `SetOpticalDepth` + but no frequency equivalent, and A1's scoped reach (ADR 007/010) was "the + armed drive's depth while leased". +2. **Nothing could confirm a frequency had arrived.** The firmware ACKs a table + it accepted; the light modulating at that rate is a different claim. +3. **The measured `a` was not trustworthy at the bottom of a ladder.** The + photodiode estimated the peak-to-peak contrast over a fixed 0.82 s window, + under one cycle for every `f < 1.2 Hz` — and the `a₀` lock divides by that + value, so a truncated estimate drives the depth up until it rails. + +## Decision + +**1. `ModulationCommandV1::SetDriveFrequency { frequency_millihz }`** (contract +addition, additive to V1) — the frequency counterpart of `SetOpticalDepth`, with +the same scoping: leased only, re-derived through the same `drive_command()` +builder, rejected when the link is closed or the armed drive has no frequency to +retarget. The owner **parks the operator's armed frequency** on the first +retarget and restores it in `end_lease`, exactly as it already does for the +depth, so a finished ladder does not leave the bench on its last point. + +**2. The ladder is a supervisor, not a third state machine.** `FreqSweep` runs +`AcquiringLease → (per point) SettingFrequency → ConfirmingFrequency → Locking → +Recording → …release`, where *Locking* and *Recording* are the **unchanged** +ADR 013 lock and ADR 010/013 point. Both gained an inherited-lease mode +(`owns_lease: false`): when the ladder starts them they neither acquire nor +release, they run on its lease. + +That is the substantive guarantee: **one lease spans the whole ladder**, so the +operator's drive settings are locked out from the first frequency to the last, +and the "the amplitude cannot change during the recorded interval" property +ADR 013 established for one point now holds across the gap between a lock and +the point that replays it. + +**3. The trigger confirms the frequency.** A point does not start until enough +phase-0 markers *at the new period* agree with the commanded frequency. On every +frequency change the retained markers and events are dropped: the measured +period is their mean spacing, so keeping them would confirm the new frequency +against a mixture of the old drive and the new one. + +**Pilot windows are dropped with them.** Windows frozen at one period are a +phase interval of *that* period; carrying them into another frequency would +score the point in the wrong window — silently, because a fold always produces +something. Re-freezing a pilot per frequency stays the operator's call; the +ladder only guarantees it never reuses a stale one. + +**4. The schedule is data.** Log spacing (a Bode ladder is read per decade), +four orders — ascending, descending, alternating, seeded random — and an +optional low-frequency reference interleaved every N points. The executed +position, the order and the seed go into every point's sidecar +(`[frequency_sweep]`), so a block is interpretable from its files rather than +from a notebook. + +**5. A bad point is skipped, not fatal.** An unreachable `a₀`, an unconfirmed +frequency, or a failed recording skips that frequency and names it in the final +summary; the lock table keeps the failed attempt. The remaining decades are +worth more than a clean abort. Only losing the lease ends the ladder. + +**6. The plan is validated before the drive moves.** The photodiode estimates +`a` over one window for the whole ladder, so its *lowest* frequency decides +whether the ladder is measurable. That, the drivability of `a₀`, the presence of +a trigger, and the destination are all checked at the button press. + +## Consequences + +- A1's scoped hardware reach widens by one parameter: it may retarget the armed + drive's **frequency** as well as its depth, still only while leased, still + through the owner's own builder and validation. Everything else about the + drive remains the operator's. +- The lease is now held for the length of a whole block rather than a point, so + its TTL is sized from the ladder (renewed per point). A lost lease ends the + run — which is the correct failure: without it the drive is no longer + provably A1's. +- Pilot-frozen windows no longer survive a frequency change. A workflow that + relied on freezing one pilot and recording several frequencies against it was + producing wrongly-scored `q_p`; it now falls back to per-fold auto-windows and + says so. +- `a₀` is still not frozen numerically here, the refractory bound is still not + checked, and references are still the operator's. The ladder automates the + mechanical repetition, not the scientific choices. diff --git a/docs/adr/015-stage-a-a1-recording-robustness.md b/docs/adr/015-stage-a-a1-recording-robustness.md new file mode 100644 index 0000000..5981e15 --- /dev/null +++ b/docs/adr/015-stage-a-a1-recording-robustness.md @@ -0,0 +1,142 @@ +# ADR 015 — Stage-A A1 recording: one folder, full duration, named failures + +- **Status:** Accepted +- **Date:** 2026-07-25 +- **Relates to:** ADR 009 (A1 as a recording coordinator — revises decision 3 and + its co-location consequence), ADR 005 (device ownership), + ADR 006 (two-plugin split), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +On the bench, *Start recording* looked like it worked and then reported a +finished run almost immediately. What actually landed on disk was: + +- an A1 `_config.toml` in the chosen output folder, +- a **truncated** camera `.raw` (plus the host's bias `.toml`) in an unrelated + directory — the host process's working directory, +- **no `.pdq` and no photodiode sidecar at all**, +- and a status message that said only "was incomplete". + +Three separate defects produced that outcome. + +1. **A photodiode failure cut the camera recording short.** The coordinator + starts the camera first, then connects, leases, and opens the PDQ. Every + photodiode-leg failure — a rejected `Connect`, a refused lease, or a + `BeginRecording` rejected because the photodiode's *Data directory* was unset + — jumped straight to `stop_camera`. The host had been recording for a few + hundred milliseconds, so the RAW was a stub that nevertheless carried a + complete finalization receipt. `stop_requested` was also set on photodiode + faults, conflating "the operator asked to stop" with "the photodiode broke". + +2. **The failure reason was discarded.** Each failure wrote a specific message + (`Photodiode start failed (invalid_path): set the data directory first`), and + `finish_recording` then overwrote it with the generic "was incomplete". + The one piece of information the operator needed was destroyed on the way out. + +3. **One measurement scattered across up to three roots.** Per ADR 009 decision 3 + each recorder confines its own writes: the host resolves plugin recording paths + below *its* output directory and **rejects absolute paths**; the photodiode + resolves PDQ paths below *its* data directory; A1 writes its sidecar below + *its* output folder. ADR 009 accepted this and called physical co-location a + configuration convention. In practice the host's output path was relative, so + its parent resolved to the process working directory, and the RAW landed in a + source checkout — nowhere near the experiment folder. + +## Decision + +1. **The camera RAW always runs its full duration.** A photodiode failure while + the camera is already recording no longer stops it. The run continues to the + requested duration and closes normally, with the sidecar and message marking + it camera-only. A complete camera-only recording is a usable measurement; a + truncated file that reports itself as finalized is a trap. `stop_requested` + now means only what its name says — an operator stop — and photodiode faults + travel in `pd_rejected`. + +2. **The photodiode is pre-flighted before the camera starts.** A recording is + refused, with nothing recorded and an actionable message, when the photodiode + is not reporting status, is not connected, has no data directory, or is leased + by another client. These were exactly the conditions that used to surface as a + PDQ rejection *after* the host was already recording. The same check feeds the + A1 status view while idle, so the blocker is visible **before** the operator + presses Record rather than after a wasted run. + + This needs the owner's data directory, so `PhotodiodeSummaryV1` gains an + additive `data_dir: Option` field (`#[serde(default)]`, absent from + older owners, ignored by older consumers — the contract version is unchanged). + +3. **The first failure is preserved and named.** `Recording::failure` keeps the + first, most specific cause; later fallout cannot overwrite it. The closing + message reads `Recording incomplete: — metadata saved to `. + +4. **A1's output folder is the destination for the whole measurement**, reversing + ADR 009's "co-location is a configuration convention". A recording started in + A1 puts every file under `//`, by two mechanisms — chosen + per recorder by how much control that owner grants a client: + + **The PDQ is written there directly.** `PdqStartSpecV1` gains an additive + `root_dir: Option`: an absolute directory the client wants the + recording written below, replacing the owner's configured data directory for + that run. The owner keeps every safety rule it already had below the new root + — the path stays relative, `..` and non-normal components are refused, parent + components must be real directories rather than symlinks, and the resolved + target must stay below the root — and additionally requires the root itself to + be absolute. Consequently an A1-driven run **does not depend on the + photodiode's own Data directory at all**, which is what removed the failure + mode in context item 1; the pre-flight in decision 2 no longer checks it. + + **The camera RAW is moved there after finalization.** The host resolves plugin + recording paths below *its* output directory and rejects absolute paths, and + it lives in the other repository, so A1 cannot name the destination up front. + Instead, once the host reports finalization — at which point the file is closed + and hashed — A1 moves the RAW and the host's bias sidecar into the measurement + folder. A `rename` on one volume, a size-verified copy-then-delete across + volumes; it never overwrites an existing destination and never removes a source + it has not verified. If a move fails the file stays put and the sidecar records + where it actually is. The same gather runs over the PDQ, which is normally a + no-op because it is already in place. + + PDQ receipts report the path **label** the client requested, not an absolute + path, so A1 resolves it against the root it named — falling back to the owner's + published `data_dir` (decision 2) and preferring whichever exists, so an owner + too old to honour `root_dir` still yields a correct path. The sidecar records + the resolved absolute path, which also fixes the previous ambiguity of storing + a bare relative label under `[files]`. + +5. **Self-inflicted pipeline restarts no longer wipe the row.** Starting and + stopping the host recorder restarts the capture pipeline, which the host + reports as `SourceChanged` — twice per recording, caused by A1 itself. That + used to clear the pilot windows, the background floor, and every response + point collected across a sweep. While a recording or sweep is in flight the + boundary now resets only the event fold, whose timeline genuinely did restart. + +## Consequences + +- A recording can now end as *camera-only*: `recording_completed_ok` stays false, + so an amplitude sweep still stops rather than silently collecting points with + no measured `a`. The RAW is complete and reusable. +- A misconfigured bench refuses to record instead of producing a stub. This is a + deliberate behaviour change: pressing Record with a disconnected photodiode + now yields a message and no files, where it previously yielded a junk RAW. +- The measurement folder is the single place to look. Files are no longer where + the host and photodiode settings happen to point, so operators do not have to + keep three roots aligned by hand. Aligning them is still harmless — a file + already in the destination is left alone. +- The photodiode's Data directory now governs only its *own* manual saves (cache + snapshots, operator-started recordings). A workflow-driven run overrides it, so + changing it mid-experiment cannot move A1's files out from under a measurement. +- A crash mid-run leaves the PDQ in the measurement folder, because it was opened + there. Only the camera RAW depends on surviving to finalization to be gathered; + if a run dies before that, the RAW is left in the host's output directory and + the sidecar (if written) names it there. +- Moving a large RAW across volumes copies it. On one volume (the normal case) + the move is a metadata operation regardless of file size. +- Both contract additions (`data_dir`, `root_dir`) are additive `#[serde(default)]` + fields, backward compatible in both directions; no ABI change and the contract + version stays at 1. Letting a client name an absolute root is a deliberate + widening of what a workflow may ask the owner to do — bounded by keeping every + traversal and symlink check, and by the owner still refusing anything it cannot + resolve below that root. +- Making the camera RAW land directly in the measurement folder would need the + host to accept a plugin-declared recording root. That belongs to `augur-rs` and + is deliberately left out of scope here; the gather makes it unnecessary. diff --git a/docs/adr/016-stage-a-lobe-endpoints-not-a-distance.md b/docs/adr/016-stage-a-lobe-endpoints-not-a-distance.md new file mode 100644 index 0000000..3783ddb --- /dev/null +++ b/docs/adr/016-stage-a-lobe-endpoints-not-a-distance.md @@ -0,0 +1,124 @@ +# ADR 016 — The Pockels lobe is two observed codes, not a code and a distance + +- **Status:** Accepted +- **Date:** 2026-07-28 +- **Relates to:** ADR 008 (optical waveform inversion), ADR 011 (measured + Pockels transfer calibration), + [Stage-A Optical Waveform Drive](../features/stage-a-optical-waveform.md), + [Stage-A Pockels Transfer Calibration](../features/stage-a-pockels-calibration.md) + +## Context + +ADR 008 made the lobe settable as `V_null` (a DAC **code**) plus `Vπ` (a DAC +**distance** from it). On the bench on 2026-07-28 the drive behaved backwards: +excitation was brightest at normalized lobe coordinate `u = 0.5` and returned +to the null at both `u = 0` and `u = 1`, with the reject-port photodiode reading its maximum at +both ends. + +Nothing was wrong with the arithmetic. Host (`waveform.rs`), firmware +(`stage-a-controller/src/stimulus_mod.cpp`) and the knowledge base +(`methodology/pockels-waveform-linearisation.md` §3) all implement the same +inverse, `V(u) = V_null + (2Vπ/π)·arcsin(√u)`, with maximum light at +`V_null + Vπ`. + +What was wrong was the question the settings pane asked. `V_null (DAC code at +min light)` and `Vπ (DAC codes, null → max light)` render one above the other, +both labelled in DAC codes, and only the second is a distance. The operator +entered the **code** where the light was brightest. With a true null `N`, a true +peak `P` and `Vπ` set to `P`, the realised light is + +``` +I(u) = sin²( (P/(P−N)) · arcsin(√u) ) +``` + +which peaks at `u = sin²((π/2)(1 − N/P))` — one half when `N ≈ P/2` — and +falls back to the null at `u = 1`. That reproduces the observed curve exactly, +including the symmetry, and is asserted as a regression witness in +`waveform::tests::the_brightest_code_typed_as_v_pi_is_what_used_to_peak_at_half`. + +A distance is not an observable. Sweeping the DAC yields two *codes* — where the +light is dimmest and where it is brightest — and the pane asked the operator to +subtract them in their head, silently, with no way for the software to check the +result. The failure is silent by construction: any positive `Vπ` produces a +valid-looking drive, so the mistake only shows up as light that does the wrong +thing. + +## Decision + +### 1. The two settings are both absolute codes + +`v_null_dac` (code at minimum light) and `v_peak_dac` (code at maximum light). +`Vπ = |V_peak − V_null|` is derived, never typed. Both fields are read straight +off a sweep or off the transfer-curve plot, so there is nothing to subtract and +nothing to confuse. + +The mis-entry that caused this ADR cannot be expressed in the new form: the code +of the brightest point **is** what `V_peak` asks for. + +### 2. `LobeInversion::resolve` is the single place a pair becomes a lobe + +It returns the ascending lobe the drive inverts, or an error. Two cases beyond +the obvious one: + +- **A pair measured running downward** (`V_peak < V_null`) is now expressible, + where before it simply could not be entered — `Vπ` was constrained positive + and the inverse only ever climbs from `V_null`. `sin²` repeats every `2Vπ`, so + the branch one full period below the observed null rises into the very maximum + that was measured; that branch is used and the status pane says so, because + the codes driven are not the ones that were typed. +- **A degenerate or unreachable pair** is refused with the measurement to redo, + rather than accepted into a drive that cannot be armed. + +### 3. The lobe is resolved against the DAC, the ceiling checks emitted codes + +Where the crystal nulls and peaks is a fact about the bench, so `resolve` bounds +the lobe by the DAC range (`0..=4095`) and not by the operator's `max_level` +safety ceiling. Resolving against the ceiling would have refused a perfectly +drivable `MANUAL` band merely because the lobe it is interpreted against extends +past the ceiling. + +What the ceiling constrains is the codes actually emitted. The four floor/ceiling +guards in `dac_band` collapse to one — the floor cannot be breached now that +every emitted code lies between two in-range endpoints — and it names the +settings that still exist: *"the modulation peak needs DAC code 2600, above the +max limit 2400; raise the max limit or lower u / a"*. + +### 4. The wire format and the fit are unchanged + +`MOD wave=WARP … v_null=… v_pi=…` still carries the quarter wave, because that +is what the firmware rebuilds the table from; the derived distance goes on the +wire. `fit_transfer` still reports `v_pi_dac`, because a fitted period *is* a +distance — the endpoint form is about what an operator types, not about how the +model is expressed internally. Applying a fit writes `V_peak = V_null + Vπ`. + +### 5. `v_pi_dac` remains settable, and only settable + +It is absent from `settings_schema` but still accepted by `set_setting`, where +it is converted to `V_peak = V_null + Vπ`. Stored configs keep loading; nothing +new can be authored against the form that caused the mix-up. + +### 6. The status pane states where normalized `u` lands + +`Lobe: Vπ = 860 codes — u 0 → 1630 (min light), 0.5 → 2060, 1 → 2490 (max +light)`. The parameters are only meaningful as the codes they produce, and a +wrong endpoint is visible there without running a sweep or looking at the light. + +This `u` is the dimensionless, floor-subtracted lobe coordinate. It is not the +physical A1 flux point `I_k`, which remains separately identified and measured. + +## Consequences + +- `u = 1` holds exactly at the measured maximum, by construction rather than + by arithmetic that has to come out right. +- Drive rejection narrows to one honest case: the max-limit ceiling cutting the + requested `u`/`a` short. The DAC floor can no longer be breached at all. +- A descending branch is expressible for the first time. +- **Breaking:** `v_pi_dac` no longer appears in the settings schema. Stored + values still load through the compatibility path above, but a saved value that + was *wrong* in the old sense (a code entered as a distance) migrates to an + equally wrong `V_peak` — the bench pair must be re-entered once, or a + calibration sweep re-applied. +- This does not make the calibration self-checking. Nothing yet compares the + applied lobe against the light; the measured sweep of ADR 011 remains the way + to establish the two codes, and this ADR only makes hand-entering them + unambiguous. diff --git a/docs/adr/017-stage-a-rail-detection-and-withheld-a-reasons.md b/docs/adr/017-stage-a-rail-detection-and-withheld-a-reasons.md new file mode 100644 index 0000000..3e5b60e --- /dev/null +++ b/docs/adr/017-stage-a-rail-detection-and-withheld-a-reasons.md @@ -0,0 +1,115 @@ +# ADR 017 — Rail detection is span-relative, and a withheld `a` names its gate across the plugin boundary + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Relates to:** ADR 011 (Pockels transfer calibration), ADR 012 (contrast + geometry is bench, not display), ADR 013 (event-count depth lock), ADR 014 + (frequency ladder), + [Stage-A Photodiode](../features/stage-a-photodiode.md), + [Stage-A A1 Analysis](../features/stage-a-a1.md), + [Stage-A A1 Event-Count Depth](../features/stage-a-a1-event-count.md) + +## Context + +Two independent defects met on the bench and produced the same symptom: every +`a₀` action refused, and the panel could not say why. + +### The clip guard was calibrated for a volt-scale detector + +`estimate_contrast` is fail-closed on ADC clipping (ADR 012 §3): codes within +`CLIP_MARGIN_CODES = 4` of either rail counted as clipped, and more than 1 ‰ of +such samples refused the window. The margin was an **absolute** code count. + +The Stage-A reject-port detector operates around **0.5–15 mV** — the range the +µV-granularity calibration inputs and the span-relative Pockels fit were +introduced for. At 3.3 V over 4095 codes (0.806 mV per code) that whole waveform +lives inside the bottom ~20 codes, so a 4-code margin covers 3.2 mV of a 14.5 mV +signal. A perfectly clean millivolt-scale sine put **30.7 %** of its samples +inside the "near the rail" band, 300× over the 1 ‰ limit, and was refused as +clipped. None of those codes was the rail; they were the signal. + +The refusal was therefore unconditional at bench gain: `optical_summary` was +never published, and `a` was never available. + +### A withheld `a` did not survive the plugin boundary + +ADR 012 §3 established that a refusal is stated, not silent — but only in the +photodiode plugin's own status readout. `PhotodiodeSummaryV1` carried +`optical_summary: Option<…>` and nothing else, so a consumer saw absence with no +cause. + +A1 gates the `a₀` lock, the amplitude sweep and the frequency ladder on that +value, and refused all three with one fixed sentence: + +> No photodiode-measured a — connect the photodiode and anchor I_tot first + +which named the two most common causes whatever the real one was. With a railed +window, too few trigger markers, or a stale snapshot, that message sent the +operator to re-check an anchor that was already correct. The resting status line +was no better: `a = — (photodiode: connected)`. + +The same shape of problem sat on A1's event ingestion. With **Live analysis** +off, nothing is ingested at all, and the panel reported `0 events, …; +free-running (no EXT_TRIGGER)` — a description of a toggle, phrased as a +description of the bench. The frequency ladder refuses without phase-0 markers, +so an operator with Live analysis off was sent to check trigger wiring. + +## Decision + +### 1. The rail margin is capped against the window's own span + +The near-rail margin exists to catch a waveform that is *about to* truncate, +which is only meaningful while the margin is small compared to the signal. It is +now `min(CLIP_MARGIN_CODES, floor(span · CLIP_MARGIN_SPAN_FRACTION))` with +`CLIP_MARGIN_SPAN_FRACTION = 0.05`, where `span` is the window's observed +peak-to-peak code range. + +- Volt-scale windows (span ≥ 80 codes) keep the previous 4-code margin exactly. +- Millivolt-scale windows collapse the margin to 0, which leaves **precisely the + rails** — code 0 and `full_scale_code` — classified as clipped. + +Genuine saturation is still refused at every gain: a waveform driven below zero +pins samples *at* code 0, and the 1 ‰ limit still catches it. `MAX_CLIP_FRACTION` +is unchanged; this ADR narrows what counts as a rail, not how much clipping is +tolerated. + +This follows the same reasoning as the span-relative `NoModulation` threshold in +the Pockels fit (ADR 011): a fixed absolute voltage cut cannot serve a detector +whose gain is a bench property. + +### 2. The refusal reason is published on the contract + +`PhotodiodeSummaryV1` gains `optical_unavailable: Option` — additive in +V1, `#[serde(default)]`, skipped when absent, so older owners and consumers are +unaffected. It carries the owner's `EstimateError` rendering, set exactly when +`optical_summary` is `None` **and** a window existed to judge. + +A1 consumes it through one `measured_a_blocker()` helper that returns the +operator action, checked in the order the data flows: no status snapshot → not +connected → stale snapshot → the owner's reason → no samples yet. Every gate that +needs `a` quotes it, and the resting status line renders it without the operator +pressing anything. + +### 3. A1 distinguishes "Live analysis is off" from "no trigger" + +The status line and the frequency-ladder refusal name whichever it is. Marker +count alone cannot tell them apart, and only one of them is fixed with a +screwdriver. + +## Consequences + +- Millivolt-scale windows publish `a`. They are **quantisation-limited**: with a + ~19-code span the complement's excitation minimum is a fraction of one code, + so `a` is sensitive to single-code noise. The estimator's 1st/99th-percentile + extrema absorb spikes, but a bench wanting precise `a` at high contrast should + still raise the detector gain. This ADR makes such windows *estimable*, not + *precise* — the clip guard was never the right place to enforce resolution. +- The `Clipped` refusal now means the signal reached a rail, not that it sat near + one. A window previously refused for being small is now accepted, so an + operator who read that refusal as "gain too low" loses that (misleading) cue. +- Every A1 gate on `a` reports one of a bounded set of causes traceable to the + owner. New `EstimateError` variants surface in A1 with no A1 change. +- `optical_unavailable` is a human-readable string, not a typed error. The + contract crate is serde-only and does not depend on `stage-a-io`, and the + consumer renders rather than branches on it. A consumer that needs to *act* + per-variant will need the typed error on the contract instead. diff --git a/docs/adr/018-stage-a-a1-required-vs-optional-inputs.md b/docs/adr/018-stage-a-a1-required-vs-optional-inputs.md new file mode 100644 index 0000000..2b0a0a2 --- /dev/null +++ b/docs/adr/018-stage-a-a1-required-vs-optional-inputs.md @@ -0,0 +1,197 @@ +# ADR 018 — A1 gates on what it needs, not on what it would like to know + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Relates to:** ADR 010 (amplitude sweep), ADR 013 (event-count depth lock), + ADR 014 (frequency ladder), ADR 015 (recording robustness), ADR 017 (rail + detection and withheld-`a` reasons), + [Stage-A A1 Analysis](../features/stage-a-a1.md), + [Stage-A A1 Event-Count Depth](../features/stage-a-a1-event-count.md) + +## Context + +Every A1 recording path was unusable on the bench, and the panel described the +symptom rather than the cause. + +### Provenance metadata was enforced as a precondition + +`begin_recording` refused unless three fields were non-empty: the output folder, +the measurement id, and the physical `I_k` flux point id. Only the first is +something the plugin actually needs — it is where the files go. The measurement +id names a folder and a file stem, and the plugin has always shipped a generated +default for it. The flux point id is pure provenance: it records *which* +illumination calibration point a row belongs to, and nothing in the recording, +the sweep or the lock reads it. + +Enforcing them anyway meant a blank field could not be distinguished from a +misconfigured bench, and the refusals were spread unevenly across the entry +points: + +| entry point | folder | measurement id | flux point id | +| --- | --- | --- | --- | +| `begin_recording` | refused | refused | **refused** | +| `begin_sweep` | refused | refused | **refused** | +| `begin_leased_sweep` | refused | refused | — | +| `begin_freq_sweep` | refused | refused | **not checked** | + +The last row is the defect. The frequency ladder validated its whole plan up +front — deliberately, so that "a plan that cannot work should say so in a +message, not two hours into a block" — but did not ask the question its own +recordings would ask. It therefore took the modulation lease, retargeted the +drive, confirmed the frequency against the trigger, and ran a closed-loop `a₀` +lock, and only then handed off to `begin_recording`, which refused on the blank +flux point id. The recording coordinator stayed idle, the sweep saw +`point_started == false`, and the point was skipped — for every point. The panel +read `Frequency sweep 1/7 … — recording` next to `Recording: idle`, which is an +accurate description of two components and an explanation of neither. + +The same held for the amplitude sweep and, transitively, for the single +event-count point. + +### The test suite could not see any of it + +Every fixture in `runtime.rs` was built from `plugin_with_markers()`, which sets +`flux_point_id: "flux-test"`, or set the field explicitly. Fifty-one tests +passed, including one that drove the entire frequency ladder to completion, +because none of them ever exercised the state an operator actually starts in: a +fresh panel with nothing typed in. + +### A converged lock disarmed itself + +`armed_lock()` required `(lock.target_a - a0_target).abs() <= 1e-6`. `a0_target` +is an `F64Drag` with a 0.01 step that round-trips through JSON on every settings +sync. One stray pixel of drag after a successful `Find a₀` silently disarmed the +lock, and `begin_a0_point` then reported + +> No converged a₀ lock for 10.000 Hz — press Find a₀ at this frequency first + +which is the one instruction that does not help, addressed to an operator who +had just done it. That sentence also covered two other causes — no lock at this +frequency at all, and a lock that ran out of trials — without distinguishing +them. + +### The panel was written for the person who wrote it + +Section descriptions ran to full paragraphs of bench physics +(`q_p`, `S_p(t)`, `I_exc = I_tot − I_pd`, "marker-bounded window", +"refractory condition 2·f·a₀/C ≪ 1/τ_refr"). The prose was accurate and +unreadable, and it competed for attention with the one line that mattered — the +status message saying why the button had just refused. + +## Decision + +### 1. A gate exists only for an input the action cannot proceed without + +The output folder stays required: there is no defensible default destination for +measurement data, and the buttons are already disabled without one, which is +discoverable before the click rather than after. + +The measurement id is filled in on use. `ensure_measurement_id()` generates one +when the field is blank and **writes it back to the field**, so the run is filed +under a name the operator can see. It is tested on the raw field, not on +`sanitize_stem`'s output — the sanitizer substitutes `A1` for anything that +reduces to nothing, so asking it whether the id was blank always answers no, and +every unnamed run would have quietly shared one folder called `A1`. + +The flux point id is recorded, never enforced. A blank one is written as the +explicit sentinel `unspecified`, which keeps "not stated" distinguishable from a +real id downstream. A missing provenance field makes a recording *less +traceable*; it never makes it *wrong*, and that is not a reason to withhold the +operator's data. + +### 2. Every gate a run will eventually hit is asked before the drive moves + +`begin_sweep` and `begin_freq_sweep` now call `photodiode_blocker()` up front, +alongside the checks they already made. The principle ADR 015 established for +the recording coordinator — check before the camera starts, not after — extends +to the supervisors: a ladder must not take a lease and move the drive to +discover, at point 1, something it could have known at point 0. + +`begin_sweep` also drops its own hand-written photodiode sentence in favour of +`measured_a_blocker()` (ADR 017 §2). It was the last gate on `a` still naming +the anchor and the cable whatever the real cause was. + +### 3. A lock arms within the operator's own tolerance + +`armed_lock()` compares `lock.target_a` to `a0_target` against +`a0_tolerance`, not against `1e-6`. The tolerance is already the operator's +statement of how close to `a₀` counts as `a₀`; applying a stricter rule to the +*same* quantity one line later was never coherent. + +`armed_lock_blocker()` returns the actual cause — no lock at this frequency, a +lock that stopped short (and where), or a lock aimed at a different `a₀` (naming +both values) — and both `begin_a0_point` and the resting status line render it. +This is ADR 017 §2's shape applied to the second gate. + +### 4. The panel speaks to the operator + +Operator-visible strings — section descriptions, tooltips, status entries, +refusal messages, and the `EstimateError` renderings A1 quotes across the plugin +boundary — state what to do, in the words of someone standing at the bench. +Quantities keep their symbols (`a`, `a₀`, `f`) because those are on the +whiteboard too; the machinery behind them (fold windows, marker-bounded +estimation, reject-port complement algebra) belongs in these documents, which is +where a reader who wants it will look. + +Refusals name an action. `"ADC clipping: 307‰ low / 0‰ high"` became +`"the signal is hitting the ends of the detector's range (307‰ at the bottom, +0‰ at the top) — lower the drive amplitude or the detector gain"`. + +### 5. A missing frequency is not a disconnected plugin, and is stated once + +A frequency reaches A1 from two independent places: the phase-0 trigger markers, +or the drive the modulation plugin has *acknowledged*. Neither is the connection +state, which `modulation_connected()` checks separately. The resting line +nonetheless read + +> Frequency: unknown — connect the modulation plugin, or the trigger cable + +on a bench whose modulation plugin was connected. The usual cause is simply that +no periodic drive has been applied yet, and telling an operator to plug in +hardware that is already plugged in is worse than saying nothing. + +`frequency_blocker()` distinguishes the cases in the order the data flows — no +owner snapshot, not connected, connected with nothing applied, a waveform that is +not periodic, a periodic waveform at 0 Hz — and `begin_a0_lock`, +`armed_lock_blocker()` and the status line all render it. This is the third +application of the `measured_a_blocker()` shape from ADR 017 §2; the pattern is +now the house style for any gate an operator can see. + +Each fact also appears on exactly one line. A missing frequency previously +occupied three — the transient message, the `Frequency:` line, and the a₀ +readiness line, each with its own phrasing of the same cause — which reads as +three problems. The a₀ line now defers to the frequency line rather than +restating it, and the response-curve line is omitted entirely when it has neither +points nor windows to report, instead of stating the absence of the two facts +above it. + +## Consequences + +- The three recording workflows run with an output folder and nothing else + typed in. Regression tests cover exactly that state, and the frequency-ladder + test now exists in both variants — ids set and ids blank. +- Sidecars from unnamed rows carry `flux_point_id = "unspecified"`. Offline + analysis that joins on the flux point must treat that value as absent; it is a + sentinel, not an id. Analysis written against the old contract never saw a + blank field, because a blank field never produced a recording. +- Generated measurement ids are timestamp-derived (`A1--`), so two + unnamed runs started in the same millisecond would collide. They cannot be: + the id is generated inside `begin_recording`, which refuses re-entry while a + recording is active. +- A lock now survives an `a₀` nudge inside the tolerance. Widening + `a0_tolerance` therefore also widens what counts as "the same target", which + is the intended reading — but an operator who widens it to 0.5 to force a + stubborn lock through will find older locks arming for targets they did not + mean. The status line always names the lock's own target. +- The panel no longer states the estimator's gates in the estimator's terms. + Someone debugging the fold or the contrast geometry reads ADR 011, 012 and 017 + rather than a tooltip. +- The resting panel is shorter, and lines disappear when they have nothing to + say. An operator scanning for "did it change?" now has fewer stable lines to + scan, but cannot rely on a fixed line count or line order — anything parsing + `status_entries()` positionally would break. Nothing does; the host renders + them as a list. +- `frequency_blocker()` reports on the *acknowledged* drive, so a frequency the + operator has typed into the modulation plugin but not applied still reads as + "not applied yet". That is the intended reading — A1 measures against what the + bench is doing, never against what a field says. diff --git a/docs/adr/019-stage-a-calibration-measures-its-own-window.md b/docs/adr/019-stage-a-calibration-measures-its-own-window.md new file mode 100644 index 0000000..4f221a8 --- /dev/null +++ b/docs/adr/019-stage-a-calibration-measures-its-own-window.md @@ -0,0 +1,145 @@ +# ADR 019 — The calibration owns its measurement window, and judges itself against its own noise + +- **Status:** Accepted +- **Date:** 2026-07-30 +- **Relates to:** ADR 011 (measured Pockels transfer calibration), ADR 016 (the + lobe is two observed codes), ADR 017 (rail detection and withheld `a`), + [Stage-A Pockels Transfer Calibration](../features/stage-a-pockels-calibration.md), + [Stage-A Photodiode](../features/stage-a-photodiode.md) + +## Context + +On 2026-07-30 the operator ran a transfer-curve sweep on the orange bench. The +curve on the plot was clean and unmistakably a Pockels lobe. The plugin reported +it as bad on three counts at once: + +- residual **22.3 %** of the detector span, +- hysteresis **25.7 %** — "the cell is drifting or the settle time is too short", +- **34 of 98** points "clipped the ADC … add attenuation and re-measure". + +The record is kept verbatim at +`plugins/stage-a-modulation/testdata/pockels-20260730-083123.json`. All three +numbers were artifacts, and the fit underneath them was exactly right. + +### Every point was four ADC samples + +Each archived `volts` is an exact multiple of a quarter code — +`0.002619 V = 13/4`, `0.04412 V = 219/4`, `0.05581 V = 277/4`. The stream ran at +500 kSa/s, so each "settled" point was **8 µs** of signal, captured *after* the +sweep had already waited 4 ms for the cell to arrive. + +`PhotodiodeLevelV1` was computed over `avg_window_samples` — the photodiode +plugin's **chart smoothing** setting, default four samples. A display preference +was setting the precision of a physical calibration, and nothing named that +coupling anywhere. At 20 kSa/s it had been 200 µs and merely mediocre; the move +to 500 kSa/s made it 8 µs without changing a line of code. + +The consequences were all downstream of that one number. Per-point scatter came +out at σ = 11.3 mV against a 50.8 mV lobe, which *is* the reported 22.3 % +residual. + +### The hysteresis was the same noise, counted twice + +Both passes measure one curve, so at a matched code they differ by two +independent errors of scale σ, and `E|Δ| = σ√2·√(2/π) = 1.128 σ`. For this sweep +that predicts 12.8 mV; the measured mean |up − down| was 13.0 mV. The metric was +reporting its own point noise as cell drift, and a fixed 5 % threshold cannot +tell the two apart on any bench whose points are not far quieter than that. + +### The clipping flag was ADR 017's bug, one layer up + +`current_level` still marked a window clipped when its minimum fell within a +fixed 4 codes of the rail. The reject-port detector's dark end genuinely sits at +~3 codes (2.6 mV), so a third of every sweep was flagged. The span-relative +margin that ADR 017 introduced in the contrast estimator had never been carried +into the published level. + +### And a gate that would have got worse + +`fit_transfer` refused a sweep whose between-code span did not exceed the median +`peak_to_peak_volts` of the settled windows. That compares a span of *means* +against a *raw within-window excursion* — wrong by √N, and wrong in a way that +tightens as the averaging window grows. This sweep cleared it by a factor of 1.9. +Lengthening the window without touching this gate would have refused the very +sweeps the longer window was meant to rescue. + +## Decision + +### 1. The published level is a measurement, not a view of the chart + +`PhotodiodeStreamV1.level` is averaged over a **fixed duration owned by the +photodiode plugin** (`LEVEL_WINDOW_SECONDS = 20 ms`), independent of +`avg_samples` and `avg_sync_freq_hz`. `sample_count` reports what it actually +was. The chart's own averaging is untouched — it remains an operator preference, +and it no longer reaches anything downstream. + +A duration rather than a sample count, because what averages noise down is +time × bandwidth, not samples. 20 ms specifically because a boxcar of exactly one +mains period has a null at 50 Hz and every harmonic of it. At 500 kSa/s that is +10 000 samples in place of 4. + +### 2. Rail detection is shared, not re-derived + +`stage_a_io::near_rail_margin` is the single span-relative margin, used by both +`estimate_contrast` and the published level. A detector running a few codes above +zero is not truncating; the rails themselves stay guarded at every gain. + +### 3. Nothing judges the sweep by `peak_to_peak_volts` + +Both surviving gates use the fit's own RMS residual, which is the scatter of the +*averaged* points about the curve — the same quantity the lobe amplitude is +measured in, so the comparison is dimensionally honest and independent of +whatever window the owner publishes. + +**Is a lobe resolved?** Refuse when `rms ≥ 0.5·|span|`. The threshold needs +margin on both sides because a free period search over pure noise does not return +zero amplitude: with `n` points the quadrature pair has scale `σ√(2/n)`, and the +best of a 600-step scan inflates it by about `√(2 ln 600)`. Measured, that puts +noise-only quality at 0.7–1.0 (0.97 in the regression fixture) while the noisiest +real record on file reads 0.22. Half-way between is a plain statement — the lobe +must be at least twice its own scatter — with better than 2× margin either way. + +**Is the up/down difference drift?** Compare `hysteresis` against +`1.128 · rms / |span|`, the value it takes under noise alone. The ratio has two +derivable endpoints: **1.0** for pure noise, and **1.77** for pure drift, because +a systematic offset inflates the residual too (the fit splits the difference +between the passes, carrying `√(Δ²/4 + σ²)` while the metric carries +`√(Δ² + (1.128σ)²)`). The range is narrow and it is not optional to know that: a +generous multiple of the floor — 2×, the obvious first guess — sits above *both* +endpoints and never fires at all. The cut is at **1.33**, which detects a +systematic offset around 1.5× the point noise. Both endpoints are asserted in +`the_hysteresis_ratio_sits_between_its_two_derived_endpoints`. + +### 4. Settling is a duration + +`SETTLE_SECONDS = 0.1`, converted through the photodiode's published sample rate, +replacing a bare `SETTLE_SAMPLES = 2_000` that was written for 20 kSa/s and had +silently become 4 ms. `SETTLE_SAMPLES` remains only as the fallback for a stream +that has not published a rate. Settling is a property of the HV amplifier and the +crystal; nothing about it follows the acquisition rate. + +### 5. Clipping says what it costs + +Rail-touching points truncate the reported detector extrema, and with them the +`I_tot` lower bound. They do **not** move `V_null` or `Vπ`, which come from the +shape. The warning says so, and no longer advises attenuation — for a +reject-port detector it is the *dark* end that reaches the bottom rail, so the +fix is more gain, not less light. + +## Consequences + +- The chart's averaging setting no longer has any downstream effect. This is a + behaviour change for anyone who had turned it up expecting quieter sweep + points; they now get a quiet sweep without asking. +- A sweep costs ~120 ms per point (100 ms settle + 20 ms window), so ~12 s for + the full 98-point pass — still inside the documented ~20 s and far inside + `POINT_TIMEOUT`. +- The real bench record is a regression fixture. Synthetic sweeps could not have + caught any of this: they carry uniform noise, while a real detector's noise is + signal-proportional, and the metrics that broke were all compared against zero. +- The synthetic fixture's own "noise" was itself wrong for this question — a + wobble alternating with the point index is perfectly anti-correlated between + the two passes, i.e. systematic. `calibration::scatter` replaces it with + deterministic per-`(code, direction)` scatter. +- Nothing in `augur-rs` changes; this is entirely inside the Stage-A plugins and + their shared I/O crate. diff --git a/docs/adr/020-stage-a-a1-depth-source.md b/docs/adr/020-stage-a-a1-depth-source.md new file mode 100644 index 0000000..65af431 --- /dev/null +++ b/docs/adr/020-stage-a-a1-depth-source.md @@ -0,0 +1,139 @@ +# ADR 020 — A1 chooses where `a` comes from, and records the choice + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Relates to:** ADR 010 (amplitude sweep), ADR 011 (Pockels transfer + calibration), ADR 012 (contrast geometry is bench, not display), ADR 013 + (event-count depth lock), ADR 014 (frequency ladder), ADR 017 (rail detection + and withheld-`a` reasons), ADR 018 (A1 gates on what it needs), + [Stage-A A1 Analysis](../features/stage-a-a1.md), + [Stage-A A1 Event-Count Depth](../features/stage-a-a1-event-count.md), + [Stage-A Pockels Calibration](../features/stage-a-pockels-calibration.md) + +## Context + +`a = ln(I_exc,max / I_exc,min)` is a property of the excitation *light*. ADR 011 +and the estimator's module docs are emphatic about it: the Pockels V→T response +is non-linear, so the commanded DAC excursion is not a modulation depth and the +photodiode trace is the only valid source of `a`. Every A1 path that needs a +depth — the amplitude sweep, the `a₀` lock, the frequency ladder, the live +response curve — therefore read exactly one number: the photodiode owner's +`measured_log_contrast`. + +That number is fail-closed by design. The photodiode refuses to publish `a` +unless it can prove the window it estimated over covers whole modulation cycles, +which it does by bounding the window between firmware **phase-0 marker frames** +on its own stream port. With fewer than three retained markers it returns +`IncompleteModulationCycles` and publishes no `a` at all. + +On the bench this turned out to be reachable with the markers simply *absent*: + +``` +Measured depth a: not available. 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 +``` + +Three and a half million samples and zero markers is not a window that is too +short. It is a marker stream that is not arriving — no `MARKER` frames on the +stream port at all. The advice the refusal gives (lower the frequency, raise the +cache) cannot fix that, and no combination of settings can: without markers the +photodiode can never publish an `a`, so `Find a₀` refuses, the amplitude sweep +refuses, and the frequency ladder refuses. The entire A1 workflow is unreachable +on a bench whose Pockels cell is calibrated and whose drive is running +correctly. + +The workflow does not actually need a *measured* `a` to run. It needs **a +depth it can name, aim at, and record**. The modulation owner already has one: +it inverts the measured `V_null` / `Vπ` transfer curve to command a depth, and +publishes it as `OpticalDriveStateV1::depth_a_milli`. That is a calibrated +number — it comes from the same measurement ADR 011 exists to make — it is +simply not verified against the light afterwards. + +## Decision + +A1 gets one operator setting, **`depth_source`**, naming where its depth `a` +comes from: + +- `DepthSource::Photodiode` (**default**) — the photodiode's measured + excitation log-contrast. Unchanged behaviour, and the source of record. +- `DepthSource::Commanded` — the depth the modulation owner's calibrated + optical drive is commanding, read back from its published + `optical_drive.depth_a_milli`. + +One accessor, `depth_a()`, resolves the setting, and *every* consumer reads it: +the sweep's settle check, the `a₀` lock's readings, all three refusal gates, the +status panel, the live response curve, and the sidecar. The source is chosen in +exactly one place, so no path can be left reading the wrong one. + +`DepthSource::Commanded` is admissible only under the same conditions that make +a commanded depth mean anything at all. `optical_drive` is published solely for +`OPTICAL_LOG_SINE` / `OPTICAL_LINEAR_SINE` under an identified transfer +calibration, so a manual DAC band or a constant level yields no depth and the +gates refuse with that reason. A DAC number is never dressed up as an `a`. + +### Consequences for the closed loop + +The `a₀` lock is a feedback loop: command a depth, measure what the light did, +correct. Open loop the measurement *is* the command, so the loop converges on +trial 1 and the correction is a no-op. This is the honest degenerate case, not a +bug — there is nothing on the bench that could contradict the command — and it +is what makes the downstream machinery (the armed lock row, the event-count +point, the ladder) work unchanged. Two rules that exist for the estimator are +therefore scoped to the photodiode source: + +- the **stale-window rule** (only count summaries published after the depth was + commanded) — a commanded depth is not read out of a window, so enforcing it + would only couple the trial to the modulation owner's device-poll cadence; +- the **window-covers-a-cycle** and **clipping** checks — both are statements + about a detector window, and neither bounds a commanded depth. + +The operator's settle dwell still applies in both modes, so the drive gets its +physical time to move either way. + +### Provenance is not optional + +Everything that records an `a` records which source produced it: + +| artefact | field | +| --- | --- | +| A1 config sidecar (`.toml`) | `depth_a_source`, `depth_a` | +| A1 sidecar, `[a0_lock]` | `depth_source` | +| camera / PDQ recorder metadata | `depth_a_source`, `depth_a`, `a0_lock_depth_source` | +| `a0_locks.json` | `depth_source` (defaults to photodiode on older tables) | +| a₀ lock host view | `a from` column | + +`measured_a` keeps its historical meaning — a number the photodiode actually +measured — so an open-loop run simply carries no `measured_a`, rather than +carrying a commanded value under that name. A `q_p(a, f)` fit that pools the two +sources without looking at `depth_a_source` would be pooling two different error +budgets; the field is there so that cannot happen silently. + +The panel follows the same rule in prose: it says *"Commanded depth a (open +loop, not measured)"*, and an open-loop lock reports that the drive *"is +commanded as"* a value rather than that it *"measures"* one. + +## Alternatives considered + +**Fall back automatically when the photodiode withholds `a`.** Rejected. The +difference between a measured and a commanded depth is the difference between +two error budgets, and a silent switch would put both into one dataset with no +way to separate them afterwards. It also hides a real bench fault (a missing +trigger cable) behind a workflow that keeps running. + +**Loosen the photodiode's marker requirement instead** — estimate over a fixed +window when no markers exist. Rejected: a sub-cycle window *under*-reports `a`, +and the `a₀` lock divides by it, so it would drive the depth up until it rails. +ADR 017's fail-closed refusal is right; what was missing was a way past it that +does not lie about what was measured. + +**Enter `a` by hand.** Rejected — that is the datasheet number ADR 011 exists to +eliminate. The commanded depth comes from a measurement of *this* cell. + +## Status on the bench + +The commanded source is a way to keep working while the phase-0 marker stream is +diagnosed, not a replacement for measuring the light. A run recorded this way +carries the Pockels calibration's error plus any drift since it was taken, and +nothing checks it. Runs that go into the final `q_p(a, f)` fit should be +photodiode-measured. diff --git a/docs/adr/021-stage-a-a1-no-search-for-a-commanded-depth.md b/docs/adr/021-stage-a-a1-no-search-for-a-commanded-depth.md new file mode 100644 index 0000000..8516808 --- /dev/null +++ b/docs/adr/021-stage-a-a1-no-search-for-a-commanded-depth.md @@ -0,0 +1,121 @@ +# ADR 021 — There is nothing to search for in a depth you are commanding + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Relates to:** ADR 013 (event-count depth lock — the search this scopes), + ADR 014 (frequency ladder), ADR 020 (depth source), + [Stage-A A1 Exact Event Count](../features/stage-a-a1-event-count.md), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +ADR 013 built a closed-loop search, `Find a₀`, for one reason. The Pockels +transfer curve is measured once, so the inversion A1 commands through is +**static**, while the depth the cell actually delivers **rolls off with +frequency**. Holding one *measured* `a₀` across a frequency ladder therefore +means re-finding, per frequency, the commanded depth that produces it: + +``` +a_cmd ← a_cmd · a₀ / a_measured (≤ 8 trials, 3 readings each) +``` + +The result is stored per frequency in `a0_locks.json`, and every downstream +action — `Record a₀ point`, each rung of the ladder — replays a stored, +converged row. That is real work and it is the scientific core of the +exact-event-count workflow. + +ADR 020 then added a second depth source: when the photodiode cannot publish an +`a` at all (no phase-0 marker stream), A1 can take `a` from the modulation +owner's *commanded* calibrated drive. That made the workflow reachable again — +and immediately made the search meaningless, because in that mode the quantity +the loop measures *is* the quantity it commands: + +| step | measured source | commanded source | +| --- | --- | --- | +| command `a₀` | drive moves | drive moves | +| read back | photodiode reports what the light did | the owner reports the number just sent | +| correct | `a_cmd · a₀/a_measured`, repeat | ratio is exactly 1 — no-op | +| result | a per-frequency commanded depth | `commanded_a = a₀`, at every frequency | + +Eight ladder points produced eight identical rows carrying no information, each +behind a lease acquisition, a settle dwell and three "readings". The operator +was required to press `Find a₀` before `Record a₀ point` would arm, for a search +whose answer was already on screen. + +It was also actively harmful. `begin_a0_lock` warm-starts from any stored row at +the current frequency, so a row left over from a *measured* session (say +`commanded_a = 0.83` for `a₀ = 0.5`) would seed an open-loop run with a +closed-loop number, fail to converge on trial 1, and spend a second trial +correcting itself back to `0.5`. A no-op that can still be wrong is worse than +no operation at all. + +## Decision + +Whether the search is needed is a property of the depth source, expressed once +as `DepthSource::needs_a0_lock()`, and three things follow from it. + +**1. The lock table stops being the way to ask "what depth is armed here?"** +That question moves to `armed_a0()`, which returns a lock row from the table +under a measured source and *synthesises* one under a commanded source +(`commanded_a = target_a = a₀` at the current frequency, `trials: 0` recording +honestly that no search happened). `begin_a0_point` and the ladder both ask +this, and neither knows which regime it is in. Nothing is written to +`a0_locks.json` open loop, because nothing was found. + +**2. The ladder skips the `Locking` phase entirely.** Per rung it becomes +lease → set frequency → confirm → record. The `FreqSweepPhase::Locking` arm and +the direct path share one `start_freq_sweep_recording`, so there is a single +place where a point becomes a recording. + +**3. `Find a₀` refuses instead of pretending.** It states that `a₀` is commanded +directly and points at `Record a₀ point` / `Record all frequencies`. The button +is disabled rather than hidden — it is what the whole a₀ workflow is documented +around, so it has to stay visible and explain itself. + +### Frequency confirmation follows the same logic + +The ladder confirmed each commanded frequency against the **camera's** phase-0 +markers, on the correct principle that an ACK says the table was accepted, not +that the light is modulating at that rate. But a bench with no marker stream — +the exact bench the commanded source exists for — can never satisfy it, so +removing the search alone would have left the ladder refusing at the next gate. + +Under a commanded source the ladder therefore confirms against the modulation +owner's **acknowledged waveform**. This is not a new trust relationship: it is +the same owner, and the same acknowledged state, that mode already trusts to +state `a`. The cost is explicit — the live `q_p` fold goes free-running without +markers — and it is confined to the live quicklook. The recorded RAW and PDQ, +which are what the offline fit actually reads, are unaffected. + +Under a measured source nothing changes: markers confirm the frequency, and +`begin_freq_sweep` still refuses up front when they are absent. + +## Consequences + +- The commanded ladder runs on a bench with **no photodiode `a` and no camera + trigger**, with `Live analysis` off, and records every planned point. +- Open loop, the panel stops reporting a saved-depth count that is structurally + always zero, and says what will happen instead: *"the drive is commanded to + a = 0.500 at 1.000 kHz — no search needed"*. +- The a₀ section's description text switches with the source, and states the + trade in the operator's own terms: nothing verifies the light reached `a₀`, + and the static inversion does deliver less depth as `f` rises. +- **The measured workflow is untouched.** `Find a₀`, the per-frequency trim, the + lock table and its disk mirror all behave exactly as ADR 013 specifies the + moment the depth source is the photodiode again. + +## Alternatives considered + +**Delete the lock outright.** Simplest possible plugin, and wrong: it would +permanently give up ADR 013's guarantee that the same *measured* depth was held +across frequencies. The roll-off it corrects is real; only its applicability to +an open-loop depth is not. + +**Keep the search but make it one trial.** Still a lease, a dwell and a table +row per frequency, to reproduce a number the operator typed. The ceremony was +the complaint, not its duration. + +**Write the synthesised rows to `a0_locks.json` anyway**, for uniformity. They +would be eight identical restatements of the `a₀` setting, and a reader of that +file could no longer tell a found depth from an assumed one. Provenance already +lives in the sidecar (`depth_a_source`, `[a0_lock].depth_source`). diff --git a/docs/adr/022-stage-a-a1-sensor-conditions-on-every-run.md b/docs/adr/022-stage-a-a1-sensor-conditions-on-every-run.md new file mode 100644 index 0000000..7802c24 --- /dev/null +++ b/docs/adr/022-stage-a-a1-sensor-conditions-on-every-run.md @@ -0,0 +1,83 @@ +# ADR 022 — Every A1 run records the bench conditions the sensor measured + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Relates to:** ADR 009 (recording coordinator), ADR 015 (recording + robustness), ADR 020 (depth source provenance), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +The A1 sidecar already reproduces everything about the *drive* — frequency, +depth, calibration, anchor, ROI, trigger — but nothing about the physical state +of the sensor while a run was taken. Three quantities the camera measures for +itself are now available on the host's per-frame context bus +(`CTX_SENSOR_MONITORING` → `SensorMonitoringV1`): + +| quantity | field | why it matters to `q_p(a, f)` | +| --- | --- | --- | +| pixel dead time (refractory period), µs | `pixel_dead_time_us` | caps how many events a pixel can emit per half-cycle; at high `f` it *is* the ceiling the response saturates against | +| scene illumination, lux | `illumination_lux` | the physical `I_k` axis the whole experiment is stratified on | +| die temperature, °C | `temperature_c` | moves the biases, so two rows at nominally identical settings are not comparable across a large drift | + +None of these is derivable from the recording afterwards, and all three drift +over a session. A row that cannot be compared to another has to be identifiable +as such at analysis time, which means the numbers belong in the artefact, not in +a lab notebook. + +## Decision + +A1 mirrors `SensorMonitoringV1` every frame and writes it into **every** +recording, in every mode — normal, pilot, background, amplitude-sweep point and +event-count point alike. This needs no per-mode work: both write paths are +already shared, so the values go into `recording_metadata()` (which both the +camera and PDQ recorders embed) and into a new `[sensor]` section of the A1 +config sidecar. + +Four properties are load-bearing. + +**Mirrored above the `live` gate.** `process_frame` returns early when Live +analysis is off, and recordings are made that way at least as often as not. The +mirror therefore sits with the ROI mirror, before the gate. + +**Frozen at recording start.** These quantities drift; the sidecar is written at +finalize, seconds to minutes later. The number that belongs to a run is the one +that held when it began, so `begin_recording` snapshots `sensor_at_start` before +any of the start handshake runs, and the writers prefer it over the live value +(falling back to the live one only if the run began before any frame carried a +reading). + +**Absent, never zero.** Every field is optional at three levels: the host +publishes nothing at all during replay, decoded imports and offline re-runs +(there is no device to ask), a sensor without a monitoring block publishes +nothing, and an individual quantity can be `None` on a sensor that has one. A +`0 °C` die or a `0 lx` scene reaching an analysis script as a measurement is the +failure mode this exists to avoid, so a missing quantity omits its key entirely +rather than defaulting. + +**Provenance only, never an input.** No result A1 computes may depend on these +values. The API's own docs are explicit about why: a plugin whose answers vary +with them would disagree between a live run and a deterministic offline re-run +of the same data. `age_s` is recorded alongside (`sensor_reading_age_s` / +`reading_age_s`) because the host polls at a few hertz — a reading is never +simultaneous with the run it is attached to, and the sidecar says how stale it +was rather than implying it was not. + +The absolute bias codes that arrive with the same struct are written too +(`bias_diff_on`, `bias_diff_off`, `bias_fo`, `bias_hpf`, `bias_refr`). The host +camera config expresses biases as *relative* offsets around a per-unit factory +trim, so these are the only absolute record of what the sensor was actually +programmed to. + +## Consequences + +- The A1 sidecar gains an optional `[sensor]` section; both recorders' metadata + gains `sensor_temperature_c`, `sensor_pixel_dead_time_us`, + `sensor_illumination_lux` and `sensor_reading_age_s`. +- The status panel shows the live reading on one line when the host reports one, + and stays silent when it does not. +- Sidecars written from replay or from a camera without a monitoring block have + no `[sensor]` section at all — an analysis script must treat it as optional, + exactly as it must treat `optical.measured_a` under ADR 020. +- Only `refr` among the biases has a vendor-documented physical unit, which is + why the other four are recorded as codes and not converted. diff --git a/docs/adr/023-stage-a-a1-nested-depth-frequency-sweep.md b/docs/adr/023-stage-a-a1-nested-depth-frequency-sweep.md new file mode 100644 index 0000000..2497030 --- /dev/null +++ b/docs/adr/023-stage-a-a1-nested-depth-frequency-sweep.md @@ -0,0 +1,118 @@ +# ADR 023 — The frequency ladder is an outer loop, not one experiment + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Relates to:** ADR 010 (amplitude sweep — the inner run), ADR 013 (`a₀` + lock), ADR 014 (frequency ladder), ADR 021 (no search for a commanded depth), + [Stage-A A1 Analysis](../features/stage-a-a1.md), + [Stage-A A1 Exact Event Count](../features/stage-a-a1-event-count.md) + +## Context + +A1 had two multi-recording runs, and they were built as if they were unrelated: + +- **The amplitude sweep** (ADR 010) — leases the drive, walks `[min_a, max_a]`, + records one measurement per depth. One `q_p(a)` curve, at whatever frequency + the operator happened to have armed. +- **The frequency ladder** (ADR 014) — leases the drive, walks a log-spaced set + of frequencies, and records *one* point at each. + +The experiment the bench actually exists to produce is `q_p(a, f)` — a response +curve per frequency, from which `a50(f)` is fitted offline. Getting it meant +driving the amplitude sweep by hand once per frequency: set `f` in the +modulation plugin, press *Record depth sweep*, wait, come back, repeat. Seven +frequencies of that is seven manual interventions, seven opportunities for the +drive to be left somewhere unintended between blocks, and — because each sweep +takes and releases its own lease — seven windows in which the operator's own +settings are re-applied on top of the run. + +Meanwhile the ladder already had every part of that missing outer loop: log +spacing, visit order (ascending / descending / alternating / seeded random), +interleaved low-frequency reference repeats, per-frequency confirmation, one +lease held across the whole block, skip-and-report on a frequency it cannot +reach, and a summary. All of it was hard-wired to record exactly one thing at +each rung. + +## Decision + +The ladder becomes an outer loop with a **mode** naming what each rung records: + +```rust +enum FreqSweepMode { + A0Point, // one event-count point at the frozen depth a₀ (ADR 013/014) + DepthSweep, // the whole [min_a, max_a] sweep — the q_p(a, f) surface +} +``` + +`DepthSweep` reaches the inner run through `begin_leased_sweep` with +`SweepKind::Amplitude` and the ladder's own `lease_id` — the *unchanged* +amplitude sweep, inheriting the lease rather than taking one. So the operator's +drive settings stay locked out from the first frequency to the last, not merely +between the points of one curve, and are handed back once at the end. + +Everything else about the ladder is shared as it stands: ordering, the reference +repeats, the frequency confirmation of ADR 021, skip-and-report, the summary. +Adding the second experiment added one enum, one dispatch and one button. + +### A depth sweep never needs a search + +`FreqSweepMode::needs_armed_depth()` is false for `DepthSweep`, so the +`Locking` phase is skipped **in both depth sources** — not only the commanded +one of ADR 021. The reasoning is different from ADR 021's and worth stating: an +`a₀` rung replays a single depth that something must have chosen, whereas a +depth sweep commands every `a` in its range itself and settles on each against +the measured value. There is nothing for a lock to contribute at any frequency. +A measured-source nested sweep is therefore still fully closed-loop — each point +waits for the photodiode to reach its own target — it simply has no `a₀`. + +### Two things that had to change underneath + +**The ladder needed the inner run's verdict, not the last recording's.** It +advanced on `recording_completed_ok`, which describes one recording. A depth +sweep that gives up on point 4 of 5 leaves that flag `true` from point 3, and +the rung would have counted as finished with a half-recorded curve. `Sweep` now +carries `completed_ok`, set only on the branch that runs out of points with +every one recorded, and `finish_sweep` publishes it as +`last_sweep_completed_ok`. The `a₀` path reads the same flag — it is also a +`Sweep` — so this replaced the weaker check rather than adding a second one. + +**File names had to carry both axes.** Amplitude-sweep points are tagged `_pNN`, +which repeats at every rung and would collide inside one measurement id. A +nested point is now `…_fHz_pNN`, so the surface sorts by frequency and then +by depth. + +**The lease TTL is sized per mode.** A depth-sweep rung costs a whole inner +sweep; a TTL computed for one `a₀` point would expire mid-curve and hand the +drive back to the operator's settings while the block was still running. + +## Consequences + +- One button — *Record depth sweep at every frequency* — produces the whole + `q_p(a, f)` block: `frequency points × depth points` recordings on a single + lease, unattended. +- It introduces **no new settings**. The depth axis is the existing Recording + section (`min_a`, `max_a`, `sweep_count`, settle, duration); the frequency + axis is the existing ladder (`min_f`, `max_f`, `freq_count`, order, seed, + reference repeats). Its own section says so explicitly, because those two + groups live under headings named after other experiments. +- The run is *large* by construction. The button's tooltip states the + multiplication rather than discovering it at runtime, and the start message + reports `N × M recordings`. +- `a0_locks.json`, `Find a₀` and the `a₀` ladder are untouched. + +## Alternatives considered + +**A separate second ladder.** A copy of the outer loop specialised to depth +sweeps. Rejected: ordering, reference repeats, confirmation, skip-and-report and +the lease discipline would then exist twice and drift apart — ADR 014's +machinery is the valuable part, and it is entirely mode-agnostic. + +**Make the inner run a list of `(f, a)` pairs in one flat sweep.** Simpler +state, but it loses the ladder's per-frequency semantics: the reference repeats +are defined per frequency, the frequency confirmation happens per frequency, and +a failure has to skip a *frequency* rather than a point. Flattening would have +made the skip granularity wrong. + +**Reuse `Record all frequencies` with a mode setting instead of a second +button.** Rejected: a control whose meaning depends on a nearby dropdown is how +an operator records the wrong experiment overnight. Two buttons, two names. diff --git a/docs/adr/024-stage-a-photodiode-learns-its-own-anchor.md b/docs/adr/024-stage-a-photodiode-learns-its-own-anchor.md new file mode 100644 index 0000000..d46bcaa --- /dev/null +++ b/docs/adr/024-stage-a-photodiode-learns-its-own-anchor.md @@ -0,0 +1,126 @@ +# ADR 024 — The photodiode learns its own total-power anchor, and dark cancels + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Relates to:** ADR 006 (two-plugin split), ADR 011 (Pockels transfer + calibration), ADR 012 (contrast geometry is bench, not display), + ADR 019 (calibration measures its own window), + [Stage-A Photodiode](../features/stage-a-photodiode.md) + +## Context + +The Stage-A detector sits behind the PBS reject port and reads the complement +of the excitation, `I_pd = I_tot − I_exc`. Recovering the excitation contrast +`a = ln(I_exc,max / I_exc,min)` therefore needs `I_tot`, and the plugin asked +the operator for it through four settings: + +| setting | what it wanted | +| --- | --- | +| `reference_volts` | the detector reading with the whole beam sent into it | +| `reference_anchor_id` | a name for that reading, for provenance | +| `reference_confirmed` | a tick-box asserting it was measured *for this setup* | +| `dark_volts` + `capture_dark` | the reading with the beam blocked | + +Nothing downstream would publish `a` until the tick-box was ticked, and editing +either the value or the id un-ticked it. That gate is why the A1 plugin's +photodiode depth source appeared not to work at all: the default configuration +withholds `a` permanently, and the reason surfaces only as one line of status +text on a different plugin. + +Three things were wrong with this: + +1. **`I_tot` is measurable, not typeable.** Getting it by hand means blocking + the sample arm, reading a number off the chart, and typing it back — a + procedure that is re-done, or silently not re-done, every time the optics + are touched. The tick-box exists precisely because nobody can tell from the + number whether it is current. + +2. **The bench already measures it.** The Pockels transfer sweep (ADR 011) + walks the DAC across the whole lobe, which drives the excitation through its + null by construction. At the null all the light goes to the reject port, so + the detector reading *there* is `I_tot`. The calibration archive has + recorded `detector_volts_at_null` all along, described as "a lower bound on + the total-power anchor". + +3. **The dark level cancels.** With a DC dark offset `D`, the corrected + excitation is `(I_tot,obs − D) − (v − D) = I_tot,obs − v`. The `D` terms + cancel *exactly*, because both sides are readings from the same DC-coupled + detector. A dark setting can therefore only do harm: entered on one side + only, it biases `a`; entered on both, it does nothing. + +## Decision + +The photodiode learns `I_tot` from its own stream and the four settings are +removed. + +`SharedState` latches `observed_peak_code`: the maximum of the completed +64-sample summary-cell means since the port was opened. On the reject port the +detector is brightest exactly where the excitation is extinguished, so this is +`I_tot` by construction. The latch is over cell *means*, not raw samples, so a +single noise spike cannot pin the anchor high for every later `a`. + +Nothing has to be entered, and nothing has to be confirmed: the transfer sweep +the operator already runs before any measurement lands on the excitation null +and teaches the anchor as a side effect. The latch survives segment restarts — +a rate change, a dropped sample or an acquisition handover does not move the +optics, and the sweep that teaches the anchor is followed by exactly such a +handover. + +Dark correction is removed entirely. `AdcCalibration::dark_volts` is fixed at +zero and the published `dark_id` says `dark-cancels` rather than implying an +unmeasured zero. + +`PhotodiodeCalibrationV1` keeps its shape; `anchor_id` becomes +`observed-peak@` — provenance for a number nobody typed. + +The removed setting keys are still accepted by `set_setting` and ignored, so a +configuration written before this ADR still loads. + +## Consequences + +- The photodiode depth source works out of the box. `a` is withheld only for + reasons that are actually about the measurement — too few cycles in the + window, clipping, or an excitation that never dims below the brightest the + detector has been. +- That last case is a new refusal, and an honest one: if the modulation has not + yet been anywhere dimmer than the running peak, there is no complement to + take a contrast of. The message says to run the transfer sweep. +- `a` is now invariant to any DC offset in the front end, provably — there is a + unit test asserting that shifting the whole detector trace and the anchor + together leaves `a` unchanged to 1e-9, and a companion test asserting that + correcting one side alone *does* change it, so the first test cannot pass + vacuously. +- **Before any sweep has run, the estimator fails closed by construction.** The + obvious worry is that with only a modulated trace observed, the "total power" + sits barely above the signal and `a` explodes. It cannot: the anchor is the + maximum of 64-sample cell *means*, which for a modulated trace is always below + the robust high percentile the estimator compares against, so + `TotalPowerBelowSignal` fires instead. There is a test asserting exactly that + refusal — by name, not merely "some error" — at two very different + cycle-to-cell ratios, because a refusal for want of whole cycles would + otherwise make it pass without exercising the anchor at all. +- The anchor is a running maximum, so it never decreases within a session. + Reducing the laser power mid-session leaves it too high until the port is + reopened. This is the conservative direction — `a` comes out low rather than + high — and reconnecting resets it (`connect()` clears the ring). +- **The trustworthy path is still the transfer sweep.** The anchor is only as + good as the dimmest excitation the detector has seen; a bench that has never + been driven through the null has no anchor worth the name, and the estimator + says so. If that ever needs to be stronger, the modulation owner's fit already + holds a settled `detector_volts_at_null` and could push it over as a scoped + command — the same direction A1 already commands the photodiode in, so no + dependency cycle. +- The modulation plugin's calibration folder gains a stated purpose: its + archived `detector_volts_at_null` is the anchor a past run's `a` was measured + against. + +## Alternatives considered + +- **Publish the anchor from the modulation plugin's fit.** It has the number + already. Rejected: the photodiode is the upstream owner in the existing + dependency direction, and pushing the anchor back down it creates a cycle + between the two device owners for a value the detector can observe itself. +- **Keep `I_tot` as an optional override.** Rejected on the operator's own + reading of it: an escape hatch that is almost never the right path is still a + setting to understand, and its presence is what made the happy path feel + conditional. diff --git a/docs/adr/025-stage-a-drive-settings-clamp-not-refuse.md b/docs/adr/025-stage-a-drive-settings-clamp-not-refuse.md new file mode 100644 index 0000000..89eb512 --- /dev/null +++ b/docs/adr/025-stage-a-drive-settings-clamp-not-refuse.md @@ -0,0 +1,92 @@ +# ADR 025 — Drive settings clamp into the achievable range, they never refuse + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Relates to:** ADR 008 (optical waveform inversion), ADR 016 (lobe endpoints, + not a distance), [Stage-A Modulation](../features/stage-a-modulation.md), + [Stage-A Optical Waveform](../features/stage-a-optical-waveform.md) + +## Context + +The calibrated drive has two coupled controls — the cycle-mean lobe point `ū` +and the optical depth `a` — bounded by one shared constraint: the peak of the +swing must stay under the top of the Pockels lobe, and under the operator's DAC +max limit. + +Every setting that feeds that constraint was validated transactionally: + +```rust +let previous = self.mode; +self.mode = mode; +if let Err(error) = self.validate_drive() { + self.mode = previous; // snap back + return Err(error); +} +``` + +Which produces this, from the bench: + +> when using one mode, setting some of the I and a parameters it is blocking +> choosing other modes sometimes (which is a bug btw?) and then the user is +> asking himself why it isn't working + +It is a bug, and the mechanism is exactly the revert. With an `a` left over +from a different lobe, selecting `OPTICAL_LOG_SINE` builds a warp table that +saturates, so the *mode* is rejected and the dropdown snaps back — reporting an +error about `a`, a control the operator was not touching. There is no +indication of which value is in the way or how far it would have to move, and +the two controls can each block the other, so the way out is guesswork. + +## Decision + +Nothing in the drive settings reverts. Two changes: + +**1. One place that knows the constraint.** `waveform::PeakLaw` names how the +peak intensity follows from `ū` and `a`, one variant per calibrated mode: + +| variant | peak | used by | +| --- | --- | --- | +| `Constant` | `ū` | `CONST` | +| `LogSwing` | `ū·e^{a/2}` | `DAC_SINE`, `SQUARE` | +| `LogSine` | `ū·e^{a/2}/I₀(a/2)` | `OPTICAL_LOG_SINE` | +| `LinearSine` | `ū·(1 + tanh(a/2))` | `OPTICAL_LINEAR_SINE` | + +Solving each relation for one variable at a time gives `max_depth_for_mean` and +`max_mean_for_depth` — the achievable range. `LobeInversion::peak_intensity_ceiling` +turns the DAC max limit into the `u_max` they are solved against. + +These are asserted to agree with `warp_table()` to within 1e-4: a range that +disagreed with what the drive builder accepts would either offer a refused +setting or hide a working one. + +**2. Edits clamp, and only the edited control moves.** `reconcile_drive` takes +a `DriveKnob` naming what the operator just touched: + +- `Depth` — dragging `a` up means "more depth", so `a` is what gets limited and + `ū` stays put. +- `Mean` — and symmetrically. +- `Lobe` — a new calibration, a new ceiling or a new mode has no such + preference, so brightness settles first and the depth that fits under it + second. + +Modes and drive methods are always accepted. The achievable range is on the +status line and in the two control labels, so the boundary is visible before +the drag reaches it rather than reported after. + +An un-sendable drive is reported (`drive not sent: …`) instead of blocking the +edit, and the report is refreshed on every reconcile — a stale rejection from +an earlier combination no longer outlives the edit that fixed it. + +## Consequences + +- Every mode is selectable from every state. There is a test that walks all + five modes from a deliberately unbuildable `(ū = 1.0, a = 6.0)` and asserts + each one leaves a drive that builds. +- The labels carry the live bound: `Optical depth a (0..1.37 at ū=0.50)`. +- A clamp is a silent change to a value the operator asked for. That is right + for a drag, and wrong for automation: `SetOperatingPoint`, `SetOpticalDepth` + and `SetDriveFrequency` still refuse out-of-range requests rather than + clamping, because a protocol asked for a specific point and quietly recording + a different one would put the wrong parameters in every sidecar of a block. +- `validate_drive` is gone; `drive_command()` is consulted directly where its + verdict is wanted. diff --git a/docs/adr/026-stage-a-applied-lobe-crosses-the-mirror-worker-boundary.md b/docs/adr/026-stage-a-applied-lobe-crosses-the-mirror-worker-boundary.md new file mode 100644 index 0000000..a063c98 --- /dev/null +++ b/docs/adr/026-stage-a-applied-lobe-crosses-the-mirror-worker-boundary.md @@ -0,0 +1,82 @@ +# ADR 026 — The applied Pockels lobe crosses the mirror/worker boundary + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Relates to:** ADR 010 (button presses cross mirror → worker), ADR 011 + (Pockels transfer calibration), ADR 016 (lobe endpoints), + [Stage-A Pockels Calibration](../features/stage-a-pockels-calibration.md) + +## Context + +"Apply to V_null / V_peak" did nothing. Pressing it after a good sweep left the +two settings showing their old values and the drive on the old lobe. + +The host runs **two instances** of every plugin: a UI mirror that renders the +settings panel, and a live worker that owns the device link. Settings travel in +one direction only. Every live-analysis pass calls +`collect_live_plugin_state_snapshot`, which reads `get_setting` from the +mirror, and `apply_live_plugin_snapshot`, which writes each value onto the +worker. + +The measured fit lives on the worker — it is the instance with the photodiode +and the DAC. So the button failed twice over: + +1. The mirror ran `apply_calibration_fit` with `self.fit == None` and reported + "nothing to apply". +2. The worker applied the fit correctly, and the next settings sync overwrote + `v_null_dac` / `v_peak_dac` with the mirror's stale pair — within one frame. + +ADR 010 solved the *press* crossing this boundary (a monotonic counter through +`get_setting`). This is the opposite direction: a **result** produced on the +worker has to reach the mirror, and no channel carried one. + +## Decision + +Both instances live in the same process — the live worker is a thread, and the +plugin is one loaded `cdylib`. The applied lobe is published through a +process-global slot with a monotonic generation: + +```rust +static APPLIED_LOBE: Mutex> = Mutex::new(None); +static APPLIED_LOBE_GENERATION: AtomicU64 = AtomicU64::new(0); +``` + +Scoped by runtime role, which is what makes it a channel rather than shared +mutable state: + +- **only the live worker publishes** — it is the only instance that can have a + fit; +- **only the UI mirror adopts** — so no instance ever reads back its own + publication. + +The mirror consults it in two places. `get_setting` and `settings_schema` read +through `effective_lobe()`, so a freshly applied lobe reaches the panel *and* +the outgoing snapshot on the next repaint. `set_setting` calls +`adopt_applied_lobe()` first, so an incoming echo of the old codes cannot land +on top of a newer applied one. + +The generation makes adoption one-way and terminal: once the mirror is at +generation *n* it accepts ordinary edits again, so applying a calibration does +not freeze the two controls. + +A fresh instance starts at generation 0, not at the current value. A mirror +built after a calibration — a plugin reload — has to pick the applied lobe up, +not assume it is already current. + +## Consequences + +- The button works, and there is a regression test: after a sweep and an apply + on a worker, a newly constructed mirror's `get_setting("v_null_dac")` returns + the applied code, and a subsequent edit on the mirror sticks. +- Applying no longer refuses on the *drive*. A lobe that cannot express the + currently armed `a`/`ū` is still a valid measurement of the bench; the two + controls clamp to the new lobe instead (ADR 025). Applying still refuses two + codes that name no monotonic lobe at all. +- One global means one bench per process, which is what the hardware is. It + does mean unit tests that fabricate several plugins share it — the role + scoping keeps that harmless, since test plugins are live workers and never + adopt. +- This is a general shape, not a one-off. Any worker-produced value that has to + survive the settings snapshot needs the same treatment; the alternative — + making the host merge worker state back into the mirror — is an `augur-rs` + API change and is not warranted by one field. diff --git a/docs/adr/027-stage-a-a1-declarative-protocols.md b/docs/adr/027-stage-a-a1-declarative-protocols.md new file mode 100644 index 0000000..b645d3b --- /dev/null +++ b/docs/adr/027-stage-a-a1-declarative-protocols.md @@ -0,0 +1,146 @@ +# ADR 027 — A1 records surveys from a declarative protocol, including the `I_k` axis + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Relates to:** ADR 009 (recording coordinator), ADR 010 (amplitude sweep), + ADR 014 (frequency ladder), ADR 023 (nested depth/frequency sweep), + ADR 025 (clamping vs. refusing), + [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +A1 could sweep two of the three axes the experiment has: + +| axis | what moves it | how it is swept | +| --- | --- | --- | +| `a` — optical depth | `SetOpticalDepth` | amplitude sweep (ADR 010) | +| `f` — frequency | `SetDriveFrequency` | frequency ladder (ADR 014) | +| `I_k` — mean illumination | *nothing* | by hand, in the modulation plugin | + +Each sweep button moves its own axis and leaves the others wherever the +operator last put them. For exploring, that is the right shape. For a survey it +is not: + +- the `I_k` axis could not be swept at all, so a brightness series was one + manual edit per point with a lease released in between; +- what a block actually recorded lived in the UI at the time it ran, not in + anything that travels with the results; +- reproducing a survey six months later means reconstructing the panel state + from the sidecars it produced. + +The modulation plugin *did* have a protocol runner — a TOML list of timed `MOD` +steps. It was undocumented, unreferenced by any feature brief, drove raw DAC +codes rather than calibrated optical parameters, explicitly refused the optical +warp modes, and recorded nothing. It was removed. + +## Decision + +A1 gains a declarative protocol: a file naming every axis for every recording, +run by a supervisor built like the frequency ladder. Two front-ends produce the +same flat list of points, chosen by file extension, so nothing downstream knows +which was used. + +**CSV — one row per recording, and the one to reach for.** One line is one +recording, every parameter is a column, and the file opens in a spreadsheet or +comes straight out of a script: + +```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, +``` + +Columns are located **by header name**, so their order does not matter and one +can be omitted entirely; blank lines and `#` comments are skipped; a blank cell +falls back to the default. Errors carry the **file line number**, because that +is what an editor and a spreadsheet both show. + +Two capabilities fall out of the row form that the block form cannot express +without one block per value: + +- **Per-recording duration and settle.** A 1 Hz point needs 40 s to cover + enough cycles and a 200 Hz point does not. This was the concrete ask. +- **A `role` column** (`normal` / `pilot` / `background`), so a file can carry + its own references — background floor first, pilot to freeze the ON/OFF + windows, then the points scored against them. A survey becomes a complete + measurement rather than something that needs two button presses first. + +**TOML — blocks and ranges.** Kept because it expresses a dense regular sweep +compactly, which a 96-row CSV does not: + +```toml +[defaults] +duration_s = 10 +settle_s = 2.0 + +[[block]] +name = "frequency-ladder" +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 +``` + +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 full +product of its three axes. + +**`I_k` is `ū`.** The third axis is the normalized cycle-mean lobe point the +modulation plugin already exposes — dimensionless, not physical flux, but the +one control that moves the mean illumination without touching the depth. It is +driven by a new contract command, `ModulationCommandV1::SetOperatingPoint`, +scoped exactly like its two siblings: leased only, calibrated method only, and +the owner parks the operator's own value on the first retarget so `end_lease` +hands it back. + +**In the block form, points run `ū` outermost, then `f`, then `a`.** That is the +order of how expensive each change is to settle — the operating point makes the sensor +re-adapt, a frequency has to be confirmed against the phase-0 trigger, and the +depth is the cheap innermost step. Any other nesting spends the run settling. + +**All three axes are commanded at every point.** Not just the ones that +changed: a protocol states a whole operating condition, and a point that +inherited an axis from its predecessor would be recorded under parameters the +file does not name. A point waits for all three retargets to be acknowledged +before recording — recording after two of them would file the run under a +condition the bench was not at. + +**The file's `duration_s` wins over the panel's.** A survey whose recording +lengths silently came from the UI would not be reproducible from the protocol +alone. + +**Validation is up front.** Ranges, spacing, bounds and the total point count +are checked on the button press, before the drive moves, along with the same +whole-cycle window check the frequency ladder makes against its lowest +frequency. `MAX_POINTS = 4096` catches a three-axis product with one zero too +many *before* the bench spends a night on it. The status line reports the point +count and the expected bench time before the first recording starts. + +**A refused point is skipped, not fatal.** A `ū`/`a` pair that runs off the top +of the lobe is the ordinary failure in a long survey. The point is skipped +carrying the modulation owner's own wording, the run continues, and — because +the per-point message is overwritten within the same tick — the reason is kept +on the run and surfaced both in the status pane and in the closing summary. + +One lease covers the whole file. + +## Consequences + +- A survey is a file. It can be reviewed, diffed, version-controlled and + archived next to the data it produced. +- `plugins/stage-a-a1/protocols/example.csv` and `example.toml` ship as + commented starting points, installed alongside the plugin, and tests parse + both — a stale example is worse than none. The CSV test additionally asserts + that the shipped file really does use several different durations and both + reference roles, so it demonstrates what it claims to. +- The CSV reader shares its field splitter with the sensor readout compactor + (`src/csv.rs`); both locate columns by name for the same reason. +- The parse/expand core is a pure module with its own tests, so the axis + algebra is verified without a bench. +- `ū` is a normalized lobe coordinate, not a calibrated physical flux. Sweeping + it walks the brightness axis reproducibly; converting a point to photons + still needs the illumination calibration, exactly as before. +- The four sweep buttons remain. They are the right tool for exploring, and the + protocol is the right tool for the run that follows. diff --git a/docs/adr/028-stage-a-sensor-readout-travels-with-the-measurement.md b/docs/adr/028-stage-a-sensor-readout-travels-with-the-measurement.md new file mode 100644 index 0000000..57fbe5d --- /dev/null +++ b/docs/adr/028-stage-a-sensor-readout-travels-with-the-measurement.md @@ -0,0 +1,92 @@ +# ADR 028 — The sensor readout travels with the measurement, column-wise + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Relates to:** ADR 009 (recording coordinator), ADR 022 (sensor conditions on + every run), [Stage-A A1 Analysis](../features/stage-a-a1.md) + +## Context + +ADR 022 put the sensor's own measurements — die temperature, pixel dead time, +scene illumination — into every A1 sidecar as provenance. That is *one* reading, +frozen at the moment the run started. + +The host separately polls the camera's monitoring block for the whole recording +and writes `.sensor-monitoring.csv` beside the RAW. Two problems: + +**1. It stayed behind.** A1 gathers the camera RAW, its bias sidecar, the +photodiode PDQ and its sidecar into one measurement folder under one name. +The telemetry was not in that list, so the record of how the bench actually +drifted during a run was separated from the run at the first move — left in the +host's capture folder under the host's own stem, alongside every other +recording's. + +**2. The layout is padding by construction.** The channels are polled on +different schedules: the die temperature drifts over minutes, the pixel dead +time is read far more often. A row-per-poll table with a column per channel is +therefore mostly empty cells. On top of that, five of its columns are bias +codes — already recorded in the camera's own bias sidecar, which *does* travel +with the RAW. + +## Decision + +`gather_into_measurement_folder` picks the telemetry up, rewrites it, and +removes the original. + +The rewrite is column-wise: one `{ t_us, value }` pair of arrays per channel, +carrying only the polls where that channel was actually read. + +```json +{ + "schema": "stage-a.a1.sensor.v1", + "measurement_id": "A1-20260801-1a2b", + "recording": "A1-20260801-1a2b_20260801-120000", + "polls": 412, + "channels": { + "pixel_dead_time_us": { "t_us": [1100,2100,…], "value": [12.7,12.8,…] }, + "temperature_c": { "t_us": [1100,61100,…], "value": [41.5,41.9,…] } + }, + "faults": [] +} +``` + +It lands in the measurement folder as `.sensor.json` and is +named in the sidecar's `[files]` block as `sensor_readout`, so it shares the +measurement's name and id like everything else in there. + +Decisions inside the rewrite: + +- **Nothing is resampled, interpolated or aligned.** The channels genuinely + have different rates; a reading exists at the instant it was taken or not at + all. Padding them onto a common grid would invent data. +- **A sample is timestamped at the midpoint of its poll.** A monitoring read + takes a few hundred microseconds; attributing it to the start would date + every reading systematically early. +- **Bias codes are dropped.** The camera's bias sidecar already carries them + and it travels with the RAW. +- **Failed polls are kept as `faults`,** so a gap in a channel is + distinguishable from a channel that was never polled — but an ordinary + "nothing due yet" row is not a fault. +- **Columns are located by name.** A host that inserts a column must not shift + every reading by one. +- **Rows that cannot be parsed are skipped, not fatal.** A truncated last line + is normal when a recording is cut short, and losing the other few thousand + samples over it would be the wrong trade. +- **The JSON is hand-rendered** so each channel's arrays stay on one line. + These files are read by eye as often as by script, and a pretty printer puts + one number per line. + +The whole path is best-effort: a missing or unreadable telemetry file is normal +(replay, a source with no monitoring block, a host that did not poll) and never +costs the operator the recording that just finished. + +## Consequences + +- A measurement folder is now self-contained for the bench conditions too: the + frozen start-of-run reading in the sidecar (ADR 022) *and* the full drift + across the run in the readout file. +- The host's capture folder is no longer littered with orphaned telemetry. +- The parse/compact core is a pure module with its own tests, including the + fault, truncation and column-reordering cases. +- The format is ours, versioned by the `schema` field. If the host ever emits + something richer, the reader changes and the schema tag moves with it. 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..a75f624 --- /dev/null +++ b/docs/adr/031-evesmlm-plugins-share-a-types-crate.md @@ -0,0 +1,86 @@ +# 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. + +## 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/architecture.md b/docs/architecture.md index 2e882ba..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,9 +74,41 @@ 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. +## Frame-Independent Plugin Services + +Hardware workflows use the host-routed control plane rather than the per-frame +JSON context. One canonical live-worker instance owns effects; GUI mirrors, +replay, and offline instances remain fail-closed. Requests target stable manifest +IDs and carry semantic operation names, request IDs, leases, and explicit +responses. Device plugins validate and execute their own operations; the host is +only the router. + +Stage-A uses a serde-only companion contract so `stage-a-a1` can orchestrate +`stage-a-modulation` and `stage-a-photodiode` without linking their implementation +crates or opening their serial ports. See +[`docs/adr/007-stage-a-owner-orchestration.md`](./adr/007-stage-a-owner-orchestration.md). + +Not every cross-plugin dependency needs that machinery. Because the host +broadcasts each plugin's `control_snapshots()` to every plugin's inbox, a plugin +that only needs to *read* another's published state can do so directly — no +lease, no service request, no router round-trip. The Pockels transfer +calibration reads photodiode levels this way while driving only its own DAC: +[`docs/adr/011-stage-a-pockels-transfer-calibration.md`](./adr/011-stage-a-pockels-transfer-calibration.md). +Reserve the leased service path for *commanding* hardware someone else owns. + +A published field is part of that contract, so its **meaning must not depend on +the publisher's UI state**. The photodiode plugin's display toggle used to +select the optical geometry the published log-contrast was computed in, which +silently retargeted A1's amplitude sweep whenever the chart was left on its +default. Geometry follows the bench, not the display: +[`docs/adr/012-stage-a-contrast-geometry-is-bench-not-display.md`](./adr/012-stage-a-contrast-geometry-is-bench-not-display.md). + ## Host Views Plugins declare host-rendered datasets and views through: @@ -82,6 +120,7 @@ Plugins declare host-rendered datasets and views through: The host owns: - analysis-panel rendering +- linked 2D/3D investigation state - standalone windows - dataset caching - exports @@ -89,8 +128,19 @@ The host owns: This repository currently uses that mechanism for: -- reconstruction table and density windows -- the shared EVE compact localization panel that can be provided by fitting or post-processing +- reconstruction table, density, and 3D localization inspection +- candidate-stage accepted/rejected raw-event layers for live tuning +- the shared EVE current-localization datasets that can be provided by fitting or post-processing + +For investigation-linked table datasets, the important host-consumed metadata is: + +- stable row ids via `row_id_column` +- 2D and 3D coordinates +- optional time columns +- layer ids and semantic labels +- dataset display metadata for title, default visibility, color, marker shape, and size + +Overlays remain useful for supplemental 2D annotations, but the host now treats structured datasets as the primary linking surface. ## Tradeoffs diff --git a/docs/features/README.md b/docs/features/README.md index b39d965..896c223 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -4,8 +4,25 @@ Repository-level feature notes for larger plugin suites, interface migrations, a ## Available Briefs +- [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). 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. 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. - [Plugin Runtime Migration Notes](./plugin-api-v0-2.md) — historical runtime-migration brief, updated with the current interface additions that matter to this repo. - [Plugin Host Views](./plugin-host-views.md) — generic host-rendered datasets, cache generations, and shared view ids. +- [TableV1 Declarative Metadata](./tablev1-declarative-metadata.md) — plugin-side adoption of row provenance, display formats, and cross-dataset relations for trustworthy table rendering. +- [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). diff --git a/docs/features/action-requests-and-refit.md b/docs/features/action-requests-and-refit.md new file mode 100644 index 0000000..d9be433 --- /dev/null +++ b/docs/features/action-requests-and-refit.md @@ -0,0 +1,93 @@ +# Action Requests And Single-Cluster Refit + +## Summary + +Plugins can declare host-rendered action buttons and consume the requests +the host publishes when the user triggers one. The eveSMLM fitting plugin +is the first concrete consumer: it exposes **Re-fit cluster…**, +**Commit refit**, and **Discard refit preview**. The re-fit action opens a +host-rendered modal driven by the plugin's `param_schema`, runs a +single-cluster fit with the captured parameters, and emits the result as a +separate `augur.evesmlm.refit_preview` dataset so it is visually distinct +from the main pipeline output. + +## Plugin Contract + +Refit is plumbed through the generic host action bus (see +`augur-rs/docs/features/investigation-action-requests.md`). In short: + +- Add `HostActionDescriptor` entries to `HostViewRegistry.actions` in + `host_views()`. Each descriptor declares: + - `id` — stable identifier used to route the request in `process_frame`. + - `title` — button label. + - `scope` — one of `Dataset`, `Row`, `Cluster` with the target + `dataset_id` (and `group_column` for `Cluster`). + - `param_schema: Option` — typically + `serde_json::to_value(my_settings_schema())`. Pass `None` when the + action takes no parameters. +- Read the persistent queue at `CTX_INVESTIGATION_ACTION_REQUESTS` + (`HostActionRequestQueue`). Filter by your cached + `last_consumed_action_request_id` so each request runs exactly once. +- For `Cluster` actions, expect the host to snapshot the selected rows into + `params["__augur_cluster_rows"]`. Plugins can reconstruct the selected + cluster from those rows instead of depending on the next frame to still + contain the same cluster. +- Emit side effects. Publish overlays/datasets for visual preview, or + mutate owned state for commit/discard. + +## Fitting Plugin Implementation + +- Three actions registered in `host_views()`: + - `augur.evesmlm.refit_cluster` — `Cluster` scope on + `augur.evesmlm.candidates.accepted_events` with + `group_column = "cluster_id"`. `param_schema` covers `fit_method`, + `sigma_min_nm`, `sigma_max_nm`, `max_fit_residual`. + - `augur.evesmlm.commit_refit` — `Row` scope on + `augur.evesmlm.refit_preview`, no params. + - `augur.evesmlm.discard_refit` — `Dataset` scope on + `augur.evesmlm.refit_preview`, no params. +- New persistent plugin state: + - `host_results: EveLocalizationResults` / `host_rejected_fits: Vec` — + host-visible history keyed by `cluster_id`, used for persistent tables, + 3D views, and post-commit durability across frames. + - `refit_preview_results: EveLocalizationResults` — preview rows. + - `refit_preview_replaces: Vec>` — parallel vec mapping each + preview row to the current-frame row it replaces on commit (or + `None` to append). + - `last_consumed_action_request_id: u64` — dedupe cursor. +- `process_frame` runs the normal analysis, merges the frame into the + host-visible history, drains the queue, then publishes + `CTX_EVE_LOCALIZATION_RESULTS`. Host tables/3D views therefore keep + committed rows and historical rejected fits visible across frames, while + the reconstruction-facing context publish stays frame-local. +- Preview rows render with a yellow filled-circle marker via + `add_marker_overlay`, distinct from accepted (green cross) and rejected + (red diamond). + +## Scope Resolution Details + +- **Re-fit** reconstructs the selected cluster from the host-supplied + `__augur_cluster_rows` snapshot when available, and only falls back to + the current frame's `EveCandidates` if no snapshot is present. This lets + the action work from persistent/historical selections instead of only the + latest frame. +- **Commit** matches the preview row by `row_id` parsed from the scope + payload, upserts the committed localization into the host-visible + history, drops any matching rejected-fit row for that cluster, and + updates the current frame-local results only if that cluster is still + present in the current frame. +- **Discard** clears the preview list. No other plugin state is touched. + +## Byte-Identical On Discard + +A targeted unit test +(`discard_clears_preview_without_touching_current_results`) clones +`current_results` before discard and asserts byte-identical JSON equality +after. The main pipeline output for the next frame is therefore unchanged +when a request is discarded. + +## References + +- `augur-rs/docs/adr/018-host-action-bus.md` +- `augur-rs/docs/features/investigation-action-requests.md` +- `plugins/evesmlm-fitting/src/lib.rs` 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/clickable-overlays-source-row.md b/docs/features/clickable-overlays-source-row.md new file mode 100644 index 0000000..d6ecb1e --- /dev/null +++ b/docs/features/clickable-overlays-source-row.md @@ -0,0 +1,46 @@ +# Clickable 2D Overlays via Marker `source_row` + +## Summary + +The augur-plugin-api ABI (bumped to 4) adds `source_dataset_id` and +`source_row_id` to `FfiMarkerOverlayItem`. Host-side, the viewer uses these +fields — when set — as the authoritative `StableRowKey` on click, instead of +falling back to the `(overlay.dataset_id, marker.stable_id)` pair. This lets a +plugin emit markers on one layer while pointing clicks at rows in a +*different* dataset. + +In-tree plugins now populate `source_row` explicitly: + +- `evesmlm-fitting` — accepted-localization crosses point at + `current_localizations`; rejected-fit diamonds point at `rejected_fits`. +- `evesmlm-postproc` — drift-corrected localization crosses point at + `current_localizations`. +- `evesmlm-candidates` — cluster centroid markers leave `source_row` empty + pending a cluster-addressable dataset (future work). + +## Effect on the EVE Failed-Fit Loop + +Combined with the Stage-2 `rejection_reason` headline and row provenance on +`rejected_fits`, clicking a red diamond in the 2D viewer now: + +1. selects the backing row in the rejected-fit `TableWindow`; +2. shows `rejection_reason` as the summary card heading; +3. auto-seeks the replay transport to the fit's anchor timestamp; +4. keeps the diamond visible while scrubbing inside the fit's declared span. + +No per-frame result cache is involved — the host filters declared rows by +`[span_start_us, span_end_us]` against the current frame window. + +## Code References + +| Path | Role | +| --- | --- | +| `plugins/evesmlm-fitting/src/lib.rs` | Populates `source_row` on accepted crosses and rejected diamonds | +| `plugins/evesmlm-postproc/src/lib.rs` | Populates `source_row` on drift-corrected localization crosses | +| `plugins/evesmlm-candidates/src/lib.rs` | Pending: cluster-addressable dataset for centroid markers | + +## Related + +- [TableV1 Declarative Metadata](./tablev1-declarative-metadata.md) +- [Investigation Workspace Alignment](./investigation-workspace-alignment.md) +- [eveSMLM Pipeline](./evesmlm.md) diff --git a/docs/features/evesmlm-temporal-diagnostics.md b/docs/features/evesmlm-temporal-diagnostics.md new file mode 100644 index 0000000..0efab1a --- /dev/null +++ b/docs/features/evesmlm-temporal-diagnostics.md @@ -0,0 +1,65 @@ +# EVE Temporal Diagnostics + +## Summary + +This feature extends the in-tree eveSMLM pipeline with better live diagnostics for candidate tuning and fit rejection analysis. + +The change adds: + +- temporal candidate clustering over retained event history +- provisional versus complete cluster tracking +- cluster-boundary overlays with clickable centroid markers +- rejected-fit investigation datasets and overlays + +## Candidate Finding + +`EVE Candidate Finding` can now request retained event history from the host and cluster over a configurable temporal lookback window instead of only the current preview frame. + +Tracked clusters keep a stable `cluster_id` while they are visible. A cluster is only published downstream once it has stopped growing for the configured number of stable frames. Until then it remains provisional. + +The candidate overlay now includes: + +- 2-sigma eigenfeature ellipses for DBSCAN and eigenfeature modes +- bounding boxes for frame-based mode +- clickable centroid markers linked to the accepted-events investigation dataset + +Accepted candidate-event rows now intentionally use `cluster_id` as the row-id column so one centroid click can select all raw events that belong to that cluster across the host table and 3D inspection views. + +This is intentionally scoped to the accepted candidate-events dataset. AugurRS still keys selection by `(dataset_id, row_id)`, so matching `cluster_id` values do not create automatic cross-dataset linking into rejected fits or other datasets. + +## Candidate Fitting + +`EVE Candidate Fitting` now records rejected fits with structured rejection reasons instead of only counting them. + +Rejected fits are exposed as a separate host dataset: + +- dataset id: `augur.evesmlm.rejected_fits` +- layer id: `augur.layer.evesmlm.rejected_fits` +- compact/table views for row-wise inspection +- linked 3D view: `augur.evesmlm.rejected_fits.scatter3d` + +Each rejected row carries: + +- stable `row_id` +- source `cluster_id` +- position and timestamp +- sigma values when available +- fit residual +- event count and polarity balance +- rejection reason + +The fitting status output now reports the rejection breakdown across fit failures, sigma-bound rejections, and residual-bound rejections. + +## Investigation Contracts + +This feature keeps the existing host-owned investigation model intact and extends it with two important conventions: + +1. Candidate centroid overlays link into the accepted raw-event dataset by reusing `cluster_id` as the stable row key. +2. Rejected fits are exposed as a first-class structured dataset instead of being implicit in a status count. + +## Verification + +```bash +cargo test -p augur-plugin-evesmlm-candidates +cargo test -p augur-plugin-evesmlm-fitting +``` diff --git a/docs/features/evesmlm.md b/docs/features/evesmlm.md index bdb2813..f65d73f 100644 --- a/docs/features/evesmlm.md +++ b/docs/features/evesmlm.md @@ -4,23 +4,53 @@ The eveSMLM pipeline is implemented as three focused plugins so each stage can b ## Stages -1. **EVE Candidate Finding** (`RawEvents`) clusters raw `CdEvent` samples into emitter candidates and publishes `EveCandidates`. -2. **EVE Candidate Fitting** (`DerivedData`) converts each candidate into one or more sub-pixel localization estimates, republishes `EveLocalizationResults` and `LocalizationResults`, and exposes the compact host-view dataset `augur.evesmlm.current_localizations`. -3. **EVE Post-Processing** (`DerivedData`) filters, drift-corrects, and evaluates the fitted localizations, then republishes the same host-view dataset id and view id with the same schema. +1. **EVE Candidate Finding** (`RawEvents`) clusters raw `CdEvent` samples into emitter candidates, can aggregate over retained event history, publishes only stable completed `EveCandidates`, and exposes accepted/rejected raw-event investigation layers plus boundary overlays. +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. +- Lets researchers compare accepted and rejected candidate-stage raw events while tuning clustering thresholds. - Lets researchers compare fitting methods on a fixed candidate set. - Allows post-processing to be toggled or replaced without touching candidate generation. - Preserves compatibility with existing downstream plugins through `LocalizationResults`. ## Host View Resolution +- `EVE Candidate Finding` publishes two investigation datasets for the current analysis window: + - accepted candidate events + - rejected candidate events +- the accepted candidate-events dataset now keys rows by `cluster_id` so centroid overlays can select every event in a cluster at once. +- both candidate datasets now register host tables as well as 3D views, so the investigation workflow has visible table targets for selection and inspection. +- both candidate datasets include timestamps, 2D coordinates, and 3D scatter metadata so the host can color them separately in linked 2D/3D inspection. +- candidate host-view titles stay short (`Accepted Events`, `Rejected Events`) because the host renders + table/window chips in narrow plugin cards; the full dataset ids remain stable. +- candidate table display metadata marks concise `X`, `Y`, `Time`, `Polarity`, and `Cluster` + labels, with accepted events using `Cluster` as the compact-card headline. +- fitting also publishes a rejected-fit investigation dataset and 3D view so fit failures and threshold rejections can be inspected alongside accepted localizations. +- cross-dataset linking is still host-limited: matching `cluster_id` values do not automatically link candidate events to rejected fits because AugurRS selections are scoped by dataset id. - The compact EVE localization panel is declared by both fitting and post-processing. +- the 3D current-localizations view is also declared by both fitting and post-processing - The host resolves duplicate ids in plugin execution order. - When **EVE Post-Processing** is enabled, it becomes the active provider for the panel view. - When post-processing is disabled, the panel falls back automatically to **EVE Candidate Fitting**. +- fitting and post-processing must therefore keep the shared current-localization dataset/view descriptors identical ## Calibration Note @@ -30,7 +60,7 @@ The fitting and post-processing stages now use that host `nm_per_pixel` value au ## Data Flow -`CdEvent` stream -> `EveCandidates` -> `EveLocalizationResults` -> filtered / corrected `EveLocalizationResults` +`CdEvent` stream -> tracked / completed `EveCandidates` -> `EveLocalizationResults` (+ rejected-fit dataset) -> filtered / corrected `EveLocalizationResults` ## Installation diff --git a/docs/features/investigation-workspace-alignment.md b/docs/features/investigation-workspace-alignment.md new file mode 100644 index 0000000..f6d9415 --- /dev/null +++ b/docs/features/investigation-workspace-alignment.md @@ -0,0 +1,79 @@ +# Investigation Workspace Alignment + +## Summary + +This pass aligns the in-tree plugins in `augur-plugins` with the host-owned investigation workspace now implemented in `augur-rs`. + +The goal is not plugin-specific UI. The goal is to expose better generic data contracts so the host can link: + +- 2D preview points +- 3D inspection layers +- host-rendered tables + +## What Changed + +- `evesmlm-candidates` now publishes two generic raw-event investigation datasets: + - accepted candidate events + - rejected candidate events +- those candidate datasets carry: + - stable row ids + - `timestamp_us` + - 2D coordinates + - 3D coordinates using time as the `z` axis + - layer/display metadata for distinct accepted vs rejected styling +- accepted candidate rows can now intentionally share a `cluster_id` row key so one centroid overlay can select every event in that cluster +- candidate datasets must register table views as well as 3D views when the workflow expects row-wise inspection and linked selection +- `evesmlm-fitting` and `evesmlm-postproc` now keep the shared `augur.evesmlm.current_localizations` contract aligned with: + - stable row ids + - `timestamp_us` + - 2D and 3D coordinate metadata + - shared layer/display metadata + - linked marker overlays carrying stable ids +- `evesmlm-fitting` also publishes `augur.evesmlm.rejected_fits` for rejected candidates with timestamps, positions, metrics, and rejection reasons +- matching ids across different datasets still do not link automatically because the host selection model keys rows by dataset id plus stable row id +- `reconstruction` now exposes the accumulated localization dataset as a fuller investigation dataset with: + - stable row ids + - `timestamp_us` + - 3D scatter metadata + - layer/display metadata +- repo-local docs and the template guidance now describe stable ids, dataset/layer metadata, and overlays as supplemental rather than primary integration surfaces + +## Important Contracts + +### Candidate Event Layers + +The candidate-finding stage now surfaces accepted and rejected raw events from the active analysis window as separate host datasets instead of hiding that distinction inside plugin-local logic or centroid-only overlays. + +That makes it possible to tune candidate parameters while seeing: + +- which events survived into clusters +- which events were rejected +- how those two groups distribute over time in the 3D view + +### Shared EVE Current Localizations + +`evesmlm-fitting` and `evesmlm-postproc` intentionally reuse the same dataset id and view ids for current localizations. + +To keep host-side linking trustworthy, those reused descriptors must stay identical across both providers: + +- same schema +- same row-id column +- same coordinate/time metadata +- same layer metadata +- same view descriptors + +The later enabled provider can then replace the dataset payload without breaking selection, styling, or view resolution. + +### Reconstruction + +The reconstruction plugin remains generic. It still publishes one accumulated dataset as the source of truth, but that dataset now participates in the linked investigation model instead of acting only as a density-view backing store. + +## Verification + +```bash +cargo check -p augur-plugin-evesmlm-candidates +cargo test -p augur-plugin-evesmlm-candidates +cargo test -p augur-plugin-evesmlm-fitting +cargo test -p augur-plugin-evesmlm-postproc +cargo test -p augur-plugin-reconstruction +``` diff --git a/docs/features/plugin-host-views.md b/docs/features/plugin-host-views.md index 0670378..12bd335 100644 --- a/docs/features/plugin-host-views.md +++ b/docs/features/plugin-host-views.md @@ -11,19 +11,28 @@ That keeps scientific state in the plugin while letting the host own rendering, ## What Plugins Can Declare - datasets with stable ids and explicit schema metadata +- stable row ids, time columns, and 2D/3D coordinate metadata for linked investigation - analysis-panel views rendered by the host - standalone windows rendered by the host - multiple views backed by the same dataset +- layer/display metadata for host-owned visibility and styling defaults +- supplemental marker overlays for 2D hit-testing when datasets alone are not enough - optional generation counters for cache invalidation ## Current In-Tree Usage -- `Localization Reconstruction` publishes `augur.localization.accumulated` once and lets the host render both: +- `Localization Reconstruction` publishes `augur.localization.accumulated` once and lets the host render: - a `Localization Table` window - a `Reconstruction` density window -- `EVE Candidate Fitting` and `EVE Post-Processing` both publish `augur.evesmlm.current_localizations` with the same schema and the same compact panel view id + - a `Localization Cloud` 3D view +- `EVE Candidate Finding` publishes accepted and rejected raw-event datasets as separate investigation layers +- `EVE Candidate Fitting` and `EVE Post-Processing` both publish `augur.evesmlm.current_localizations` with the same schema and the same view ids -Because the host resolves duplicate ids in plugin execution order, `EVE Post-Processing` becomes the active provider whenever it is enabled; otherwise the compact table falls back to `EVE Candidate Fitting`. +Because the host resolves duplicate ids in plugin execution order, `EVE Post-Processing` becomes the active provider whenever it is enabled; otherwise the shared current-localizations dataset falls back to `EVE Candidate Fitting`. + +`Scatter3dFromTable` descriptors are consumed by AugurRS as main investigation 3D scene layers. +Plugins should still declare them with stable ids and coordinate metadata, but should not rely on +them appearing as separate dock/window chips. ## Why The Split Matters diff --git a/docs/features/plugin-install-reload.md b/docs/features/plugin-install-reload.md new file mode 100644 index 0000000..5399755 --- /dev/null +++ b/docs/features/plugin-install-reload.md @@ -0,0 +1,40 @@ +# Plugin Install And Reload + +## Goal + +Keep locally installed runtime plugins reloadable on macOS even when they are built from an in-flight sibling `augur-rs` checkout. + +## Problem + +Cargo's macOS `cdylib` outputs keep an absolute `LC_ID_DYLIB` that points back into the build tree, for example: + +```text +/path/to/augur-plugins/target/release/deps/libaugur_plugin_localization.dylib +``` + +That identity is harmless when the library stays in `target/`, but it becomes a footgun once the plugin is copied into `~/.augur/plugins//`. The host scans the installed copy, yet dyld can still treat the plugin as the build-tree image identity during later loads or reloads. + +In practice that makes plugin updates look stale: the Plugin Manager can keep reporting an older ABI or older code path even though the copied file in `~/.augur/plugins/` was rebuilt. + +## Repo-Level Fix + +- `scripts/install-built-plugins.sh` still copies each built runtime plugin into the standard `~/.augur/plugins//` layout. +- On macOS, the script now rewrites the copied library's `LC_ID_DYLIB` to `@loader_path/` with `install_name_tool`. +- That keeps the installed artifact self-identified by its installed location instead of Cargo's build-path identity, which makes rescans/reloads behave like the user expects. + +## Authoring Guidance + +- Prefer `./scripts/install-built-plugins.sh --profile release` over manual `cp` steps when installing local plugins on macOS. +- If you do copy a plugin by hand on macOS, rewrite the installed dylib id after copying: + +```bash +install_name_tool -id "@loader_path/libaugur_plugin_my_plugin.dylib" \ + ~/.augur/plugins/my-plugin/libaugur_plugin_my_plugin.dylib +``` + +- After an ABI bump in `augur-plugin-api`, rebuild the plugin and replace the installed runtime library before using **Scan for New Plugins** or **Reload** in `augur-gui`. + +## Verification + +- The installed runtime libraries continue to hash-match the built release artifacts apart from the macOS dylib id rewrite. +- `otool -D ~/.augur/plugins//libaugur_plugin_.dylib` now reports `@loader_path/...` instead of an absolute path into `target/release/deps/`. diff --git a/docs/features/reconstruction.md b/docs/features/reconstruction.md index 3320741..f85ae59 100644 --- a/docs/features/reconstruction.md +++ b/docs/features/reconstruction.md @@ -5,15 +5,26 @@ The reconstruction workflow publishes one accumulated host-view dataset instead ## Components 1. **Localization Reconstruction** (`DerivedData`) reads `LocalizationResults` from `HostContext` and stores a capped nanometer-space accumulation table. -2. **`host_views()`** declares one dataset, `augur.localization.accumulated`, plus two host-rendered window views: +2. **`host_views()`** declares one dataset, `augur.localization.accumulated`, plus host-rendered views for: - `Localization Table` - `Reconstruction` + - `Localization Cloud` 3. **`host_view_dataset()`** serves one columnar `TableV1` snapshot that both windows consume. ## Source Of Truth - the reconstruction plugin owns the only accumulated localization state -- the full table window and density reconstruction window read the same dataset id +- the full table window, density reconstruction window, and 3D scatter inspection all read the same dataset id + +## Investigation Metadata + +The accumulated localization dataset now participates directly in the host investigation workspace through: + +- stable row ids via `id` +- `timestamp_us` as the shared time column +- 2D nanometer coordinates for linked preview/table filtering +- 3D scatter coordinates using `timestamp_us` on the `z` axis +- layer/display metadata for default visibility and styling ## Resource Use @@ -28,7 +39,7 @@ AugurRS now publishes host-owned calibration on `CTX_GLOBAL_SETTINGS` as `Global ## Data Flow -`LocalizationResults` -> `augur.localization.accumulated` -> host table window / host density window +`LocalizationResults` -> `augur.localization.accumulated` -> host table window / density window / 3D localization cloud ## Installation diff --git a/docs/features/stage-a-a1-automation.md b/docs/features/stage-a-a1-automation.md new file mode 100644 index 0000000..16bc9fa --- /dev/null +++ b/docs/features/stage-a-a1-automation.md @@ -0,0 +1,125 @@ +# Stage-A A1 Automation — Plan (partially implemented) + +- **Crate:** `plugins/stage-a-a1` (`augur-plugin-stage-a-a1`) +- **Status:** **Partially built.** §1 (scoped A1→modulation control path), §2 + (settle detection), §3 (per-point recording) and the single-row core of §4 + (the amplitude loop) now exist as the **Start sweep** button — see + [ADR 010](../adr/010-stage-a-a1-amplitude-sweep.md). Still open: scout phase, + randomized point order, multi-`f`/multi-`I_k` rows, `UNIDENTIFIABLE` stop + rule, and the offline `a50` fit (§5–§6). +- **Relates to:** [Stage-A A1 Analysis](./stage-a-a1.md), + [Optical Waveform Drive](./stage-a-optical-waveform.md), + [ADR 007](../adr/007-stage-a-owner-orchestration.md) (the earlier full + orchestrator this deliberately re-adds in a *focused* form), + [ADR 009](../adr/009-stage-a-a1-recording-coordinator.md) (the per-recording + RAW + PDQ + sidecar coordinator, now built — see §3). + +> **Update (2026-07-23):** the manual per-recording coordinator in §3 now exists +> (ADR 009): one *Start recording* button records camera RAW + photodiode PDQ + +> an A1 config sidecar per `(I_k, f)` measurement, over an operator-set duration. +> +> **Update (2026-07-23, later):** the single-row amplitude sweep now exists +> (ADR 010): *Start sweep* leases the modulation owner, retargets the armed +> drive per point via `SetOpticalDepth`, waits for the photodiode-measured `a` +> to settle (tolerance + dwell, 30 s cap), and records each point through the +> §3 coordinator with `sweep.requested_a` / `point_index` / `point_total` in +> the sidecar. Remaining below: scout/randomized order, multi-`f`/`I_k` +> iteration, the `UNIDENTIFIABLE` rule, and the `a50` fit. +> +> **Update (2026-07-25):** the *second* workflow — **exact event count**, holding +> one measured depth `a₀` across the frequency sweep — now has its own blocks +> (ADR 013, [brief](./stage-a-a1-event-count.md)): a closed-loop **Find a₀** per +> frequency (§1–§2 applied the other way round — measure, then correct the +> *commanded* depth) and a **Record a₀ point** that replays the locked depth under +> the lease through the §3 coordinator. The multi-`f` iteration below stays +> deliberately manual there: randomising the frequency order, interleaving a +> low-frequency reference and repeating independent blocks are scientific ordering +> decisions, so A1 exposes them as per-frequency button presses rather than one +> opaque run. + +## Goal + +Semi-automate the researcher's normal A1 workflow: for one illumination `I_k` +and frequency `f`, sweep the modulation depth `a` and record the response curve +`q̂_p(a,f)`, automatically starting/stopping/saving each recording with proper +naming and full parameters. Later, repeat over `f` and over `I_k`. + +## Already in place (the foundation) + +- Two live plots `r_{p,k}` and `S_p`, plus the response curve `q̂_p(a)`. +- Marker-anchored phase folding from the firmware **phase-0 EXT_TRIGGER**; the + trigger **defines the frequency** (measured marker spacing); event-latency + handling (marker shift + optional self-alignment). +- **Pilot capture** → frozen ON/OFF phase windows; manual "record point" + appends `(measured a, q_on, q_off)` to the curve. +- **ROI + masked pixels** come from the host camera config (`GlobalSettings`); + `N_valid = |ROI| − |masked|`. +- Photodiode-measured **`a`** (rejected-complement geometry) published and + surfaced in A1. +- Optical drive with a **fixed normalized cycle mean `ū`** and swept `a`; + physical cycle-mean flux `I_k` is a separately calibrated/verified row quantity + (`OPTICAL_LOG_SINE`/`OPTICAL_LINEAR_SINE`), power-capped. +- Events sourced exactly from the retained **EventStore** over a sliding window. + +## To build (the automation) + +### 1. A1 → modulation control path (scoped) +Re-introduce a *focused* control path (the contract + modulation plugin still +support it): acquire a modulation lease, set the optical drive +`(target, ū, a, f, V_null, Vπ)`, start, stop, release. No full workflow zoo — +just set-amplitude / start / stop. The plugin's Bessel normalization preserves +`ū` to the controller's milli-unit resolution and publishes the resolved mean; +the independently calibrated physical `I_k` still needs bench feedback and a +flux-point ID. + +### 2. Settle detection +Before collecting a point, wait until the photodiode confirms the optical +waveform has stabilised at the new `a` — e.g. the published `measured_a` is +within tolerance of the target and clip-free for a short dwell. Only then start +the counting window. + +### 3. Per-point recording (proper naming + parameters) +For the pilot, background, and every amplitude point, orchestrate: +- host camera **RAW** recording (re-add the host recording commands), +- photodiode **PDQ** recording (`stage-a-photodiode` begin/finalize), +- a **config sidecar** with everything needed to reproduce/replay: `I_k`, `f`, + requested + measured `a`, ON/OFF windows, ROI, masked pixels, `N_valid`, `M`, + latency, biases, run/session ids, timestamps, settle/clip status. +- **Deterministic naming**: `/A1////-...`. + +### 4. Sweep state machine +`SAFE → BACKGROUND(a=0) → PILOT(high, freeze windows) → SCOUT(locate the +transition) → SWEEP(5–7 settled amplitudes spanning ~10–90 %, randomized or +alternating order) → NEXT_FREQUENCY → … → NEXT_ILLUMINATION`. Windows are frozen +from the pilot and **must not** be re-derived from measurement points. + +### 5. Classification and stop rules +- `q̂_p(a,f) = (1/(N_valid·M)) Σ_i Σ_c z_{i,c,p}`, ON/OFF independent + (already implemented for a single point — the sweep just repeats it). +- If the transition cannot reach ~90 % within the safe amplitude range, mark the + frequency **`UNIDENTIFIABLE`** (do not keep increasing `a`). + +### 6. Final fit (per curve) +Fit the background-floor logistic `p(a) = p0 + (1−p0)·logistic((a−a50)/slope)` +to get **`a50`** with a cycle/spatial-tile bootstrap interval; label quality +(VALIDATED / DEGRADED-no-background / UNVALIDATED-no-pilot). (This was the old +`response.rs`; re-add as the sweep's summary output.) + +## Known hard problem (only if per-cycle cross-stream correlation is ever needed) + +The camera trigger and the photodiode stream marker are the *same* firmware +phase-0 on two clocks, consumed **independently** today — nothing pairs cycle +*k* across the two streams, and nothing needs to. If a future metric correlates +per-cycle optical depth with per-cycle camera response, ordinal matching is +fragile (start offset, asymmetric drops, drift). The robust fix is a **cycle +counter** in the PD `MarkerPayload` plus a **distinctive fiducial pattern** +(e.g. a periodic marker cycle) visible in both streams to align on and detect +drops — see the controller's `a1-marker-cycles.md`. + +## Open decisions to confirm at build time + +- Amplitude list: explicit list vs. min/max/count range (randomized order). +- Mask source already resolved: host `GlobalSettings.masked_pixels`. +- Whether RAW+PDQ per point is always on or gated by a "record" toggle + (user already asked for full RAW + PDQ + params per recording). +- Live is a quicklook; the **RAW/PDQ replay is authoritative** for the final fit. diff --git a/docs/features/stage-a-a1-event-count.md b/docs/features/stage-a-a1-event-count.md new file mode 100644 index 0000000..6fc82aa --- /dev/null +++ b/docs/features/stage-a-a1-event-count.md @@ -0,0 +1,323 @@ +# Stage-A A1 Exact Event Count — the `a₀` depth lock + +- **Crate:** `plugins/stage-a-a1` (`augur-plugin-stage-a-a1`) +- **Status:** Built — per-frequency `a₀` lock, one-button event-count point, and + an unattended frequency ladder that does both at every planned `f` +- **Design:** [ADR 013](../adr/013-stage-a-a1-event-count-depth-lock.md) (the + lock) and [ADR 014](../adr/014-stage-a-a1-frequency-ladder.md) (the ladder); + builds on [ADR 010](../adr/010-stage-a-a1-amplitude-sweep.md) (leased + `SetOpticalDepth`), [ADR 009](../adr/009-stage-a-a1-recording-coordinator.md) + (the RAW + PDQ + sidecar coordinator) and + [ADR 012](../adr/012-stage-a-contrast-geometry-is-bench-not-display.md) (the + geometry the measured `a` is defined in) and + [ADR 017](../adr/017-stage-a-rail-detection-and-withheld-a-reasons.md) (why a + gate refused, and millivolt-scale rail detection) and + [ADR 018](../adr/018-stage-a-a1-required-vs-optional-inputs.md) (a lock arms + within the operator's own `a₀` tolerance, and a lock that cannot arm names + which of the three causes it is) and + [ADR 020](../adr/020-stage-a-a1-depth-source.md) (`a` comes from the + photodiode or from the commanded drive) and + [ADR 021](../adr/021-stage-a-a1-no-search-for-a-commanded-depth.md) (with a + commanded depth there is nothing to search for: no `Find a₀`, no lock table, + and the ladder confirms each frequency against the modulation owner) +- **Relates to:** [Stage-A A1 Analysis](./stage-a-a1.md), + [Stage-A Pockels Transfer Calibration](./stage-a-pockels-calibration.md), + [Stage-A Photodiode](./stage-a-photodiode.md) + +## Purpose + +The minimum-depth workflow sweeps `a` at one frequency to fit `a50`. The +**exact-event-count** workflow is the complement: freeze **one** depth + +```math +a_0=\ln\!\left(\frac{I_{\mathrm{exc,max}}}{I_{\mathrm{exc,min}}}\right), +\qquad I_\mathrm{exc}=I_\mathrm{tot}-I_\mathrm{pd} +``` + +and hold that **photodiode-measured** value constant while the frequency varies, +so event counts per half-cycle are comparable across `f` at equal optical +contrast. `a₀` is a measured log contrast — **never** a DAC-code excursion, and +never the reject-port detector's own contrast (ADR 012). + +## Why a lock is needed at all + +`ModulationCommandV1::SetOpticalDepth` commands a depth through the *measured* +Pockels inversion (`V_null`, `Vπ`, `u_k` — see the calibration brief). That +inversion is static, so at higher frequencies the drive electronics and crystal +response roll off and the delivered optical depth falls short of the commanded +one. The amplitude sweep (ADR 010) only *waits* for the measured `a`, which +cannot correct a systematic gain error — it would hit the 30 s settle cap and +record at the wrong depth. + +The lock closes that loop: it commands, measures, and corrects until the +photodiode reports `a₀`. + +## What the measured `a` needs to be worth dividing by + +The lock divides by the measured `a`, so a *biased* measurement is not noise — +it is a systematic push on the drive. Two properties of the photodiode estimate +therefore matter more here than anywhere else, and both are enforced: + +- **Whole cycles.** `a` is peak-to-peak, so its window has to span at least one + full modulation cycle. The photodiode sizes its contrast window from the + phase-0 markers to cover several cycles, and **withholds `a` entirely** below + one. A fixed 0.82 s window — what it used before — is under one cycle for + every `f < 1.2 Hz`, exactly where the A1 plateau reference lives, and would + have under-reported `a` and driven the depth up until it railed. Its length + and cycle count are published as `window_seconds` / `covered_cycles`. +- **A window that has turned over.** A reading taken sooner than one window + after a depth change still contains the old depth. The lock's per-trial dwell + is therefore at least one window (never less than **Sweep settle (s)**), and + its three readings are spaced by half a window so they are not three views of + the same samples. The trial value is their **median**; if they spread by more + than twice the tolerance the operating point is called unsettled and the lock + aborts rather than latching onto a drifting drive. + +If the ladder's lowest frequency needs a longer window than the photodiode's +ring holds, raise its **Cache length**; the refusal says so and names the +seconds needed. + +## The workflow, one frequency at a time + +Everything up to the references is unchanged and stays the operator's: freeze the +flux point and camera configuration, reuse the same film position, ROI/mask, +optical pedestal, bias set, gates and reference epoch as the minimum-depth +measurement, keep ON and OFF separate, and per frequency record the full-extinction +`I_tot` anchor, the zero-depth background and the high non-saturating pilot (the +existing **Record pilot** / **Record background** buttons; background reuse +across frequencies is not automated, i.e. off by default). Then: + +1. Set the frequency `f` in the modulation plugin (yours — the drive is armed + there, A1 only reads it). +2. Enter **a₀** once for the whole sweep, and press **Find a₀**. A1 leases the + modulation owner and trims the commanded depth until the photodiode measures + `a₀` at *this* frequency. Nothing is recorded; the drive is left at the depth + it found and the result is stored for `f`. +3. Press **Record a₀ point (event-count)**. A1 re-applies the found depth under a + modulation lease, waits for the measured `a` to hold `a₀`, and records one + atomic camera RAW + photodiode PDQ + sidecar under one run id. +4. Repeat for the next frequency. Repeating independent blocks (three where + practical) is yours — every point is one button press. + +Steps 1–4 are what **Start frequency sweep** automates; see below. + +### When a button refuses + +Every step from 2 on needs a photodiode-measured `a`, and each of these is +**fail-closed**: nothing touches the drive until the whole precondition set +passes. The refusal quotes the photodiode owner's own reason — a missing or +unconfirmed `I_tot` anchor, too few phase-0 markers in its ring, a window shorter +than one cycle, a railed window, a stale snapshot — instead of naming the two most +common causes regardless of the real one (ADR 017). The same reason is on the +resting status line as `Measured depth a: not available — `, so it can be +read without pressing anything, and the reason itself names an action rather than +an estimator gate (ADR 018). + +Some benches cannot produce a measured `a` at all — with no phase-0 markers on +the photodiode's stream port the estimator refuses whatever the settings say. +Every one of these refusals therefore also names the way past it: switching +**Depth `a` source** to the commanded drive (ADR 020). + +**Everything on this page below here describes the *measured* workflow.** With a +commanded depth there is nothing to search for, so the search does not run at +all (ADR 021): `Find a₀` is disabled and says so, `Record a₀ point` commands `a₀` +directly, the ladder goes lease → set `f` → confirm → record with no `Locking` +phase, and `a0_locks.json` stays untouched because nothing was found. The +ladder also confirms each frequency against the modulation owner's acknowledged +waveform rather than the camera trigger, so it needs neither EXT_TRIGGER markers +nor Live analysis. The whole workflow reduces to: set `a₀`, press **Record all +frequencies**. + +The trade is exactly the one the lock exists to remove — nothing verifies the +light reached `a₀`, and the static inversion delivers less depth as `f` rises — +so switch back to the photodiode once its markers work. + +The ladder additionally needs phase-0 markers on the **camera** side to confirm a +commanded frequency, which means **Live analysis** must be on. Its refusal says +which of the two is missing — the toggle or the trigger wiring. + +Preconditions a run will hit *later* are checked before the drive moves. The +ladder and the amplitude sweep both ask the recording's own photodiode question +up front: taking the lease, retargeting the drive and locking `a₀` only to be +refused by `begin_recording` at point 1 is what produced a panel reading +`Frequency sweep 1/7 … — recording` next to `Recording: idle` (ADR 018). + +## The frequency ladder (unattended) + +**Start frequency sweep (find a₀ + record per f)** runs the whole ladder on +**one** modulation lease. Per point it retargets the drive's frequency +(`ModulationCommandV1::SetDriveFrequency`), waits for the phase-0 trigger to +actually report the new period, runs the `a₀` lock, and records one atomic +event-count point — then moves on. + +| Control | Meaning | +|---|---| +| Sweep min f / max f (Hz) | ends of the ladder, both included | +| Frequency points | how many, **log-spaced** — `\|H(f)\|` is read per decade | +| Frequency order | ascending / descending / alternating / random (seeded) | +| Random order seed | makes the random schedule reproducible; recorded per point | +| Low-f reference every N points | re-visit the lowest frequency every N points | +| Start frequency sweep | run the ladder | +| Stop | aborts the ladder and whichever child is mid-flight | + +What it guarantees: + +- **One lease for the whole ladder.** The lock and the recording run on the + ladder's lease instead of taking their own, so the operator's drive settings + are locked out from the first frequency to the last — the amplitude provably + cannot move between a lock and the point that replays it. The owner parks the + operator's frequency *and* depth on the first retarget and hands both back + when the lease is released. +- **The trigger confirms the frequency, not the firmware.** An ACK says a table + was accepted; the phase-0 markers say the light is modulating at that rate. + A point only starts once enough markers at the *new* period agree with the + commanded frequency. +- **Nothing from the previous frequency survives.** Retained markers and events + are dropped on every frequency change — the measured period is their mean + spacing, so keeping them would confirm the new frequency against a mixture. + **Pilot windows are dropped too**: windows frozen at one period do not + transfer to another, and scoring a point in the wrong window is a silent + error. Re-freeze a pilot per frequency if you need pilot-frozen windows. +- **A bad point is skipped, not fatal.** A frequency whose `a₀` is unreachable, + whose trigger never confirms, or whose recording fails is skipped and named in + the final summary; the remaining decades are still recorded. The lock table + keeps the failed attempt. +- **The plan is checked before the drive moves.** The lowest planned frequency + decides whether the photodiode can measure `a` at all, so it is checked up + front — not at the ninth point, two hours in. + +Every point's sidecar gains a `[frequency_sweep]` section: `min_f`, `max_f`, +`planned_points`, the position in the **executed** order, the order name, the +seed, whether the point is an interleaved reference, and the requested +frequency (`[trigger]` carries what the markers measured). + +### What the ladder still does not do + +The flux point `I_k`, the camera configuration, ROI/mask, pedestal, bias set, +gates, the `I_tot` anchor, the zero-depth background, the pilot, and repeating +independent blocks stay the operator's. `a₀` itself is an operator input, and +the refractory condition `2·f·a₀/C ≪ 1/τ_refr` is **not** checked — verify it at +your highest planned frequency when you pick `a₀`. + +## Controls + +| Control | Meaning | +|---|---| +| a₀ (measured log contrast) | the one photodiode-measured depth held across the whole frequency sweep | +| a₀ tolerance (absolute) | convergence band on `|measured a − a₀|`; also the settle band an event-count point must hold before it records (default ±0.02) | +| Find a₀ (lock the drive depth) | closed-loop trim of the commanded depth at the current frequency; records nothing, stores the result, leaves the drive there | +| Record a₀ point (event-count) | re-applies the locked depth under the lease and records one atomic frequency point (`…_ec_fHz`) | +| Clear a₀ lock table | drops every stored lock and rewrites `a0_locks.json` | +| Stop (abort recording / sweep) | also aborts a running lock | + +The **Sweep settle (s)** value in the Recording section is reused as the +per-trial dwell before the lock starts averaging. + +## The lock loop + +```math +a_\text{cmd} \leftarrow a_\text{cmd}\cdot\frac{a_0}{a_\text{measured}} +``` + +- Starts from an earlier lock at the same frequency when one exists, otherwise + from `a₀` itself (the calibrated open-loop guess). +- Converges when `|measured − a₀| ≤ tolerance`; at most **8 trials**, each + correction capped at ×2/÷2 and clamped to the owner's `0.01..=6.0`. +- Per trial it waits the settle dwell, then averages **three fresh** photodiode + optical summaries (one per new `service_revision`, so a slow publisher is not + averaged once per control tick); it evaluates early with fewer readings only if + the 30 s measurement deadline hits first. +- Ends with the owner's own wording when a commanded depth is **rejected** (lobe + ceiling, DAC limit) — that is the "`a₀` is unreachable at this operating point, + lower `a₀` or `I_k`" answer — or reports the drivable limit when the correction + rails at `0.01`/`6.0`. +- Photodiode clipping above 1 % is called out in the result message and stored + with the lock: a clipped window makes the measured `a` a truncated estimate. +- Releases the lease with `safe_off = false`, so the drive holds the found depth. + +## The lock table + +One row per frequency (a re-lock within 1 % of a stored frequency replaces it): +frequency, target `a₀`, commanded `a`, observed `a`, **which source that `a` +came from**, trials, state, locked-at. +Visible as the **A1 a₀ locks** host view and mirrored to +`/a0_locks.json`, so the found depths survive a restart and can be +cited offline. A non-converged row is kept for the record but **never** arms a +recording; a stored lock only arms an event-count point when both its frequency +**and** its `a₀` still match the current settings. + +"Still matches" is judged against the operator's own **a₀ tolerance**, not exact +equality. `a₀` is a drag control with a 0.01 step, and the earlier `1e-6` +comparison meant one stray pixel of drag silently disarmed a lock that had just +converged — after which the panel asked for the `Find a₀` that had already been +done. The tolerance is already the statement of how close to `a₀` counts as +`a₀`; applying a stricter rule to the same quantity was never coherent (ADR 018). + +When no lock arms, the panel and the *Record a₀ point* refusal name **which** of +the three causes it is — no lock at this frequency, a lock that stopped short +(and the measured `a` it stopped at), or a lock aimed at a different `a₀` (naming +both values) — rather than telling the operator to press `Find a₀` in all three +cases. + +## Why recording re-applies the depth + +*Record a₀ point* does not simply record at whatever the drive currently is. It +runs the ADR 010 sweep machinery as a one-point sweep of a new kind — lease → +command the locked depth → confirm the measured `a` holds `a₀` → record → release +— which buys three things: + +- the depth is **re-asserted**, so an intervening modulation settings sync (which + re-applies the operator's own `depth a`) cannot silently spoil the point; +- the lease **locks the operator's modulation settings out** for the whole point, + so *"never change amplitude during the recorded interval"* is enforced rather + than trusted; +- it stays one button press. + +Internally a sweep point is now a pair: the depth the drive is **commanded** to +and the depth it is **expected to measure**. The amplitude sweep sets both equal; +an event-count point deliberately does not, and the difference is the roll-off +the lock absorbed. + +## Naming and sidecar + +Event-count points use the role suffix `_ec` and carry the **frequency** in the +stem instead of a sweep-point index — one measurement id spans the whole +frequency sweep at the single frozen depth: + +- `/__ec_f50Hz.raw` (+ the host's own `.toml`) +- `/__ec_f50Hz_pd.pdq` + `_pd.json` +- `/__ec_f50Hz_config.toml` + +Sub-hertz frequencies keep the decimal as `p` (`f0p5Hz`). The A1 sidecar adds +`sweep.commanded_a` and an `[a0_lock]` section (`target_a`, `commanded_a`, +`measured_a_at_lock`, `depth_source`, `frequency_hz_at_lock`, `trials`, +`converged`, `locked_at_utc`); both recorders' own sidecars carry `a0_target`, +`a0_commanded_a`, `a0_lock_measured_a`, `a0_lock_depth_source` and +`a0_lock_frequency_hz` as metadata. The measured `a` of the recording itself +stays in `[optical]` as for every run, and its provenance in the top-level +`depth_a_source` (ADR 020). + +## Choosing `a₀` (still an operator decision) + +No numerical `a₀` is frozen in this repository — the plugin default is a +placeholder. Pick it from the low-frequency scout so that + +- the low-frequency response gives **several** events, not the one-event floor; +- the event count is still **proportional** to depth and has not saturated; +- the refractory condition `2 f a₀/C ≪ 1/τ_refr` holds at the **highest** + frequency (checked once by you when picking `a₀`; the plugin does not test it); +- the same measured `a₀` is **reachable at every frequency** in the sweep — the + lock reports when it is not, before any data is recorded. + +Today's low-frequency `a50` result is a sensible starting point; targeting +several plateau events per pixel per half-cycle is a good scout criterion. + +## Tests + +`cargo test -p augur-plugin-stage-a-a1` covers the lock converging against a +simulated 60 %-gain bench (and leaving the drive at the found depth with a +`safe_off = false` release), the unreachable-depth case railing at the drive +limit without arming a recording, an owner rejection surfacing verbatim, an +event-count point commanding the **locked** depth rather than `a₀`, the +`_ec_fHz` stem and `[a0_lock]` sidecar section, file-safe frequency tags, and +the one-row-per-frequency lock table round-tripping through `a0_locks.json`. diff --git a/docs/features/stage-a-a1.md b/docs/features/stage-a-a1.md new file mode 100644 index 0000000..4b27e73 --- /dev/null +++ b/docs/features/stage-a-a1.md @@ -0,0 +1,702 @@ +# Stage-A A1 Analysis + +- **Crate:** `plugins/stage-a-a1` (`augur-plugin-stage-a-a1`) +- **Status:** Recording coordinator + live quicklooks + amplitude sweep + `a₀` lock + + unattended frequency ladder +- **Design:** [ADR 009](../adr/009-stage-a-a1-recording-coordinator.md), + [ADR 010](../adr/010-stage-a-a1-amplitude-sweep.md) (sweep + button + press forwarding), + [ADR 015](../adr/015-stage-a-a1-recording-robustness.md) (one folder, full + duration, named failures), + [ADR 014](../adr/014-stage-a-a1-frequency-ladder.md) (the unattended ladder), + [ADR 013](../adr/013-stage-a-a1-event-count-depth-lock.md) (exact-event-count + `a₀` lock), + [ADR 017](../adr/017-stage-a-rail-detection-and-withheld-a-reasons.md) (a + withheld `a` names its gate; Live analysis vs. trigger), + [ADR 018](../adr/018-stage-a-a1-required-vs-optional-inputs.md) (the output + folder is the only required input; every gate is asked before the drive moves; + the panel speaks to the operator), + [ADR 020](../adr/020-stage-a-a1-depth-source.md) (`a` comes from the + photodiode or from the commanded drive, and every artefact says which), + [ADR 021](../adr/021-stage-a-a1-no-search-for-a-commanded-depth.md) (no `a₀` + search when `a` is the command; the ladder skips it), + [ADR 022](../adr/022-stage-a-a1-sensor-conditions-on-every-run.md) (die + temperature, pixel dead time and scene illumination on every run), + [ADR 023](../adr/023-stage-a-a1-nested-depth-frequency-sweep.md) (the + frequency ladder is an outer loop: a whole depth sweep per frequency gives the + `q_p(a, f)` surface in one press), + [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), + [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 + +## Purpose + +A1 has two jobs on the Stage-A bench, both deliberately thin: + +1. **Recording coordinator.** One *Start recording* button records the camera + **RAW** stream and the photodiode **PDQ** stream together for a fixed duration, + grouped under a per-`(I_k, f)` measurement id, and writes an A1 **config + sidecar** (`.toml`) linking them with everything needed to reproduce and + analyse the run offline. +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 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 + +The experiment sweeps the modulation depth `a = ln(I_max/I_min)` at a fixed +illumination `I_k` and frequency `f`, taking several recordings per `(I_k, f)` +pair (a background `a≈0`, a bright pilot, then settled amplitudes). One +**measurement id = one `(I_k, f)` row**; every recording under it lands in the same +folder. A1 makes each recording one button press: + +| Control | Meaning | +|---|---| +| **Depth `a` source** | where every depth-dependent path reads `a` from: the **photodiode** (measured, default) or the **modulation drive** (commanded, open loop) — see below (ADR 020) | +| Output folder | **the only required field**: where the A1 config sidecar is written (recommended shared experiment root) | +| Measurement id | one per `(I_k, f)` row; auto-generated default, editable, or press **New id**. Optional — a blank field is filled in on the first recording and written back, so the panel shows the id that was used (ADR 018) | +| Duration (s) | each recording auto-stops and finalizes after this; applies to every button | +| Settle time (s) | dwell the depth or frequency must hold after being retargeted, before the recording starts; ignored by **Record once** | +| Depth axis: min a / max a / points | the `a`-range **Sweep a** walks, stored in every sidecar | +| Frequency axis: min f / max f / points / order / seed / repeat-lowest | the `f`-ladder **Sweep f** walks: log-spaced, visit order and interleaved reference repeats | +| **Record once** | one recording with the light exactly as armed: start camera RAW → connect and lease photodiode → start PDQ → auto-stop and save both → sidecar. Nothing is retargeted | +| **Sweep a** | per point: lease the modulation owner → retarget the calibrated drive to `a_i` → settle → one recording (`…_pNN`) → next | +| **Sweep f** | one recording per frequency at the same depth — the exact-event-count workflow, see [its brief](./stage-a-a1-event-count.md) | +| **Sweep a × f** | the **`q_p(a, f)` surface**: the whole depth sweep at every frequency, on one lease — see [below](#the-q_pa-f-surface-in-one-press-adr-023) | +| Record pilot | records a bright reference (`…_pilot`) **and** freezes the ON/OFF windows for the row from the live signal | +| Record background | records an unmodulated reference (`…_background`) **and** captures the false-response floor `q0` | +| **Stop** | stops whatever is running — a recording, a sweep, a ladder or a protocol — at its next safe point, so the file in flight is still finished and saved | + +All of it lives in **one Record section**. It used to be spread over three +(`Recording`, `Depth sweep at every frequency`, `Same depth at every +frequency`), each carrying part of the settings the others needed — so the +frequency axis was configured in the a₀ section and read by a button two +sections above it. **Live analysis** moved to the top of the panel for the same +reason: almost everything reads it. + +The record and sweep buttons stay **disabled until an output folder is +selected**. + +### Where `a` comes from (ADR 020) + +`a = ln(I_max/I_min)` is a property of the light, so the photodiode measurement +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: + +``` +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 +``` + +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: + +| Setting | `a` is | Needs | Verified against the light | +|---|---|---|---| +| `photodiode (measured)` — default | the photodiode's measured excitation log-contrast | phase-0 markers, a confirmed `I_tot` anchor, an unclipped window | yes | +| `modulation drive (commanded, open loop)` | the depth the modulation owner's calibrated drive is commanding (`optical_drive.depth_a_milli`) | an applied Pockels calibration and `OPTICAL_LOG_SINE` armed | **no** | + +The commanded depth is still a *calibrated* number — the modulation plugin +inverts the measured `V_null` / `V_peak` curve to produce it — it is simply not +checked afterwards, so it carries the calibration's error plus any drift since. +It is not a datasheet value and it is not a DAC excursion: a manual DAC band or +a constant level publishes no optical drive, and the gates refuse rather than +inventing a depth. + +Open loop there is **nothing to search for**, so `Find a₀` is not used at all and +the frequency ladder skips it — see [below](#a-and-the-a-ladder-adr-021). The +photodiode's window-length and clipping checks are skipped in this mode too, +because neither bounds a commanded depth; the operator's settle dwell still +applies. + +**Every artefact says which source it used**: the sidecar's `depth_a_source` / +`depth_a`, `[a0_lock].depth_source`, the recorders' `depth_a_source` metadata, +the `depth_source` field in `a0_locks.json`, and the *a from* column of the a₀ +lock view. `measured_a` keeps its narrow meaning — a number the photodiode +actually measured — so an open-loop run carries none, rather than carrying a +commanded value under that name. Runs destined for the final `q_p(a, f)` fit +should be photodiode-measured. + +### `a₀` and the a₀ ladder (ADR 021) + +`Find a₀` exists for one reason: the Pockels inversion is measured once and is +therefore **static**, while the depth the cell delivers **rolls off with +frequency**. Holding one *measured* `a₀` across a ladder means re-finding the +commanded depth that produces it at each frequency +(`a_cmd ← a_cmd · a₀/a_measured`). That is real work — and it only exists for a +measured depth. + +With the **commanded** source the loop measures the number it commands, so the +correction ratio is exactly 1. A search would command `a₀`, read back `a₀`, stop, +and store one identical row per frequency. So it is not run: + +| | photodiode (measured) | modulation drive (commanded) | +|---|---|---| +| `Find a₀` | trims the depth per frequency, stores a lock | **not needed** — disabled, and says so | +| `Record a₀ point` | replays the stored converged lock | commands `a₀` directly | +| Ladder per rung | lease → set `f` → **confirm via camera markers** → search → record | lease → set `f` → **confirm via the modulation owner's ACK** → record | +| `a0_locks.json` | one row per frequency | untouched — nothing was found | +| Needs camera EXT_TRIGGER + Live analysis | **yes** | no | + +The ladder's frequency check follows the same logic. Measured mode holds out for +the camera's phase-0 markers, because they define the period *and* anchor the +fold the point is scored in. Commanded mode asks the modulation owner instead — +the same owner, and the same acknowledged state, it already trusts for `a`. The +cost is confined to the live quicklook (the `q_p` fold goes free-running without +markers); the recorded RAW and PDQ that the offline fit reads are unaffected. + +**Net effect:** with the commanded source the ladder runs on a bench with no +photodiode `a`, no camera trigger and Live analysis off — set `a₀`, press +*Record all frequencies*. The trade is that nothing verifies the light reached +`a₀` at each frequency, and the roll-off the search corrects is real, so switch +back to the photodiode once its markers work. + +### The `q_p(a, f)` surface in one press (ADR 023) + +The frequency ladder is an **outer loop**, and what it records at each rung is a +mode: + +| button | per frequency | produces | +|---|---|---| +| *Record all frequencies* | one event-count point at `a₀` | `q_p(a₀, f)` — the same depth everywhere | +| **Record depth sweep at every frequency** | the **whole** `[min_a, max_a]` sweep | `q_p(a, f)` — one response curve per `f` | + +The second is the experiment `a50(f)` is fitted from, and it was previously a +manual loop: set `f`, press *Record depth sweep*, wait, repeat. It now runs +unattended as `frequency points × depth points` recordings **on a single lease**, +so the operator's drive settings stay locked out from the first frequency to the +last instead of being re-applied between blocks. + +It adds **no new settings**. The depth axis is the Recording section +(`Sweep min a` / `max a` / `points` / settle / duration); the frequency axis is +the ladder in the a₀ section (`Sweep min f` / `max f` / `points` / order / seed / +reference repeats). Ordering, the interleaved low-frequency reference, +per-frequency confirmation, skip-and-report and the summary are all the +unchanged ADR 014 machinery. + +**No `a₀` and no `Find a₀` are involved at any point**, in either depth source — +a depth sweep commands and settles every `a` in its range itself, so there is +nothing for a lock to contribute. With the photodiode source each point is still +fully closed-loop against the measured `a`; it simply has no `a₀`. + +Points are named `…_fHz_pNN`, so the surface sorts by frequency and then by +depth. A frequency whose curve cannot be recorded is skipped and named in the +summary rather than stopping the block — and a rung counts as done only when its +inner sweep recorded *every* point, not when its last recording happened to +succeed. + +### Protocol — a survey from a file (ADR 027) + +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. 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 +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`, `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. + +Two capabilities follow from the row form: + +- **A different duration per recording** — a 1 Hz point needs 40 s of cycles + and a 200 Hz point does not. +- **A `role` column**, so a file carries its own background floor and pilot and + then the points scored against them: a complete measurement rather than one + that needs two button presses first. + +**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" +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. 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 + physical flux, but the one control that moves the mean illumination without + touching the depth. +- **Points run `ū` outermost, then `f`, then `a`** — the order of how expensive + each change is to settle. Any other nesting spends the run settling. +- **All three axes are commanded at every point,** and the point waits for all + three acknowledgements before recording. A point that inherited an axis from + 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, 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. + +### 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. + +### Bench conditions on every run (ADR 022) + +Every recording — normal, pilot, background, sweep point, a₀ point — also +records what the camera measures about itself, from the host's +`CTX_SENSOR_MONITORING`: + +| quantity | sidecar `[sensor]` | recorder metadata | +|---|---|---| +| die temperature, °C | `temperature_c` | `sensor_temperature_c` | +| pixel dead time (refractory period), µs | `pixel_dead_time_us` | `sensor_pixel_dead_time_us` | +| 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 +biases. The values are **frozen when the recording starts** (they drift, and the +sidecar is written at finalize), mirrored even with Live analysis off, and are +**provenance only** — no A1 result depends on them, or a live run would disagree +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. + +**The sweep is the one scoped exception.** Start sweep leases the modulation +owner (`SERVICE_STAGE_A_MODULATION_CONTROL_V1`) and, per point, issues +`ModulationCommandV1::SetOpticalDepth` — which only retargets the *depth* of the +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 *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 +of sweep recordings additionally carry `sweep.requested_a`, `sweep.point_index` +and `sweep.point_total`. After the sweep releases the lease, the drive holds the +last sweep amplitude until the operator's own `depth a` setting is re-applied +(any modulation settings change re-sends it) — which is exactly why an +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): + +- `/_.raw` — camera RAW, with the host's own `.toml` sidecar + (camera biases, ROI) next to it. +- `/__pd.pdq` + `_pd.json` — photodiode PDQ + sidecar. +- `/__config.toml` — the A1 sidecar. + +**Everything lands under `//`** (ADR 015). That folder is +the only setting deciding where a measurement ends up — the host output root and +the photodiode Data directory no longer have to be kept aligned by hand: + +- **The PDQ and its sidecar are written there directly.** A1 names the + destination root in the start spec (`PdqStartSpecV1::root_dir`), which replaces + the photodiode's own Data directory for that run. An A1-driven recording + therefore does not depend on the photodiode's folder setting at all. +- **The camera RAW and the host's bias `.toml` are moved there after + finalization.** The host resolves plugin recording paths below *its* output + directory and rejects absolute ones, so A1 cannot name the destination up + front; instead it gathers the file once the host reports it closed and hashed. + A rename on one volume, a size-verified copy across volumes. A file that cannot + be moved stays where it is and the sidecar points at it there. + +- **The host's sensor telemetry is compacted in on the way.** The host writes a + wide `.sensor-monitoring.csv` beside the RAW; A1 rewrites it + column-wise as `.sensor.json` in the measurement folder and removes the + original. One `{ t_us, value }` pair of arrays per channel, carrying only the + polls where that channel was actually read — the channels sample on different + 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 (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 +`calibration_id`, optical target, requested and resolved normalized mean `ū`, +internal `u_g`/`u_c`, requested `a`, `V_null` and `V_peak`); the depth this run +was driven and judged by with its provenance (`depth_a`, `depth_a_source`); the +photodiode-measured `a`, extrema, geometric pedestal, +headroom, clip fractions, ADC id, and the learned `I_tot` anchor with its +provenance (ADR 024); ROI + +masked-pixel count + `N_valid`; the `[sensor]` bench conditions (die temperature, +pixel dead time, illumination — ADR 022); +trigger info (marker-anchored, marker count, +measured period); and the resolved paths of the RAW (+ its camera-config +sidecar), the PDQ (+ its sidecar) and the `sensor_readout`. The **pilot** run +additionally records the frozen ON/OFF windows and the **background** run the +floor `q0`, so returning to a measurement (folder + id) auto-reloads them for +the `q_p` plot. + +**Mechanism.** A small control-plane state machine in `process_control` starts +the host camera recorder first and waits for its receipt. Only after the host +has completed the Preview → Recording switch does A1 connect and lease the +photodiode and open the PDQ with the same run id. The duration begins when the +PDQ start receipt arrives, so setup time is never deducted from the requested +recording. On completion A1 atomically finalizes the PDQ and releases its lease +while camera effects are still live, then stops the host recorder, waits for its +final receipt, and writes the config sidecar. A recording is successful only +when the host receipt is complete and the photodiode returns a valid finalized +receipt with both PDQ paths. The status panel shows only the current phase and +one concise result or error message; it does not render an internal event log. +A1 declares `host_commands = ["start_recording", "stop_recording"]` in its +manifest. Every role uses this same lifecycle. + +**When something is wrong** (ADR 015): + +- **Before the camera starts**, A1 refuses the recording — writing nothing — if + the photodiode is not reporting status, is not connected, or is leased by + someone else. The same hint fills the status `message` cell while idle, so it + is visible before the button is pressed. (The photodiode's *Data directory* is + deliberately not among these: A1 supplies the destination itself.) +- **If the photodiode fails once the camera is running**, the camera keeps + recording for the full requested duration and closes normally. The run is + marked camera-only: `recording_completed_ok` stays false (so a sweep stops), + but the RAW is complete rather than a truncated stub. +- **The first, most specific failure is what you see.** The closing message is + `Recording incomplete: — metadata saved to `; later fallout + cannot overwrite the original cause. +- **Starting and stopping the host recorder restarts the capture pipeline**, which + the host reports as a `SourceChanged` discontinuity — twice per recording. While + a recording or sweep is in flight that boundary resets only the event fold, not + the row's pilot windows, background floor, or collected response points. + +**Host-side note.** The camera RAW leg restarts the host pipeline into +Recording mode and stops it again at finalize. After the file is finalized, the +host restores Preview before returning the receipt, so a sweep or another button +press can start the next recording automatically. + +**File locations.** One place: `//` holds `.raw` +(+ the host's `.toml`), `_pd.pdq` + `_pd.json`, and +`_config.toml`. + +## The two live plots + +Both fold the camera event stream on `T` (from the firmware phase-0 `EXT_TRIGGER` +marker spacing, which *defines* the frequency; the modulation acknowledged waveform +is the only fallback). Enable **Live analysis** to keep them updating. + +With **Live analysis** off nothing is ingested at all — no events *and* no +phase-0 markers — so the status line says so by name rather than reporting +`0 events; free-running (no EXT_TRIGGER)`, which reads as a wiring fault. The +frequency ladder refuses on the marker count and distinguishes the two cases in +its message (ADR 017). + +Marker hygiene: preview windows overlap, so the same trigger edge arrives on +several consecutive frames — the marker buffer is sorted and deduplicated on +every merge (duplicates used to fail marker validation and blank the plots). +When marker validation still rejects a fold (dropped-trigger jitter), the +quicklook falls back to the free-running fold on `T` instead of going empty. + +1. **Rolling half-period response** + + ```math + S_p(t) = \frac{N_p(t-T/2,\,t]}{N_\text{valid}} + ``` + + events per valid pixel in the trailing half-cycle, ON and OFF. A live indicator: + are events appearing, does the ON/OFF timing look sane, is the response + saturating? It counts *every* event in the ROI, so a noisy pixel weighs heavily + — it is a quicklook, not the response metric. + + `N_valid` is **ROI area minus masked pixels**, the same denominator `q_p` uses, + and the numerator counts only events inside that same region. The two are shown + side by side and have to mean the same thing; normalising `S_p` over the whole + sensor under-reported it by the ROI/frame ratio while counting events from + outside the ROI. + +2. **Response probability** `q_p` + + ```math + z_{i,c,p} = \mathbf{1}[\text{pixel } i \text{ fires in } W_p \text{ during cycle } c], + \qquad + \hat q_p(a,f) = \frac{1}{N_\text{valid} M}\sum_i\sum_c z_{i,c,p} + ``` + + the fraction of valid pixel-cycles that fire at least once in the ON/OFF phase + window `W_p` — each pixel-cycle counts **once** (unlike `S_p`). The windows come + from the row's **pilot** when one has been recorded (frozen, held across the + whole row), otherwise from the trigger-anchored fold automatically: since the + `EXT_TRIGGER` fixes the phase, ON and OFF live in opposite half-cycles, so each + window is anchored on its histogram peak and grown outward until events fall + below the **window floor** (default 10 % of the peak) or the opposite polarity + takes over. `Record point` appends one `(measured a, q_on, q_off)` dot. The ROI + and masked pixels come from the augur-rs camera config + (`N_valid = |ROI| − |masked|`). + + **Why the pilot is per row.** The window phase depends on the event latency, + which is a *phase* shift `τ·f` — negligible at low `f`, up to a full cycle at + high `f` — and also drifts with `I_k`. So the windows must be defined **per + `(I_k, f)` row** and held fixed across that row's `a`-sweep (re-deriving them + per amplitude would bias the curve). One pilot per measurement id captures that + exactly. This live `q_p` stays a quicklook; the **authoritative** `q_p(a, f)` + fit (`a50`, background floor) is computed offline from the recordings. + +## Button presses across the UI-mirror / live-worker split + +The host loads two instances of every dynamic plugin: a **UI mirror** (renders +the settings, never touches hardware) and the **live worker** (runs +`process_frame` / `process_control`, owns the recording state machine). A +`SettingKind::Button` click calls `set_setting(key, true)` **on the mirror +only**; the worker receives settings through the host's snapshot, which carries +whatever `get_setting` returns. A1 therefore exports every button as a +**monotonic press counter** (`PressLatch`): the mirror increments it per click, +the snapshot transports it, and the worker treats a counter advance as exactly +one press edge (the first value a freshly loaded worker sees is adopted +silently, so reloads never replay old presses). This is why the record buttons +used to do nothing — the presses died on the mirror. + +Related: A1 overrides `on_discontinuity` to ignore `SettingsChanged` (raised on +*every* settings sync of any plugin), so the response curve, pilot windows and +background floor survive ordinary UI interaction. Source changes and seeks reset +everything **unless** a recording or sweep is in flight, in which case the +boundary is A1's own pipeline restart and only the event fold resets (ADR 015). + +## Where the inputs come from + +| Input | Source | +|---|---| +| camera events, valid pixels | retained **EventStore** over a trailing analysis window; falls back to `frame.events()`, trimmed to the same window | +| phase-0 markers | rising `frame.external_triggers()` — the host **banks trigger edges from dropped preview frames** into the next processed frame (drain-to-newest and the preview throttle drop whole frames; at low modulation frequencies the survivors alone rarely held 2 markers inside the analysis window) | +| modulation period `T` | measured from the `EXT_TRIGGER` marker spacing; else the modulation plugin's acknowledged waveform — which, since the board-echo fallback, includes the **operator-armed UI drive**, not only service-path (leased) targets | +| optical modulation depth `a` | per the **Depth `a` source** setting (ADR 020). *Photodiode* (default): fresh optical summary (`measured_log_contrast`) from complete marker-bounded cycles and a confirmed `I_tot` anchor — always the *excitation* contrast, independent of display mode (ADR 012); when absent, `optical_unavailable` from the same snapshot carries the owner's refusal reason (ADR 017), and A1 appends the way past it. *Commanded*: the modulation owner's `optical_drive.depth_a_milli`, published only for a calibrated optical drive — open loop, tagged as such everywhere it is recorded | +| ROI, masked pixels | augur-rs camera config (`CTX_GLOBAL_SETTINGS`) | +| die temperature, pixel dead time, illumination, bias codes | host `CTX_SENSOR_MONITORING` (`SensorMonitoringV1`), mirrored every frame regardless of Live analysis and frozen at recording start. Provenance only — absent on replay, imports and cameras without a monitoring block (ADR 022) | + +## Tests + +`cargo test -p augur-plugin-stage-a-a1` covers trigger-defined period, marker-anchored +folding, ON/OFF separation of the rolling dataset, auto-window detection and the `q_p` +path, file-safe id generation, UTC timestamp formatting, the config-sidecar builder, +the pilot-window round-trip through the measurement folder, press-latch edge/baseline +semantics, the jittery-marker free-running fallback, sweep-point spacing, the +sweep-point sidecar fields, the ordered camera → PDQ → PDQ finalize → camera +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 +closing message, and still gathers the RAW and its bias sidecar into the +measurement folder; and a self-inflicted `SourceChanged` during a recording keeps +the row's response points and pilot windows while still resetting the event fold. + +Four more cover the depth source (ADR 020): a withheld photodiode `a` keeps the +owner's own reason *and* names the setting that gets past it; the commanded +source reports a depth with no photodiode present at all, and refuses a drive +that is not a calibrated optical one; and both the recorder metadata and the +config sidecar carry `depth_a_source` on every run, with `measured_a` present +only when something actually measured it. + +Three cover the simplified ladder (ADR 021): `Find a₀` refuses to search for a +depth it is commanding and takes no lease doing so; an a₀ point is armed with no +stored lock and `trials: 0`; and the whole ladder runs to `3/3 points recorded` +with no photodiode `a`, **no camera trigger markers** and an empty lock table, +panicking if it ever enters the search phase. + +Two cover the bench conditions (ADR 022): the start-of-run snapshot wins over a +drifted live reading and reaches both the metadata and the sidecar's `[sensor]` +section; and a quantity the sensor cannot report is omitted rather than written +as a zero. + +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`). diff --git a/docs/features/stage-a-a2.md b/docs/features/stage-a-a2.md new file mode 100644 index 0000000..51b45b4 --- /dev/null +++ b/docs/features/stage-a-a2.md @@ -0,0 +1,50 @@ +# Stage-A A2 latency automation + +A2 applies repeated calibrated optical log-square steps at several fluorescence +pedestals and records the first camera event after each measured optical edge. +The workflow plugin owns no serial port. It leases and orchestrates the permanent +modulation and photodiode owners and the host camera recorder. + +## Acquisition contract + +- The full TOML is parsed and all hardware/optical gates are checked before a + lease is acquired. +- A named, complete camera profile is applied and confirmed before either + hardware lease. EXT_TRIGGER and sensor telemetry must be on; STC, Trail and + ERC must be explicitly off. The host restores the pre-run state on success, + operator stop and failure. +- `PrepareA2` is accepted only when firmware confirms comparator trigger source, + an armed comparator and `LOG_SQUARE`. +- Dark points record camera RAW and photodiode PDQ for their declared duration + with modulation forced safe/off. Stepped points additionally require the + commanded number of both EXT_TRIGGER polarities (tolerance: one edge). +- A sidecar records protocol identity/SHA-256, row, commanded pedestal/depth, + comparator configuration, optical placement, final file receipts, dynamic + sensor values and trigger/load evidence. It links the host-owned camera and + sensor-monitoring companions instead of duplicating their bias/configuration + data. The exact protocol source is archived once by SHA-256. +- Implausible stepped trigger counts, a partial RAW/PDQ, an exceeded + pre-qualified recorder safety limit, + missing sensor dead-time, stale owner reply or expired lease fails closed. +- The plugin contains no scientific fit. Censoring-aware first-event latency and + jitter are computed offline, ON and OFF separately. + +## Current bench topology + +The immediate protocol is scoped to `fluorescence_chain`: ATTO647 sample, +fluorescence filter, 50:50 splitter, camera and the sole photodiode in the +emission path. It does not use rejected-port complement geometry or `I_tot`. + +## Hardware status + +Firmware mode A2, `CMP`, `LOG_SQUARE`, `min_half_us`, comparator source ID 2 and +trigger-source status exist in `stage-a-controller`. The sources build, but the +comparator has not been bench-qualified. H4 loopback, H5 polarity/offset and the +emission optical-edge qualification remain mandatory protocol gates. + +The optional PDQ cross-check is not the A2 time base. Production firmware now +mirrors comparator marker frames (`source=2`) through the non-blocking +photodiode stream path, so a PDQ can carry the independent comparator-edge +record. Camera-clock EXT_TRIGGER remains the latency clock of record. Marker +drops are explicit firmware integrity evidence; H4 loopback and H5 +polarity/offset calibration remain mandatory and are cited in the protocol. diff --git a/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-modulation.md b/docs/features/stage-a-modulation.md new file mode 100644 index 0000000..a6c8daf --- /dev/null +++ b/docs/features/stage-a-modulation.md @@ -0,0 +1,133 @@ +# Stage-A Modulation + +- **Crate:** `plugins/stage-a-modulation` (`augur-plugin-stage-a-modulation`) +- **Firmware:** `stage-a-controller` 0.3.0+ (`MOD` capability), Teensy **command port** +- **Status:** Active (2026-07-15) — replaces `stage-a-funcgen` and the drive half of + `stage-a-monitor` + +## What it is + +Laser-modulation control for the Stage-A bench with two orthogonal axes: + +- **Drive method** defines the DAC operating band. `MANUAL` uses Power + Min threshold; + `CALIBRATED` derives it from the lobe endpoints `V_null`/`V_peak`, the normalized cycle + mean `ū`, and the optical depth `a`. +- **Mode** defines the shape that fills the band: `CONST`, `DAC_SINE`, `SQUARE`, + `OPTICAL_LOG_SINE`, or `OPTICAL_LINEAR_SINE`. All five remain available under both methods. + +The always-visible **max limit** is the hard DAC ceiling for every manual and calibrated drive. +The settings schema shows only the selected method's parameter block and refreshes when Method +changes; Manual is the default. + +| Mode | Manual band `[min, power]` | Calibrated band from `ū`, `a`, `V_null`, `V_peak` | +|---|---|---| +| `CONST` | hold `power` | hold the DAC code for `ū` | +| `DAC_SINE` | DAC sine across the band | DAC sine across the band | +| `SQUARE` | DAC square across the band | DAC square across the band | +| `OPTICAL_LOG_SINE` | intensity log-sine across the band | mean `ū`, converted to `u_g=ū/I_0(a/2)` | +| `OPTICAL_LINEAR_SINE` | intensity linear-sine across the band | centre/mean `u_c=ū` | + +Manual optical modes reuse the persisted `V_null`/`V_peak` lobe parameters and derive effective +`(u, a)` from the manual DAC band through the forward `sin²` transfer. Both optical modes then +use the same inversion path described in [Optical waveform drive](./stage-a-optical-waveform.md). +`ū` is dimensionless and must not be confused with physical cycle-mean A1 flux `I_k`. + +`V_null`/`V_peak` are measured, not typed: the Calibration section sweeps settled `CONST` codes +against the photodiode and fits the lobe — see +[Pockels transfer calibration](./stage-a-pockels-calibration.md). Both are **absolute DAC codes** +an operator can point at on the transfer curve; the half-wave span between them is derived and +never entered, and `Vπ` no longer appears anywhere the operator sets something (ADR 025). + +## Achievable ranges — settings clamp, they never refuse + +`ū` and `a` are coupled through one constraint: the peak of the swing has to stay under the top of +the lobe and under the max limit. `waveform::PeakLaw` names how the peak follows from the two, one +variant per mode (`Constant`, `LogSwing` for the DAC sine/square, `LogSine`, `LinearSine`), and +solving it for one variable at a time gives the achievable range. + +Edits **clamp into that range**; nothing reverts. Only the control the operator just touched is +limited — dragging `a` up means "more depth", so `a` is what stops and `ū` stays put — and a lobe, +ceiling or mode change settles brightness first, depth second. Modes and methods are always +accepted. + +Both bounds are live in the control labels (`Optical depth a (0..1.37 at ū=0.50)`) and on the +status line, along with where the current drive actually peaks. An un-sendable drive is reported as +`drive not sent: …` rather than blocking the edit. Previously a leftover `a` made an optical mode +simply unselectable, with an error naming a control the operator was not editing — see +[ADR 025](../adr/025-stage-a-drive-settings-clamp-not-refuse.md). + +Every accepted setting change is transferred to the Teensy **immediately** as one `MOD` command — +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 + stream port belongs to `stage-a-photodiode`. +- Uses `stage-a-io` (`StageAClient`, `Command`) for framing and idempotent retries; slider drags + coalesce into a single pending command the device thread drains. +- **Frame-independent**: connecting is a checkbox setting and all serial I/O lives in a + plugin-owned device thread, because the host only calls `process_frame()` while camera frames + flow — bench control must work with no camera attached. `process_frame()` only disconnects + defensively in replay mode. +- Firmware output is **set-and-hold** (`stage-a-controller` ADR 002): disconnecting does not stop + the modulation. Manual Power at 0 drives 0 V; automation has an explicit `SafeOff` operation. +- Safety invariants enforced plugin-side: `min_level ≤ level ≤ max_level` for Manual and every + resolved calibrated/optical peak must be `≤ max_level`; invalid drives are refused. +- Status and commanded summaries include Method and the resolved `(lo, hi, hold)` DAC band. +- `ModulationStateV1.optical_drive` publishes the exact resolved optical + target, requested and resolved normalized mean `ū`, internal `u_g`/`u_c`, + requested `a`, `V_null` and `V_peak` as an additive V1 field; A1 sidecars no + 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). +- `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. +- **`SetOpticalDepth`** (ADR 010): under an automation lease the service can retarget the *depth* + `a` through the same `drive_command()` builder as the UI path. It is accepted only with an + applied transfer calibration and armed `OPTICAL_LOG_SINE`; no device link, manual/constant, + DAC/square/linear modes, or an unidentified hand-entered lobe are refused. Used by the A1 + amplitude sweep. +- **Link watchdog**: the device thread exits after 5 consecutive serial failures (marking the + device disconnected/faulted), and the control tick reaps a finished device thread and + auto-reconnects with a 2 s backoff while `connect` stays requested. Previously a wedged or dead + link silently swallowed every queued command — the UI kept accepting mode changes while the + board held the old waveform. +- **`SetOperatingPoint`** (ADR 027): the leased counterpart for the *operating point* `ū` — the + third axis, alongside depth and frequency, and the one that moves the mean illumination without + touching the depth. Calibrated method only; the owner parks the operator's own `ū` on the first + retarget and restores it when the lease ends. Used by the A1 protocol runner's `I_k` axis. + Unlike an interactive edit it **refuses** rather than clamping: a protocol asked for a specific + brightness, and quietly recording a different one would put the wrong `ū` in every sidecar. +- **The applied lobe crosses to the UI mirror** (ADR 026): "Apply to V_null / V_peak" used to do + nothing, because the fit lives on the live worker while the settings snapshot is collected from + the mirror — so the mirror's stale codes overwrote the applied ones on the next sync. The applied + lobe is now published through a process-global generation the mirror adopts. +- **No protocol section.** The undocumented TOML `MOD`-step runner was removed; declarative + recording protocols belong to the A1 plugin, which can also record what they produce + ([ADR 027](../adr/027-stage-a-a1-declarative-protocols.md)). +- **Board-echo `acknowledged` fallback**: the published `ModulationStateV1.acknowledged` now falls + back to a revision-0 target built from the board's `MOD`/`STATUS` echo (`mod_wave`, `mod_level`, + `mod_min`, `mod_freq_mhz`) when no service-path acknowledgement exists. UI-driven drives never + produce a service ACK, so consumers (A1's fallback modulation period) previously saw no waveform + at all for the normal operator workflow. WARP (optical) echoes map to `Periodic` — the fallback's + consumers only need the frequency. + +## Verification + +`cargo test -p augur-plugin-stage-a-modulation` covers method/mode enum index round-trips, +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. diff --git a/docs/features/stage-a-optical-waveform.md b/docs/features/stage-a-optical-waveform.md new file mode 100644 index 0000000..0c7925c --- /dev/null +++ b/docs/features/stage-a-optical-waveform.md @@ -0,0 +1,195 @@ +# Stage-A Optical Waveform Drive + +- **Crate:** `plugins/stage-a-modulation` (`waveform.rs`) +- **Firmware:** `stage-a-controller` — `MOD wave=WARP` (`stimulus_mod::configureWarp`) +- **Status:** Analytic inversion, fed by a measured `V_null`/`V_peak` pair + ([Pockels transfer calibration](./stage-a-pockels-calibration.md)); a fully + measured LUT remains a documented follow-up +- **ADR:** [ADR 008](../adr/008-stage-a-optical-waveform-inversion.md), + [ADR 016](../adr/016-stage-a-lobe-endpoints-not-a-distance.md) (the lobe is two + observed codes, not a code and a distance) + +## Why + +The Pockels/PBS amplitude modulator has a `sin²` transfer, so a pure DAC sine +does **not** produce a sinusoidal *optical* target. On one monotonic lobe: + +```math +I(V) = I_\text{floor} + (I_\text{ceil}-I_\text{floor})\,\sin^2[\alpha (V - V_\text{null})], +\qquad \alpha = \frac{\pi}{2 V_\pi}. +``` + +To hit a chosen optical target the DAC must be pre-warped by inverting it: + +```math +u(t) = \frac{I_d(t)-I_\text{floor}}{I_\text{ceil}-I_\text{floor}},\qquad +V(u) = V_\text{null} + \frac{2 V_\pi}{\pi}\,\arcsin\!\sqrt{u}. +``` + +The installed cell is the Excelitas **LM 0202**, part `84502049000`: four +KD*P crystals, 3×3 mm aperture, 400–850 nm, 5 W, nominal half-wave voltage +`210 V ±10 %` at 633 nm. The catalog value is a hardware plausibility check, +not a drive calibration; the plugin uses the measured DAC-domain +`V_null`/`Vπ`. + +## Targets + +- **`OPTICAL_LOG_SINE`** (recommended A1 input): + `ln u = ln u_g + (a/2)\sin\omega t`. +- **`OPTICAL_LINEAR_SINE`**: + `u = u_c(1 + m\sin\omega t)`, `m = \tanh(a/2)`. + +Both operate around an explicit operating point and are refused if their optical +maximum exceeds the lobe ceiling. Their headroom tests use their own target law; +the linear target is not tested against the log-sine endpoints. `DAC_SINE` +remains the pure-DAC sine. + +## Inversion parameters (settable — you do not need a rig to start) + +| Setting | Meaning | +|---|---| +| `V_null` | DAC code at the excitation minimum (`sin² = 0`) | +| `V_peak` | DAC code at the excitation maximum, on the same lobe | +| `a` | requested peak-to-trough natural-log contrast `ln(I_max/I_min)` | +| `ū` | requested dimensionless floor-subtracted **cycle mean** in `(0,1]` | + +Both endpoints are **absolute codes you observe** — sweep the DAC and read off +where the light is dimmest and where it is brightest. The quarter wave +`Vπ = |V_peak − V_null|` is derived, never typed: an earlier form asked for +`Vπ` as a *distance* directly beneath `V_null` as a code, and entering the +brightest code there puts maximum light at `u ≈ 0.5` with a null back at +`u = 1` (ADR 016). `u = 1` now holds exactly at `V_peak` by construction. + +The pair is resolved against the **DAC** range, not the `max_level` ceiling — +where the crystal nulls and peaks is a fact about the bench. A ceiling that cuts +the lobe short is reported against the code it actually blocks (*"the modulation +peak needs DAC code 2600, above the max limit 2400"*), not against the lobe. + +A pair entered running downward in code (`V_peak < V_null`) is accepted: the +transfer repeats every `2Vπ`, so the drive uses the ascending branch one period +below, which rises into the same measured maximum, and says so in the status +pane. A degenerate pair, or one whose lobe fits nowhere inside `0..max_level`, +is refused rather than armed. + +The status pane spells the resolved lobe out in codes — +`Lobe: Vπ = 860 codes — u 0 → 1630 (min light), 0.5 → 2060, 1 → 2490 (max +light)` — so a wrong endpoint is visible without measuring anything. + +### `u` is not the physical flux point `I_k` + +The modulation setting `ū` is a normalized cycle mean, not photons per pixel +per second. For a log target the plugin derives the geometric pedestal +`u_g=ū/I_0(a/2)` before generating/sending the WARP parameters; for a linear +target the arithmetic centre is already `u_c=ū`. The physical A1 quantity +`I_k` is the cycle-mean local excitation flux after all optics and +sample/spatial mapping. A1 therefore records a separate, required +`flux_point_id`; it never derives absolute `I_k` from `ū`. + +For the log target, `u(t)=u_g exp[(a/2)sin ωt]` and +`⟨u⟩=u_g I_0(a/2)=ū`. The implemented Bessel normalization therefore holds +the normalized—and, for a stable affine floor/span, physical—cycle mean while +`a` is swept, to the existing `u_k_milli` wire resolution. The acknowledged +state and A1 sidecar publish both the requested mean and the resolved mean after +that milli-unit quantization, plus the internal `u_g`. The independent flux +calibration still supplies the absolute local `I_k` and verifies it on the +bench. + +`CONST` maps only `u` +through the inverse lobe and ignores `a`. For example, `V_null=1630`, +`V_peak=2490` gives DAC `2490` at `u=1` and DAC `1685` at `u=0.01`. +Periodic modes still require the headroom above. Invalid setting changes are +rejected transactionally, so the UI retains the last applied value instead of +showing a target that the board never received. Photodiode RAW/EXCITATION mode +does not participate in this DAC calculation. + +### Drive method and hard ceiling + +Under `CALIBRATED`, `V_null`/`V_peak`/`ū`/`a` define the operating band directly. +Under `MANUAL`, the Power + Min-threshold DAC endpoints are passed through the +forward `sin²` transfer and converted to the target law's effective `(u, a)`; +the same inverse-warp implementation then fills that band. + +Warp codes are absolute lobe codes and cannot be rescaled without distorting the +target. The plugin therefore **refuses** any drive whose peak exceeds the +always-visible `max_level` hard ceiling. Raise the max limit, or lower the +operating band / `ū` / `a`, to fit. Under `CALIBRATED` the DAC *floor* can no +longer be breached — every emitted code lies between the two endpoints — so only +the ceiling is ever reported. + +### Modulation reference range + +For the current method the plugin reports the resolved DAC lower endpoint, +upper endpoint, constant hold code, and peak-to-peak swing. + +### Measured parameters (built) and the measured LUT (still future) + +`V_null`/`V_peak` are no longer typed in from a datasheet: the +[Pockels transfer calibration](./stage-a-pockels-calibration.md) sweeps settled +constant DAC codes, reads the photodiode level at each, and fits the lobe those +two parameters describe. The analytic `sin²` inversion above is unchanged — it is +now fed measured parameters. + +The fully measured **LUT** remains open: keep the swept `(code → optical level)` +table for one monotonic lobe and invert it directly instead of the analytic +form, dropping in behind the same `warp_table` interface and superseding +`V_null`/`V_peak` entirely. The calibration record already archives the points such +a table would need. + +This matters when the floor is finite. The implemented drive makes `u` obey the +requested target, so with `I=I_floor+(I_ceil-I_floor)u` the realised physical +contrast is + +```math +a_\mathrm{phys} = +\ln\frac{I_\mathrm{floor}+S u_g e^{a/2}} + {I_\mathrm{floor}+S u_g e^{-a/2}}, +\qquad S=I_\mathrm{ceil}-I_\mathrm{floor}. +``` + +It equals the requested `a` only for zero floor (or if contrast is explicitly +defined on floor-subtracted intensity). The measured photodiode `a` is therefore +the authority, and quantitative A1 acquisition requires the residual/floor +validation or the measured-LUT extension. + +## Wire form (firmware line limit) + +The command line is capped at 192 bytes, too small for a 256-code table, so the +plugin computes and validates the warp table locally (for the operator preview +and range guard) but sends the compact **parameters**: + +``` +MOD wave=WARP freq_mhz= target= a_milli= u_k_milli= v_null= v_pi= +``` + +For log-sine, the plugin first converts the UI's `ū` to +`u_g=ū/I_0(a/2)` and transmits that backward-compatible `u_k_milli` field. +The firmware rebuilds the identical 256-entry DAC table with the same formula +(`stimulus_mod::normalisedIntensity` + `dacForU`) and plays it back at the drive +frequency. A chunked **table upload** command is the natural extension for the +measured LUT. + +## Relationship to the measured `a` + +The requested `a` here is a *drive* target. The realised optical depth is always +the photodiode-measured `a` from the [photodiode plugin](./stage-a-photodiode.md) +(estimator geometry, rejected-complement corrected), never the commanded value. + +## Tests + +`cargo test -p augur-plugin-stage-a-modulation waveform` verifies both targets +stay in the DAC range, that feeding the warp table back through the `sin²` lobe +recovers the intended optical intensity, that the recovered log-contrast matches +the requested `a`, that a manual DAC band round-trips through +`OpticalDrive::from_dac_band`, and that invalid depth/inversion and lobe overruns +are refused. It also pins the endpoint form: that `u` rises monotonically to +the measured maximum for any observed pair, that a pair measured downward folds +onto the branch into the same peak, and — as a regression witness for the bench +report of 2026-07-28 — that entering the brightest *code* where the quarter-wave +*distance* belongs is what peaked the light at `u = 0.5`. +`cargo test -p stage-a-io mod_warp` covers the mock command surface. +The modulation-plugin tests also pin the full-lobe `CONST` values above and +verify that a rejected periodic `ū` change cannot diverge from the board +target, that Bessel normalization preserves the log-sine cycle mean, and that +linear-sine headroom uses the linear target law. The resolved optical-drive +provenance test also checks the exact milli-unit value sent to the controller +and the corresponding resolved cycle mean. diff --git a/docs/features/stage-a-photodiode.md b/docs/features/stage-a-photodiode.md new file mode 100644 index 0000000..416c969 --- /dev/null +++ b/docs/features/stage-a-photodiode.md @@ -0,0 +1,182 @@ +# Stage-A Photodiode + +- **Crate:** `plugins/stage-a-photodiode` (`augur-plugin-stage-a-photodiode`) +- **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 + contrast geometry), + [ADR 024](../adr/024-stage-a-photodiode-learns-its-own-anchor.md) (the + learned total-power anchor; dark cancels), + [ADR 017](../adr/017-stage-a-rail-detection-and-withheld-a-reasons.md) + (span-relative rail detection; the published refusal reason), + [ADR 019](../adr/019-stage-a-calibration-measures-its-own-window.md) (the + published level owns its window) + +## What it is + +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 (**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. + +## Phase-0 trigger overlay + +The firmware stamps a device-clock **`Marker` frame** (wire type 4) on the stream at every +modulation phase-0, in step with the J24 camera trigger. Because the chart is on the device +(Teensy) sample clock — not the camera clock — this stream marker is the correctly-aligned phase-0 +source (the camera `EXT_TRIGGER` belongs to A1's camera-clock analysis, not here). + +- **Show phase-0 trigger markers** (opt-in) overlays them as one toggleable vertical curve + ("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. + +## Modes + +The mode is a **display** choice only. It selects what the chart and the sample readout show; it +never changes a published quantity (ADR 012). + +- **RAW** — ADC code and volts (`V = code · 3.3 / 4095`). +- **EXCITATION** — the diode sits at the PBS reject port and measures the light + removed from the sample beam (`I_pd = I_tot − I_exc`), so the plugin inverts against the learned + total-power anchor: `I_exc = I_tot − I_pd`. Nothing to enter — see below. + +## Optical log-contrast `a` + +`measured_log_contrast` in the published `PhotodiodeOpticalSummaryV1` is **always** the excitation +contrast `a = ln(I_exc,max / I_exc,min)`, in **both** display modes. The detector sits behind the +PBS reject port and measures the complement — that is a property of the bench, not of the display — +so the estimator always runs the `RejectedComplement` geometry against the learned anchor. A1's +amplitude sweep settles on this value, so a display toggle must not be able to move it (ADR 012). + +- **`I_tot` is learned, not entered** (ADR 024). The plugin latches the highest + smoothed detector level it has seen since the port was opened. On the reject + port the detector is brightest exactly where the excitation is extinguished, + so that reading *is* `I_tot` — and the Pockels transfer sweep, which walks the + DAC across the whole lobe, lands on the excitation null by construction. Run + the sweep once and the anchor is right. The latch is over completed 64-sample + summary-cell means, so one noise spike cannot pin it high, and it survives + segment restarts (a rate change or an acquisition handover does not move the + optics). Reconnecting the port relearns it. Provenance is published as + `anchor_id = "observed-peak@"`. +- **There is no dark level, and that is exact, not an approximation.** With a DC + dark offset `D`, the excitation is `(I_tot,obs − D) − (v − D) = I_tot,obs − v` + — the offset cancels, because both sides are readings from the same + DC-coupled detector. `dark_volts` is fixed at 0 and `dark_id` reads + `dark-cancels`. Two unit tests hold this down: one asserts that shifting the + whole trace *and* the anchor leaves `a` unchanged to 1e-9, and a companion + asserts that correcting only one side *does* move it, so the first cannot pass + vacuously. +- The estimator uses only marker-bounded windows containing at least **two + complete modulation cycles**, ending on phase 0. It no longer estimates + extrema from an arbitrary trailing sample count; a low-frequency trace that + does not fit the bounded window is withheld rather than phase biased. +- **The ring sizes itself to the drive** (ADR 033). Because that window is the + gate, the retained ring is the larger of the operator's **Cache length** and + nine marker-measured periods, capped at 16 M samples. Two cycles at the A1 + protocols' 0.075 Hz floor are 26.7 s, which no default cache covers — left to + a setting, "raise the cache before starting a sub-hertz file" is a + precondition nothing checks and a whole survey fails on, one full-length + recording at a time. Two phase-0 markers are enough to know the period, the + ring shrinks back when the frequency goes up, and below ~0.06 Hz at 500 kSa/s + the cap binds and the estimator refuses — correctly, since nothing retains + two cycles there. +- The estimator is **fail-closed**: it refuses when no anchor has been observed + yet, on incomplete cycles, on ADC clipping, and when the excitation never dims + below the brightest the detector has been — where there is no complement left + to take a contrast of, and the fix is to run the transfer sweep. A refusal is shown as `a unavailable: ` + rather than a missing row — a wrong `a` is worse than no `a`. +- The refusal reason is also **published** on the contract as + `PhotodiodeSummaryV1::optical_unavailable`, so a consumer that gates on `a` + (A1's a₀ lock, amplitude sweep and frequency ladder) can name the gate rather + than report absence. Set exactly when `optical_summary` is absent and a window + existed to judge (ADR 017). +- Clip detection is **span-relative**: the near-rail margin is capped at 5 % of + the window's own peak-to-peak code range. At this detector's 0.5–15 mV + operating range the whole waveform sits inside the bottom ~20 of 4095 codes, + where the former absolute 4-code margin classified 30 % of a clean sine as + clipped and withheld `a` unconditionally. The rails themselves (code 0, full + scale) stay guarded at every gain, so a waveform driven below zero is still + refused (ADR 017). +- The same span-relative margin decides `PhotodiodeLevelV1::clipped`, so the + Pockels sweep is not told that a detector running a few codes above zero is + truncating. + +## The published level owns its window + +`PhotodiodeStreamV1.level` is the settled detector reading other plugins consume +— today, the modulation plugin's Pockels transfer sweep, which reads one per +commanded DAC code. It is averaged over a **fixed 20 ms**, set here and +independent of every display setting; `sample_count` reports what it was. + +It used to be averaged over the chart's moving-average window below. That made a +display preference set the precision of a physical calibration: at the bench's +500 kSa/s the default of four samples published **8 µs** of signal per settled +code, and a clean Pockels curve came back reported as a 22 % residual with 26 % +"hysteresis" (ADR 019). 20 ms is one mains period, so the boxcar has a null at +50 Hz and every harmonic of it — and the chart's averaging is once again nothing +but a chart setting. + +## Chart + +- The visible window is decimated into at most 1 000 buckets; when a bucket covers more than one + sample the chart shows the bucket **mean** plus a **min/max envelope**, so narrow modulation + peaks stay visible at any zoom. Windows short enough to fit raw samples render them directly. +- **Moving average** (for the low-voltage regime): a smoothed overlay line plus a numeric readout. + The window is either a fixed sample count (`avg_samples`, default 4; 1 = off) or — the right + tool for modulated signals — **one full period of a user-given frequency** + (`avg_sync_freq_hz`, e.g. the MOD drive frequency): window = `rate / f` samples, which makes + the mean independent of the modulation phase instead of riding the waveform. + +## Data (cache snapshot + disk recording) + +- The monitor cache always holds the last *N* seconds (`cache_s`). **Save cache + snapshot** writes it **once** as `pd_cache_.csv` + JSON sidecar. +- **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. +- 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` + handler fired on every host settings sync — one unwanted CSV per settings + change of *any* plugin — and the old `record` checkbox synced the mirror's + 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 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 + are counted and shown in the status table's integrity column together with the firmware's + cumulative drop counter and the segment-restart count. +- `mock` port synthesizes a noisy 5 Hz sine at 20 kSa/s in firmware-sized blocks for + hardware-free testing. + +## Verification + +`cargo test -p augur-plugin-stage-a-photodiode` — frame ingestion incl. segment restarts on index +jumps and rate changes, duration-bounded ring with aligned indexes, moving-average window +derivation from the sync frequency, newest-window average, envelope decimation bounds and +min ≤ mean ≤ max, raw rendering for short windows, excitation inversion, mock reader, settings +round-trips, the forwarded snapshot counter saving exactly once, and the record start/stop +buttons. diff --git a/docs/features/stage-a-pockels-calibration.md b/docs/features/stage-a-pockels-calibration.md new file mode 100644 index 0000000..b3f2509 --- /dev/null +++ b/docs/features/stage-a-pockels-calibration.md @@ -0,0 +1,282 @@ +# Stage-A Pockels Transfer Calibration + +- **Crate:** `plugins/stage-a-modulation` (`calibration.rs`) +- **Depends on:** `stage-a-photodiode` publishing `PhotodiodeStreamV1.level` +- **Status:** built +- **ADR:** [ADR 011](../adr/011-stage-a-pockels-transfer-calibration.md), + [ADR 016](../adr/016-stage-a-lobe-endpoints-not-a-distance.md) (what the two + settings ask for), + [ADR 019](../adr/019-stage-a-calibration-measures-its-own-window.md) (the + measurement window, and judging the sweep against its own noise) +- **Knowledge base:** `methodology/pockels-waveform-linearisation.md` §4, + `setup/optical-path.md` + +## Why + +`V_null` and `V_peak` drive every calibrated waveform through the optical inversion +([Stage-A Optical Waveform Drive](./stage-a-optical-waveform.md)), but they were +two bare number fields whose tooltip said *"measure it; do not trust nominal +Vπ"* — with no way to measure it. Nothing in the UI connected a DAC code to an +observed photodiode value, so the operator had to hand-sweep `CONST`, watch a +chart in another panel, and do the arithmetic by eye. + +## What it does + +One button. The modulation plugin steps settled `CONST` DAC codes across +`0..max_level` (49 points up, then the same 49 back down, ~20 s), reads the +photodiode level at each, and fits the lobe: + +```math +P(c) = p_0 + p_1 \sin^2\!\left[\frac{\pi (c - V_\text{null})}{2 V_\pi}\right] +``` + +The fit is then reviewed and applied by a second, explicit press. + +## Why the modulation plugin owns it + +It already owns the `V_null`/`V_peak` settings and the DAC. The fit still +derives the internal span `Vπ = |V_peak - V_null|`. The host broadcasts every plugin's +control snapshot to every plugin's inbox, so it reads photodiode levels **read +only** — no lease, no service command, no coordinating plugin, and no +photodiode recording. The photodiode simply needs to be connected. + +## Three things the physics forces + +**The detector port is an input, not a result.** `sin²` is symmetric about its +peak, so `(v, p_0, p_1)` and `(v + V_\pi, p_0 + p_1, -p_1)` fit the measured +curve *identically* — the data cannot say which extremum is zero excitation. + +The setting asks one observable question: *when the light reaching the sample +gets brighter, does the photodiode reading go up or down?* Stage-A's photodiode +sits on the PBS **reject** port and reads the light the sample does not get, +`I_pd = I_tot − I_exc`, so it falls as the sample brightens — and reads its +**maximum** at `V_null`. That is `REJECT PORT`, the default. `DIRECT` is for a +detector watching the sample beam itself. Declaring it wrong places `V_null` +one half-wave-voltage span off and runs the drive on the inverted branch. + +**The shape needs no dark measurement and no anchor.** `p_0` absorbs the dark +level and any DC offset; `p_1` absorbs the front-end gain. `V_null` and `Vπ` +are immune to both, which is why this procedure is one button and not a +protocol. + +**The absolute scale is *not* recoverable here.** On the reject port the +residual transmitted floor cannot be separated from the total-power anchor +`I_tot` (knowledge base §4.4). The detector level at the null is therefore +reported as a **lower bound** on `I_tot`, explicitly not as the anchor, and no +maximum achievable `a` is derived from it. Freezing a real anchor still needs a +transmitted-port power measurement. + +## How the fit works + +Because `sin²(x) = (1 − cos 2x)/2`, the model is a constant plus **one sinusoid +of period `2Vπ`**, and a sinusoid of known period is linear in its quadrature +components. So for each candidate `Vπ` the phase (hence `V_null`) and both +amplitudes come from a 3×3 linear solve, and only `Vπ` is searched: a +log-spaced scan over every period the sweep can resolve, then a golden-section +refine. + +Seeding the period from the measured extrema — the obvious approach — breaks on +exactly the sweeps that matter. At a realistic `Vπ ≈ 860` the DAC range holds +~2.4 lobes, so the global minimum and maximum can sit whole periods apart. + +Several nulls are valid when a sweep spans multiple lobes; the fit reports the +**lowest** one whose `[V_null, V_null + Vπ]` fits inside the max limit — least +voltage across the crystal, most headroom, and a rule the operator can predict. + +## Each point is a measurement the sweep controls + +Two quantities, both owned deliberately and neither borrowed from a display +setting (ADR 019): + +**Settling is proven, not timed.** Every published level carries +`end_sample_index` and `sample_count` on the device sample clock. A point is +accepted only from a window that *began* at least `SETTLE_SECONDS` (0.1 s, +converted through the photodiode's published sample rate) after its code was +commanded. No shared wall clock, no sleeps, immune to control-tick jitter. A +duration and not a sample count, because settling is a property of the HV +amplifier and the crystal: the former fixed 2 000 samples was written for +20 kSa/s and silently became 4 ms when the bench moved to 500 kSa/s. + +**The level is averaged over 20 ms**, fixed by the photodiode plugin and +independent of its chart-averaging setting. That setting used to decide it, at a +default of four samples — 8 µs at 500 kSa/s — which is how a clean bench lobe +came back with a 22 % residual. 20 ms is one mains period, so the boxcar nulls +50 Hz and its harmonics. + +A point therefore costs ~120 ms, and the full 98-point sweep ~12 s. + +## The sweep owns the DAC while it runs + +`send_modulation` is silent for the duration. The host re-applies the *whole* +settings snapshot on every sync and most drive handlers push to the board +unconditionally, so without this the operator's armed waveform would be +re-armed on top of every commanded code — the board would play the armed drive +through the sweep, every point would read the same waveform-averaged level, and +the fit would report "the detector level did not change" on a bench where the +light was plainly modulating. Same shape as the automation-lease guard: a sweep +is another owner of the DAC. + +Settings changed mid-sweep are withheld, not rejected, and reach the board when +the sweep ends — the restore prefers the current drive and falls back to the +command captured at sweep start. + +## Interlocks + +The sweep refuses to start, and aborts if any becomes true mid-run, unless: +hardware effects are allowed on this instance, the command port is connected, +**no automation lease is held** (A1 must not be sweeping the drive at the same +time), no protocol is running, and a photodiode level is arriving. + +It always restores the pre-sweep drive — on completion, abort, stop press, +disconnect, or a stalled stream. A calibration sweep leaves the bench as it +found it. + +## Robustness: strays are dropped, the rest is a warning + +The fit runs twice. The first pass finds the period; points whose residual +exceeds **6× the median** absolute residual are then dropped and the fit is +repeated on what is left. The cut is on the median, not the mean or standard +deviation, because those are themselves dragged out by the very points being +looked for. `6 × median` is roughly 4σ for Gaussian noise, so ordinary scatter +survives untouched. + +This matters because of how the numbers behave in synthetic stress tests. The +following table is test-model output, not a bench measurement: + +| Condition | Residual | Fitted `Vπ` | +|---|---|---| +| clean | 0.0 % | 860 | +| 5 mV noise | 3.1 % | 864 | +| 10 mV drift across the sweep | 3.2 % | 861 | +| 10 mV hysteresis | 5.5 % | 861 | +| **one stray point** | **9.9 %** | **863** | +| amplifier compressing the top of the range | 15.2 % | 1110 ✗ | + +A single bad sample inflates the residual fivefold while leaving `Vπ` accurate +to three codes — and it is invisible in the plot. That is why the residual +**warns and never blocks**: blocking on it withholds a good calibration for a +bad reason. A residual that stays high after rejection, with a visibly poor +overlay, is the real signal — and as the last row shows, it comes with a `Vπ` +that is wrong in a way the plot makes obvious. + +One real bench sweep is kept as a fixture at +`plugins/stage-a-modulation/testdata/pockels-20260730-083123.json` and asserted +against directly. Synthetic sweeps carry uniform noise; a real detector's is +signal-proportional, and every metric that broke on that record was one compared +against zero (ADR 019). It is worth keeping for the same reason the table above +is: it is what the failure actually looked like. + +There is deliberately no absolute minimum voltage. An early implementation +rejected every detector span below **10 mV**, while the real Stage-A photodiode +commonly reads only about **0.5–15 mV**. Its replacement — the between-code span +against the median `peak_to_peak_volts` — was scale-free but still wrong: that +compares a span of *means* to a *raw within-window excursion*, so it tightens as +the averaging window grows, and it cleared a real bench sweep by only a factor of +1.9. + +Both gates are now measured against the fit's **own RMS residual**, the scatter +of the averaged points about the curve — the same quantity the lobe amplitude is +in, so the comparison is dimensionally honest and cannot be moved by how the +photodiode owner happens to average (ADR 019). + +A lobe counts as resolved when it stands at **twice its own scatter** +(`rms < 0.5·|span|`). The margin is not decoration: a free period search over +pure noise returns an apparent lobe, not zero, landing noise-only quality at +0.7–1.0 — while the noisiest real record on file reads 0.22. The regression suite +pins both ends, and includes a 4 mV transfer that the old absolute threshold +always refused. + +The fit is **never** applied automatically, and applying re-validates the +resulting drive: a calibration that cannot be armed is rolled back rather than +stored. Warnings surface as `Check:` lines in the status: + +| Warning | Meaning | +|---|---| +| residual > 5 % of the span | compare fit and points in the plot before trusting `Vπ` | +| points dropped | a couple is ordinary; a large share means the sweep is the problem | +| hysteresis past its noise floor | the cell is drifting, or the settle time is too short | +| points at an end of the detector's range | the reported extrema are truncated; `V_null`/`Vπ` are not | + +Two of those are stated carefully, because the obvious versions are wrong. + +**Hysteresis is compared against noise, not against zero.** Two independently +noisy passes over one curve already differ by `1.128 σ` on average, so a bare 5 % +cut fires on any bench whose points are not far quieter than that — and it did, +on a drift-free cell. The metric is judged against `1.128 · rms / |span|`, the +value it takes under noise alone. That ratio runs between two derivable ends: +**1.0** for pure noise and **1.77** for pure drift, because a systematic offset +inflates the residual as well. The range is narrow and worth knowing — the +obvious "warn at 2× the floor" sits above both ends and never fires. The cut is +at 1.33. + +**Clipping is a caveat on the extrema, not a verdict on the lobe.** Rail-touching +points truncate `detector_volts_at_null`/`_at_peak` and the `I_tot` lower bound; +`V_null` and `Vπ` come from the shape and barely move. The advice is to change +the detector **gain** — for a reject-port detector it is the dark end that +reaches the bottom rail, so adding attenuation is backwards. + +There is no separate "lobe coverage" gate: `fit_transfer` already refuses a +sweep in which no full lobe fits inside the commandable range, so `Vπ` is always +measured rather than extrapolated by the time a fit exists. + +## The transfer-curve view + +A `LineSeriesWindow` host view, `Pockels transfer curve`: + +- **before any sweep** — the lobe the *configured* `V_null`/`V_peak` claim, on a + normalised `u` axis, with markers at `V_null` and `V_peak`. This works + with no hardware attached and is the answer to "what are these two numbers". + The markers are the settings themselves: both are absolute DAC codes, so the + plot can be read straight back into the two fields (ADR 016). +- **after a fit** — `measured ↑`, `measured ↓`, the fitted curve, and (while + they differ) the configured lobe on the fit's own scale, in detector volts. + +## Provenance + +Applying writes `pockels-.json` into the optional calibration folder +(points, fit, geometry, residual, hysteresis, and the anchor caveat) and sets +`ModulationStateV1.calibration_id`, so a consumer's sidecar can cite which +inversion produced a run's optical depth. Leaving the folder empty applies the +fit without archiving, and says so. + +## Dual-instance note + +The host renders `settings_schema()` from the **UI mirror**, which never owns +the device link, a lease, a sweep, or a fit. A `SettingKind::Button { enabled }` +may therefore only depend on state that is itself a setting — anything else is +invisible to the instance that draws it and disables the button forever. The +calibration buttons gate on "the operator asked to connect"; every real +interlock is enforced on the worker and reported in the status lines, which the +host does take from the worker. + +## Settings + +| Key | Meaning | +|---|---| +| `detector_geometry` | which PBS port the photodiode watches (`REJECT PORT` default) | +| `calibrate` | measure the transfer curve; press again to abort | +| `calibrate_apply` | write the reviewed fit into `V_null`/`V_peak` | +| `calibration_dir` | optional archive folder for the calibration record | + +`V_null`/`V_peak` remain directly editable as the manual override. + +## Verification + +- `calibration.rs` unit tests recover a known lobe from **both** ports, across + a multi-lobe sweep, and with a null at code 0; they check the geometry input + selects between the two equivalent representations, accept a resolved + sub-10-mV transfer, and ensure flat/noise-level sweeps, short sweeps, and + out-of-range lobes are refused. +- An end-to-end test runs the sweep against the mock board, synthesizing the + light the reject-port detector *would* report for whatever code the board is + actually holding — ground truth for commanding, settle gating, point + collection, the fit, and the drive restore. + +## Limits + +- Analytic `sin²` inversion, not a measured LUT (the knowledge base's eventual + target); the calibration record stores the points a LUT would need. +- Dark level and the total-power anchor remain separate measurements. +- Static transfer only. A static calibration must never be used to correct + dynamic roll-off — that would manufacture the Bode curve A1 measures + (knowledge base "Gotchas"). diff --git a/docs/features/stage-a.md b/docs/features/stage-a.md new file mode 100644 index 0000000..e3bfbe2 --- /dev/null +++ b/docs/features/stage-a.md @@ -0,0 +1,35 @@ +# Stage-A Bench Stack + +- **Status:** Two persistent owners plus orchestrated experiment workflows (ADR 007) +- **Firmware:** `stage-a-controller` 0.4.0+ (Teensy 4.1 on Hermit V2r1, `USB_DUAL_SERIAL`) + +## Current shape + +The Teensy enumerates as **two** USB serial ports, and each is owned by exactly one plugin: + +| Port | Content | Owner | +|---|---|---| +| command port (first) | v1 ASCII commands + PDA1 binary frames | [`stage-a-modulation`](./stage-a-modulation.md) | +| stream port (second) | free-running PDA1 `SamplesU16` frames, 20 kSa/s default | [`stage-a-photodiode`](./stage-a-photodiode.md) | + +- **`stage-a-modulation`** — Manual/Calibrated operating-band selection plus five independent + waveform modes under one hard DAC ceiling, transferred to J23 immediately; shows the resolved + band and board-reported DAC code. Firmware output is set-and-hold; automation uses an explicit + `SafeOff` operation. +- **`stage-a-photodiode`** — live readout of SMA5/pin 18/A4, raw or inverted to excitation power + `I_exc = I_tot − I_pd` against a user-set reference. +- **`stage-a-io`** (shared non-plugin library) — PDA1 wire format, typed client with idempotent + retries, bounded I/O worker, and a firmware-faithful mock (including the 0.3.0 `MOD` verb). + The photodiode owner uses the parser/PDQ modules; experiment plugins may use + hardware-free readers/analysis but never open the ports. +- **`stage-a-a1`** — orchestrates both owner services and camera recording; it + never opens a Teensy port or writes PDQ directly. Architecture: ADR 007. + +## History + +The earlier commissioning stack (`stage-a-monitor`, `stage-a-funcgen`, old `stage-a-a1` — device +monitor with calibrated contrast, waveform familiarisation, and the A1 minimum-depth Bode sweep) +was removed on 2026-07-15 as too complex for the current bench stage (ADR 006). It remains in git +history; the new A1 implementation uses different statistics and host-routed +orchestration. Device-ownership and safety rules: ADR 005 as amended by ADR 006 +and ADR 007. diff --git a/docs/features/tablev1-declarative-metadata.md b/docs/features/tablev1-declarative-metadata.md new file mode 100644 index 0000000..ae24fe0 --- /dev/null +++ b/docs/features/tablev1-declarative-metadata.md @@ -0,0 +1,59 @@ +# TableV1 Declarative Metadata For Plugins + +## Summary + +Plugins that expose `HostDatasetKind::TableV1` now describe row provenance, cross-dataset +relations, and per-column display formatting declaratively. The host consumes these +descriptors to render timestamps as `mm:ss.uuu`, size columns sensibly, drive summary cards, +auto-seek replay to the anchor timestamp of a selected row, and resolve derived-row selections +back to contributing raw events for 3D emphasis. + +This replaces implicit conventions (where the host guessed from type or column name) with +explicit, serializable metadata carried on `TableSchema` and `HostDatasetDescriptor`. + +## What Plugins Populate + +On `TableSchema`: + +- `provenance: Some(TableRowProvenance { anchor_time_column, span_start_column, span_end_column })` + — typically `anchor_time_column: Some("timestamp_us")`. Spans are used by the host for + span-based visibility and anchor fallback, so `span_start_column` / `span_end_column` should + describe the real contributing interval rather than repeating the anchor timestamp. +- `column_display: Vec` — one entry per column you want formatted: + - timestamp columns → `TableColumnDisplayFormat::TimestampMicros` + - positions, widths, residuals → `FixedPrecision { digits: N }` + - `row_id` columns → `Identifier` with `hidden: true` + - enum-like columns (methods, reasons) → `Category` (promote with `headline: true` in + failure-result schemas to make the reason the summary-card heading) + - Width priority: `High` (~160px) for labels and text; `Medium` (~100px) for numeric; + `Low` (~60px) for compact identifiers. + +On `HostDatasetDescriptor`: + +- `relations: Vec` — + declare joins from this dataset's row to another dataset. Example: candidate-event rows + relate to localizations via `cluster_id`. The host can follow these joins transitively to map a + selected derived row back to raw accepted-event identities. + +All new fields are additive with serde defaults; omitting them keeps the prior behavior. + +## Implemented Datasets + +- `evesmlm-fitting`: `augur.evesmlm.current_localizations`, `augur.evesmlm.rejected_fits` — full + provenance with real `span_start_us` / `span_end_us`, per-column formatting, cluster relations + back to accepted candidate events, and `rejection_reason` marked `headline: true` for rejected + fits. +- `evesmlm-candidates`: accepted/rejected candidate events — provenance on `timestamp_us`, + relation to `current_localizations` via `cluster_id` on accepted events. + +## Descriptor Parity + +`evesmlm-postproc` re-exports the `current_localizations` registry builder from +`evesmlm-fitting`, so the descriptor is structurally identical by construction. A parity test +in `plugins/evesmlm-postproc/src/lib.rs` serializes both registries to JSON and asserts +equality to catch accidental divergence. + +## Related Host Behavior + +See the companion host feature brief: [Investigation Table Trustworthiness](https://github.com/muthmann/augur-rs/blob/main/docs/features/investigation-table-trustworthiness.md) +and [ADR 017](https://github.com/muthmann/augur-rs/blob/main/docs/adr/017-declarative-tablev1-metadata.md). diff --git a/docs/installing-plugins.md b/docs/installing-plugins.md index ba8797b..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 @@ -47,6 +74,14 @@ cp target/release/libaugur_plugin_localization.dylib ~/.augur/plugins/localizati Install each plugin into its own directory under `~/.augur/plugins//`. +On macOS, a plain `cp` keeps Cargo's build-path dylib identity in the copied file. Rewrite the +installed copy so reloads do not keep resolving back to the build tree: + +```bash +install_name_tool -id "@loader_path/libaugur_plugin_localization.dylib" \ + ~/.augur/plugins/localization/libaugur_plugin_localization.dylib +``` + ## Install All Built Plugins ```bash @@ -54,6 +89,8 @@ Install each plugin into its own directory under `~/.augur/plugins//`. ``` This copies every plugin that already has a built runtime library in `target/release/`. +On macOS it also rewrites each installed dylib id to `@loader_path/` so Plugin Manager +reloads do not stay pinned to Cargo's original build-path identity. ## Load Or Reload In The GUI @@ -85,6 +122,24 @@ You copied a source directory instead of the built library. Build the plugin and The library was built against an older plugin interface or does not export the runtime vtable. Port it to `augur-plugin-api::Plugin` and export it with `export_plugin!`. +### “plugin ABI mismatch” + +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/" ...`. + +If you overwrote a plugin while `augur-gui` was already running, restart the host once after the ABI bump to clear any previously loaded image from the process. + ### The plugin loads but host-owned settings are missing `GlobalSettings` are published through `augur.global_settings` by newer hosts. If a plugin tolerates `None` there, verify that the installed plugin and the `augur-gui` build come from compatible `augur-rs` / `augur-plugins` revisions. diff --git a/docs/plugin-api.md b/docs/plugin-api.md index f1138d6..c5c884a 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -1,10 +1,11 @@ # Runtime Plugin API -This repository now follows the runtime-only plugin surface documented in `augur-rs`. +This repository follows the runtime-only plugin surface documented in `augur-rs`. Use the upstream guide as the canonical contract: - [`augur-rs/docs/features/plugin-authoring-guide.md`](https://github.com/muthmann/augur-rs/blob/main/docs/features/plugin-authoring-guide.md) +- [`augur-rs/docs/features/investigation-workspace.md`](https://github.com/muthmann/augur-rs/blob/main/docs/features/investigation-workspace.md) This page summarizes the parts authors working in `augur-plugins` touch most often. @@ -25,37 +26,6 @@ This page summarizes the parts authors working in `augur-plugins` touch most oft - `Series1dV1` - `CTX_GLOBAL_SETTINGS` -## Minimal Plugin - -```rust -use augur_plugin_api::{ - export_plugin, EventStoreHandle, HostContext, HostOutput, Plugin, PluginFrame, -}; - -#[derive(Default)] -struct MyPlugin { - enabled: bool, -} - -impl Plugin for MyPlugin { - fn name(&self) -> &'static str { "My Plugin" } - fn enabled(&self) -> bool { self.enabled } - fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; } - fn reset(&mut self) {} - - fn process_frame( - &mut self, - _frame: &PluginFrame<'_>, - _output: &mut HostOutput<'_>, - _context: &mut HostContext<'_>, - _event_store: &EventStoreHandle<'_>, - ) { - } -} - -export_plugin!(MyPlugin); -``` - ## Execution Model `input_kind()` and retained history are separate concerns. @@ -94,64 +64,42 @@ context.publish("my.plugin.results", &results)?; let upstream = context.get::("my.plugin.results")?; ``` -Prefer standard shared payloads such as `CTX_LOCALIZATION_RESULTS` when they exist. The standard localization payload now lives in `augur-plugin-types`. If several plugins need the same domain-specific type, put that type in a companion crate instead of copying it into multiple plugin crates. - -Persistent helpers are still available for plugin-owned caches, but shared scientific outputs should normally stay on the per-frame context bus. +Prefer standard shared payloads such as `CTX_LOCALIZATION_RESULTS` when they exist. If several plugins need the same domain-specific type, put that type in a companion crate instead of copying it into multiple plugin crates. ## Host-Owned Global Settings -The host now publishes shared runtime settings on the normal context bus: +The host publishes shared runtime settings on the normal context bus: - key: `CTX_GLOBAL_SETTINGS` - type: `GlobalSettings` -Example: - -```rust -use augur_plugin_api::{GlobalSettings, CTX_GLOBAL_SETTINGS}; - -if let Some(globals) = context.get::(CTX_GLOBAL_SETTINGS)? { - let nm_per_pixel = globals.nm_per_pixel; - let sensor_width = globals.sensor_width; - let sensor_height = globals.sensor_height; - let acq_time_ms = globals.acq_time_ms; - let event_store_budget_bytes = globals.event_store_budget_bytes; - let _ = ( - nm_per_pixel, - sensor_width, - sensor_height, - acq_time_ms, - event_store_budget_bytes, - ); -} -``` - New plugins should prefer `GlobalSettings` over duplicating host-owned defaults such as pixel scale or sensor geometry. -Plugins must tolerate `None` when run against an older host build. - -## Dependencies - -Override `dependencies()` only when the plugin truly requires a specific upstream producer by name: - -```rust -fn dependencies(&self) -> &[&'static str] { - &["EVE Candidate Finding"] -} -``` +## Linked Investigation Datasets -If the plugin can degrade gracefully when an upstream payload is absent, prefer a runtime warning over a hard dependency declaration. +The host now treats structured datasets as the primary integration surface for linked 2D, 3D, and table workflows. Overlays are supplemental. -## Settings And Status +When a table dataset should participate in linked investigation, populate as many of these additive fields as the plugin can support: -Plugins describe settings declaratively through: +- `TableSchema.coordinate_space_2d` +- `TableSchema.coordinate_space_3d` +- `TableSchema.row_id_column` +- `TableSchema.time_column` +- `TableSchema.layer_id` +- `TableSchema.semantic_label` +- `HostDatasetDescriptor.display` + - `layer_title` + - `default_visibility` + - `default_color` + - `default_marker_shape` + - `default_size` -- `settings_schema()` -- `get_setting()` -- `set_setting()` -- optional `status_entries()` +Guidelines: -Common setting kinds include `Bool`, slider/drag values, and `Enum`. The host owns rendering and persistence of the UI state. +- Use stable ids from the scientific data when possible. +- Fall back to deterministic plugin-generated ids when no natural id exists. +- Key reusable shared views by dataset id and keep descriptors byte-for-byte identical across providers that intentionally reuse the same ids. +- Prefer dataset/layer ids for styling and visibility instead of plugin-name-specific logic. ## Host Views @@ -169,6 +117,7 @@ Plugins can declare host-rendered datasets and views through `host_views()` and - `HostViewKind::TableWindow` - `HostViewKind::Density2dFromTable` - `HostViewKind::Scatter2dFromTable` +- `HostViewKind::Scatter3dFromTable` - `HostViewKind::ImageWindow` - `HostViewKind::LineSeriesWindow` @@ -178,10 +127,20 @@ Plugins can declare host-rendered datasets and views through `host_views()` and fn host_views(&self) -> HostViewRegistry { HostViewRegistry { datasets: vec![HostDatasetDescriptor { - id: "example.table".into(), - title: "Example Table".into(), + id: "example.points".into(), + title: "Example Points".into(), kind: HostDatasetKind::TableV1(TableSchema { columns: vec![ + TableColumn { + id: "row_id".into(), + title: "ID".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "timestamp_us".into(), + title: "Timestamp (us)".into(), + value_type: TableValueType::U64, + }, TableColumn { id: "x".into(), title: "X".into(), @@ -193,48 +152,79 @@ fn host_views(&self) -> HostViewRegistry { value_type: TableValueType::F64, }, ], - coordinate_space_2d: None, + coordinate_space_2d: Some(TableCoordinateSpace2d { + x_column: "x".into(), + y_column: "y".into(), + x_min: 0.0, + x_max: 128.0, + y_min: 0.0, + y_max: 128.0, + }), + coordinate_space_3d: Some(TableCoordinateSpace3d { + x_column: "x".into(), + y_column: "y".into(), + z_column: "timestamp_us".into(), + x_min: 0.0, + x_max: 128.0, + y_min: 0.0, + y_max: 128.0, + z_min: 0.0, + z_max: 5_000.0, + }), + row_id_column: Some("row_id".into()), + time_column: Some("timestamp_us".into()), + layer_id: Some("example.layer.points".into()), + semantic_label: Some("points".into()), }), empty_message: "No rows yet.".into(), + display: Some(HostDatasetDisplayMetadata { + layer_title: Some("Example points".into()), + default_visibility: Some(true), + default_color: Some([80, 200, 255, 255]), + default_marker_shape: Some(HostMarkerShape::Point), + default_size: Some(3.0), + }), }], views: vec![HostViewDescriptor { - id: "example.table.compact".into(), - title: "Current Rows".into(), - dataset_id: "example.table".into(), - placement: HostViewPlacement::AnalysisPanel, - kind: HostViewKind::CompactTable, + id: "example.points.3d".into(), + title: "Example 3D".into(), + dataset_id: "example.points".into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::Scatter3dFromTable { + x_column: "x".into(), + y_column: "y".into(), + z_column: "timestamp_us".into(), + }, }], } } - -fn host_view_dataset(&self, dataset_id: &str) -> Option> { - if dataset_id != "example.table" { - return None; - } - - let dataset = TableDatasetV1::new(vec![ - TableColumnData { - column_id: "x".into(), - values: TableColumnValues::F64(vec![1.0, 2.0]), - }, - TableColumnData { - column_id: "y".into(), - values: TableColumnValues::F64(vec![3.0, 4.0]), - }, - ]).ok()?; - - serde_json::to_vec(&dataset).ok() -} - -fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { - if dataset_id == "example.table" { 1 } else { 0 } -} ``` `host_view_dataset_generation()` is optional but recommended when the host should invalidate a cached snapshot only after the dataset changes. The host owns rendering, exports, caching, and window state. Plugins do not render `egui` directly. +## Marker Overlays + +Use structured datasets for the primary linked-workspace model. Use overlays when the plugin needs extra 2D annotations or hit-testing that supplements the dataset. + +Current overlay helpers: + +- `add_highlight_pixels(...)` +- `add_crosshair_markers(...)` +- `add_marker_overlay(...)` +- `add_warning(...)` + +`add_marker_overlay(...)` supports: + +- point, cross, box, ellipse, diamond, and filled-circle shapes +- per-item color and size +- optional timestamp +- optional stable id +- optional dataset id, layer id, and source label + +That makes it the right choice when a 2D preview annotation should resolve back into the same host selection model. + ## Event History `process_frame()` always receives `event_store: &EventStoreHandle<'_>`. Plugins that need only the current frame can ignore it. History-aware plugins can query: @@ -247,13 +237,14 @@ The host owns rendering, exports, caching, and window state. Plugins do not rend - `collect_events_in_range(start_us, end_us, out)` - `oldest_timestamp_us()` -## Migration From Older Plugin Code +## Migration Notes -When porting older code, replace: +When porting older code: -- `AnalysisPlugin` with `Plugin` -- typed `PluginContext` exchange with `HostContext` -- direct `egui` UI code with declarative settings/status -- special-case host rendering hooks with `host_views()` and `host_view_dataset()` -- duplicated host-owned calibration values with `GlobalSettings` -- compile-time registration with `export_plugin!` plus a built `cdylib` +- replace `AnalysisPlugin` with `Plugin` +- replace typed `PluginContext` exchange with `HostContext` +- replace direct `egui` UI code with declarative settings/status +- replace special-case host rendering hooks with `host_views()` / `host_view_dataset()` +- replace row-index-based linking assumptions with stable row ids where possible +- replace plugin-name-based styling assumptions with dataset/layer metadata +- keep overlays as supplemental annotations, not the primary data contract 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 69% rename from plugins/evesmlm-candidates/src/types.rs rename to evesmlm-types/src/candidates.rs index 1f1fbd1..2320657 100644 --- a/plugins/evesmlm-candidates/src/types.rs +++ b/evesmlm-types/src/candidates.rs @@ -2,6 +2,11 @@ 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 +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -13,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", @@ -33,7 +54,7 @@ pub struct EveEvent { impl From for EveEvent { fn from(value: FfiCdEvent) -> Self { Self { - timestamp: value.timestamp, + timestamp: value.timestamp_us(), x: value.x, y: value.y, polarity: value.polarity != 0, @@ -41,8 +62,28 @@ impl From for EveEvent { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ClusterBoundary { + BoundingBox { + x_min: u16, + x_max: u16, + y_min: u16, + y_max: u16, + }, + Ellipse { + cx: f64, + cy: f64, + semi_major: f64, + semi_minor: f64, + angle_rad: f64, + }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EveCluster { + #[serde(default)] + pub cluster_id: u64, /// Per-pixel event histogram: (x, y, n_positive, n_negative) pub pixel_histogram: Vec<(u16, u16, u32, u32)>, /// All raw events assigned to this cluster. @@ -55,6 +96,10 @@ pub struct EveCluster { pub x_max: u16, pub y_min: u16, pub y_max: u16, + #[serde(default = "default_cluster_complete")] + pub complete: bool, + #[serde(default)] + pub boundary: Option, } impl 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 60% rename from plugins/evesmlm-fitting/src/types.rs rename to evesmlm-types/src/localization.rs index 3c37295..85684bc 100644 --- a/plugins/evesmlm-fitting/src/types.rs +++ b/evesmlm-types/src/localization.rs @@ -2,6 +2,32 @@ use serde::{Deserialize, Serialize}; pub const CTX_EVE_LOCALIZATION_RESULTS: &str = "augur.evesmlm.localization_results"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RejectionReason { + FitFailed, + SigmaOutOfBounds, + ResidualTooHigh, +} + +impl RejectionReason { + pub fn label(self) -> &'static str { + match self { + Self::FitFailed => "Fit failed", + Self::SigmaOutOfBounds => "Sigma out of bounds", + Self::ResidualTooHigh => "Residual too high", + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::FitFailed => "fit_failed", + Self::SigmaOutOfBounds => "sigma_out_of_bounds", + Self::ResidualTooHigh => "residual_too_high", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FitMethod { @@ -51,11 +77,14 @@ impl FitMethod { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EveLocalization { + pub cluster_id: u64, pub x: f64, pub y: f64, pub sigma_x: f64, pub sigma_y: f64, pub timestamp_us: u64, + pub span_start_us: u64, + pub span_end_us: u64, pub n_events: usize, pub polarity_balance: f64, pub fit_residual: f64, @@ -68,3 +97,20 @@ pub struct EveLocalizationResults { pub frame_window_start_us: u64, pub frame_window_end_us: u64, } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RejectedFitRow { + pub row_id: u64, + pub cluster_id: u64, + pub x: f64, + pub y: f64, + pub sigma_x: f64, + pub sigma_y: f64, + pub fit_residual: f64, + pub n_events: u64, + pub polarity_balance: f64, + pub rejection_reason: RejectionReason, + pub timestamp_us: u64, + pub span_start_us: u64, + pub span_end_us: u64, +} diff --git a/plugin-template/README.md b/plugin-template/README.md index d525db8..ae32b4c 100644 --- a/plugin-template/README.md +++ b/plugin-template/README.md @@ -22,6 +22,15 @@ If this plugin publishes results to `HostContext` for downstream consumers, desc If this plugin declares datasets or views through `host_views()`, document the dataset ids, view ids, and expected schema here. +For investigation-linked table datasets, also document: + +- which column provides stable row identity +- whether 2D coordinates are exposed +- whether 3D coordinates and time are exposed +- which layer id and display defaults the host should expect + +Prefer structured datasets for linked 2D/3D/table workflows. Use overlays as supplemental annotations rather than the only way to inspect results. + ## Dependencies List any hard upstream plugin dependencies this plugin declares through `dependencies()` (or "None"). 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/README.md b/plugins/evesmlm-candidates/README.md index de3a9a1..89c1b47 100644 --- a/plugins/evesmlm-candidates/README.md +++ b/plugins/evesmlm-candidates/README.md @@ -20,26 +20,43 @@ Raw-event candidate discovery for eveSMLM. This plugin groups `CdEvent` samples | Polarity | `Both` | Use positive, negative, or all events | | Epsilon | `3.0` px | Neighborhood radius for DBSCAN | | Min events | `5` | Minimum cluster size | +| Lookback | `66_000` us | Retained-history window used for temporal clustering; set to `0` for single-frame behavior | +| Stable frames | `2` | Number of consecutive no-growth frames before a cluster is published | | Max spatial extent | `5.0` px | Eigenfeature upper bound on the major covariance axis | | Min isotropy | `0.2` | Eigenfeature lower bound on `lambda2 / lambda1` | | Threshold factor | `1.5` | Wavelet threshold multiplier for frame-based mode | | Fit radius | `4` px | Event gathering radius in frame-based mode | | Max candidates | `512` | Safety cap on published candidates | -| Show overlay | `true` | Highlight candidate centroids in the preview | +| Show centroids | `true` | Draw clickable centroid markers linked to accepted candidate events | +| Show boundaries | `true` | Draw 2-sigma ellipses or bounding boxes around visible clusters | +| Show provisional | `true` | Keep still-growing clusters visible in the overlay | ## Execution Phase -`RawEvents` — consumes the raw `CdEvent` stream for the current preview window. +`RawEvents` — consumes the raw `CdEvent` stream for the current preview window and can optionally gather retained events from earlier frames. ## Published Data Publishes `EveCandidates` on the context key `augur.evesmlm.candidates`, containing: -- `clusters: Vec` with raw events, per-pixel histograms, centroid, and bounds +- `clusters: Vec` with stable `cluster_id`, raw events, per-pixel histograms, centroid, bounds, and optional boundary metadata - `frame_window_start_us`, `frame_window_end_us` - `n_events_processed` - `finding_method` +It also exposes two host investigation datasets for the current analysis window: + +- accepted candidate events +- rejected candidate events + +Both datasets carry stable row ids, timestamps, 2D coordinates, and 3D scatter metadata so the host can render accepted and rejected raw events as separate layers during live parameter tuning. + +The plugin now also registers compact and windowed host tables for both datasets, so centroid selection has a visible table target inside AugurRS without requiring plugin-specific UI. + +Accepted candidate-event rows intentionally use the string form of `cluster_id` as the row-id column so one centroid click can select the whole cluster in the accepted-events table and its 3D view. + +That selection is dataset-local: it links the centroid marker to the accepted-events dataset, but it does not cross-select unrelated datasets such as rejected fits because AugurRS stable row keys include the dataset id. + ## Dependencies None. diff --git a/plugins/evesmlm-candidates/src/eigenfeature.rs b/plugins/evesmlm-candidates/src/eigenfeature.rs index e75df0c..60a9e7f 100644 --- a/plugins/evesmlm-candidates/src/eigenfeature.rs +++ b/plugins/evesmlm-candidates/src/eigenfeature.rs @@ -2,6 +2,13 @@ use nalgebra::Matrix2; use crate::EveEvent; +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ClusterEigenInfo { + pub lambda_1: f64, + pub lambda_2: f64, + pub angle_rad: f64, +} + pub fn filter_clusters( events: &[EveEvent], clusters: Vec>, @@ -13,20 +20,20 @@ pub fn filter_clusters( clusters .into_iter() .filter(|indices| { - let Some((lambda_1, lambda_2)) = cluster_eigenvalues(events, indices) else { + let Some(info) = cluster_eigen_info(events, indices) else { return false; }; - let isotropy = if lambda_1 <= 1e-9 { + let isotropy = if info.lambda_1 <= 1e-9 { 1.0 } else { - lambda_2 / lambda_1 + info.lambda_2 / info.lambda_1 }; - lambda_1 <= max_variance && isotropy >= min_isotropy + info.lambda_1 <= max_variance && isotropy >= min_isotropy }) .collect() } -pub fn cluster_eigenvalues(events: &[EveEvent], indices: &[usize]) -> Option<(f64, f64)> { +pub fn cluster_eigen_info(events: &[EveEvent], indices: &[usize]) -> Option { if indices.len() < 2 { return None; } @@ -55,7 +62,21 @@ pub fn cluster_eigenvalues(events: &[EveEvent], indices: &[usize]) -> Option<(f6 covariance /= n.max(1.0); let eigen = covariance.symmetric_eigen(); - let mut eigenvalues = [eigen.eigenvalues[0], eigen.eigenvalues[1]]; - eigenvalues.sort_by(|left, right| right.total_cmp(left)); - Some((eigenvalues[0], eigenvalues[1])) + let major_index = if eigen.eigenvalues[0] >= eigen.eigenvalues[1] { + 0 + } else { + 1 + }; + let minor_index = 1 - major_index; + let major_vector = eigen.eigenvectors.column(major_index); + + Some(ClusterEigenInfo { + lambda_1: eigen.eigenvalues[major_index], + lambda_2: eigen.eigenvalues[minor_index], + angle_rad: major_vector[1].atan2(major_vector[0]), + }) +} + +pub fn cluster_eigenvalues(events: &[EveEvent], indices: &[usize]) -> Option<(f64, f64)> { + cluster_eigen_info(events, indices).map(|info| (info.lambda_1, info.lambda_2)) } diff --git a/plugins/evesmlm-candidates/src/lib.rs b/plugins/evesmlm-candidates/src/lib.rs index 8b3d215..4556b2e 100644 --- a/plugins/evesmlm-candidates/src/lib.rs +++ b/plugins/evesmlm-candidates/src/lib.rs @@ -6,18 +6,28 @@ pub mod dbscan; pub mod eigenfeature; -pub mod types; +mod tracking; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use augur_plugin_api::{ - export_plugin, AnalysisSeverity, EventStoreHandle, FfiCdEvent, FfiPixel, HostContext, - HostOutput, Plugin, PluginFrame, PluginInput, SettingItem, SettingKind, SettingsSchema, - SettingsSection, StatusEntry, + export_plugin, AnalysisSeverity, EventStoreHandle, FfiCdEvent, FfiColorRgba, + FfiMarkerOverlayItem, FfiMarkerShape, FfiPixel, FfiString, HostContext, HostDatasetDescriptor, + HostDatasetDisplayMetadata, HostDatasetKind, HostDatasetRelation, HostMarkerShape, HostOutput, + HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, Plugin, + PluginCapabilities, PluginFrame, PluginInput, PluginStateKind, SettingItem, SettingKind, + SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, + TableColumnDisplayEntry, TableColumnDisplayFormat, TableColumnDisplayMetadata, + TableColumnValues, TableColumnWidthPriority, TableCoordinateSpace2d, TableCoordinateSpace3d, + TableDatasetV1, TableRowProvenance, TableSchema, TableValueType, }; use serde_json::{json, Value}; -pub use types::{CandidateFindingMethod, EveCandidates, EveCluster, EveEvent, CTX_EVE_CANDIDATES}; +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] = [ @@ -31,7 +41,56 @@ const KERNEL_G2: [f64; 9] = [ 0.0, 1.0 / 16.0, ]; -const OVERLAY_COLOR: [u8; 4] = [255, 210, 32, 220]; +const ACCEPTED_EVENTS_COLOR: [u8; 4] = [60, 220, 140, 255]; +const REJECTED_EVENTS_COLOR: [u8; 4] = [255, 110, 110, 235]; +const COMPLETE_BOUNDARY_COLOR: [u8; 4] = [255, 255, 255, 60]; +const PROVISIONAL_BOUNDARY_COLOR: [u8; 4] = [255, 255, 255, 28]; +const COMPLETE_MARKER_COLOR: [u8; 4] = [255, 255, 255, 180]; +const PROVISIONAL_MARKER_COLOR: [u8; 4] = [255, 255, 255, 110]; +const ACCEPTED_EVENTS_DATASET_ID: &str = "augur.evesmlm.candidates.accepted_events"; +const REJECTED_EVENTS_DATASET_ID: &str = "augur.evesmlm.candidates.rejected_events"; +const ACCEPTED_EVENTS_LAYER_ID: &str = "augur.layer.evesmlm.accepted_events"; +const REJECTED_EVENTS_LAYER_ID: &str = "augur.layer.evesmlm.rejected_events"; +const ACCEPTED_EVENTS_COMPACT_VIEW_ID: &str = "augur.evesmlm.candidates.accepted_events.compact"; +const REJECTED_EVENTS_COMPACT_VIEW_ID: &str = "augur.evesmlm.candidates.rejected_events.compact"; +const ACCEPTED_EVENTS_TABLE_VIEW_ID: &str = "augur.evesmlm.candidates.accepted_events.table"; +const REJECTED_EVENTS_TABLE_VIEW_ID: &str = "augur.evesmlm.candidates.rejected_events.table"; +const ACCEPTED_EVENTS_3D_VIEW_ID: &str = "augur.evesmlm.candidates.accepted_events.scatter3d"; +const REJECTED_EVENTS_3D_VIEW_ID: &str = "augur.evesmlm.candidates.rejected_events.scatter3d"; +const CANDIDATE_FINDINGS_DATASET_ID: &str = "augur.evesmlm.candidates.candidate_findings"; +const CANDIDATE_FINDING_PIXELS_DATASET_ID: &str = + "augur.evesmlm.candidates.candidate_finding_pixels"; +const CANDIDATE_FINDINGS_LAYER_ID: &str = "augur.layer.evesmlm.candidate_findings"; +const CANDIDATE_FINDINGS_COMPACT_VIEW_ID: &str = + "augur.evesmlm.candidates.candidate_findings.compact"; +const CANDIDATE_FINDINGS_TABLE_VIEW_ID: &str = "augur.evesmlm.candidates.candidate_findings.table"; +const CANDIDATE_FINDING_PIXELS_TABLE_VIEW_ID: &str = + "augur.evesmlm.candidates.candidate_finding_pixels.table"; + +#[derive(Debug, Clone)] +struct CandidateEventRow { + event_id: u64, + x_px: f64, + y_px: f64, + timestamp_us: u64, + polarity: bool, + cluster_id: String, +} + +#[derive(Debug, Clone, Default)] +struct CandidateEventDatasets { + accepted: Vec, + rejected: Vec, + sensor_dims: Option<(u16, u16)>, + frame_window_start_us: u64, + frame_window_end_us: u64, +} + +#[derive(Debug, Clone)] +struct CandidateFinding { + cluster: EveCluster, + method: CandidateFindingMethod, +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PolarityMode { @@ -75,36 +134,22 @@ 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, pub polarity: PolarityMode, pub epsilon_px: f64, pub min_events: usize, + pub lookback_us: u64, + pub stable_frames: usize, pub max_spatial_extent_px: f64, pub min_isotropy: f64, pub threshold_factor: f64, pub fit_radius_px: usize, pub max_candidates: usize, pub show_overlay: bool, + pub show_boundaries: bool, + pub show_provisional: bool, } impl Default for CandidateSettings { @@ -114,12 +159,16 @@ impl Default for CandidateSettings { polarity: PolarityMode::Both, epsilon_px: 3.0, min_events: 5, + lookback_us: 66_000, + stable_frames: 2, max_spatial_extent_px: 5.0, min_isotropy: 0.2, threshold_factor: 1.5, fit_radius_px: 4, max_candidates: 512, show_overlay: true, + show_boundaries: true, + show_provisional: true, } } } @@ -127,9 +176,19 @@ impl Default for CandidateSettings { pub struct EveSmlmCandidatePlugin { enabled: bool, settings: CandidateSettings, + current_event_datasets: CandidateEventDatasets, last_candidate_count: usize, + last_complete_visible_count: usize, + last_provisional_count: usize, last_event_count: usize, last_status: String, + dataset_generation: u64, + findings: Vec, + findings_generation: u64, + frame_counter: u64, + next_cluster_id: u64, + tracked_clusters: Vec, + event_buffer: Vec, } impl Default for EveSmlmCandidatePlugin { @@ -137,24 +196,107 @@ impl Default for EveSmlmCandidatePlugin { Self { enabled: false, settings: CandidateSettings::default(), + current_event_datasets: CandidateEventDatasets::default(), last_candidate_count: 0, + last_complete_visible_count: 0, + last_provisional_count: 0, last_event_count: 0, last_status: "Enable the plugin to cluster raw eveSMLM events into candidates.".into(), + dataset_generation: 0, + findings: Vec::new(), + findings_generation: 0, + frame_counter: 0, + next_cluster_id: 0, + tracked_clusters: Vec::new(), + event_buffer: Vec::new(), } } } impl EveSmlmCandidatePlugin { + fn reset_tracking_state(&mut self) { + self.frame_counter = 0; + self.next_cluster_id = 0; + self.tracked_clusters.clear(); + self.event_buffer.clear(); + self.findings.clear(); + self.findings_generation = self.findings_generation.wrapping_add(1); + } + + fn append_findings(&mut self, clusters: &[EveCluster]) { + if clusters.is_empty() { + return; + } + + let method = self.settings.finding_method; + self.findings.extend( + clusters + .iter() + .cloned() + .map(|cluster| CandidateFinding { cluster, method }), + ); + self.findings_generation = self.findings_generation.wrapping_add(1); + } + + fn collect_analysis_events( + &mut self, + frame: &PluginFrame<'_>, + event_store: &EventStoreHandle<'_>, + ) -> (Vec, u64, u64, bool) { + let mut analysis_events = std::mem::take(&mut self.event_buffer); + let mut analysis_window_start = frame.window_start_us(); + let analysis_window_end = frame.window_end_us(); + let temporal_enabled = self.settings.lookback_us > 0 && event_store.frame_count() > 0; + + analysis_events.clear(); + if temporal_enabled { + let buffered_start = analysis_window_end.saturating_sub(self.settings.lookback_us); + analysis_window_start = buffered_start.max(event_store.oldest_timestamp_us()); + event_store.collect_events_in_range( + analysis_window_start, + analysis_window_end, + &mut analysis_events, + ); + } + + if analysis_events.is_empty() { + analysis_events.extend_from_slice(frame.events()); + analysis_window_start = frame.window_start_us(); + } + + ( + analysis_events, + analysis_window_start, + analysis_window_end, + temporal_enabled, + ) + } + fn analyze_frame( &mut self, frame: &PluginFrame<'_>, raw_events: &[FfiCdEvent], + analysis_window_start_us: u64, + analysis_window_end_us: u64, + temporal_enabled: bool, output: &mut HostOutput<'_>, ) -> EveCandidates { if raw_events.is_empty() { + self.current_event_datasets = CandidateEventDatasets { + sensor_dims: Some((frame.width(), frame.height())), + frame_window_start_us: analysis_window_start_us, + frame_window_end_us: analysis_window_end_us, + ..CandidateEventDatasets::default() + }; self.last_candidate_count = 0; + self.last_complete_visible_count = 0; + self.last_provisional_count = 0; self.last_event_count = 0; - self.last_status = "Raw events are unavailable for this preview frame.".into(); + self.last_status = if temporal_enabled { + "No retained raw events are available in the requested temporal lookback.".into() + } else { + "Raw events are unavailable for this preview frame.".into() + }; Self::warning( output, AnalysisSeverity::Info, @@ -171,18 +313,26 @@ impl EveSmlmCandidatePlugin { .collect(); self.last_event_count = filtered_events.len(); if filtered_events.is_empty() { + self.current_event_datasets = CandidateEventDatasets { + sensor_dims: Some((frame.width(), frame.height())), + frame_window_start_us: analysis_window_start_us, + frame_window_end_us: analysis_window_end_us, + ..CandidateEventDatasets::default() + }; self.last_candidate_count = 0; + self.last_complete_visible_count = 0; + self.last_provisional_count = 0; self.last_status = "No events passed the configured polarity filter.".into(); return EveCandidates { clusters: Vec::new(), - frame_window_start_us: frame.window_start_us(), - frame_window_end_us: frame.window_end_us(), + frame_window_start_us: analysis_window_start_us, + frame_window_end_us: analysis_window_end_us, n_events_processed: 0, finding_method: self.settings.finding_method, }; } - let cluster_indices = match self.settings.finding_method { + let mut cluster_indices = match self.settings.finding_method { CandidateFindingMethod::Dbscan => dbscan::cluster_event_indices( &filtered_events, self.settings.epsilon_px, @@ -207,44 +357,264 @@ impl EveSmlmCandidatePlugin { } }; - let mut clusters = clusters_from_indices(&filtered_events, cluster_indices); - clusters.sort_by_key(|cluster| std::cmp::Reverse(cluster.event_count())); - if clusters.len() > self.settings.max_candidates { - clusters.truncate(self.settings.max_candidates); + cluster_indices.sort_by_key(|indices| std::cmp::Reverse(indices.len())); + if cluster_indices.len() > self.settings.max_candidates { + cluster_indices.truncate(self.settings.max_candidates); } - self.last_candidate_count = clusters.len(); - self.last_status = format!( - "{} candidates from {} events using {}.", - self.last_candidate_count, - self.last_event_count, - self.settings.finding_method.label() + let detected_clusters = clusters_from_indices( + &filtered_events, + cluster_indices.clone(), + self.settings.finding_method, + ); + let (visible_clusters, published_clusters) = + self.update_tracked_clusters(detected_clusters, temporal_enabled); + self.append_findings(&published_clusters); + + self.current_event_datasets = build_candidate_event_datasets( + (frame.width(), frame.height()), + analysis_window_start_us, + analysis_window_end_us, + &filtered_events, + &cluster_indices, + &visible_clusters, ); - if self.settings.show_overlay && !clusters.is_empty() { - let pixels: Vec = clusters - .iter() - .map(|cluster| FfiPixel { - x: cluster.centroid_x.round().max(0.0) as u16, - y: cluster.centroid_y.round().max(0.0) as u16, - }) - .collect(); - output.add_highlight_pixels(&pixels, OVERLAY_COLOR); - } + self.last_candidate_count = published_clusters.len(); + self.last_complete_visible_count = visible_clusters + .iter() + .filter(|cluster| cluster.complete) + .count(); + self.last_provisional_count = visible_clusters + .len() + .saturating_sub(self.last_complete_visible_count); + + let window_span_us = analysis_window_end_us.saturating_sub(analysis_window_start_us); + let boundary_summary = if self.settings.show_boundaries && !visible_clusters.is_empty() { + format!( + " Showing {} for {} visible clusters.", + boundary_label(self.settings.finding_method), + visible_clusters.len() + ) + } else { + String::new() + }; + self.last_status = if temporal_enabled { + format!( + "{} published, {} complete visible, {} provisional from {} events using {} over {} us.{}", + self.last_candidate_count, + self.last_complete_visible_count, + self.last_provisional_count, + self.last_event_count, + self.settings.finding_method.label(), + window_span_us, + boundary_summary + ) + } else { + format!( + "{} published from {} events using {} in the current frame.{}", + self.last_candidate_count, + self.last_event_count, + self.settings.finding_method.label(), + boundary_summary + ) + }; + + self.render_cluster_overlay(frame, &visible_clusters, output); EveCandidates { - clusters, - frame_window_start_us: frame.window_start_us(), - frame_window_end_us: frame.window_end_us(), + clusters: published_clusters, + frame_window_start_us: analysis_window_start_us, + frame_window_end_us: analysis_window_end_us, n_events_processed: filtered_events.len(), finding_method: self.settings.finding_method, } } + fn update_tracked_clusters( + &mut self, + mut detected_clusters: Vec, + temporal_enabled: bool, + ) -> (Vec, Vec) { + self.frame_counter = self.frame_counter.wrapping_add(1); + let stable_frames = self.settings.stable_frames.max(1); + let retention_frames = stable_frames.saturating_mul(2).max(1); + let matching_radius = self.settings.epsilon_px.max(0.5); + + let mut candidate_pairs = Vec::new(); + for (detected_index, cluster) in detected_clusters.iter().enumerate() { + for (tracked_index, tracked) in self.tracked_clusters.iter().enumerate() { + let dx = cluster.centroid_x - tracked.centroid_x; + let dy = cluster.centroid_y - tracked.centroid_y; + let distance = (dx * dx + dy * dy).sqrt(); + if distance <= matching_radius { + candidate_pairs.push((distance, detected_index, tracked_index)); + } + } + } + candidate_pairs.sort_by(|left, right| left.0.total_cmp(&right.0)); + + let mut detected_to_tracked = vec![None; detected_clusters.len()]; + let mut tracked_taken = vec![false; self.tracked_clusters.len()]; + for (_, detected_index, tracked_index) in candidate_pairs { + if detected_to_tracked[detected_index].is_none() && !tracked_taken[tracked_index] { + detected_to_tracked[detected_index] = Some(tracked_index); + tracked_taken[tracked_index] = true; + } + } + + for (detected_index, cluster) in detected_clusters.iter_mut().enumerate() { + if let Some(tracked_index) = detected_to_tracked[detected_index] { + let tracked = &mut self.tracked_clusters[tracked_index]; + let current_count = cluster.event_count(); + tracked.centroid_x = cluster.centroid_x; + tracked.centroid_y = cluster.centroid_y; + tracked.last_seen_frame = self.frame_counter; + + if current_count > tracked.event_count { + tracked.event_count = current_count; + tracked.last_grown_frame = self.frame_counter; + tracked.frames_since_growth = 0; + tracked.cluster = cluster.clone(); + if temporal_enabled { + tracked.complete = false; + } + } else { + tracked.frames_since_growth = tracked.frames_since_growth.saturating_add(1); + if current_count == tracked.event_count { + tracked.cluster = cluster.clone(); + } + } + + if !temporal_enabled || tracked.frames_since_growth >= stable_frames { + tracked.complete = true; + } + + tracked.cluster.cluster_id = tracked.id; + tracked.cluster.complete = tracked.complete; + cluster.cluster_id = tracked.id; + cluster.complete = tracked.complete; + } else { + let cluster_id = self.next_cluster_id; + self.next_cluster_id = self.next_cluster_id.wrapping_add(1); + cluster.cluster_id = cluster_id; + cluster.complete = !temporal_enabled; + self.tracked_clusters.push(TrackedCluster { + id: cluster_id, + centroid_x: cluster.centroid_x, + centroid_y: cluster.centroid_y, + event_count: cluster.event_count(), + last_seen_frame: self.frame_counter, + last_grown_frame: self.frame_counter, + frames_since_growth: 0, + complete: cluster.complete, + emitted: false, + cluster: cluster.clone(), + }); + } + } + + for tracked in &mut self.tracked_clusters { + if tracked.last_seen_frame != self.frame_counter { + tracked.frames_since_growth = tracked.frames_since_growth.saturating_add(1); + if temporal_enabled && tracked.frames_since_growth >= stable_frames { + tracked.complete = true; + } + } + if !temporal_enabled { + tracked.complete = true; + } + tracked.cluster.cluster_id = tracked.id; + tracked.cluster.complete = tracked.complete; + } + + let mut published_clusters = Vec::new(); + for tracked in &mut self.tracked_clusters { + if tracked.complete && !tracked.emitted { + tracked.emitted = true; + let mut cluster = tracked.cluster.clone(); + cluster.cluster_id = tracked.id; + cluster.complete = true; + published_clusters.push(cluster); + } + } + + self.tracked_clusters.retain(|tracked| { + self.frame_counter.saturating_sub(tracked.last_seen_frame) as usize <= retention_frames + }); + + (detected_clusters, published_clusters) + } + + fn render_cluster_overlay( + &self, + frame: &PluginFrame<'_>, + visible_clusters: &[EveCluster], + output: &mut HostOutput<'_>, + ) { + let overlay_clusters: Vec<&EveCluster> = visible_clusters + .iter() + .filter(|cluster| cluster.complete || self.settings.show_provisional) + .collect(); + + if self.settings.show_boundaries && !overlay_clusters.is_empty() { + let (complete_pixels, provisional_pixels) = + boundary_pixels(&overlay_clusters, frame.width(), frame.height()); + if !complete_pixels.is_empty() { + output.add_highlight_pixels(&complete_pixels, COMPLETE_BOUNDARY_COLOR); + } + if !provisional_pixels.is_empty() { + output.add_highlight_pixels(&provisional_pixels, PROVISIONAL_BOUNDARY_COLOR); + } + } + + if self.settings.show_overlay && !overlay_clusters.is_empty() { + let stable_ids: Vec = overlay_clusters + .iter() + .map(|cluster| cluster.cluster_id.to_string()) + .collect(); + let markers: Vec = overlay_clusters + .iter() + .zip(stable_ids.iter()) + .map(|(cluster, stable_id)| FfiMarkerOverlayItem { + x: cluster.centroid_x as f32, + y: cluster.centroid_y as f32, + shape: FfiMarkerShape::FilledCircle, + size: 4.0, + color: FfiColorRgba::from_rgba(if cluster.complete { + COMPLETE_MARKER_COLOR + } else { + PROVISIONAL_MARKER_COLOR + }), + timestamp_us: cluster + .events + .last() + .map(|event| event.timestamp) + .unwrap_or(frame.window_end_us()), + has_timestamp: !cluster.events.is_empty(), + stable_id: stable_id.as_str().into(), + source_dataset_id: FfiString::empty(), + source_row_id: FfiString::empty(), + }) + .collect(); + output.add_marker_overlay( + &markers, + Some(ACCEPTED_EVENTS_DATASET_ID), + Some(ACCEPTED_EVENTS_LAYER_ID), + Some(self.name()), + ); + } + } + pub fn reset(&mut self) { + self.reset_tracking_state(); + self.current_event_datasets = CandidateEventDatasets::default(); self.last_candidate_count = 0; + self.last_complete_visible_count = 0; + self.last_provisional_count = 0; self.last_event_count = 0; self.last_status = "Waiting for the next preview frame.".into(); + self.dataset_generation = self.dataset_generation.wrapping_add(1); } fn parse_usize(value: Value) -> Option { @@ -289,9 +659,20 @@ impl Plugin for EveSmlmCandidatePlugin { frame: &PluginFrame<'_>, output: &mut HostOutput<'_>, context: &mut HostContext<'_>, - _event_store: &EventStoreHandle<'_>, + event_store: &EventStoreHandle<'_>, ) { - let candidates = self.analyze_frame(frame, frame.events(), output); + let (analysis_events, analysis_window_start_us, analysis_window_end_us, temporal_enabled) = + self.collect_analysis_events(frame, event_store); + let candidates = self.analyze_frame( + frame, + &analysis_events, + analysis_window_start_us, + analysis_window_end_us, + temporal_enabled, + output, + ); + self.event_buffer = analysis_events; + self.dataset_generation = self.dataset_generation.wrapping_add(1); if let Err(err) = context.publish(CTX_EVE_CANDIDATES, &candidates) { Self::warning( output, @@ -301,6 +682,12 @@ impl Plugin for EveSmlmCandidatePlugin { } } + fn capabilities(&self) -> PluginCapabilities { + PluginCapabilities { + retained_event_history: self.settings.lookback_us > 0, + } + } + fn settings_schema(&self) -> SettingsSchema { SettingsSchema { sections: vec![ @@ -373,10 +760,59 @@ impl Plugin for EveSmlmCandidatePlugin { }, ], }, + SettingsSection { + label: "Temporal aggregation".into(), + description: Some( + "Optionally cluster across retained event history and only publish clusters once they stop growing." + .into(), + ), + default_open: false, + items: vec![ + SettingItem { + key: "lookback_us".into(), + label: "Lookback".into(), + tooltip: Some( + "How far back in retained event history to gather events before clustering. Set to 0 for single-frame behavior." + .into(), + ), + kind: SettingKind::I64Slider { + min: 0, + max: 500_000, + default: i64::try_from(self.settings.lookback_us).unwrap_or(66_000), + suffix: Some(" us".into()), + }, + }, + SettingItem { + key: "stable_frames".into(), + label: "Stable frames".into(), + tooltip: Some( + "How many consecutive frames without cluster growth are required before a cluster is published to fitting." + .into(), + ), + kind: SettingKind::I64Slider { + min: 1, + max: 8, + default: i64::try_from(self.settings.stable_frames).unwrap_or(2), + suffix: Some(" frames".into()), + }, + }, + SettingItem { + key: "show_provisional".into(), + label: "Show provisional".into(), + tooltip: Some( + "Show still-growing clusters in the preview overlay and boundary layer." + .into(), + ), + kind: SettingKind::Bool { + default: self.settings.show_provisional, + }, + }, + ], + }, SettingsSection { label: "Refinement".into(), description: Some( - "Frame-based mode and eigenfeature filtering use these thresholds to reject broad or anisotropic clusters." + "Frame-based mode, eigenfeature filtering, and preview overlays use these thresholds and display controls." .into(), ), default_open: false, @@ -427,12 +863,26 @@ impl Plugin for EveSmlmCandidatePlugin { }, SettingItem { key: "show_overlay".into(), - label: "Show overlay".into(), - tooltip: Some("Highlight candidate centroids on the preview.".into()), + label: "Show centroids".into(), + tooltip: Some( + "Draw clickable centroid markers that link into the accepted candidate-events dataset." + .into(), + ), kind: SettingKind::Bool { default: self.settings.show_overlay, }, }, + SettingItem { + key: "show_boundaries".into(), + label: "Show boundaries".into(), + tooltip: Some( + "Draw 2-sigma eigenfeature ellipses or bounding boxes around visible clusters." + .into(), + ), + kind: SettingKind::Bool { + default: self.settings.show_boundaries, + }, + }, ], }, ], @@ -445,71 +895,99 @@ impl Plugin for EveSmlmCandidatePlugin { "polarity" => Some(json!(self.settings.polarity.index())), "epsilon_px" => Some(json!(self.settings.epsilon_px)), "min_events" => Some(json!(self.settings.min_events)), + "lookback_us" => Some(json!(self.settings.lookback_us)), + "stable_frames" => Some(json!(self.settings.stable_frames)), "max_spatial_extent_px" => Some(json!(self.settings.max_spatial_extent_px)), "min_isotropy" => Some(json!(self.settings.min_isotropy)), "threshold_factor" => Some(json!(self.settings.threshold_factor)), "fit_radius_px" => Some(json!(self.settings.fit_radius_px)), "max_candidates" => Some(json!(self.settings.max_candidates)), "show_overlay" => Some(json!(self.settings.show_overlay)), + "show_boundaries" => Some(json!(self.settings.show_boundaries)), + "show_provisional" => Some(json!(self.settings.show_provisional)), _ => None, } } fn set_setting(&mut self, key: &str, value: Value) -> Result<(), String> { + let mut reset_tracking = false; match key { "finding_method" => { let Some(value) = Self::parse_usize(value) else { return Err("finding_method must be an integer".into()); }; self.settings.finding_method = CandidateFindingMethod::from_index(value); + reset_tracking = true; } "polarity" => { let Some(value) = Self::parse_usize(value) else { return Err("polarity must be an integer".into()); }; self.settings.polarity = PolarityMode::from_index(value); + reset_tracking = true; } "epsilon_px" => { let Some(value) = value.as_f64() else { return Err("epsilon_px must be numeric".into()); }; self.settings.epsilon_px = value.clamp(1.0, 10.0); + reset_tracking = true; } "min_events" => { let Some(value) = Self::parse_usize(value) else { return Err("min_events must be an integer".into()); }; self.settings.min_events = value.clamp(1, 64); + reset_tracking = true; + } + "lookback_us" => { + let Some(value) = value.as_u64() else { + return Err("lookback_us must be an integer".into()); + }; + self.settings.lookback_us = value.min(500_000); + reset_tracking = true; + } + "stable_frames" => { + let Some(value) = Self::parse_usize(value) else { + return Err("stable_frames must be an integer".into()); + }; + self.settings.stable_frames = value.clamp(1, 8); + reset_tracking = true; } "max_spatial_extent_px" => { let Some(value) = value.as_f64() else { return Err("max_spatial_extent_px must be numeric".into()); }; self.settings.max_spatial_extent_px = value.clamp(1.0, 20.0); + reset_tracking = true; } "min_isotropy" => { let Some(value) = value.as_f64() else { return Err("min_isotropy must be numeric".into()); }; self.settings.min_isotropy = value.clamp(0.0, 1.0); + reset_tracking = true; } "threshold_factor" => { let Some(value) = value.as_f64() else { return Err("threshold_factor must be numeric".into()); }; self.settings.threshold_factor = value.clamp(0.5, 6.0); + reset_tracking = true; } "fit_radius_px" => { let Some(value) = Self::parse_usize(value) else { return Err("fit_radius_px must be an integer".into()); }; self.settings.fit_radius_px = value.clamp(1, 16); + reset_tracking = true; } "max_candidates" => { let Some(value) = Self::parse_usize(value) else { return Err("max_candidates must be an integer".into()); }; self.settings.max_candidates = value.clamp(1, 2048); + reset_tracking = true; } "show_overlay" => { let Some(value) = value.as_bool() else { @@ -517,14 +995,30 @@ impl Plugin for EveSmlmCandidatePlugin { }; self.settings.show_overlay = value; } + "show_boundaries" => { + let Some(value) = value.as_bool() else { + return Err("show_boundaries must be a boolean".into()); + }; + self.settings.show_boundaries = value; + } + "show_provisional" => { + let Some(value) = value.as_bool() else { + return Err("show_provisional must be a boolean".into()); + }; + self.settings.show_provisional = value; + } _ => return Err(format!("unknown setting: {key}")), } + if reset_tracking { + self.reset_tracking_state(); + } + Ok(()) } fn status_entries(&self) -> Vec { - vec![ + let mut entries = vec![ StatusEntry::Text(self.last_status.clone()), StatusEntry::LabeledValue { label: "Events".into(), @@ -532,16 +1026,508 @@ impl Plugin for EveSmlmCandidatePlugin { color: None, }, StatusEntry::LabeledValue { - label: "Candidates".into(), + label: "Published".into(), value: self.last_candidate_count.to_string(), color: None, }, + StatusEntry::LabeledValue { + label: "Complete".into(), + value: self.last_complete_visible_count.to_string(), + color: None, + }, + StatusEntry::LabeledValue { + label: "Provisional".into(), + value: self.last_provisional_count.to_string(), + color: None, + }, StatusEntry::LabeledValue { label: "Method".into(), value: self.settings.finding_method.label().into(), color: None, }, - ] + ]; + if self.settings.lookback_us > 0 { + entries.push(StatusEntry::LabeledValue { + label: "Lookback".into(), + value: format!("{} us", self.settings.lookback_us), + color: None, + }); + } + entries + } + + fn host_views(&self) -> HostViewRegistry { + candidate_event_registry(&self.current_event_datasets) + } + + fn host_view_dataset(&self, dataset_id: &str) -> Option> { + let dataset = match dataset_id { + ACCEPTED_EVENTS_DATASET_ID => { + candidate_events_dataset(&self.current_event_datasets.accepted) + } + REJECTED_EVENTS_DATASET_ID => { + candidate_events_dataset(&self.current_event_datasets.rejected) + } + _ => return None, + }; + serde_json::to_vec(&dataset).ok() + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + match dataset_id { + ACCEPTED_EVENTS_DATASET_ID | REJECTED_EVENTS_DATASET_ID => self.dataset_generation, + _ => 0, + } + } +} + +fn boundary_label(method: CandidateFindingMethod) -> &'static str { + match method { + CandidateFindingMethod::FrameBased => "bounding boxes", + CandidateFindingMethod::Dbscan | CandidateFindingMethod::Eigenfeature => { + "2-sigma eigenfeature ellipses" + } + } +} + +fn boundary_pixels( + clusters: &[&EveCluster], + width: u16, + height: u16, +) -> (Vec, Vec) { + let mut complete = HashSet::new(); + let mut provisional = HashSet::new(); + + for cluster in clusters { + let target = if cluster.complete { + &mut complete + } else { + &mut provisional + }; + rasterize_cluster_boundary( + cluster.boundary.as_ref(), + width, + height, + !cluster.complete, + target, + ); + } + + let to_pixels = |points: HashSet<(u16, u16)>| { + let mut pixels: Vec<_> = points.into_iter().map(|(x, y)| FfiPixel { x, y }).collect(); + pixels.sort_by_key(|pixel| (pixel.y, pixel.x)); + pixels + }; + + (to_pixels(complete), to_pixels(provisional)) +} + +fn rasterize_cluster_boundary( + boundary: Option<&ClusterBoundary>, + width: u16, + height: u16, + dashed: bool, + out: &mut HashSet<(u16, u16)>, +) { + let Some(boundary) = boundary else { + return; + }; + + match boundary { + ClusterBoundary::BoundingBox { + x_min, + x_max, + y_min, + y_max, + } => { + for (step, x) in (*x_min..=*x_max).enumerate() { + if !dashed || step % 2 == 0 { + push_boundary_pixel(out, width, height, x as f64, f64::from(*y_min)); + push_boundary_pixel(out, width, height, x as f64, f64::from(*y_max)); + } + } + for (step, y) in (*y_min..=*y_max).enumerate() { + if !dashed || step % 2 == 0 { + push_boundary_pixel(out, width, height, f64::from(*x_min), y as f64); + push_boundary_pixel(out, width, height, f64::from(*x_max), y as f64); + } + } + } + ClusterBoundary::Ellipse { + cx, + cy, + semi_major, + semi_minor, + angle_rad, + } => { + let steps = ((semi_major.max(*semi_minor) * 10.0).ceil() as usize).clamp(24, 240); + let cos_angle = angle_rad.cos(); + let sin_angle = angle_rad.sin(); + for step in 0..=steps { + if dashed && step % 2 == 1 { + continue; + } + let theta = std::f64::consts::TAU * step as f64 / steps as f64; + let ellipse_x = semi_major * theta.cos(); + let ellipse_y = semi_minor * theta.sin(); + let rotated_x = ellipse_x * cos_angle - ellipse_y * sin_angle; + let rotated_y = ellipse_x * sin_angle + ellipse_y * cos_angle; + push_boundary_pixel(out, width, height, cx + rotated_x, cy + rotated_y); + } + } + } +} + +fn push_boundary_pixel(out: &mut HashSet<(u16, u16)>, width: u16, height: u16, x: f64, y: f64) { + let x = x.round(); + let y = y.round(); + if x < 0.0 || y < 0.0 { + return; + } + + let x = x as u16; + let y = y as u16; + if x < width && y < height { + out.insert((x, y)); + } +} + +fn candidate_event_registry(datasets: &CandidateEventDatasets) -> HostViewRegistry { + HostViewRegistry { + datasets: vec![ + HostDatasetDescriptor { + id: ACCEPTED_EVENTS_DATASET_ID.into(), + title: "Accepted EVE events".into(), + kind: HostDatasetKind::TableV1(candidate_events_schema( + datasets, + ACCEPTED_EVENTS_LAYER_ID, + "accepted candidate events", + "cluster_id", + )), + empty_message: "No accepted candidate events in the current analysis window." + .into(), + display: Some(candidate_event_display_metadata( + "Accepted candidate events", + ACCEPTED_EVENTS_COLOR, + )), + relations: vec![HostDatasetRelation { + target_dataset_id: "augur.evesmlm.current_localizations".into(), + via_column: "cluster_id".into(), + target_column: "cluster_id".into(), + }], + }, + HostDatasetDescriptor { + id: REJECTED_EVENTS_DATASET_ID.into(), + title: "Rejected EVE events".into(), + kind: HostDatasetKind::TableV1(candidate_events_schema( + datasets, + REJECTED_EVENTS_LAYER_ID, + "rejected candidate events", + "event_id", + )), + empty_message: "No rejected candidate events in the current analysis window." + .into(), + display: Some(candidate_event_display_metadata( + "Rejected candidate events", + REJECTED_EVENTS_COLOR, + )), + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: ACCEPTED_EVENTS_COMPACT_VIEW_ID.into(), + title: "Accepted Events".into(), + dataset_id: ACCEPTED_EVENTS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: REJECTED_EVENTS_COMPACT_VIEW_ID.into(), + title: "Rejected Events".into(), + dataset_id: REJECTED_EVENTS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: ACCEPTED_EVENTS_TABLE_VIEW_ID.into(), + title: "Accepted Events".into(), + dataset_id: ACCEPTED_EVENTS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::TableWindow, + }, + HostViewDescriptor { + id: REJECTED_EVENTS_TABLE_VIEW_ID.into(), + title: "Rejected Events".into(), + dataset_id: REJECTED_EVENTS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::TableWindow, + }, + HostViewDescriptor { + id: ACCEPTED_EVENTS_3D_VIEW_ID.into(), + title: "Accepted Events 3D".into(), + dataset_id: ACCEPTED_EVENTS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::Scatter3dFromTable { + x_column: "x_px".into(), + y_column: "y_px".into(), + z_column: "timestamp_us".into(), + }, + }, + HostViewDescriptor { + id: REJECTED_EVENTS_3D_VIEW_ID.into(), + title: "Rejected Events 3D".into(), + dataset_id: REJECTED_EVENTS_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(), + } +} + +fn candidate_event_display_metadata( + layer_title: &str, + color: [u8; 4], +) -> HostDatasetDisplayMetadata { + HostDatasetDisplayMetadata { + layer_title: Some(layer_title.into()), + default_visibility: Some(true), + default_color: Some(color), + default_marker_shape: Some(HostMarkerShape::Point), + default_size: Some(2.5), + } +} + +fn candidate_events_schema( + datasets: &CandidateEventDatasets, + layer_id: &str, + semantic_label: &str, + row_id_column: &str, +) -> TableSchema { + TableSchema { + columns: vec![ + TableColumn { + id: "event_id".into(), + title: "Event ID".into(), + value_type: TableValueType::U64, + }, + TableColumn { + id: "timestamp_us".into(), + title: "Timestamp (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: "polarity".into(), + title: "Polarity".into(), + value_type: TableValueType::Bool, + }, + TableColumn { + id: "cluster_id".into(), + title: "Cluster".into(), + value_type: TableValueType::String, + }, + ], + coordinate_space_2d: datasets + .sensor_dims + .map(|(width, height)| TableCoordinateSpace2d { + x_column: "x_px".into(), + y_column: "y_px".into(), + x_min: 0.0, + x_max: f64::from(width), + y_min: 0.0, + y_max: f64::from(height), + }), + coordinate_space_3d: datasets + .sensor_dims + .map(|(width, height)| TableCoordinateSpace3d { + x_column: "x_px".into(), + y_column: "y_px".into(), + z_column: "timestamp_us".into(), + x_min: 0.0, + x_max: f64::from(width), + y_min: 0.0, + y_max: f64::from(height), + z_min: datasets.frame_window_start_us as f64, + z_max: datasets + .frame_window_end_us + .max(datasets.frame_window_start_us) as f64, + }), + row_id_column: Some(row_id_column.into()), + time_column: Some("timestamp_us".into()), + layer_id: Some(layer_id.into()), + semantic_label: Some(semantic_label.into()), + provenance: Some(TableRowProvenance { + anchor_time_column: Some("timestamp_us".into()), + span_start_column: Some("timestamp_us".into()), + span_end_column: Some("timestamp_us".into()), + anchor_frame_column: None, + }), + column_display: vec![ + TableColumnDisplayEntry { + column_id: "event_id".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Identifier), + width_priority: Some(TableColumnWidthPriority::Low), + hide_in_compact: true, + label: None, + headline: false, + }, + }, + TableColumnDisplayEntry { + column_id: "timestamp_us".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::TimestampMicros), + width_priority: Some(TableColumnWidthPriority::Medium), + hide_in_compact: false, + label: Some("Time".into()), + headline: false, + }, + }, + TableColumnDisplayEntry { + column_id: "x_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), + width_priority: Some(TableColumnWidthPriority::Low), + hide_in_compact: false, + label: Some("X".into()), + headline: false, + }, + }, + TableColumnDisplayEntry { + column_id: "y_px".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::FixedPrecision { digits: 1 }), + width_priority: Some(TableColumnWidthPriority::Low), + hide_in_compact: false, + label: Some("Y".into()), + headline: false, + }, + }, + TableColumnDisplayEntry { + column_id: "polarity".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Category), + width_priority: Some(TableColumnWidthPriority::Low), + hide_in_compact: false, + label: Some("Polarity".into()), + headline: false, + }, + }, + TableColumnDisplayEntry { + column_id: "cluster_id".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Category), + width_priority: Some(TableColumnWidthPriority::Medium), + hide_in_compact: false, + label: Some("Cluster".into()), + headline: row_id_column == "cluster_id", + }, + }, + ], + } +} + +fn candidate_events_dataset(rows: &[CandidateEventRow]) -> TableDatasetV1 { + TableDatasetV1::new(vec![ + TableColumnData { + column_id: "event_id".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.event_id).collect()), + }, + TableColumnData { + column_id: "timestamp_us".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.timestamp_us).collect()), + }, + TableColumnData { + column_id: "x_px".into(), + values: TableColumnValues::F64(rows.iter().map(|row| row.x_px).collect()), + }, + TableColumnData { + column_id: "y_px".into(), + values: TableColumnValues::F64(rows.iter().map(|row| row.y_px).collect()), + }, + TableColumnData { + column_id: "polarity".into(), + values: TableColumnValues::Bool(rows.iter().map(|row| row.polarity).collect()), + }, + TableColumnData { + column_id: "cluster_id".into(), + values: TableColumnValues::String( + rows.iter().map(|row| row.cluster_id.clone()).collect(), + ), + }, + ]) + .expect("candidate event columns must stay aligned") +} + +fn candidate_event_row_id(event: &EveEvent, occurrence: u32) -> u64 { + event.timestamp + ^ u64::from(event.x).rotate_left(11) + ^ u64::from(event.y).rotate_left(23) + ^ u64::from(event.polarity as u8).rotate_left(37) + ^ u64::from(occurrence).rotate_left(47) +} + +fn build_candidate_event_datasets( + sensor_dims: (u16, u16), + frame_window_start_us: u64, + frame_window_end_us: u64, + events: &[EveEvent], + cluster_indices: &[Vec], + visible_clusters: &[EveCluster], +) -> CandidateEventDatasets { + let mut cluster_by_event = vec![None; events.len()]; + for (cluster, indices) in visible_clusters.iter().zip(cluster_indices.iter()) { + for &event_index in indices { + if let Some(slot) = cluster_by_event.get_mut(event_index) { + *slot = Some(cluster.cluster_id.to_string()); + } + } + } + + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + let mut seen_occurrences = HashMap::new(); + for (event_index, event) in events.iter().enumerate() { + let occurrence = seen_occurrences + .entry((event.timestamp, event.x, event.y, event.polarity)) + .or_insert(0u32); + let row = CandidateEventRow { + event_id: candidate_event_row_id(event, *occurrence), + x_px: f64::from(event.x), + y_px: f64::from(event.y), + timestamp_us: event.timestamp, + polarity: event.polarity, + cluster_id: cluster_by_event[event_index].clone().unwrap_or_default(), + }; + *occurrence = occurrence.saturating_add(1); + if cluster_by_event[event_index].is_some() { + accepted.push(row); + } else { + rejected.push(row); + } + } + + CandidateEventDatasets { + accepted, + rejected, + sensor_dims: Some(sensor_dims), + frame_window_start_us, + frame_window_end_us, } } @@ -555,7 +1541,46 @@ fn empty_candidates(frame: &PluginFrame<'_>, method: CandidateFindingMethod) -> } } -fn clusters_from_indices(events: &[EveEvent], cluster_indices: Vec>) -> Vec { +#[allow(clippy::too_many_arguments)] +fn cluster_boundary_for_indices( + events: &[EveEvent], + indices: &[usize], + centroid_x: f64, + centroid_y: f64, + x_min: u16, + x_max: u16, + y_min: u16, + y_max: u16, + method: CandidateFindingMethod, +) -> ClusterBoundary { + if matches!( + method, + CandidateFindingMethod::Dbscan | CandidateFindingMethod::Eigenfeature + ) { + if let Some(info) = eigenfeature::cluster_eigen_info(events, indices) { + return ClusterBoundary::Ellipse { + cx: centroid_x, + cy: centroid_y, + semi_major: (2.0 * info.lambda_1.max(0.0).sqrt()).max(1.0), + semi_minor: (2.0 * info.lambda_2.max(0.0).sqrt()).max(1.0), + angle_rad: info.angle_rad, + }; + } + } + + ClusterBoundary::BoundingBox { + x_min, + x_max, + y_min, + y_max, + } +} + +fn clusters_from_indices( + events: &[EveEvent], + cluster_indices: Vec>, + method: CandidateFindingMethod, +) -> Vec { cluster_indices .into_iter() .filter_map(|indices| { @@ -572,7 +1597,7 @@ fn clusters_from_indices(events: &[EveEvent], cluster_indices: Vec>) let mut y_min = u16::MAX; let mut y_max = 0; - for index in indices { + for &index in &indices { let event = events[index]; cluster_events.push(event); sum_x += f64::from(event.x); @@ -597,15 +1622,24 @@ fn clusters_from_indices(events: &[EveEvent], cluster_indices: Vec>) .collect(); histogram_entries.sort_by_key(|entry| (entry.1, entry.0)); + let centroid_x = sum_x / count; + let centroid_y = sum_y / count; + let boundary = cluster_boundary_for_indices( + events, &indices, centroid_x, centroid_y, x_min, x_max, y_min, y_max, method, + ); + Some(EveCluster { + cluster_id: 0, pixel_histogram: histogram_entries, events: cluster_events, - centroid_x: sum_x / count, - centroid_y: sum_y / count, + centroid_x, + centroid_y, x_min, x_max, y_min, y_max, + complete: false, + boundary: Some(boundary), }) }) .collect() @@ -871,7 +1905,11 @@ mod tests { event(10, 11, true, 4), ]; - let clusters = clusters_from_indices(&events, vec![vec![0, 1, 2, 3]]); + let clusters = clusters_from_indices( + &events, + vec![vec![0, 1, 2, 3]], + CandidateFindingMethod::Dbscan, + ); assert_eq!(clusters.len(), 1); let cluster = &clusters[0]; assert_eq!(cluster.event_count(), 4); @@ -880,6 +1918,7 @@ mod tests { assert_eq!(cluster.pixel_histogram.len(), 3); assert!((cluster.centroid_x - 10.25).abs() < 1e-6); assert!((cluster.centroid_y - 10.25).abs() < 1e-6); + assert!(cluster.boundary.is_some()); } #[test] @@ -892,7 +1931,7 @@ mod tests { }; let image = build_analysis_image(&frame.into_plugin_frame(), &events); - let index = 1usize * 6 + 2usize; + let index = 6usize + 2usize; assert_eq!(image[index], 20.0); } @@ -909,6 +1948,139 @@ mod tests { assert!(maxima.iter().any(|(x, y, _)| (*x, *y) == (3, 3))); } + #[test] + fn candidate_event_datasets_split_accepted_and_rejected_events() { + let events = vec![ + event(10, 10, true, 101), + event(11, 10, true, 102), + event(12, 10, false, 103), + event(30, 20, true, 104), + ]; + let visible_clusters = vec![EveCluster { + cluster_id: 42, + pixel_histogram: vec![(10, 10, 1, 0), (12, 10, 0, 1)], + events: vec![events[0], events[2]], + centroid_x: 11.0, + centroid_y: 10.0, + x_min: 10, + x_max: 12, + y_min: 10, + y_max: 10, + complete: false, + boundary: Some(ClusterBoundary::BoundingBox { + x_min: 10, + x_max: 12, + y_min: 10, + y_max: 10, + }), + }]; + + let datasets = build_candidate_event_datasets( + (32, 24), + 100, + 101, + &events, + &[vec![0, 2]], + &visible_clusters, + ); + assert_eq!(datasets.accepted.len(), 2); + assert_eq!(datasets.rejected.len(), 2); + assert_eq!(datasets.accepted[0].cluster_id, "42"); + assert_eq!(datasets.rejected[0].cluster_id, ""); + } + + #[test] + fn candidate_event_registry_exposes_table_and_3d_views() { + let registry = candidate_event_registry(&CandidateEventDatasets { + accepted: Vec::new(), + rejected: Vec::new(), + sensor_dims: Some((128, 64)), + frame_window_start_us: 10, + frame_window_end_us: 20, + }); + assert_eq!(registry.datasets.len(), 2); + assert_eq!(registry.views.len(), 6); + assert_eq!(registry.views[0].id, ACCEPTED_EVENTS_COMPACT_VIEW_ID); + assert_eq!(registry.views[0].title, "Accepted Events"); + assert!(matches!(registry.views[0].kind, HostViewKind::CompactTable)); + assert_eq!(registry.views[2].id, ACCEPTED_EVENTS_TABLE_VIEW_ID); + assert_eq!(registry.views[2].title, "Accepted Events"); + assert!(matches!(registry.views[2].kind, HostViewKind::TableWindow)); + assert_eq!(registry.views[4].id, ACCEPTED_EVENTS_3D_VIEW_ID); + let schema = match ®istry.datasets[0].kind { + HostDatasetKind::TableV1(schema) => schema, + other => panic!("unexpected dataset kind: {other:?}"), + }; + assert_eq!(schema.row_id_column.as_deref(), Some("cluster_id")); + let cluster_column = schema.column("cluster_id").expect("cluster id column"); + assert_eq!(cluster_column.value_type, TableValueType::String); + assert_eq!( + schema + .column_display("cluster_id") + .map(|display| display.headline), + Some(true) + ); + assert_eq!( + schema + .coordinate_space_3d + .as_ref() + .map(|space| space.z_column.as_str()), + Some("timestamp_us") + ); + let rejected_schema = match ®istry.datasets[1].kind { + HostDatasetKind::TableV1(schema) => schema, + other => panic!("unexpected dataset kind: {other:?}"), + }; + assert_eq!(rejected_schema.row_id_column.as_deref(), Some("event_id")); + } + + #[test] + fn temporal_tracking_waits_for_stable_frames_before_publishing() { + let mut plugin = EveSmlmCandidatePlugin::default(); + plugin.settings.stable_frames = 2; + + let make_cluster = || EveCluster { + cluster_id: 0, + pixel_histogram: vec![(10, 10, 3, 0), (11, 10, 2, 0)], + events: vec![ + event(10, 10, true, 1), + event(10, 10, true, 2), + event(10, 10, true, 3), + event(11, 10, true, 4), + event(11, 10, true, 5), + ], + centroid_x: 10.4, + centroid_y: 10.0, + x_min: 10, + x_max: 11, + y_min: 10, + y_max: 10, + complete: false, + boundary: Some(ClusterBoundary::BoundingBox { + x_min: 10, + x_max: 11, + y_min: 10, + y_max: 10, + }), + }; + + let (visible, published) = plugin.update_tracked_clusters(vec![make_cluster()], true); + assert_eq!(visible.len(), 1); + assert!(!visible[0].complete); + assert!(published.is_empty()); + + let (visible, published) = plugin.update_tracked_clusters(vec![make_cluster()], true); + assert_eq!(visible.len(), 1); + assert!(!visible[0].complete); + assert!(published.is_empty()); + + let (visible, published) = plugin.update_tracked_clusters(vec![make_cluster()], true); + assert_eq!(visible.len(), 1); + assert!(visible[0].complete); + assert_eq!(published.len(), 1); + assert_eq!(published[0].cluster_id, visible[0].cluster_id); + } + struct TestFrame { width: u16, height: u16, @@ -924,6 +2096,7 @@ mod tests { events: augur_plugin_api::FfiSlice::from_slice( &[] as &[augur_plugin_api::FfiCdEvent] ), + external_triggers: augur_plugin_api::FfiSlice::default(), window_start_us: self.window_start_us, window_end_us: self.window_start_us + 1, })); 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/README.md b/plugins/evesmlm-fitting/README.md index 7fdf312..ba5be95 100644 --- a/plugins/evesmlm-fitting/README.md +++ b/plugins/evesmlm-fitting/README.md @@ -23,6 +23,7 @@ Sub-pixel localization for eveSMLM candidate clusters. The plugin consumes `EveC | Sigma max | `200.0` nm | Upper accepted sigma bound for sigma-producing methods | | Max fit residual | `0.5` | Reject fits above this residual | | Show overlay | `true` | Highlight accepted localization positions | +| Show rejected | `false` | Draw rejected fits as linked diamond markers | AugurRS now publishes host-owned calibration on `CTX_GLOBAL_SETTINGS` as `GlobalSettings`. This plugin uses the host `nm_per_pixel` value automatically for sigma filtering when it is available, while retaining a hidden fallback for older hosts. @@ -34,11 +35,24 @@ AugurRS now publishes host-owned calibration on `CTX_GLOBAL_SETTINGS` as `Global - `EveLocalizationResults` on `augur.evesmlm.localization_results` - `LocalizationResults` on `augur.localization.results` for compatibility with plugins such as Focus Metrics -- the compact host-view dataset `augur.evesmlm.current_localizations` +- the shared host-view dataset `augur.evesmlm.current_localizations` +- the rejected-fit investigation dataset `augur.evesmlm.rejected_fits` ## Host View -The plugin declares the compact analysis-panel view `augur.evesmlm.current_localizations.compact`. If `EVE Post-Processing` is also enabled, the host resolves that same view id to the later post-processing stage instead. +The plugin declares the shared current-localizations dataset plus: + +- the compact analysis-panel view `augur.evesmlm.current_localizations.compact` +- a linked 3D scatter view over the same dataset +- compact, windowed, and 3D views for rejected fits + +That dataset now carries stable row ids, timestamps, 2D/3D coordinate metadata, and layer/display metadata so the host can keep selection stable across tables, overlays, and 3D inspection. + +Rejected fits are exposed as structured rows with `cluster_id`, timestamps, fit metrics, and a categorical rejection reason so fit failures and threshold rejections can be inspected directly instead of inferred from a counter alone. + +Rejected-fit selection is currently local to the rejected-fits dataset. Matching `cluster_id` values do not create cross-dataset linking back to candidate-event rows because AugurRS stable row keys are scoped by dataset id. + +If `EVE Post-Processing` is also enabled, the host resolves those same dataset/view ids to the later post-processing stage instead. ## Dependencies 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 bd7a370..51103d9 100644 --- a/plugins/evesmlm-fitting/src/lib.rs +++ b/plugins/evesmlm-fitting/src/lib.rs @@ -3,56 +3,273 @@ //! Consumes `EveCandidates` and localizes each raw-event cluster to //! sub-pixel precision with a configurable fitting backend. +use std::collections::HashMap; + pub mod gaussian; 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, FfiSubpixelMarker, GlobalSettings, + export_plugin, AnalysisSeverity, EventStoreHandle, FfiColorRgba, FfiMarkerOverlayItem, + FfiMarkerShape, GlobalSettings, HostActionDescriptor, HostActionRequestQueue, HostActionScope, HostContext, HostOutput, Plugin, PluginFrame, PluginInput, SettingItem, SettingKind, SettingsSchema, SettingsSection, StatusEntry, CTX_GLOBAL_SETTINGS, + CTX_INVESTIGATION_ACTION_REQUESTS, HOST_ACTION_CLUSTER_ROWS_PARAM, }; use augur_plugin_api::{ - HostDatasetDescriptor, HostDatasetKind, HostViewDescriptor, HostViewKind, HostViewPlacement, - HostViewRegistry, TableColumn, TableColumnData, TableColumnValues, TableDatasetV1, TableSchema, - TableValueType, -}; -pub use augur_plugin_evesmlm_candidates::{ - CandidateFindingMethod, EveCandidates, EveCluster, EveEvent, CTX_EVE_CANDIDATES, + 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, CTX_LOCALIZATION_RESULTS}; +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, + 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}; -pub use types::{EveLocalization, EveLocalizationResults, FitMethod, CTX_EVE_LOCALIZATION_RESULTS}; 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_VIEW_ID: &str = "augur.evesmlm.current_localizations.compact"; - -pub fn current_localizations_registry() -> HostViewRegistry { +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"; +pub const REJECTED_FITS_TABLE_VIEW_ID: &str = "augur.evesmlm.rejected_fits.table"; +pub const REJECTED_FITS_3D_VIEW_ID: &str = "augur.evesmlm.rejected_fits.scatter3d"; + +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 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 refit_preview_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()), - empty_message: "No EVE localizations in the current frame.".into(), + id: REFIT_PREVIEW_DATASET_ID.into(), + title: "Refit preview".into(), + kind: HostDatasetKind::TableV1(refit_preview_schema(results, sensor_dims)), + empty_message: "No pending re-fit preview.".into(), + display: Some(HostDatasetDisplayMetadata { + layer_title: Some("Refit preview".into()), + default_visibility: Some(true), + default_color: Some([255, 210, 90, 240]), + default_marker_shape: Some(HostMarkerShape::Circle), + default_size: Some(8.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(), + id: REFIT_PREVIEW_VIEW_ID.into(), + title: "Refit Preview".into(), + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), placement: HostViewPlacement::AnalysisPanel, kind: HostViewKind::CompactTable, }], + actions: Vec::new(), + } +} + +pub fn refit_preview_schema( + results: &EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, +) -> TableSchema { + let mut schema = current_localizations_schema_for_results(results, sensor_dims); + schema.layer_id = Some(REFIT_PREVIEW_LAYER_ID.into()); + schema.semantic_label = Some("refit preview".into()); + schema +} + +pub fn refit_preview_dataset(results: &EveLocalizationResults) -> TableDatasetV1 { + current_localizations_dataset(results) +} + +fn refit_action_param_schema() -> SettingsSchema { + SettingsSchema { + sections: vec![SettingsSection { + label: "Refit parameters".into(), + description: Some( + "Re-run the chosen cluster's fit with these parameters and preview the result before committing." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "fit_method".into(), + label: "Method".into(), + tooltip: Some("Fitting backend to use for this cluster.".into()), + kind: SettingKind::Enum { + variants: vec![ + FitMethod::LogGaussian.label().into(), + FitMethod::Gaussian.label().into(), + FitMethod::RadialSymmetry.label().into(), + FitMethod::Phasor.label().into(), + FitMethod::MeanXY.label().into(), + ], + default: FitMethod::LogGaussian.index(), + }, + }, + SettingItem { + key: "sigma_min_nm".into(), + label: "Sigma min".into(), + tooltip: Some("Reject fits with sigma below this bound.".into()), + kind: SettingKind::F64Slider { + min: 10.0, + max: 500.0, + default: FittingSettings::default().sigma_min_nm, + suffix: Some(" nm".into()), + }, + }, + SettingItem { + key: "sigma_max_nm".into(), + label: "Sigma max".into(), + tooltip: Some("Reject fits with sigma above this bound.".into()), + kind: SettingKind::F64Slider { + min: 10.0, + max: 500.0, + default: FittingSettings::default().sigma_max_nm, + suffix: Some(" nm".into()), + }, + }, + SettingItem { + key: "max_fit_residual".into(), + label: "Max residual".into(), + tooltip: Some("Reject fits whose residual exceeds this threshold.".into()), + kind: SettingKind::F64Drag { + min: 0.0, + max: 10.0, + speed: 0.01, + default: FittingSettings::default().max_fit_residual, + }, + }, + ], + }], + } +} + +pub fn rejected_fit_row_id(row: &RejectedFitRow) -> u64 { + row.timestamp_us + ^ row.cluster_id.rotate_left(7) + ^ row.x.to_bits().rotate_left(19) + ^ row.y.to_bits().rotate_left(31) + ^ row.fit_residual.to_bits().rotate_left(43) + ^ (row.rejection_reason as u64).rotate_left(53) + ^ row.span_start_us.rotate_left(17) + ^ row.span_end_us.rotate_left(29) +} + +fn rejected_fits_registry( + rows: &[RejectedFitRow], + sensor_dims: Option<(u16, u16)>, + frame_window_start_us: u64, + frame_window_end_us: u64, +) -> HostViewRegistry { + HostViewRegistry { + datasets: vec![HostDatasetDescriptor { + id: REJECTED_FITS_DATASET_ID.into(), + title: "Rejected EVE fits".into(), + kind: HostDatasetKind::TableV1(rejected_fits_schema( + rows, + sensor_dims, + frame_window_start_us, + frame_window_end_us, + )), + empty_message: "No rejected EVE fits in the current analysis window.".into(), + display: Some(HostDatasetDisplayMetadata { + layer_title: Some("Rejected EVE fits".into()), + default_visibility: Some(false), + default_color: Some([255, 90, 90, 200]), + default_marker_shape: Some(HostMarkerShape::Diamond), + default_size: Some(5.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: REJECTED_FITS_COMPACT_VIEW_ID.into(), + title: "Rejected Fits".into(), + dataset_id: REJECTED_FITS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: REJECTED_FITS_TABLE_VIEW_ID.into(), + title: "Rejected Fits Table".into(), + dataset_id: REJECTED_FITS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::TableWindow, + }, + HostViewDescriptor { + id: REJECTED_FITS_3D_VIEW_ID.into(), + title: "Rejected Fits 3D".into(), + dataset_id: REJECTED_FITS_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 { +fn rejected_fits_schema( + rows: &[RejectedFitRow], + sensor_dims: Option<(u16, u16)>, + frame_window_start_us: u64, + frame_window_end_us: u64, +) -> 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(), @@ -73,62 +290,260 @@ pub fn current_localizations_schema() -> TableSchema { title: "Sigma Y (px)".into(), value_type: TableValueType::F64, }, + TableColumn { + id: "fit_residual".into(), + title: "Fit residual".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: "rejection_reason".into(), + title: "Rejection reason".into(), + value_type: TableValueType::String, + }, + ], + coordinate_space_2d: rejected_fits_2d_space(rows, sensor_dims), + coordinate_space_3d: rejected_fits_3d_space( + rows, + sensor_dims, + frame_window_start_us, + frame_window_end_us, + ), + row_id_column: Some("row_id".into()), + time_column: Some("timestamp_us".into()), + layer_id: Some(REJECTED_FITS_LAYER_ID.into()), + semantic_label: Some("rejected fits".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: "rejection_reason".into(), + display: TableColumnDisplayMetadata { + format: Some(TableColumnDisplayFormat::Category), + headline: true, + ..Default::default() + }, + }, ], - coordinate_space_2d: None, } } -pub fn current_localizations_dataset(results: &EveLocalizationResults) -> TableDatasetV1 { +fn rejected_fits_dataset(rows: &[RejectedFitRow]) -> TableDatasetV1 { TableDatasetV1::new(vec![ + TableColumnData { + column_id: "row_id".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.row_id).collect()), + }, + TableColumnData { + column_id: "cluster_id".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.cluster_id).collect()), + }, + TableColumnData { + column_id: "timestamp_us".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.timestamp_us).collect()), + }, + TableColumnData { + column_id: "span_start_us".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.span_start_us).collect()), + }, + TableColumnData { + column_id: "span_end_us".into(), + values: TableColumnValues::U64(rows.iter().map(|row| row.span_end_us).collect()), + }, TableColumnData { column_id: "x_px".into(), - values: TableColumnValues::F64( - results.localizations.iter().map(|value| value.x).collect(), - ), + values: TableColumnValues::F64(rows.iter().map(|row| row.x).collect()), }, TableColumnData { column_id: "y_px".into(), - values: TableColumnValues::F64( - results.localizations.iter().map(|value| value.y).collect(), - ), + values: TableColumnValues::F64(rows.iter().map(|row| row.y).collect()), }, TableColumnData { column_id: "sigma_x_px".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.sigma_x) - .collect(), - ), + values: TableColumnValues::F64(rows.iter().map(|row| row.sigma_x).collect()), }, TableColumnData { column_id: "sigma_y_px".into(), - values: TableColumnValues::F64( - results - .localizations - .iter() - .map(|value| value.sigma_y) - .collect(), - ), + values: TableColumnValues::F64(rows.iter().map(|row| row.sigma_y).collect()), + }, + TableColumnData { + column_id: "fit_residual".into(), + values: TableColumnValues::F64(rows.iter().map(|row| row.fit_residual).collect()), }, TableColumnData { column_id: "n_events".into(), - values: TableColumnValues::U64( - results - .localizations - .iter() - .map(|value| value.n_events as u64) + values: TableColumnValues::U64(rows.iter().map(|row| row.n_events).collect()), + }, + TableColumnData { + column_id: "polarity_balance".into(), + values: TableColumnValues::F64(rows.iter().map(|row| row.polarity_balance).collect()), + }, + TableColumnData { + column_id: "rejection_reason".into(), + values: TableColumnValues::String( + rows.iter() + .map(|row| row.rejection_reason.as_str().to_owned()) .collect(), ), }, ]) - .expect("current localization columns should stay aligned") + .expect("rejected-fit columns should stay aligned") +} + +fn rejected_fits_2d_space( + rows: &[RejectedFitRow], + sensor_dims: Option<(u16, u16)>, +) -> Option { + sensor_dims + .map(|(width, height)| (0.0, f64::from(width), 0.0, f64::from(height))) + .or_else(|| rejected_fit_xy_bounds(rows)) + .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 rejected_fits_3d_space( + rows: &[RejectedFitRow], + sensor_dims: Option<(u16, u16)>, + frame_window_start_us: u64, + frame_window_end_us: u64, +) -> 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(|| rejected_fit_xy_bounds(rows))?; + let (z_min, z_max) = rejected_fit_time_bounds(rows) + .unwrap_or((frame_window_start_us as f64, frame_window_end_us as f64)); + 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, + }) +} + +fn rejected_fit_xy_bounds(rows: &[RejectedFitRow]) -> Option<(f64, f64, f64, f64)> { + let mut rows = rows.iter(); + let first = rows.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 row in rows { + x_min = x_min.min(row.x); + x_max = x_max.max(row.x); + y_min = y_min.min(row.y); + y_max = y_max.max(row.y); + } + Some((x_min, x_max.max(x_min), y_min, y_max.max(y_min))) +} + +fn rejected_fit_time_bounds(rows: &[RejectedFitRow]) -> Option<(f64, f64)> { + let mut rows = rows.iter(); + let first = rows.next()?; + let mut min_time = first.timestamp_us; + let mut max_time = first.timestamp_us; + for row in rows { + min_time = min_time.min(row.timestamp_us); + max_time = max_time.max(row.timestamp_us); + } + Some((min_time as f64, max_time.max(min_time) as f64)) } #[derive(Debug, Clone, Copy)] @@ -148,6 +563,7 @@ pub struct FittingSettings { pub sigma_max_nm: f64, pub max_fit_residual: f64, pub show_overlay: bool, + pub show_rejected_overlay: bool, } impl Default for FittingSettings { @@ -159,6 +575,7 @@ impl Default for FittingSettings { sigma_max_nm: 200.0, max_fit_residual: 0.5, show_overlay: true, + show_rejected_overlay: false, } } } @@ -167,10 +584,24 @@ pub struct EveSmlmFittingPlugin { enabled: bool, settings: FittingSettings, current_results: EveLocalizationResults, + current_rejected_fits: Vec, + host_results: EveLocalizationResults, + host_rejected_fits: Vec, + sensor_dims: Option<(u16, u16)>, last_localization_count: usize, last_rejection_count: usize, + last_fit_failure_count: usize, + last_sigma_rejection_count: usize, + last_residual_rejection_count: usize, last_status: String, dataset_generation: u64, + refit_preview_results: EveLocalizationResults, + /// Parallel to `refit_preview_results.localizations`: for each preview + /// row, the `row_id` of the current localization it should replace on + /// commit (or `None` if commit should append). + refit_preview_replaces: Vec>, + last_consumed_action_request_id: u64, + last_action_notice: Option, } impl Default for EveSmlmFittingPlugin { @@ -179,11 +610,22 @@ impl Default for EveSmlmFittingPlugin { enabled: false, settings: FittingSettings::default(), current_results: EveLocalizationResults::default(), + current_rejected_fits: Vec::new(), + host_results: EveLocalizationResults::default(), + host_rejected_fits: Vec::new(), + sensor_dims: None, last_localization_count: 0, last_rejection_count: 0, + last_fit_failure_count: 0, + last_sigma_rejection_count: 0, + last_residual_rejection_count: 0, last_status: "Enable the plugin to fit EVE candidate clusters to sub-pixel localizations.".into(), dataset_generation: 0, + refit_preview_results: EveLocalizationResults::default(), + refit_preview_replaces: Vec::new(), + last_consumed_action_request_id: 0, + last_action_notice: None, } } } @@ -198,15 +640,31 @@ impl EveSmlmFittingPlugin { .unwrap_or(self.settings.nm_per_pixel) } + fn sync_sensor_dims(&mut self, context: &HostContext<'_>, frame: &PluginFrame<'_>) { + self.sensor_dims = context + .get::(CTX_GLOBAL_SETTINGS) + .ok() + .flatten() + .map(|settings| (settings.sensor_width, settings.sensor_height)) + .or(Some((frame.width(), frame.height()))); + } + fn analyze_candidates( &mut self, candidates: Option<&EveCandidates>, output: &mut HostOutput<'_>, nm_per_pixel: f64, - ) -> (EveLocalizationResults, LocalizationResults) { + ) -> ( + EveLocalizationResults, + LocalizationResults, + Vec, + ) { let Some(candidates) = candidates else { self.last_localization_count = 0; self.last_rejection_count = 0; + self.last_fit_failure_count = 0; + self.last_sigma_rejection_count = 0; + self.last_residual_rejection_count = 0; self.last_status = "Waiting for EVE Candidate Finding.".into(); Self::warning( output, @@ -216,14 +674,42 @@ impl EveSmlmFittingPlugin { return ( EveLocalizationResults::default(), LocalizationResults::default(), + Vec::new(), ); }; let mut localizations = Vec::new(); - let mut rejected = 0; + let mut rejected_fits = Vec::new(); + let mut fit_failures = 0usize; + let mut sigma_rejections = 0usize; + let mut residual_rejections = 0usize; for cluster in &candidates.clusters { + let (span_start_us, span_end_us) = cluster_time_span(cluster); + let timestamp_fallback = estimate_timestamp_us( + &cluster.events, + cluster.centroid_x, + cluster.centroid_y, + cluster_extent_radius(cluster), + ); let Some(fit) = fit_cluster(cluster, self.settings.fit_method) else { - rejected += 1; + fit_failures += 1; + let mut rejected = RejectedFitRow { + row_id: 0, + cluster_id: cluster.cluster_id, + x: cluster.centroid_x, + y: cluster.centroid_y, + sigma_x: 0.0, + sigma_y: 0.0, + fit_residual: 0.0, + n_events: cluster.event_count() as u64, + polarity_balance: cluster.polarity_balance(), + rejection_reason: RejectionReason::FitFailed, + timestamp_us: timestamp_fallback, + span_start_us, + span_end_us, + }; + rejected.row_id = rejected_fit_row_id(&rejected); + rejected_fits.push(rejected); continue; }; @@ -235,17 +721,60 @@ impl EveSmlmFittingPlugin { || sigma_y_nm < self.settings.sigma_min_nm || sigma_y_nm > self.settings.sigma_max_nm { - rejected += 1; + sigma_rejections += 1; + let timestamp_us = estimate_timestamp_us( + &cluster.events, + fit.x, + fit.y, + fit_radius(cluster, &fit), + ); + let mut rejected = RejectedFitRow { + row_id: 0, + cluster_id: cluster.cluster_id, + x: fit.x, + y: fit.y, + sigma_x: fit.sigma_x, + sigma_y: fit.sigma_y, + fit_residual: fit.residual, + n_events: cluster.event_count() as u64, + polarity_balance: cluster.polarity_balance(), + rejection_reason: RejectionReason::SigmaOutOfBounds, + timestamp_us, + span_start_us, + span_end_us, + }; + rejected.row_id = rejected_fit_row_id(&rejected); + rejected_fits.push(rejected); continue; } } if fit.residual > self.settings.max_fit_residual { - rejected += 1; + residual_rejections += 1; + let timestamp_us = + estimate_timestamp_us(&cluster.events, fit.x, fit.y, fit_radius(cluster, &fit)); + let mut rejected = RejectedFitRow { + row_id: 0, + cluster_id: cluster.cluster_id, + x: fit.x, + y: fit.y, + sigma_x: fit.sigma_x, + sigma_y: fit.sigma_y, + fit_residual: fit.residual, + n_events: cluster.event_count() as u64, + polarity_balance: cluster.polarity_balance(), + rejection_reason: RejectionReason::ResidualTooHigh, + timestamp_us, + span_start_us, + span_end_us, + }; + rejected.row_id = rejected_fit_row_id(&rejected); + rejected_fits.push(rejected); continue; } localizations.push(EveLocalization { + cluster_id: cluster.cluster_id, x: fit.x, y: fit.y, sigma_x: fit.sigma_x, @@ -256,6 +785,8 @@ impl EveSmlmFittingPlugin { fit.y, fit_radius(cluster, &fit), ), + span_start_us, + span_end_us, n_events: cluster.event_count(), polarity_balance: cluster.polarity_balance(), fit_residual: fit.residual, @@ -264,23 +795,76 @@ impl EveSmlmFittingPlugin { } self.last_localization_count = localizations.len(); - self.last_rejection_count = rejected; + self.last_fit_failure_count = fit_failures; + self.last_sigma_rejection_count = sigma_rejections; + self.last_residual_rejection_count = residual_rejections; + self.last_rejection_count = fit_failures + sigma_rejections + residual_rejections; self.last_status = format!( - "{} localizations accepted, {} rejected with {}.", + "{} accepted, {} rejected ({} fit failures, {} sigma bounds, {} residual) with {}.", self.last_localization_count, self.last_rejection_count, + self.last_fit_failure_count, + self.last_sigma_rejection_count, + self.last_residual_rejection_count, self.settings.fit_method.label() ); if self.settings.show_overlay && !localizations.is_empty() { - let markers: Vec = localizations + let stable_ids: Vec = localizations + .iter() + .map(|localization| localization_row_id(localization).to_string()) + .collect(); + let markers: Vec = localizations .iter() - .map(|localization| FfiSubpixelMarker { + .zip(stable_ids.iter()) + .map(|(localization, stable_id)| FfiMarkerOverlayItem { x: localization.x as f32, y: localization.y as f32, + shape: FfiMarkerShape::Cross, + size: 6.0, + color: FfiColorRgba::from_rgba(OVERLAY_COLOR), + timestamp_us: localization.timestamp_us, + has_timestamp: true, + stable_id: stable_id.as_str().into(), + source_dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), + source_row_id: stable_id.as_str().into(), }) .collect(); - output.add_crosshair_markers(&markers, OVERLAY_COLOR, 5); + output.add_marker_overlay( + &markers, + Some(CURRENT_LOCALIZATIONS_DATASET_ID), + Some(CURRENT_LOCALIZATIONS_LAYER_ID), + Some(self.name()), + ); + } + + if self.settings.show_rejected_overlay && !rejected_fits.is_empty() { + let stable_ids: Vec = rejected_fits + .iter() + .map(|row| row.row_id.to_string()) + .collect(); + let markers: Vec = rejected_fits + .iter() + .zip(stable_ids.iter()) + .map(|(row, stable_id)| FfiMarkerOverlayItem { + x: row.x as f32, + y: row.y as f32, + shape: FfiMarkerShape::Diamond, + size: 5.0, + color: FfiColorRgba::from_rgba([255, 90, 90, 180]), + timestamp_us: row.timestamp_us, + has_timestamp: true, + stable_id: stable_id.as_str().into(), + source_dataset_id: REJECTED_FITS_DATASET_ID.into(), + source_row_id: stable_id.as_str().into(), + }) + .collect(); + output.add_marker_overlay( + &markers, + Some(REJECTED_FITS_DATASET_ID), + Some(REJECTED_FITS_LAYER_ID), + Some(self.name()), + ); } let eve_results = EveLocalizationResults { @@ -290,14 +874,24 @@ impl EveSmlmFittingPlugin { }; let compatibility_results = to_localization_results(&eve_results); - (eve_results, compatibility_results) + (eve_results, compatibility_results, rejected_fits) } pub fn reset(&mut self) { self.current_results = EveLocalizationResults::default(); + self.current_rejected_fits.clear(); + self.host_results = EveLocalizationResults::default(); + self.host_rejected_fits.clear(); + self.sensor_dims = None; self.last_localization_count = 0; self.last_rejection_count = 0; + self.last_fit_failure_count = 0; + self.last_sigma_rejection_count = 0; + self.last_residual_rejection_count = 0; self.last_status = "Waiting for the next candidate set.".into(); + self.refit_preview_results = EveLocalizationResults::default(); + self.refit_preview_replaces.clear(); + self.last_action_notice = None; self.dataset_generation = self.dataset_generation.wrapping_add(1); } @@ -305,9 +899,575 @@ impl EveSmlmFittingPlugin { value.as_u64().and_then(|value| usize::try_from(value).ok()) } + fn update_history_bounds(results: &mut EveLocalizationResults) { + let Some(first) = results.localizations.first() else { + results.frame_window_start_us = 0; + results.frame_window_end_us = 0; + return; + }; + let mut start = first.span_start_us; + let mut end = first.span_end_us.max(first.span_start_us); + for localization in &results.localizations[1..] { + start = start.min(localization.span_start_us); + end = end.max(localization.span_end_us.max(localization.span_start_us)); + } + results.frame_window_start_us = start; + results.frame_window_end_us = end; + } + + fn upsert_history_localization(&mut self, localization: EveLocalization) { + self.host_rejected_fits + .retain(|row| row.cluster_id != localization.cluster_id); + if let Some(index) = self + .host_results + .localizations + .iter() + .position(|existing| existing.cluster_id == localization.cluster_id) + { + self.host_results.localizations[index] = localization; + } else { + self.host_results.localizations.push(localization); + } + self.host_results.localizations.sort_by_key(|row| { + ( + row.span_start_us, + row.span_end_us, + row.timestamp_us, + row.cluster_id, + ) + }); + Self::update_history_bounds(&mut self.host_results); + } + + fn upsert_history_rejected_fit(&mut self, row: RejectedFitRow) { + if self + .host_results + .localizations + .iter() + .any(|localization| localization.cluster_id == row.cluster_id) + { + return; + } + if let Some(index) = self + .host_rejected_fits + .iter() + .position(|existing| existing.cluster_id == row.cluster_id) + { + self.host_rejected_fits[index] = row; + } else { + self.host_rejected_fits.push(row); + } + self.host_rejected_fits.sort_by_key(|entry| { + ( + entry.span_start_us, + entry.span_end_us, + entry.timestamp_us, + entry.cluster_id, + ) + }); + } + + fn integrate_frame_history( + &mut self, + localizations: &[EveLocalization], + rejected_fits: &[RejectedFitRow], + ) { + for localization in localizations.iter().cloned() { + self.upsert_history_localization(localization); + } + for row in rejected_fits.iter().cloned() { + self.upsert_history_rejected_fit(row); + } + } + + fn parse_u64_field(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) + } + + fn parse_u16_field(value: &Value) -> Option { + Self::parse_u64_field(value) + .and_then(|value| u16::try_from(value).ok()) + .or_else(|| { + value + .as_f64() + .map(|value| value.round().clamp(0.0, f64::from(u16::MAX)) as u16) + }) + } + + fn parse_bool_field(value: &Value) -> Option { + value + .as_bool() + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) + } + + fn cluster_from_action_params(params: &Value, expected_cluster_id: u64) -> Option { + let rows = params.get(HOST_ACTION_CLUSTER_ROWS_PARAM)?.as_array()?; + if rows.is_empty() { + return None; + } + + let mut events = Vec::with_capacity(rows.len()); + let mut pixel_histogram: HashMap<(u16, u16), (u32, u32)> = HashMap::new(); + let mut sum_x = 0.0; + let mut sum_y = 0.0; + let mut count: f64 = 0.0; + let mut x_min = u16::MAX; + let mut x_max = 0u16; + let mut y_min = u16::MAX; + let mut y_max = 0u16; + + for row in rows { + let object = row.as_object()?; + let cluster_id = Self::parse_u64_field(object.get("cluster_id")?)?; + if cluster_id != expected_cluster_id { + return None; + } + let x = Self::parse_u16_field(object.get("x_px")?)?; + let y = Self::parse_u16_field(object.get("y_px")?)?; + let timestamp = Self::parse_u64_field(object.get("timestamp_us")?)?; + let polarity = Self::parse_bool_field(object.get("polarity")?)?; + + events.push(EveEvent { + timestamp, + x, + y, + polarity, + }); + + let entry = pixel_histogram.entry((x, y)).or_insert((0, 0)); + if polarity { + entry.0 = entry.0.saturating_add(1); + } else { + entry.1 = entry.1.saturating_add(1); + } + x_min = x_min.min(x); + x_max = x_max.max(x); + y_min = y_min.min(y); + y_max = y_max.max(y); + sum_x += f64::from(x); + sum_y += f64::from(y); + count += 1.0; + } + + if events.is_empty() { + return None; + } + + let mut pixel_histogram: Vec<_> = pixel_histogram + .into_iter() + .map(|((x, y), (positive, negative))| (x, y, positive, negative)) + .collect(); + pixel_histogram.sort_by_key(|(x, y, _, _)| (*y, *x)); + + Some(EveCluster { + cluster_id: expected_cluster_id, + pixel_histogram, + events, + centroid_x: sum_x / count.max(1.0), + centroid_y: sum_y / count.max(1.0), + x_min, + x_max, + y_min, + y_max, + complete: true, + boundary: None, + }) + } + fn warning(output: &mut HostOutput<'_>, severity: AnalysisSeverity, message: &str) { output.add_warning("EVE Candidate Fitting", severity, message); } + + fn handle_action_requests( + &mut self, + context: &mut HostContext<'_>, + output: &mut HostOutput<'_>, + candidates: Option<&EveCandidates>, + nm_per_pixel: f64, + ) { + let queue = match context + .get_persistent::(CTX_INVESTIGATION_ACTION_REQUESTS) + { + Ok(Some(queue)) => queue, + Ok(None) => return, + Err(err) => { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Reading action requests failed: {err}"), + ); + return; + } + }; + + let mut handled_any = false; + for request in &queue.requests { + if request.request_id <= self.last_consumed_action_request_id { + continue; + } + match request.action_id.as_str() { + ACTION_REFIT_CLUSTER => { + self.handle_refit_cluster(request, output, candidates, nm_per_pixel); + handled_any = true; + } + ACTION_COMMIT_REFIT => { + self.handle_commit_refit(request, output); + handled_any = true; + } + ACTION_DISCARD_REFIT => { + self.handle_discard_refit(request, output); + handled_any = true; + } + _ => continue, + } + self.last_consumed_action_request_id = request.request_id; + } + + if handled_any { + self.dataset_generation = self.dataset_generation.wrapping_add(1); + } + } + + fn handle_refit_cluster( + &mut self, + request: &augur_plugin_api::HostActionRequest, + output: &mut HostOutput<'_>, + candidates: Option<&EveCandidates>, + nm_per_pixel: f64, + ) { + use augur_plugin_api::HostActionScopePayload; + let (dataset_id, group_column, group_value) = match &request.scope_payload { + HostActionScopePayload::Cluster { + dataset_id, + group_column, + group_value, + } => ( + dataset_id.clone(), + group_column.clone(), + group_value.clone(), + ), + _ => { + Self::warning( + output, + AnalysisSeverity::Warning, + "Re-fit action requires a Cluster scope payload.", + ); + return; + } + }; + if dataset_id != ACCEPTED_CANDIDATE_EVENTS_DATASET_ID || group_column != "cluster_id" { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!( + "Ignoring re-fit request for unsupported scope ({dataset_id}/{group_column})." + ), + ); + return; + } + + let cluster_id: u64 = match group_value.parse() { + Ok(value) => value, + Err(_) => { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Re-fit request has non-numeric cluster id: {group_value}"), + ); + return; + } + }; + + let params = &request.params; + let cluster_from_params = Self::cluster_from_action_params(params, cluster_id); + let cluster_from_candidates = candidates.and_then(|candidates| { + candidates + .clusters + .iter() + .find(|cluster| cluster.cluster_id == cluster_id) + .cloned() + }); + let Some(cluster) = cluster_from_params.or(cluster_from_candidates) else { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Re-fit request for cluster {cluster_id} has no usable cluster snapshot."), + ); + return; + }; + let fit_method = params + .get("fit_method") + .and_then(|value| Self::parse_usize(value.clone())) + .map(FitMethod::from_index) + .unwrap_or(self.settings.fit_method); + let sigma_min_nm = params + .get("sigma_min_nm") + .and_then(Value::as_f64) + .unwrap_or(self.settings.sigma_min_nm); + let sigma_max_nm = params + .get("sigma_max_nm") + .and_then(Value::as_f64) + .unwrap_or(self.settings.sigma_max_nm); + let max_fit_residual = params + .get("max_fit_residual") + .and_then(Value::as_f64) + .unwrap_or(self.settings.max_fit_residual); + + let Some(fit) = fit_cluster(&cluster, fit_method) else { + self.last_action_notice = Some(format!("Re-fit failed for cluster {cluster_id}.")); + Self::warning( + output, + AnalysisSeverity::Info, + &format!("Re-fit for cluster {cluster_id} did not converge."), + ); + return; + }; + + if fit_method.produces_sigma() { + let sigma_x_nm = fit.sigma_x * nm_per_pixel; + let sigma_y_nm = fit.sigma_y * nm_per_pixel; + if sigma_x_nm < sigma_min_nm + || sigma_x_nm > sigma_max_nm + || sigma_y_nm < sigma_min_nm + || sigma_y_nm > sigma_max_nm + { + self.last_action_notice = Some(format!( + "Re-fit for cluster {cluster_id} is outside sigma bounds." + )); + Self::warning( + output, + AnalysisSeverity::Info, + &format!("Re-fit for cluster {cluster_id} rejected by sigma bounds."), + ); + return; + } + } + + if fit.residual > max_fit_residual { + self.last_action_notice = Some(format!( + "Re-fit for cluster {cluster_id} exceeds residual threshold." + )); + Self::warning( + output, + AnalysisSeverity::Info, + &format!("Re-fit for cluster {cluster_id} rejected by residual threshold."), + ); + return; + } + + let timestamp_us = + estimate_timestamp_us(&cluster.events, fit.x, fit.y, fit_radius(&cluster, &fit)); + let (span_start_us, span_end_us) = cluster_time_span(&cluster); + let new_localization = EveLocalization { + cluster_id, + x: fit.x, + y: fit.y, + sigma_x: fit.sigma_x, + sigma_y: fit.sigma_y, + timestamp_us, + span_start_us, + span_end_us, + n_events: cluster.event_count(), + polarity_balance: cluster.polarity_balance(), + fit_residual: fit.residual, + fit_method, + }; + + let replaces = find_current_localization_for_cluster(&self.host_results, &cluster) + .map(localization_row_id); + + self.refit_preview_results + .localizations + .push(new_localization); + self.refit_preview_replaces.push(replaces); + Self::update_history_bounds(&mut self.refit_preview_results); + + self.last_action_notice = Some(format!( + "Re-fit preview added for cluster {cluster_id} ({}).", + fit_method.label() + )); + } + + fn handle_commit_refit( + &mut self, + request: &augur_plugin_api::HostActionRequest, + output: &mut HostOutput<'_>, + ) { + use augur_plugin_api::HostActionScopePayload; + let (dataset_id, row_id) = match &request.scope_payload { + HostActionScopePayload::Row { dataset_id, row_id } => { + (dataset_id.clone(), row_id.clone()) + } + _ => { + Self::warning( + output, + AnalysisSeverity::Warning, + "Commit action requires a Row scope payload.", + ); + return; + } + }; + if dataset_id != REFIT_PREVIEW_DATASET_ID { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Ignoring commit for unsupported dataset {dataset_id}."), + ); + return; + } + + let target_row_id: u64 = match row_id.parse() { + Ok(value) => value, + Err(_) => { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Commit row_id is not numeric: {row_id}"), + ); + return; + } + }; + + let index = self + .refit_preview_results + .localizations + .iter() + .position(|localization| localization_row_id(localization) == target_row_id); + let Some(index) = index else { + Self::warning( + output, + AnalysisSeverity::Info, + &format!("Commit row {target_row_id} is not in the preview."), + ); + return; + }; + + let localization = self.refit_preview_results.localizations.remove(index); + self.refit_preview_replaces.remove(index); + let cluster_id = localization.cluster_id; + self.upsert_history_localization(localization.clone()); + self.host_rejected_fits + .retain(|row| row.cluster_id != cluster_id); + + if let Some(old_index) = self + .current_results + .localizations + .iter() + .position(|entry| entry.cluster_id == cluster_id) + { + self.current_results.localizations[old_index] = localization; + } + + Self::update_history_bounds(&mut self.refit_preview_results); + + self.last_action_notice = Some(format!("Committed refit preview row {target_row_id}.")); + } + + fn handle_discard_refit( + &mut self, + request: &augur_plugin_api::HostActionRequest, + output: &mut HostOutput<'_>, + ) { + use augur_plugin_api::HostActionScopePayload; + let dataset_id = match &request.scope_payload { + HostActionScopePayload::Dataset { dataset_id } => dataset_id.clone(), + _ => { + Self::warning( + output, + AnalysisSeverity::Warning, + "Discard action requires a Dataset scope payload.", + ); + return; + } + }; + if dataset_id != REFIT_PREVIEW_DATASET_ID { + Self::warning( + output, + AnalysisSeverity::Warning, + &format!("Ignoring discard for unsupported dataset {dataset_id}."), + ); + return; + } + + let dropped = self.refit_preview_results.localizations.len(); + self.refit_preview_results = EveLocalizationResults::default(); + self.refit_preview_replaces.clear(); + self.last_action_notice = Some(format!("Discarded {dropped} preview row(s).")); + } + + fn emit_refit_preview_overlay(&self, output: &mut HostOutput<'_>) { + let localizations = &self.refit_preview_results.localizations; + let stable_ids: Vec = localizations + .iter() + .map(|localization| localization_row_id(localization).to_string()) + .collect(); + let markers: Vec = localizations + .iter() + .zip(stable_ids.iter()) + .map(|(localization, stable_id)| FfiMarkerOverlayItem { + x: localization.x as f32, + y: localization.y as f32, + shape: FfiMarkerShape::FilledCircle, + size: 8.0, + color: FfiColorRgba::from_rgba([255, 210, 90, 240]), + timestamp_us: localization.timestamp_us, + has_timestamp: true, + stable_id: stable_id.as_str().into(), + source_dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + source_row_id: stable_id.as_str().into(), + }) + .collect(); + output.add_marker_overlay( + &markers, + Some(REFIT_PREVIEW_DATASET_ID), + Some(REFIT_PREVIEW_LAYER_ID), + Some(self.name()), + ); + } +} + +fn find_current_localization_for_cluster<'a>( + results: &'a EveLocalizationResults, + cluster: &EveCluster, +) -> Option<&'a EveLocalization> { + if let Some(localization) = results + .localizations + .iter() + .find(|localization| localization.cluster_id == cluster.cluster_id) + { + return Some(localization); + } + let timestamp_range_us: i64 = 2_000; + let mut best: Option<(f64, &'a EveLocalization)> = None; + for localization in &results.localizations { + let dt = (localization.timestamp_us as i64) + .saturating_sub_unsigned(cluster_anchor_timestamp(cluster)); + if dt.abs() > timestamp_range_us { + continue; + } + let dx = localization.x - cluster.centroid_x; + let dy = localization.y - cluster.centroid_y; + let score = dx * dx + dy * dy + (dt as f64).powi(2) * 1e-6; + if best.map_or(true, |(b, _)| score < b) { + best = Some((score, localization)); + } + } + best.map(|(_, localization)| localization) +} + +fn cluster_anchor_timestamp(cluster: &EveCluster) -> u64 { + if cluster.events.is_empty() { + return 0; + } + let sum: u128 = cluster + .events + .iter() + .map(|event| event.timestamp as u128) + .sum(); + (sum / cluster.events.len() as u128) as u64 } impl Plugin for EveSmlmFittingPlugin { @@ -344,11 +1504,12 @@ impl Plugin for EveSmlmFittingPlugin { fn process_frame( &mut self, - _frame: &PluginFrame<'_>, + frame: &PluginFrame<'_>, output: &mut HostOutput<'_>, context: &mut HostContext<'_>, _event_store: &EventStoreHandle<'_>, ) { + self.sync_sensor_dims(context, frame); let nm_per_pixel = self.nm_per_pixel(context); let candidates = match context.get::(CTX_EVE_CANDIDATES) { Ok(value) => value, @@ -362,11 +1523,22 @@ impl Plugin for EveSmlmFittingPlugin { } }; - let (eve_results, compatibility) = + let (eve_results, _compatibility, rejected_fits) = self.analyze_candidates(candidates.as_ref(), output, nm_per_pixel); self.current_results = eve_results.clone(); + self.current_rejected_fits = rejected_fits.clone(); + self.integrate_frame_history(&eve_results.localizations, &rejected_fits); self.dataset_generation = self.dataset_generation.wrapping_add(1); - if let Err(err) = context.publish(CTX_EVE_LOCALIZATION_RESULTS, &eve_results) { + + self.handle_action_requests(context, output, candidates.as_ref(), nm_per_pixel); + + if self.settings.show_overlay && !self.refit_preview_results.localizations.is_empty() { + self.emit_refit_preview_overlay(output); + } + + let published_results = self.current_results.clone(); + let compatibility = to_localization_results(&published_results); + if let Err(err) = context.publish(CTX_EVE_LOCALIZATION_RESULTS, &published_results) { Self::warning( output, AnalysisSeverity::Warning, @@ -462,6 +1634,16 @@ impl Plugin for EveSmlmFittingPlugin { default: self.settings.show_overlay, }, }, + SettingItem { + key: "show_rejected_overlay".into(), + label: "Show rejected".into(), + tooltip: Some( + "Draw rejected fits as linked diamond markers in the preview.".into(), + ), + kind: SettingKind::Bool { + default: self.settings.show_rejected_overlay, + }, + }, ], }], } @@ -474,6 +1656,7 @@ impl Plugin for EveSmlmFittingPlugin { "sigma_max_nm" => Some(json!(self.settings.sigma_max_nm)), "max_fit_residual" => Some(json!(self.settings.max_fit_residual)), "show_overlay" => Some(json!(self.settings.show_overlay)), + "show_rejected_overlay" => Some(json!(self.settings.show_rejected_overlay)), _ => None, } } @@ -520,6 +1703,12 @@ impl Plugin for EveSmlmFittingPlugin { }; self.settings.show_overlay = value; } + "show_rejected_overlay" => { + let Some(value) = value.as_bool() else { + return Err("show_rejected_overlay must be a boolean".into()); + }; + self.settings.show_rejected_overlay = value; + } _ => return Err(format!("unknown setting: {key}")), } @@ -527,7 +1716,7 @@ impl Plugin for EveSmlmFittingPlugin { } fn status_entries(&self) -> Vec { - vec![ + let mut entries = vec![ StatusEntry::Text(self.last_status.clone()), StatusEntry::LabeledValue { label: "Accepted".into(), @@ -544,26 +1733,95 @@ impl Plugin for EveSmlmFittingPlugin { value: self.settings.fit_method.label().into(), color: None, }, - ] + ]; + if self.last_rejection_count > 0 { + entries.push(StatusEntry::LabeledValue { + label: "Fit fail".into(), + value: self.last_fit_failure_count.to_string(), + color: None, + }); + entries.push(StatusEntry::LabeledValue { + label: "Sigma".into(), + value: self.last_sigma_rejection_count.to_string(), + color: None, + }); + entries.push(StatusEntry::LabeledValue { + label: "Residual".into(), + value: self.last_residual_rejection_count.to_string(), + color: None, + }); + } + entries } fn host_views(&self) -> HostViewRegistry { - current_localizations_registry() + let mut registry = + current_localizations_registry_for_results(&self.host_results, self.sensor_dims); + let rejected_registry = rejected_fits_registry( + &self.host_rejected_fits, + self.sensor_dims, + self.host_results.frame_window_start_us, + self.host_results.frame_window_end_us, + ); + registry.datasets.extend(rejected_registry.datasets); + registry.views.extend(rejected_registry.views); + let preview_registry = + refit_preview_registry_for_results(&self.refit_preview_results, self.sensor_dims); + registry.datasets.extend(preview_registry.datasets); + registry.views.extend(preview_registry.views); + + let param_schema = serde_json::to_value(refit_action_param_schema()).ok(); + registry.actions = vec![ + HostActionDescriptor { + id: ACTION_REFIT_CLUSTER.into(), + title: "Re-fit cluster…".into(), + scope: HostActionScope::Cluster { + dataset_id: ACCEPTED_CANDIDATE_EVENTS_DATASET_ID.into(), + group_column: "cluster_id".into(), + }, + param_schema, + }, + HostActionDescriptor { + id: ACTION_COMMIT_REFIT.into(), + title: "Commit refit".into(), + scope: HostActionScope::Row { + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + }, + param_schema: None, + }, + HostActionDescriptor { + id: ACTION_DISCARD_REFIT.into(), + title: "Discard refit preview".into(), + scope: HostActionScope::Dataset { + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + }, + param_schema: None, + }, + ]; + registry } fn host_view_dataset(&self, dataset_id: &str) -> Option> { - if dataset_id != CURRENT_LOCALIZATIONS_DATASET_ID { - return None; + match dataset_id { + CURRENT_LOCALIZATIONS_DATASET_ID => { + serde_json::to_vec(¤t_localizations_dataset(&self.host_results)).ok() + } + REJECTED_FITS_DATASET_ID => { + serde_json::to_vec(&rejected_fits_dataset(&self.host_rejected_fits)).ok() + } + REFIT_PREVIEW_DATASET_ID => { + serde_json::to_vec(&refit_preview_dataset(&self.refit_preview_results)).ok() + } + _ => None, } - - serde_json::to_vec(¤t_localizations_dataset(&self.current_results)).ok() } fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { - if dataset_id == CURRENT_LOCALIZATIONS_DATASET_ID { - self.dataset_generation - } else { - 0 + match dataset_id { + CURRENT_LOCALIZATIONS_DATASET_ID + | REJECTED_FITS_DATASET_ID + | REFIT_PREVIEW_DATASET_ID => self.dataset_generation, + _ => 0, } } } @@ -578,16 +1836,33 @@ fn fit_cluster(cluster: &EveCluster, method: FitMethod) -> Option { } } +fn cluster_extent_radius(cluster: &EveCluster) -> f64 { + let dx = f64::from(cluster.x_max.saturating_sub(cluster.x_min)) + 1.0; + let dy = f64::from(cluster.y_max.saturating_sub(cluster.y_min)) + 1.0; + 0.5 * dx.max(dy).max(1.0) +} + fn fit_radius(cluster: &EveCluster, fit: &FitEstimate) -> f64 { if fit.sigma_x > 0.0 && fit.sigma_y > 0.0 { 2.5 * fit.sigma_x.max(fit.sigma_y).max(1.0) } else { - let dx = f64::from(cluster.x_max.saturating_sub(cluster.x_min)) + 1.0; - let dy = f64::from(cluster.y_max.saturating_sub(cluster.y_min)) + 1.0; - 0.5 * dx.max(dy).max(1.0) + cluster_extent_radius(cluster) } } +fn cluster_time_span(cluster: &EveCluster) -> (u64, u64) { + let Some(first) = cluster.events.first() else { + return (0, 0); + }; + let mut start = first.timestamp; + let mut end = first.timestamp; + for event in &cluster.events[1..] { + start = start.min(event.timestamp); + end = end.max(event.timestamp); + } + (start, end.max(start)) +} + fn estimate_timestamp_us(events: &[EveEvent], x: f64, y: f64, radius: f64) -> u64 { if events.is_empty() { return 0; @@ -620,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::*; @@ -654,6 +1908,23 @@ mod tests { } } + fn localization(x: f64, y: f64, timestamp_us: u64) -> EveLocalization { + EveLocalization { + cluster_id: timestamp_us, + x, + y, + sigma_x: 0.7, + sigma_y: 0.8, + timestamp_us, + span_start_us: timestamp_us.saturating_sub(1), + span_end_us: timestamp_us.saturating_add(1), + n_events: 7, + polarity_balance: 0.1, + fit_residual: 0.02, + fit_method: FitMethod::LogGaussian, + } + } + fn cluster_from_histogram(entries: &[(u16, u16, u32)]) -> EveCluster { let mut pixel_histogram = Vec::new(); let mut events = Vec::new(); @@ -682,6 +1953,7 @@ mod tests { } EveCluster { + cluster_id: 0, pixel_histogram, events, centroid_x: if total > 0.0 { sum_x / total } else { 0.0 }, @@ -690,6 +1962,8 @@ mod tests { x_max, y_min, y_max, + complete: true, + boundary: None, } } @@ -799,6 +2073,7 @@ mod tests { #[test] fn empty_cluster_returns_none() { let cluster = EveCluster { + cluster_id: 0, pixel_histogram: Vec::new(), events: Vec::new(), centroid_x: 0.0, @@ -807,6 +2082,8 @@ mod tests { x_max: 0, y_min: 0, y_max: 0, + complete: true, + boundary: None, }; assert!(mean_xy::fit(&cluster).is_none()); @@ -819,33 +2096,442 @@ mod tests { let registry = current_localizations_registry(); assert_eq!(registry.datasets.len(), 1); - assert_eq!(registry.views.len(), 1); + assert_eq!(registry.views.len(), 2); assert_eq!(registry.datasets[0].id, CURRENT_LOCALIZATIONS_DATASET_ID); assert_eq!(registry.views[0].id, CURRENT_LOCALIZATIONS_VIEW_ID); + assert_eq!(registry.views[1].id, CURRENT_LOCALIZATIONS_3D_VIEW_ID); + assert!(registry.datasets[0].display.is_some()); } #[test] fn host_view_dataset_is_columnar_and_aligned() { let dataset = current_localizations_dataset(&EveLocalizationResults { - localizations: vec![EveLocalization { - x: 1.5, - y: 2.5, - sigma_x: 0.7, - sigma_y: 0.8, - timestamp_us: 10, - n_events: 7, - polarity_balance: 0.1, - fit_residual: 0.02, - fit_method: FitMethod::LogGaussian, - }], + localizations: vec![localization(1.5, 2.5, 10)], frame_window_start_us: 0, frame_window_end_us: 50, }); assert_eq!(dataset.row_count(), 1); - assert_eq!(dataset.columns.len(), 5); - assert_eq!(dataset.columns[0].column_id, "x_px"); - assert_eq!(dataset.columns[4].column_id, "n_events"); + assert_eq!(dataset.columns.len(), 13); + assert_eq!(dataset.columns[0].column_id, "row_id"); + assert_eq!(dataset.columns[1].column_id, "cluster_id"); + assert_eq!(dataset.columns[4].column_id, "span_end_us"); + assert_eq!(dataset.columns[12].column_id, "fit_method"); + } + + #[test] + fn current_localization_schema_exposes_linking_metadata() { + let schema = current_localizations_schema_for_results( + &EveLocalizationResults { + localizations: vec![localization(12.0, 18.0, 15)], + frame_window_start_us: 10, + frame_window_end_us: 20, + }, + Some((128, 64)), + ); + assert_eq!(schema.row_id_column.as_deref(), Some("row_id")); + assert_eq!(schema.time_column.as_deref(), Some("timestamp_us")); + assert_eq!( + schema + .coordinate_space_3d + .as_ref() + .map(|space| space.z_column.as_str()), + Some("timestamp_us") + ); + assert_eq!( + schema.layer_id.as_deref(), + Some(CURRENT_LOCALIZATIONS_LAYER_ID) + ); + let provenance = schema.provenance.as_ref().expect("provenance"); + assert_eq!( + provenance.anchor_time_column.as_deref(), + Some("timestamp_us") + ); + assert_eq!( + provenance.span_start_column.as_deref(), + Some("span_start_us") + ); + assert_eq!(provenance.span_end_column.as_deref(), Some("span_end_us")); + } + + #[test] + fn current_localization_registry_relates_rows_to_accepted_candidate_events() { + let registry = current_localizations_registry_for_results( + &EveLocalizationResults { + localizations: vec![localization(12.0, 18.0, 15)], + frame_window_start_us: 10, + frame_window_end_us: 20, + }, + Some((128, 64)), + ); + let relations = ®istry.datasets[0].relations; + assert_eq!(relations.len(), 1); + assert_eq!( + relations[0].target_dataset_id, + ACCEPTED_CANDIDATE_EVENTS_DATASET_ID + ); + assert_eq!(relations[0].via_column, "cluster_id"); + assert_eq!(relations[0].target_column, "cluster_id"); + } + + #[test] + fn current_localization_dataset_uses_repeatable_row_ids() { + let dataset = current_localizations_dataset(&EveLocalizationResults { + localizations: vec![localization(1.0, 2.0, 11), localization(1.0, 2.0, 11)], + frame_window_start_us: 0, + frame_window_end_us: 50, + }); + let ids = match &dataset.column("row_id").expect("row id column").values { + TableColumnValues::U64(values) => values.clone(), + other => panic!("unexpected row id values: {other:?}"), + }; + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], ids[1]); + } + + #[test] + fn rejected_fit_registry_exposes_dataset_table_and_3d_views() { + let registry = rejected_fits_registry( + &[RejectedFitRow { + row_id: 1, + cluster_id: 7, + x: 10.5, + y: 12.5, + sigma_x: 0.0, + sigma_y: 0.0, + fit_residual: 0.0, + n_events: 5, + polarity_balance: 0.2, + rejection_reason: RejectionReason::FitFailed, + timestamp_us: 15, + span_start_us: 10, + span_end_us: 20, + }], + Some((128, 64)), + 10, + 20, + ); + + assert_eq!(registry.datasets.len(), 1); + assert_eq!(registry.views.len(), 3); + assert_eq!(registry.datasets[0].id, REJECTED_FITS_DATASET_ID); + assert_eq!(registry.views[0].id, REJECTED_FITS_COMPACT_VIEW_ID); + assert!(matches!(registry.views[0].kind, HostViewKind::CompactTable)); + assert_eq!(registry.views[1].id, REJECTED_FITS_TABLE_VIEW_ID); + assert!(matches!(registry.views[1].kind, HostViewKind::TableWindow)); + assert_eq!(registry.views[2].id, REJECTED_FITS_3D_VIEW_ID); + let schema = match ®istry.datasets[0].kind { + HostDatasetKind::TableV1(schema) => schema, + other => panic!("unexpected dataset kind: {other:?}"), + }; + assert_eq!(schema.row_id_column.as_deref(), Some("row_id")); + assert_eq!(schema.layer_id.as_deref(), Some(REJECTED_FITS_LAYER_ID)); + let provenance = schema.provenance.as_ref().expect("provenance"); + assert_eq!( + provenance.anchor_time_column.as_deref(), + Some("timestamp_us") + ); + assert_eq!( + provenance.span_start_column.as_deref(), + Some("span_start_us") + ); + assert_eq!(provenance.span_end_column.as_deref(), Some("span_end_us")); + assert_eq!(registry.datasets[0].relations.len(), 1); + assert_eq!( + registry.datasets[0].relations[0].target_dataset_id, + ACCEPTED_CANDIDATE_EVENTS_DATASET_ID + ); + } + + #[test] + fn rejected_fit_dataset_is_columnar_and_repeatable() { + let row = RejectedFitRow { + row_id: 99, + cluster_id: 5, + x: 4.0, + y: 6.0, + sigma_x: 0.0, + sigma_y: 0.0, + fit_residual: 0.1, + n_events: 8, + polarity_balance: -0.25, + rejection_reason: RejectionReason::ResidualTooHigh, + timestamp_us: 22, + span_start_us: 20, + span_end_us: 30, + }; + let dataset = rejected_fits_dataset(&[row.clone(), row]); + + assert_eq!(dataset.row_count(), 2); + assert_eq!(dataset.columns.len(), 13); + assert_eq!(dataset.columns[0].column_id, "row_id"); + assert_eq!(dataset.columns[12].column_id, "rejection_reason"); + } + + use std::ffi::c_void; + + use augur_plugin_api::{ + FfiColorRgba as TestFfiColorRgba, FfiMarkerOverlayItem as TestFfiMarkerOverlayItem, + FfiOutputCallbacks, FfiPixel, FfiSlice, FfiString, FfiSubpixelMarker, + }; + + unsafe extern "C" fn noop_pixels( + _ctx: *mut c_void, + _pixels: FfiSlice, + _color: TestFfiColorRgba, + ) { + } + unsafe extern "C" fn noop_crosshairs( + _ctx: *mut c_void, + _markers: FfiSlice, + _color: TestFfiColorRgba, + _arm: u16, + ) { + } + unsafe extern "C" fn noop_marker_overlay( + _ctx: *mut c_void, + _markers: FfiSlice, + _dataset: FfiString, + _layer: FfiString, + _src: FfiString, + ) { + } + unsafe extern "C" fn noop_warning( + _ctx: *mut c_void, + _source: FfiString, + _severity: AnalysisSeverity, + _message: FfiString, + ) { + } + + fn noop_output_callbacks() -> FfiOutputCallbacks { + FfiOutputCallbacks { + ctx: std::ptr::null_mut(), + add_highlight_pixels: noop_pixels, + add_crosshair_markers: noop_crosshairs, + add_marker_overlay: noop_marker_overlay, + add_warning: noop_warning, + } + } + + fn cluster_snapshot_params( + cluster_id: u64, + events: &[(u16, u16, bool, u64)], + fit_method: FitMethod, + ) -> Value { + let rows = events + .iter() + .map(|(x, y, polarity, timestamp_us)| { + json!({ + "cluster_id": cluster_id, + "x_px": x, + "y_px": y, + "polarity": polarity, + "timestamp_us": timestamp_us, + }) + }) + .collect(); + let mut params = serde_json::Map::new(); + params.insert("fit_method".into(), json!(fit_method.index() as u64)); + params.insert(HOST_ACTION_CLUSTER_ROWS_PARAM.into(), Value::Array(rows)); + Value::Object(params) + } + + #[test] + fn refit_preview_registry_uses_distinct_layer_and_dataset_ids() { + let registry = + refit_preview_registry_for_results(&EveLocalizationResults::default(), Some((64, 64))); + assert_eq!(registry.datasets.len(), 1); + assert_eq!(registry.datasets[0].id, REFIT_PREVIEW_DATASET_ID); + assert_eq!(registry.views.len(), 1); + assert_eq!(registry.views[0].id, REFIT_PREVIEW_VIEW_ID); + let schema = match ®istry.datasets[0].kind { + HostDatasetKind::TableV1(schema) => schema, + other => panic!("unexpected dataset kind: {other:?}"), + }; + assert_eq!(schema.layer_id.as_deref(), Some(REFIT_PREVIEW_LAYER_ID)); + assert_eq!(schema.semantic_label.as_deref(), Some("refit preview")); + } + + #[test] + fn host_views_registers_three_actions_with_expected_scopes() { + let plugin = EveSmlmFittingPlugin::default(); + let registry = plugin.host_views(); + + assert_eq!(registry.actions.len(), 3); + assert_eq!(registry.actions[0].id, ACTION_REFIT_CLUSTER); + assert!(matches!( + registry.actions[0].scope, + HostActionScope::Cluster { ref dataset_id, ref group_column } + if dataset_id == ACCEPTED_CANDIDATE_EVENTS_DATASET_ID + && group_column == "cluster_id" + )); + assert!(registry.actions[0].param_schema.is_some()); + + assert_eq!(registry.actions[1].id, ACTION_COMMIT_REFIT); + assert!(matches!( + registry.actions[1].scope, + HostActionScope::Row { ref dataset_id } if dataset_id == REFIT_PREVIEW_DATASET_ID + )); + assert!(registry.actions[1].param_schema.is_none()); + + assert_eq!(registry.actions[2].id, ACTION_DISCARD_REFIT); + assert!(matches!( + registry.actions[2].scope, + HostActionScope::Dataset { ref dataset_id } if dataset_id == REFIT_PREVIEW_DATASET_ID + )); + } + + #[test] + fn refit_cluster_uses_snapshot_rows_when_current_frame_cluster_is_missing() { + let mut plugin = EveSmlmFittingPlugin::default(); + let request = augur_plugin_api::HostActionRequest { + request_id: 1, + action_id: ACTION_REFIT_CLUSTER.into(), + scope_payload: augur_plugin_api::HostActionScopePayload::Cluster { + dataset_id: ACCEPTED_CANDIDATE_EVENTS_DATASET_ID.into(), + group_column: "cluster_id".into(), + group_value: "7".into(), + }, + params: cluster_snapshot_params( + 7, + &[(10, 20, true, 100), (12, 20, false, 130)], + FitMethod::MeanXY, + ), + }; + + let mut callbacks = noop_output_callbacks(); + let mut output = augur_plugin_api::HostOutput::new(&mut callbacks); + plugin.handle_refit_cluster(&request, &mut output, None, 65.0); + + assert_eq!(plugin.refit_preview_results.localizations.len(), 1); + let preview = &plugin.refit_preview_results.localizations[0]; + assert_eq!(preview.cluster_id, 7); + assert!((preview.x - 11.0).abs() < 1e-6); + assert!((preview.y - 20.0).abs() < 1e-6); + assert_eq!(preview.n_events, 2); + assert_eq!(preview.span_start_us, 100); + assert_eq!(preview.span_end_us, 130); + assert_eq!(plugin.refit_preview_replaces, vec![None]); + } + + #[test] + fn commit_persists_preview_into_host_results_even_without_current_frame_match() { + let mut plugin = EveSmlmFittingPlugin::default(); + let preview = localization(3.5, 4.5, 200); + let preview_row_id = localization_row_id(&preview); + plugin.refit_preview_results.localizations.push(preview); + plugin.refit_preview_replaces.push(None); + plugin.host_rejected_fits.push(RejectedFitRow { + row_id: 99, + cluster_id: 200, + x: 3.0, + y: 4.0, + sigma_x: 0.0, + sigma_y: 0.0, + fit_residual: 0.2, + n_events: 6, + polarity_balance: 0.1, + rejection_reason: RejectionReason::ResidualTooHigh, + timestamp_us: 180, + span_start_us: 170, + span_end_us: 210, + }); + + let request = augur_plugin_api::HostActionRequest { + request_id: 1, + action_id: ACTION_COMMIT_REFIT.into(), + scope_payload: augur_plugin_api::HostActionScopePayload::Row { + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + row_id: preview_row_id.to_string(), + }, + params: serde_json::json!({}), + }; + + let mut callbacks = noop_output_callbacks(); + let mut output = augur_plugin_api::HostOutput::new(&mut callbacks); + plugin.handle_commit_refit(&request, &mut output); + + assert!(plugin.refit_preview_results.localizations.is_empty()); + assert!(plugin.current_results.localizations.is_empty()); + assert_eq!(plugin.host_results.localizations.len(), 1); + assert_eq!(plugin.host_results.localizations[0].cluster_id, 200); + assert_eq!(plugin.host_results.localizations[0].x, 3.5); + assert!(plugin.host_rejected_fits.is_empty()); + + let dataset_bytes = plugin + .host_view_dataset(CURRENT_LOCALIZATIONS_DATASET_ID) + .expect("host dataset bytes"); + let dataset: TableDatasetV1 = + serde_json::from_slice(&dataset_bytes).expect("table dataset should deserialize"); + assert_eq!(dataset.row_count(), 1); + } + + #[test] + fn commit_replaces_current_localization_when_cluster_matches_current_frame() { + let mut plugin = EveSmlmFittingPlugin::default(); + let old = localization(1.0, 2.0, 100); + plugin.current_results.localizations.push(old); + + let preview = localization(1.1, 2.1, 100); + let preview_row_id = localization_row_id(&preview); + plugin.refit_preview_results.localizations.push(preview); + plugin.refit_preview_replaces.push(None); + + let request = augur_plugin_api::HostActionRequest { + request_id: 1, + action_id: ACTION_COMMIT_REFIT.into(), + scope_payload: augur_plugin_api::HostActionScopePayload::Row { + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + row_id: preview_row_id.to_string(), + }, + params: serde_json::json!({}), + }; + + let mut callbacks = noop_output_callbacks(); + let mut output = augur_plugin_api::HostOutput::new(&mut callbacks); + plugin.handle_commit_refit(&request, &mut output); + + assert_eq!(plugin.current_results.localizations.len(), 1); + assert_eq!(plugin.current_results.localizations[0].x, 1.1); + assert_eq!(plugin.current_results.localizations[0].y, 2.1); + assert_eq!(plugin.host_results.localizations.len(), 1); + assert_eq!(plugin.host_results.localizations[0].cluster_id, 100); + } + + #[test] + fn discard_clears_preview_without_touching_current_results() { + let mut plugin = EveSmlmFittingPlugin::default(); + plugin + .current_results + .localizations + .push(localization(1.0, 2.0, 100)); + let baseline = plugin.current_results.clone(); + + plugin + .refit_preview_results + .localizations + .push(localization(9.0, 9.0, 900)); + plugin.refit_preview_replaces.push(None); + + let request = augur_plugin_api::HostActionRequest { + request_id: 1, + action_id: ACTION_DISCARD_REFIT.into(), + scope_payload: augur_plugin_api::HostActionScopePayload::Dataset { + dataset_id: REFIT_PREVIEW_DATASET_ID.into(), + }, + params: serde_json::json!({}), + }; + + let mut callbacks = noop_output_callbacks(); + let mut output = augur_plugin_api::HostOutput::new(&mut callbacks); + plugin.handle_discard_refit(&request, &mut output); + + assert!(plugin.refit_preview_results.localizations.is_empty()); + assert!(plugin.refit_preview_replaces.is_empty()); + let baseline_bytes = serde_json::to_vec(&baseline).unwrap(); + let after_bytes = serde_json::to_vec(&plugin.current_results).unwrap(); + assert_eq!(baseline_bytes, after_bytes); } } 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/README.md b/plugins/evesmlm-postproc/README.md index 932624f..03c5116 100644 --- a/plugins/evesmlm-postproc/README.md +++ b/plugins/evesmlm-postproc/README.md @@ -32,11 +32,20 @@ AugurRS now publishes host-owned calibration on `CTX_GLOBAL_SETTINGS` as `Global ## Published Data -Publishes filtered and drift-corrected `EveLocalizationResults` on `augur.evesmlm.localization_results`, republishes standard `LocalizationResults` on `augur.localization.results` for downstream compatibility, and serves the compact host-view dataset `augur.evesmlm.current_localizations`. +Publishes filtered and drift-corrected `EveLocalizationResults` on `augur.evesmlm.localization_results`, republishes standard `LocalizationResults` on `augur.localization.results` for downstream compatibility, and serves the shared host-view dataset `augur.evesmlm.current_localizations`. ## Host View -This plugin deliberately reuses the same dataset id and compact panel view id as `EVE Candidate Fitting`. Because post-processing resolves later in the pipeline, it becomes the active provider whenever it is enabled. +This plugin deliberately reuses the same dataset id and view ids as `EVE Candidate Fitting`. + +The shared current-localizations contract includes: + +- stable row ids +- timestamps +- 2D and 3D coordinate metadata +- layer/display metadata + +Because post-processing resolves later in the pipeline, it becomes the active provider whenever it is enabled. ## Dependencies 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 4d96c65..44b0739 100644 --- a/plugins/evesmlm-postproc/src/lib.rs +++ b/plugins/evesmlm-postproc/src/lib.rs @@ -10,17 +10,18 @@ pub mod filtering; use std::collections::VecDeque; use augur_plugin_api::{ - export_plugin, AnalysisSeverity, EventStoreHandle, FfiSubpixelMarker, GlobalSettings, - HostContext, HostOutput, HostViewRegistry, Plugin, PluginFrame, PluginInput, SettingItem, - SettingKind, SettingsSchema, SettingsSection, StatusEntry, CTX_GLOBAL_SETTINGS, -}; -pub use augur_plugin_evesmlm_fitting::{ - current_localizations_dataset, current_localizations_registry, to_localization_results, - EveLocalization, EveLocalizationResults, FitMethod, CTX_EVE_LOCALIZATION_RESULTS, - CURRENT_LOCALIZATIONS_DATASET_ID, + export_plugin, AnalysisSeverity, EventStoreHandle, FfiColorRgba, FfiMarkerOverlayItem, + FfiMarkerShape, GlobalSettings, HostContext, HostOutput, HostViewRegistry, Plugin, PluginFrame, + PluginInput, SettingItem, SettingKind, SettingsSchema, SettingsSection, StatusEntry, + CTX_GLOBAL_SETTINGS, }; 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 serde_json::{json, Value}; const OVERLAY_COLOR: [u8; 4] = [90, 170, 255, 220]; @@ -63,6 +64,7 @@ pub struct EveSmlmPostProcPlugin { enabled: bool, settings: PostProcSettings, current_results: EveLocalizationResults, + sensor_dims: Option<(u16, u16)>, corrected_history: VecDeque>, evaluation: EvaluationState, last_input_count: usize, @@ -78,6 +80,7 @@ impl Default for EveSmlmPostProcPlugin { enabled: false, settings: PostProcSettings::default(), current_results: EveLocalizationResults::default(), + sensor_dims: None, corrected_history: VecDeque::new(), evaluation: EvaluationState::default(), last_input_count: 0, @@ -91,13 +94,16 @@ impl Default for EveSmlmPostProcPlugin { } impl EveSmlmPostProcPlugin { - fn sync_runtime_settings(&mut self, context: &HostContext<'_>) { + fn sync_runtime_settings(&mut self, context: &HostContext<'_>, frame: &PluginFrame<'_>) { if let Some(settings) = context .get::(CTX_GLOBAL_SETTINGS) .ok() .flatten() { self.settings.nm_per_pixel = settings.nm_per_pixel; + self.sensor_dims = Some((settings.sensor_width, settings.sensor_height)); + } else { + self.sensor_dims = Some((frame.width(), frame.height())); } } @@ -159,15 +165,34 @@ impl EveSmlmPostProcPlugin { self.evaluation.update(&corrected); if self.settings.show_overlay && !corrected.localizations.is_empty() { - let markers: Vec = corrected + let stable_ids: Vec = corrected + .localizations + .iter() + .map(|localization| localization_row_id(localization).to_string()) + .collect(); + let markers: Vec = corrected .localizations .iter() - .map(|localization| FfiSubpixelMarker { + .zip(stable_ids.iter()) + .map(|(localization, stable_id)| FfiMarkerOverlayItem { x: localization.x as f32, y: localization.y as f32, + shape: FfiMarkerShape::Cross, + size: 5.5, + color: FfiColorRgba::from_rgba(OVERLAY_COLOR), + timestamp_us: localization.timestamp_us, + has_timestamp: true, + stable_id: stable_id.as_str().into(), + source_dataset_id: CURRENT_LOCALIZATIONS_DATASET_ID.into(), + source_row_id: stable_id.as_str().into(), }) .collect(); - output.add_crosshair_markers(&markers, OVERLAY_COLOR, 4); + output.add_marker_overlay( + &markers, + Some(CURRENT_LOCALIZATIONS_DATASET_ID), + Some(CURRENT_LOCALIZATIONS_LAYER_ID), + Some(self.name()), + ); } let mut status = format!( @@ -192,6 +217,7 @@ impl EveSmlmPostProcPlugin { pub fn reset(&mut self) { self.current_results = EveLocalizationResults::default(); + self.sensor_dims = None; self.corrected_history.clear(); self.evaluation.reset(); self.last_input_count = 0; @@ -270,12 +296,12 @@ impl Plugin for EveSmlmPostProcPlugin { fn process_frame( &mut self, - _frame: &PluginFrame<'_>, + frame: &PluginFrame<'_>, output: &mut HostOutput<'_>, context: &mut HostContext<'_>, _event_store: &EventStoreHandle<'_>, ) { - self.sync_runtime_settings(context); + self.sync_runtime_settings(context, frame); let input = match context.get::(CTX_EVE_LOCALIZATION_RESULTS) { Ok(value) => value, Err(err) => { @@ -592,7 +618,7 @@ impl Plugin for EveSmlmPostProcPlugin { } fn host_views(&self) -> HostViewRegistry { - current_localizations_registry() + current_localizations_registry_for_results(&self.current_results, self.sensor_dims) } fn host_view_dataset(&self, dataset_id: &str) -> Option> { @@ -618,11 +644,14 @@ mod tests { fn localization(x: f64, y: f64, n_events: usize) -> EveLocalization { EveLocalization { + cluster_id: x.to_bits() ^ y.to_bits(), x, y, sigma_x: 1.2, sigma_y: 1.2, timestamp_us: 0, + span_start_us: 0, + span_end_us: 0, n_events, polarity_balance: 0.0, fit_residual: 0.1, @@ -659,6 +688,22 @@ mod tests { assert!(correction.1.abs() <= 0.1); } + #[test] + fn current_localizations_descriptor_matches_fitting() { + 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); + let fitting_json = + serde_json::to_value(&fitting).expect("fitting registry should serialize"); + let postproc_json = + serde_json::to_value(&postproc).expect("postproc registry should serialize"); + assert_eq!( + fitting_json, postproc_json, + "postproc must mirror fitting's current_localizations descriptor byte-for-byte", + ); + } + #[test] fn enena_accumulation_collects_expected_nearest_neighbor_distances() { let mut evaluation = EvaluationState::default(); diff --git a/plugins/focus-metrics/src/lib.rs b/plugins/focus-metrics/src/lib.rs index 47c5d23..49a69ab 100644 --- a/plugins/focus-metrics/src/lib.rs +++ b/plugins/focus-metrics/src/lib.rs @@ -656,6 +656,7 @@ mod tests { height: 16, pixels: FfiSlice::from_slice(&pixels), events: FfiSlice::default(), + external_triggers: FfiSlice::default(), window_start_us: 0, window_end_us: 1_000, }; diff --git a/plugins/localization/src/lib.rs b/plugins/localization/src/lib.rs index c542b38..9367630 100644 --- a/plugins/localization/src/lib.rs +++ b/plugins/localization/src/lib.rs @@ -467,7 +467,7 @@ fn build_analysis_image(frame: &PluginFrame<'_>, raw_events: Option<&[FfiCdEvent } let idx = event.y as usize * frame.width() as usize + event.x as usize; let weight = event - .timestamp + .timestamp_us() .saturating_sub(frame.window_start_us()) .max(1) as f64; if event.polarity != 0 { @@ -807,7 +807,7 @@ fn estimate_timestamp_us( continue; } let weight = 1.0 / (1.0 + dist2); - weighted_timestamp += event.timestamp as f64 * weight; + weighted_timestamp += event.timestamp_us() as f64 * weight; weight_sum += weight; } diff --git a/plugins/reconstruction/README.md b/plugins/reconstruction/README.md index d2726b6..b333429 100644 --- a/plugins/reconstruction/README.md +++ b/plugins/reconstruction/README.md @@ -18,12 +18,13 @@ AugurRS now publishes host-owned calibration on `CTX_GLOBAL_SETTINGS` as `Global ## Host Views -The plugin publishes one dataset, `augur.localization.accumulated`, and two host-rendered window views over that dataset: +The plugin publishes one dataset, `augur.localization.accumulated`, and three host-rendered views over that dataset: - `Localization Table` - `Reconstruction` +- `Localization Cloud` -Both views read the same accumulated source of truth. +The dataset now carries stable row ids, timestamps, nanometer-space 2D coordinates, generic 3D scatter metadata, and layer/display metadata. All views read the same accumulated source of truth. ## Compatibility diff --git a/plugins/reconstruction/src/lib.rs b/plugins/reconstruction/src/lib.rs index de404b4..9d03646 100644 --- a/plugins/reconstruction/src/lib.rs +++ b/plugins/reconstruction/src/lib.rs @@ -12,8 +12,10 @@ use std::collections::VecDeque; const DEFAULT_NM_PER_PIXEL: f64 = 65.0; const DEFAULT_MAX_LOCALIZATIONS: usize = 1_000_000; const ACCUMULATED_DATASET_ID: &str = "augur.localization.accumulated"; +const ACCUMULATED_LAYER_ID: &str = "augur.layer.localization.accumulated"; const LOCALIZATION_TABLE_VIEW_ID: &str = "augur.localization.accumulated.table"; const RECONSTRUCTION_VIEW_ID: &str = "augur.localization.accumulated.density"; +const RECONSTRUCTION_3D_VIEW_ID: &str = "augur.localization.accumulated.scatter3d"; #[derive(Debug, Clone)] struct ReconstructionSettings { @@ -149,6 +151,27 @@ impl ReconstructionPlugin { }) } + fn accumulated_coordinate_space_3d(&self) -> Option { + let (sensor_width, sensor_height) = self.sensor_dims?; + let z_min = self.table.front()?.timestamp_us as f64; + let z_max = self + .table + .back()? + .timestamp_us + .max(self.table.front()?.timestamp_us) as f64; + Some(augur_plugin_api::TableCoordinateSpace3d { + x_column: "x_nm".into(), + y_column: "y_nm".into(), + z_column: "timestamp_us".into(), + x_min: 0.0, + x_max: f64::from(sensor_width) * self.settings.nm_per_pixel, + y_min: 0.0, + y_max: f64::from(sensor_height) * self.settings.nm_per_pixel, + z_min, + z_max, + }) + } + fn accumulated_schema(&self) -> augur_plugin_api::TableSchema { augur_plugin_api::TableSchema { columns: vec![ @@ -199,6 +222,71 @@ impl ReconstructionPlugin { }, ], coordinate_space_2d: self.accumulated_coordinate_space(), + coordinate_space_3d: self.accumulated_coordinate_space_3d(), + row_id_column: Some("id".into()), + time_column: Some("timestamp_us".into()), + layer_id: Some(ACCUMULATED_LAYER_ID.into()), + semantic_label: Some("localizations".into()), + provenance: Some(augur_plugin_api::TableRowProvenance { + anchor_time_column: Some("timestamp_us".into()), + span_start_column: Some("timestamp_us".into()), + span_end_column: Some("timestamp_us".into()), + anchor_frame_column: Some("frame".into()), + }), + column_display: vec![ + augur_plugin_api::TableColumnDisplayEntry { + column_id: "id".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::Identifier), + hide_in_compact: true, + ..Default::default() + }, + }, + augur_plugin_api::TableColumnDisplayEntry { + column_id: "timestamp_us".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::TimestampMicros), + label: Some("Time".into()), + ..Default::default() + }, + }, + augur_plugin_api::TableColumnDisplayEntry { + column_id: "x_nm".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::FixedPrecision { + digits: 1, + }), + ..Default::default() + }, + }, + augur_plugin_api::TableColumnDisplayEntry { + column_id: "y_nm".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::FixedPrecision { + digits: 1, + }), + ..Default::default() + }, + }, + augur_plugin_api::TableColumnDisplayEntry { + column_id: "sigma_nm".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::FixedPrecision { + digits: 2, + }), + ..Default::default() + }, + }, + augur_plugin_api::TableColumnDisplayEntry { + column_id: "uncertainty_xy_nm".into(), + display: augur_plugin_api::TableColumnDisplayMetadata { + format: Some(augur_plugin_api::TableColumnDisplayFormat::FixedPrecision { + digits: 2, + }), + ..Default::default() + }, + }, + ], } } @@ -269,6 +357,14 @@ impl ReconstructionPlugin { title: "Accumulated localizations".into(), kind: augur_plugin_api::HostDatasetKind::TableV1(self.accumulated_schema()), empty_message: "No accumulated localizations yet.".into(), + display: Some(augur_plugin_api::HostDatasetDisplayMetadata { + layer_title: Some("Accumulated localizations".into()), + default_visibility: Some(true), + default_color: Some([255, 180, 80, 255]), + default_marker_shape: Some(augur_plugin_api::HostMarkerShape::Circle), + default_size: Some(3.5), + }), + relations: Vec::new(), }], views: vec![ augur_plugin_api::HostViewDescriptor { @@ -288,7 +384,19 @@ impl ReconstructionPlugin { y_column: "y_nm".into(), }, }, + augur_plugin_api::HostViewDescriptor { + id: RECONSTRUCTION_3D_VIEW_ID.into(), + title: "Localization Cloud".into(), + dataset_id: ACCUMULATED_DATASET_ID.into(), + placement: augur_plugin_api::HostViewPlacement::Window, + kind: augur_plugin_api::HostViewKind::Scatter3dFromTable { + x_column: "x_nm".into(), + y_column: "y_nm".into(), + z_column: "timestamp_us".into(), + }, + }, ], + actions: Vec::new(), } } } @@ -505,16 +613,23 @@ mod tests { } #[test] - fn host_view_registry_exposes_one_dataset_and_two_window_views() { + fn host_view_registry_exposes_one_dataset_and_investigation_views() { let mut plugin = ReconstructionPlugin::default(); plugin.sensor_dims = Some((1280, 720)); let registry = plugin.host_view_registry(); assert_eq!(registry.datasets.len(), 1); - assert_eq!(registry.views.len(), 2); + assert_eq!(registry.views.len(), 3); assert_eq!(registry.datasets[0].id, ACCUMULATED_DATASET_ID); assert_eq!(registry.views[0].id, LOCALIZATION_TABLE_VIEW_ID); assert_eq!(registry.views[1].id, RECONSTRUCTION_VIEW_ID); + assert_eq!(registry.views[2].id, RECONSTRUCTION_3D_VIEW_ID); + let schema = match ®istry.datasets[0].kind { + augur_plugin_api::HostDatasetKind::TableV1(schema) => schema, + other => panic!("unexpected dataset kind: {other:?}"), + }; + assert_eq!(schema.row_id_column.as_deref(), Some("id")); + assert_eq!(schema.time_column.as_deref(), Some("timestamp_us")); } } diff --git a/plugins/stage-a-a1/Cargo.toml b/plugins/stage-a-a1/Cargo.toml new file mode 100644 index 0000000..9e02ca3 --- /dev/null +++ b/plugins/stage-a-a1/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "augur-plugin-stage-a-a1" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A A1 workflow orchestrator and pure minimum-depth analysis core" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2 = "0.10" +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[lints.rust] +unsafe_code = "forbid" diff --git a/plugins/stage-a-a1/README.md b/plugins/stage-a-a1/README.md new file mode 100644 index 0000000..597b95f --- /dev/null +++ b/plugins/stage-a-a1/README.md @@ -0,0 +1,312 @@ +# Stage-A A1 Analysis + +`stage-a-a1` is the Stage-A **recording coordinator** plus two live sanity quicklooks. One button +records the camera **RAW** stream and the photodiode **PDQ** stream together for a fixed duration, +groups them under a per-`(I_k, f)` measurement id, and writes an A1 config sidecar (`.toml`) linking +the files with the modulation settings, the measured modulation depth `a`, the ROI, and the trigger +info needed to reproduce and analyse the run offline. A second button, **Start sweep**, repeats that +per amplitude: it leases the modulation owner, retargets the armed calibrated drive to each `a` in +`[Sweep min a, Sweep max a]`, waits for the photodiode-measured `a` to settle, and records every +point (`…_pNN`). Outside the leased sweep A1 owns no hardware and never drives the Teensy — arm the +optical drive in the modulation plugin; A1 only reads its published settings. + +## Where `a` comes from + +**Depth a source** picks what every depth-dependent path — the sweep, **Find a₀**, the frequency +ladder, the response curve — reads as `a`: + +- **photodiode (measured)** — the default and the source of record. Fail-closed: the photodiode + publishes no `a` unless firmware phase-0 markers on its stream port prove the estimator window + covers whole modulation cycles. If those markers never arrive (no trigger, or firmware that does + not stamp them) it refuses forever — *"0 trigger(s) in the last 3446784 samples"* is a missing + marker stream, not a short window, and no setting fixes it. +- **modulation drive (commanded, open loop)** — the depth the modulation owner's calibrated drive is + commanding (`optical_drive.depth_a_milli`). Still a calibrated number, inverted from the measured + `V_null`/`V_peak` curve, but **not checked against the light**: it carries the calibration's error plus + any drift since. Needs an applied calibration and `OPTICAL_LOG_SINE` armed; a manual DAC band + publishes no optical drive and the gates refuse rather than inventing a depth. + +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.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). + +## Recording + +- **Output folder** — where the A1 config sidecar is written (recommended shared experiment root). + The only field that has to be filled in before recording. +- **Measurement id** — one per `(I_k, f)` pair; auto-generated default, editable, or press **New id**. + Optional: a blank field is filled in on the first recording and written back (ADR 018). +- **Duration (s)** — each recording auto-stops and finalizes after this. +- **Start recording** — starts camera RAW, then connects/leases the photodiode and starts PDQ; + the timer begins after both acknowledge. It auto-finalizes PDQ first, camera second, then writes + the sidecar. **Stop** saves the current recording early (and aborts a running sweep). +- **Start sweep** — records **Sweep points (count)** amplitudes spanning `[Sweep min a, Sweep max a]` + (min > 0): per point it renews the modulation lease, issues `SetOpticalDepth`, waits for the + fresh marker-bounded measured `a` to hold the target for **Sweep settle (s)**; timeout aborts + rather than recording an unsettled point. The modulation owner also requires an applied + transfer calibration and `OPTICAL_LOG_SINE`, and runs + one normal recording. Sidecars carry `sweep.requested_a` / `point_index` / `point_total`. +- The record/sweep buttons are disabled until an output folder is selected. + +## Exact event count (`a₀` lock) + +The second Stage-A workflow holds **one** photodiode-measured depth +`a₀ = ln(I_exc,max / I_exc,min)` constant while the frequency varies. Because the measured Pockels +inversion is static, the delivered depth rolls off with frequency — so the depth must be found by +measurement, not calculated. + +- **a₀** / **a₀ tolerance** — the frozen measured depth and its convergence band (default ±0.02). +- **Find a₀** — per frequency: leases the modulation owner and iterates + `commanded a ← commanded a · a₀/measured a` (≤ 8 trials, averaging three fresh photodiode summaries + per trial after **Sweep settle (s)**) until the photodiode measures `a₀`. Records nothing, leaves the + drive at the depth it found, and stores one row per frequency — shown in the **A1 a₀ locks** view and + mirrored to `a0_locks.json`. An unreachable `a₀` is reported (drive limit or the owner's own + rejection) before any data is recorded. +- **Record a₀ point (event-count)** — re-applies the locked depth under the lease (so the amplitude + cannot change during the recorded interval), waits for the measured `a` to hold `a₀`, and records + one atomic frequency point named `…_ec_fHz` with an `[a0_lock]` sidecar section. +- **Clear a₀ lock table** — after changing the illumination, the calibration or `a₀` itself. + +Frequency order, the interleaved low-frequency reference and the repeated blocks stay yours — every +point is one button press. + +### With the commanded depth source there is no search + +Everything above describes the *measured* workflow. `Find a₀` exists only because a **measured** `a₀` +has to be re-found per frequency against the static inversion's roll-off. A **commanded** depth is +the number being commanded, so the correction ratio is exactly 1 and a search would command `a₀`, +read back `a₀` and stop. It is therefore not run at all (ADR 021): + +| | photodiode (measured) | modulation drive (commanded) | +|---|---|---| +| `Find a₀` | trims per frequency, stores a lock | **disabled** — says why | +| `Record a₀ point` | replays the stored lock | commands `a₀` directly | +| ladder per rung | set `f` → confirm via **camera markers** → search → record | set `f` → confirm via the **modulation owner's ACK** → record | +| `a0_locks.json` | one row per frequency | untouched | +| needs EXT_TRIGGER + Live analysis | **yes** | no | + +So open loop the whole workflow is: set `a₀`, press **Record all frequencies**. The trade is the one +the lock removes — nothing verifies the light reached `a₀`, and the roll-off is real — so switch back +to the photodiode once its markers work. + +## Depth sweep at every frequency (the `q_p(a, f)` surface) + +The frequency ladder is an **outer loop**; what it records per rung is a mode: + +| button | per frequency | produces | +|---|---|---| +| **Record all frequencies** | one event-count point at `a₀` | `q_p(a₀, f)` | +| **Record depth sweep at every frequency** | the whole `[min a, max a]` sweep | `q_p(a, f)` — a curve per `f` | + +The second runs the block `a50(f)` is fitted from, unattended: `frequency points × +depth points` recordings on **one lease**, so the drive cannot move between rungs. +It adds no new settings — the depth axis is `Sweep min a`/`max a`/`points` from +**Recording**, the frequency axis is `Sweep min f`/`max f`/`points`/order/seed from +the a₀ section — and reuses the ladder's ordering, reference repeats, +per-frequency confirmation and skip-and-report unchanged. + +No `a₀` and no **Find a₀** are involved in either depth source: a depth sweep +commands and settles every `a` itself. Points are named `…_fHz_pNN`. A rung +counts as done only when its inner sweep recorded every point. See +[ADR 023](../../docs/adr/023-stage-a-a1-nested-depth-frequency-sweep.md). + +## Bench conditions on every run + +Every recording, in every mode, also records what the camera measures about itself (host +`CTX_SENSOR_MONITORING`): die **temperature** (°C), pixel **dead time / refractory period** (µs), +scene **illumination** (lux), the reading's age, and the absolute bias codes. They land in the +sidecar's `[sensor]` section and in both recorders' metadata as `sensor_*`. + +Frozen when the recording starts (they drift), mirrored even with Live analysis off, and provenance +only — no result depends on them. A quantity the sensor cannot report is **omitted, never `0`**; +replay and cameras without a monitoring block produce no `[sensor]` section at all. See +[ADR 022](../../docs/adr/022-stage-a-a1-sensor-conditions-on-every-run.md). + +The host also polls those quantities for the *whole* recording and writes a wide +`.sensor-monitoring.csv` beside the RAW. A1 gathers it into the measurement folder as +`.sensor.json`, rewritten column-wise — one `{ t_us, value }` pair of arrays per channel, +carrying only the polls where that channel was read. The channels are sampled on different +schedules, so a row-per-poll table is padding by construction; the bias columns are dropped because +the camera's own bias sidecar already carries them. Named in the sidecar's `[files]` block as +`sensor_readout`. See +[ADR 028](../../docs/adr/028-stage-a-sensor-readout-travels-with-the-measurement.md). + +Files share an `_` stem: `/_.raw` (camera, under the host output root), +`/__pd.pdq` + `.json` (photodiode, under its data root), +`/_.sensor.json` (sensor readout), and +`/__config.toml` (A1, under the chosen folder). Point all three roots at the same +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** +names every axis for every recording instead, in a file that travels with the results. The reader +is chosen by extension. + +### CSV — one row per recording (the one to reach for) + +```csv +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 | | | +|---|---|---| +| `mean_u` | required | normalized cycle-mean lobe point `ū` — the brightness (`I_k`) axis, 0.01–1.0 | +| `frequency_hz` | required | 0.01–2000 | +| `depth_a` | required | `a = ln(I_max/I_min)`, 0.01–6 | +| `duration_s` | optional, default 10 | seconds for **this** row, 1–3600 | +| `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 — +a complete measurement, not one that needs two button presses first. + +### TOML — blocks and ranges + +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" +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 +``` + +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 + +**`mean_u` is the `I_k` axis** — the normalized cycle-mean lobe point, driven by the new +`SetOperatingPoint` command, and the axis no button could sweep. All three axes are commanded at +every point and the point waits for all three acknowledgements before recording, so nothing is +filed under parameters the file does not state. The file's `duration_s` wins over the panel's. One +lease covers the whole run; a point whose drive the modulation owner refuses is skipped carrying +its wording, and the reasons are kept on the status pane and in the closing summary. The whole file +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). + +## Live quicklooks + +- **Rolling half-period response** `S_p(t) = N_p(t−T/2, t] / N_valid` — events per valid pixel in the + trailing half-cycle, ON and OFF. A live "are events appearing, is the ON/OFF timing sane?" check. +- **Response probability** `q_p` — fraction of valid pixel-cycles that fire at least once in the + ON/OFF phase window (each pixel-cycle counts once, unlike `S_p`). The windows come from the row's + **pilot** when one has been recorded (frozen and held across the row), otherwise auto-detected + from the trigger-anchored fold (each grows out from its histogram peak to the window floor, + default 10 % of peak). `Record pilot` / `Record background` (in the Recording section) capture the + frozen windows and the floor `q0` into the measurement folder and are auto-reloaded when you + return to that folder + id. Record one point per amplitude vs the photodiode-measured `a`. The + authoritative `q_p(a, f)` fit is computed **offline** from the recordings; this is a quicklook. + +The period `T` comes from the firmware phase-0 `EXT_TRIGGER` marker spacing (the trigger *defines* +the frequency), falling back to the modulation plugin's acknowledged waveform. The ROI and masked +pixels come from the augur-rs camera config. + +See [docs/features/stage-a-a1.md](../../docs/features/stage-a-a1.md) for the full brief, +[ADR 009](../../docs/adr/009-stage-a-a1-recording-coordinator.md) for the coordinator design, +[docs/features/stage-a-a1-event-count.md](../../docs/features/stage-a-a1-event-count.md) plus +[ADR 013](../../docs/adr/013-stage-a-a1-event-count-depth-lock.md) for the `a₀` lock, +[ADR 014](../../docs/adr/014-stage-a-a1-frequency-ladder.md) for the unattended +frequency ladder, +[ADR 015](../../docs/adr/015-stage-a-a1-recording-robustness.md) for the +recording coordinator's one-folder/full-duration guarantees, +[ADR 017](../../docs/adr/017-stage-a-rail-detection-and-withheld-a-reasons.md) +for why an `a₀` gate refused and how Live analysis is distinguished from a +missing trigger, and +[docs/features/stage-a-a1-automation.md](../../docs/features/stage-a-a1-automation.md) for the +planned amplitude-sweep automation on top of this. diff --git a/plugins/stage-a-a1/plugin.toml b/plugins/stage-a-a1/plugin.toml new file mode 100644 index 0000000..32cfd84 --- /dev/null +++ b/plugins/stage-a-a1/plugin.toml @@ -0,0 +1,14 @@ +id = "stage-a.a1" +name = "Stage-A A1 Analysis" +version = "0.3.0" +description = "Stage-A A1 recording coordinator: one-button synchronized camera .raw + photodiode .pdq recording with a config sidecar, plus live rolling-response and response-probability quicklooks." +domain = "stage-a" +library = "augur_plugin_stage_a_a1" +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-a1/protocols/a1_fc_flux_discriminator.csv b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv new file mode 100644 index 0000000..2e78779 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_fc_flux_discriminator.csv @@ -0,0 +1,333 @@ +# A1 Drei-Fluss-Tiefensteigung — f_c proportional I Discriminator +# Zweck: den korrigierten Tiefensteigungs-Pol bei drei Flusswerten messen. +# Primaer: beta_p(f)=d E[N_p]/da aus vollstaendigen Triggerzyklen; Intercept frei. +# Piloten, Floors, Referenzen und verschiedene mean_u niemals als Grid poolen. +# +# Frequenzen: 5, 12.5, 25, 50, 100, 160, 220, 280, 350, 450, 640, 800 Hz. +# Sechs Tiefen je Frequenz: 0.15, 0.28, 0.45, 0.80, 1.30, 1.70. +# 220 Hz wird in beiden Paessen wiederholt und ist die Zustands-/Driftbruecke. +# Pass B kehrt Frequenz- und Flussrichtung um; Tiefenrichtung wechselt je Frequenz. +# 640/800 Hz sind Censoring-/Null-Guards und werden nur bei bestandener Linearitaet +# in einen Fit aufgenommen. Fitband vor dem Fit gemeinsam fuer alle Fluesse festlegen. +# +# VORHER: a1_lux_dark_offset.csv blockiert, danach a1_illuminated_smoke.csv +# beleuchtet und mit Depth a source = Photodiode measured erfolgreich fahren. +# Bias-v1, ROI, Probe, Optik, Kalibration und PD-Last bleiben ueber beide Paesse fest. +# Ausfuehrbare Aufbau-/Abbruchregeln: knowledge base/experiments/A1-bode/ +# next-flux-discriminator-protocol.md. +# +# Umfang: 297 Recordings, ~153 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# === a: Frequenzen [5.0, 25.0, 100.0, 220.0, 350.0, 640.0]; Flussfolge [0.15, 0.3, 0.45] +# +# --- a, mean_u=0.15 +A1-bias-v1-monitoring,floor_a,0.15,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.15,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.15,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.15,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.15,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.15,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.15,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +# +# --- a, mean_u=0.30 +A1-bias-v1-monitoring,floor_a,0.30,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.30,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.30,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.30,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.30,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.30,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.30,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +# +# --- a, mean_u=0.45 +A1-bias-v1-monitoring,floor_a,0.45,5,0.02,20,8,background +A1-bias-v1-monitoring,windows_a,0.45,5,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.45,100,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.45,100,1.70,20,3,pilot +A1-bias-v1-monitoring,floor_a,0.45,350,0.02,20,3,background +A1-bias-v1-monitoring,windows_a,0.45,350,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,5,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,25,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,100,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.15,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,350,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,1.70,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,1.30,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.80,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.45,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.28,20,3, +A1-bias-v1-monitoring,grid_a,0.45,640,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +# +# === b: Frequenzen [800.0, 450.0, 280.0, 220.0, 160.0, 50.0, 12.5]; Flussfolge [0.45, 0.3, 0.15] +# +# --- b, mean_u=0.45 +A1-bias-v1-monitoring,floor_b,0.45,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.45,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.45,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.45,220,0.80,20,3, +# +# --- b, mean_u=0.30 +A1-bias-v1-monitoring,floor_b,0.30,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.30,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.30,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.30,220,0.80,20,3, +# +# --- b, mean_u=0.15 +A1-bias-v1-monitoring,floor_b,0.15,800,0.02,20,8,background +A1-bias-v1-monitoring,windows_b,0.15,800,1.70,20,3,pilot +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,800,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,450,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,280,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,220,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,160,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,1.70,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,50,0.15,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.15,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.28,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.45,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,0.80,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,1.30,20,3, +A1-bias-v1-monitoring,grid_b,0.15,12.5,1.70,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, +A1-bias-v1-monitoring,ref,0.15,220,0.80,20,3, diff --git a/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv b/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv new file mode 100644 index 0000000..423a13e --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_illuminated_smoke.csv @@ -0,0 +1,13 @@ +# A1 beleuchteter Smoke-Test — separat nach Lamp-off, vor dem Hauptlauf +# VOR START: Strahl kontrolliert freigeben; ATTO647-Fluoreszenz muss Kamera und +# Emissions-PD hinter Fluoreszenzfilter und 50:50-Strahlteiler erreichen. +# A1 Depth a source auf Photodiode measured stellen; dieselbe gemessene +# Lamp-off-Darkreferenz, Optik, Probe, ROI, Maske, Biases und PD-Last beibehalten. +# Stop: kein frisches measured_a, falsche Frequenz/Trigger, ADC-Clipping, fehlender +# positiver Dark-Headroom, PD-SNR < 10 oder Fehler in RAW/PDQ/Sensor-Sidecars. +# Der Punkt ist identisch zu einem Hauptgitterpunkt: mean_u=0.30, f=100 Hz, a=0.45. +# Er qualifiziert den Fluoreszenzpfad; er misst keine transmittierte Anregung. +# +# Umfang: 1 Recordings, ~1 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +A1-bias-v1-monitoring,illuminated_smoke,0.30,100,0.45,20,8, diff --git a/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv b/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv new file mode 100644 index 0000000..c490d11 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_lux_dark_offset.csv @@ -0,0 +1,14 @@ +# A1 Lamp-off-Offset und H14-Sham — separat vor dem Hauptlauf fahren +# VOR START: Strahl physisch blockieren; Raumlicht und Kameraposition unveraendert. +# Diese Datei nie direkt mit dem Hauptprotokoll verketten: nach dem Recording stoppen, +# Dateien finalisieren, dann den Strahlengang kontrolliert wieder freigeben. +# Ausgabe: Median/Streuung illumination_lux, Kamera-Untergrund je Polaritaet und +# Darkwert der Emissions-PD hinter Filter/50:50. Der Offset gilt nur fuer diese Sitzung. +# Die kommandierte a=0.02-Modulation ist kein optischer Background; bei blockiertem +# Licht dient sie ausschliesslich als koharenter H14-Uebersprechtest. +# A1 Depth a source fuer diesen Lauf auf Commanded stellen. Vor dem beleuchteten +# Smoke-Test wieder auf Photodiode measured zurueckstellen. +# +# Umfang: 1 Recordings, ~1 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +camera_profile,label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +A1-bias-v1-monitoring,lux_dark_h14,0.30,100,0.02,30,8,background diff --git a/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv b/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv new file mode 100644 index 0000000..866276a --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe1_bode_dc.csv @@ -0,0 +1,147 @@ +# A1 Stufe 1 — vollstaendige Bode-Kurve + DC-Aufhebungstest +# EINE Sitzung, EINE Intensitaetseinstellung, EINE durchgehende Kurve. +# +# (A) Bode-Kurve, 0.1 bis 68 Hz. Der Sweep vom 31.07. begann bei 1 Hz und lag +# dort schon im Abfall (f_c ~ 2-3 Hz) — ohne gemessenes Plateau sind |H| +# und C nur Obergrenzen. Der ganze Bereich wird neu gefahren, nicht nur +# das fehlende Stueck: |H| = N(f)/N_Plateau braucht Plateau UND Abfall auf +# derselben Kurve. Zwei Sitzungen zusammenzukleben ginge nur, wenn die +# Intensitaet exakt dieselbe waere — sie ist es nicht (siehe unten). +# Die hohen Frequenzen sind billig (20 s), das kostet nur ~15 min extra. +# Oberhalb 68 Hz war zuletzt |H| < 0.03, deshalb endet die Leiter dort. +# +# (B) DC-Aufhebung. Bei festem a muss die Eventzahl pro Zyklus bei tiefem f +# unabhaengig von der Helligkeit sein ('I_0 verschiebt das Knie, a setzt +# die Hoehe'). Die Methodik nennt das den schaerfsten Test der Stufe A. +# Faellt er durch, ist die Log-Frontend-Annahme kaputt. Drei +# Arbeitspunkte, gleiches a, gleiches f. +# +# ZWINGEND VOR DEM START — sonst laeuft kein einziges Recording durch. +# Der Lauf vom 03.08. ist an genau diesen drei Punkten gescheitert (0/73): +# +# 1. I_tot-ANKER. stage-a-a1 verweigert den Sidecar ohne ihn: 'cannot write +# a quantitative A1 sidecar without a fresh photodiode optical summary +# from a confirmed I_tot anchor'. Das ist eine Software-Vorbedingung, +# nicht eine Anforderung der Auswertung — der Sidecar gilt sonst als +# nicht quantitativ. Also: Pockels auf volle Anregungsextinktion fahren, +# PD-Maximum als I_tot bestaetigen. Nach der Sitzung wiederholen, die +# Differenz ist das Driftbudget. +# +# 2. AUTOMATION LEASE fuer Modulations- UND Photodioden-Owner. Ohne aktive +# Lease lehnt jeder Retarget ab ('the modulation owner requires an +# active automation lease'). Sie laeuft ab — bei 60 min Sitzung darauf +# achten, dass die Gueltigkeit reicht. +# +# 3. LOBE-DECKE. stage-a-modulation rechnet +# u_g = mean_u / I_0(a/2) (geometrischer Pedestal) +# u_peak = u_g * exp(a/2) und verlangt u_peak <= 1, +# weil die Transmission oberhalb der Lobe-Spitze nicht mehr monoton ist. +# Dieses Protokoll haelt das fuer jede Zeile ein. ABER: steht in der +# Oberflaeche ein groesseres Sweep-Maximum a als in dieser Datei, wird +# DAS geprueft. Der 03.08. meldete u_peak = 1.222 bei mean_u = 0.400 — +# das entspricht a ~ 3.6 und stammt NICHT aus dieser Datei (max 1.70). +# Also Sweep-Maximum in der Oberflaeche auf 1.70 setzen. Zur Kontrolle, +# groesstes zulaessiges mean_u = I_0(a/2) * exp(-a/2): +# a = 1.70 -> 0.508 a = 3.00 -> 0.367 a = 5.50 -> 0.255 +# +# Was trotzdem ins Logbuch gehoert: die eingestellten AOM-/Laserwerte und der +# Lux-Wert. Lux taugt nicht als Flussachse (ueber 100 Recordings bei festem I_k +# streut er 6.5 % gegen 1.8 % beim PD-Mittel und korreliert mit dem optischen +# Pegel nur zu r = +0.10), aber als grober Wiederfindungshinweis und +# Raumlicht-Waechter kostet er nichts. +# +# Der 31.07.-Datensatz wird durch diese Sitzung ersetzt, nicht ergaenzt. +# +# Umfang: 73 Recordings, ~61 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# === Teil A: Bode-Kurve 0.1 - 68 Hz bei mean_u = 0.30 =================== +# Bei 0.10/0.20 Hz nur 4 Amplituden — dort kostet ein Punkt bis 2 min. +# Ab 0.40 Hz sechs Amplituden, dicht um den Antwortuebergang a_50 ~ 0.23. +# +# --- Flusspunkt mean_u = 0.30 ---------------------------------------- +floor,0.30,2,0.02,20,8,background +windows,0.30,0.1,1.70,120,3,pilot +windows,0.30,1,1.70,20,3,pilot +windows,0.30,68.219,1.70,20,3,pilot +plateau,0.30,0.1,0.15,120,3, +plateau,0.30,0.1,0.28,120,3, +plateau,0.30,0.1,0.80,120,3, +plateau,0.30,0.1,1.70,120,3, +plateau,0.30,0.2,1.70,100,3, +plateau,0.30,0.2,0.80,100,3, +plateau,0.30,0.2,0.28,100,3, +plateau,0.30,0.2,0.15,100,3, +plateau,0.30,0.4,0.15,50,3, +plateau,0.30,0.4,0.28,50,3, +plateau,0.30,0.4,0.45,50,3, +plateau,0.30,0.4,0.80,50,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,0.4,1.30,50,3, +plateau,0.30,0.4,1.70,50,3, +plateau,0.30,0.7,1.70,29,3, +plateau,0.30,0.7,1.30,29,3, +plateau,0.30,0.7,0.80,29,3, +plateau,0.30,0.7,0.45,29,3, +plateau,0.30,0.7,0.28,29,3, +plateau,0.30,0.7,0.15,29,3, +plateau,0.30,1,0.15,20,3, +plateau,0.30,1,0.28,20,3, +plateau,0.30,1,0.45,20,3, +plateau,0.30,1,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,1,1.30,20,3, +plateau,0.30,1,1.70,20,3, +plateau,0.30,2,1.70,20,3, +plateau,0.30,2,1.30,20,3, +plateau,0.30,2,0.80,20,3, +plateau,0.30,2,0.45,20,3, +plateau,0.30,2,0.28,20,3, +plateau,0.30,2,0.15,20,3, +plateau,0.30,5.415,0.15,20,3, +plateau,0.30,5.415,0.28,20,3, +plateau,0.30,5.415,0.45,20,3, +plateau,0.30,5.415,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,5.415,1.30,20,3, +plateau,0.30,5.415,1.70,20,3, +plateau,0.30,12.599,1.70,20,3, +plateau,0.30,12.599,1.30,20,3, +plateau,0.30,12.599,0.80,20,3, +plateau,0.30,12.599,0.45,20,3, +plateau,0.30,12.599,0.28,20,3, +plateau,0.30,12.599,0.15,20,3, +plateau,0.30,29.317,0.15,20,3, +plateau,0.30,29.317,0.28,20,3, +plateau,0.30,29.317,0.45,20,3, +plateau,0.30,29.317,0.80,20,3, +ref,0.30,0.5,0.80,40,3, +plateau,0.30,29.317,1.30,20,3, +plateau,0.30,29.317,1.70,20,3, +plateau,0.30,68.219,1.70,20,3, +plateau,0.30,68.219,1.30,20,3, +plateau,0.30,68.219,0.80,20,3, +plateau,0.30,68.219,0.45,20,3, +plateau,0.30,68.219,0.28,20,3, +plateau,0.30,68.219,0.15,20,3, +# +# === Teil B: DC-Aufhebungstest ========================================== +# f = 0.4 Hz, a = [0.45, 1.3] — beides schon in Teil A gefahren, deshalb ist +# der Arm mean_u = 0.30 dort bereits enthalten und wird hier nicht wiederholt. +# Erwartung: gleiche Events/Zyklus/Pixel bei gleichem a, unabhaengig von der +# Helligkeit. Abweichung > 10 % ist ein Befund, kein Rauschen. +# +# --- Flusspunkt mean_u = 0.15 ---------------------------------------- +floor,0.15,0.4,0.02,50,8,background +windows,0.15,0.4,1.70,50,3,pilot +dc,0.15,0.4,0.45,50,3, +dc,0.15,0.4,1.30,50,3, +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,0.4,0.02,50,8,background +windows,0.45,0.4,1.70,50,3,pilot +dc,0.45,0.4,0.45,50,3, +dc,0.45,0.4,1.30,50,3, +# +# Abschluss: Referenz zurueck auf den Startpunkt (Drift ueber die Sitzung). +ref,0.30,0.5,0.80,40,8, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv new file mode 100644 index 0000000..87824ac --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u010.csv @@ -0,0 +1,83 @@ +# A1 Stufe 2 — vollstaendiger Flusspunkt mean_u=0.10 +# Ein unabhaengig fahrbarer Flusspunkt fuer f_c(I), C(I) und ON/OFF. +# Die heutige 0.30-Kurve ersetzt die tiefen Frequenzen hier NICHT: |H(f)| +# wird bei demselben I_k durch das lokale Plateau normiert, und f_c verschiebt +# sich mit I_k. Deshalb bleiben zwei lokale Plateaupunkte erhalten; die dichte +# Niederfrequenzsuche aus Stufe 1 wird aber nicht wiederholt. +# +# Aktueller Prior: f_c(0.10) ~ 0.725 Hz, skaliert aus +# f_c(0.40)=2.9 Hz. Nach dem heutigen 0.30-Quicklook neu zentrieren, +# falls gemessenes f_c oder die realisierten I_k-Verhaeltnisse deutlich abweichen. +# +# Umfang je Punkt: sieben Frequenzen; an den zwei tiefen Frequenzen fuenf +# a-Werte (0.15, 0.28, 0.45, 0.80, 1.70), sonst alle sechs bis 1.70. +# Die eigentlichen Messpunkte enthalten 20 Zyklen (maximal 300 s); Pilot, +# Background und lokale Plateau-Referenzen sind separat enthalten. +# +# Laserleistung, ND, Bias, Probe/ROI und Ausrichtung innerhalb des Blocks fest +# halten. mean_u ist nur der Setpoint; ausgewertet wird das gemessene lokale I_k. +# Vor/nach dem Block ADC-Dark und I_tot ankern; RAW, PDQ, beide JSON/TOML- +# Sidecars und den ausgefuehrten Zeitplan pruefen. Automations-Leases muessen +# fuer die gesamte Laufzeit gelten; GUI-Sweep-Maximum vor Start auf a=1.70. +# Photodioden-Cache: nichts einzustellen. Der Ring waechst selbst auf die +# gemessene Markerperiode, am tiefsten Punkt also auf die zwei vollen Zyklen, +# die die drei Phasenmarker brauchen. Grenze bleibt der harte 32-s-Ring bei +# 500 kSa/s (ADR 033). +# +# Umfang: 47 Recordings, ~68 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.10 erwartetes f_c ~ 0.72 Hz Leiter 0.075 .. 8.7 Hz +# Lokales Plateau: 0.075 und 0.145 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.10 ---------------------------------------- +floor,0.10,0.725,0.02,28,8,background +windows,0.10,0.075,1.70,120,3,pilot +windows,0.10,8.7,1.70,20,3,pilot +ladder,0.10,0.075,0.15,267,3, +ladder,0.10,0.075,0.28,267,3, +ladder,0.10,0.075,0.45,267,3, +ladder,0.10,0.075,0.80,267,3, +ladder,0.10,0.075,1.70,267,3, +ladder,0.10,0.145,1.70,138,3, +ladder,0.10,0.145,0.80,138,3, +ladder,0.10,0.145,0.45,138,3, +ladder,0.10,0.145,0.28,138,3, +ladder,0.10,0.145,0.15,138,3, +ladder,0.10,0.362,0.15,56,3, +ladder,0.10,0.362,0.28,56,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,0.362,0.45,56,3, +ladder,0.10,0.362,0.80,56,3, +ladder,0.10,0.362,1.30,56,3, +ladder,0.10,0.362,1.70,56,3, +ladder,0.10,0.725,1.70,28,3, +ladder,0.10,0.725,1.30,28,3, +ladder,0.10,0.725,0.80,28,3, +ladder,0.10,0.725,0.45,28,3, +ladder,0.10,0.725,0.28,28,3, +ladder,0.10,0.725,0.15,28,3, +ladder,0.10,1.595,0.15,20,3, +ladder,0.10,1.595,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,1.595,0.45,20,3, +ladder,0.10,1.595,0.80,20,3, +ladder,0.10,1.595,1.30,20,3, +ladder,0.10,1.595,1.70,20,3, +ladder,0.10,3.625,1.70,20,3, +ladder,0.10,3.625,1.30,20,3, +ladder,0.10,3.625,0.80,20,3, +ladder,0.10,3.625,0.45,20,3, +ladder,0.10,3.625,0.28,20,3, +ladder,0.10,3.625,0.15,20,3, +ladder,0.10,8.7,0.15,20,3, +ladder,0.10,8.7,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,8.7,0.45,20,3, +ladder,0.10,8.7,0.80,20,3, +ladder,0.10,8.7,1.30,20,3, +ladder,0.10,8.7,1.70,20,3, +# +# Abschluss: lokale Plateau-Referenz bei 0.145 Hz, a=0.8. +ref,0.10,0.145,0.80,120,3, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv new file mode 100644 index 0000000..a57c062 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_bode_u045.csv @@ -0,0 +1,83 @@ +# A1 Stufe 2 — vollstaendiger Flusspunkt mean_u=0.45 +# Ein unabhaengig fahrbarer Flusspunkt fuer f_c(I), C(I) und ON/OFF. +# Die heutige 0.30-Kurve ersetzt die tiefen Frequenzen hier NICHT: |H(f)| +# wird bei demselben I_k durch das lokale Plateau normiert, und f_c verschiebt +# sich mit I_k. Deshalb bleiben zwei lokale Plateaupunkte erhalten; die dichte +# Niederfrequenzsuche aus Stufe 1 wird aber nicht wiederholt. +# +# Aktueller Prior: f_c(0.45) ~ 3.262 Hz, skaliert aus +# f_c(0.40)=2.9 Hz. Nach dem heutigen 0.30-Quicklook neu zentrieren, +# falls gemessenes f_c oder die realisierten I_k-Verhaeltnisse deutlich abweichen. +# +# Umfang je Punkt: sieben Frequenzen; an den zwei tiefen Frequenzen fuenf +# a-Werte (0.15, 0.28, 0.45, 0.80, 1.70), sonst alle sechs bis 1.70. +# Die eigentlichen Messpunkte enthalten 20 Zyklen (maximal 300 s); Pilot, +# Background und lokale Plateau-Referenzen sind separat enthalten. +# +# Laserleistung, ND, Bias, Probe/ROI und Ausrichtung innerhalb des Blocks fest +# halten. mean_u ist nur der Setpoint; ausgewertet wird das gemessene lokale I_k. +# Vor/nach dem Block ADC-Dark und I_tot ankern; RAW, PDQ, beide JSON/TOML- +# Sidecars und den ausgefuehrten Zeitplan pruefen. Automations-Leases muessen +# fuer die gesamte Laufzeit gelten; GUI-Sweep-Maximum vor Start auf a=1.70. +# Photodioden-Cache: nichts einzustellen. Der Ring waechst selbst auf die +# gemessene Markerperiode, am tiefsten Punkt also auf die zwei vollen Zyklen, +# die die drei Phasenmarker brauchen. Grenze bleibt der harte 32-s-Ring bei +# 500 kSa/s (ADR 033). +# +# Umfang: 47 Recordings, ~32 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.45 erwartetes f_c ~ 3.26 Hz Leiter 0.261 .. 39.15 Hz +# Lokales Plateau: 0.261 und 0.652 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,3.262,0.02,20,8,background +windows,0.45,0.261,1.70,77,3,pilot +windows,0.45,39.15,1.70,20,3,pilot +ladder,0.45,0.261,0.15,77,3, +ladder,0.45,0.261,0.28,77,3, +ladder,0.45,0.261,0.45,77,3, +ladder,0.45,0.261,0.80,77,3, +ladder,0.45,0.261,1.70,77,3, +ladder,0.45,0.652,1.70,31,3, +ladder,0.45,0.652,0.80,31,3, +ladder,0.45,0.652,0.45,31,3, +ladder,0.45,0.652,0.28,31,3, +ladder,0.45,0.652,0.15,31,3, +ladder,0.45,1.631,0.15,20,3, +ladder,0.45,1.631,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,1.631,0.45,20,3, +ladder,0.45,1.631,0.80,20,3, +ladder,0.45,1.631,1.30,20,3, +ladder,0.45,1.631,1.70,20,3, +ladder,0.45,3.262,1.70,20,3, +ladder,0.45,3.262,1.30,20,3, +ladder,0.45,3.262,0.80,20,3, +ladder,0.45,3.262,0.45,20,3, +ladder,0.45,3.262,0.28,20,3, +ladder,0.45,3.262,0.15,20,3, +ladder,0.45,7.178,0.15,20,3, +ladder,0.45,7.178,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,7.178,0.45,20,3, +ladder,0.45,7.178,0.80,20,3, +ladder,0.45,7.178,1.30,20,3, +ladder,0.45,7.178,1.70,20,3, +ladder,0.45,16.312,1.70,20,3, +ladder,0.45,16.312,1.30,20,3, +ladder,0.45,16.312,0.80,20,3, +ladder,0.45,16.312,0.45,20,3, +ladder,0.45,16.312,0.28,20,3, +ladder,0.45,16.312,0.15,20,3, +ladder,0.45,39.15,0.15,20,3, +ladder,0.45,39.15,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,39.15,0.45,20,3, +ladder,0.45,39.15,0.80,20,3, +ladder,0.45,39.15,1.30,20,3, +ladder,0.45,39.15,1.70,20,3, +# +# Abschluss: lokale Plateau-Referenz bei 0.652 Hz, a=0.8. +ref,0.45,0.652,0.80,31,3, diff --git a/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv b/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv new file mode 100644 index 0000000..ed60579 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_stufe2_flussleiter.csv @@ -0,0 +1,286 @@ +# A1 Stufe 2 — Flussleiter: f_c(I), C(I) und die ON/OFF-Asymmetrie +# Zweck: das eigentliche A1-Ergebnis. f_c an mehreren Arbeitspunkten, um +# 'f_c ~ I' zu pruefen — das Akzeptanzkriterium, an dem sich entscheidet, ob die +# Flussachse stimmt (Kruemmung deutet auf einen Kalibrierfehler, nicht auf neue +# Pixelphysik; dann geht es zu A6). +# +# ERST FAHREN, WENN STUFE 1 BESTANDEN IST. Ohne Plateau ist jedes f_c hier +# wieder nur eine Obergrenze, und ohne bestandenen DC-Test ist die Normierung +# nicht gerechtfertigt. +# +# Frequenzleiter je Arbeitspunkt: etwa 0.08 bis 12 x das ERWARTETE f_c, +# geometrisch. Beim dunkelsten Punkt wird f_min auf 0.075 Hz angehoben, weil +# die urspruenglichen 0.058 Hz bei 500 kSa/s nicht in den 32-s-Cache passen. +# Die Erwartung skaliert linear aus dem Messwert f_c(0.40) = 2.9 Hz — genau die +# Annahme, die geprueft wird. Liegt f_c weit daneben, sitzt die Leiter schief; +# nach dem ersten Arbeitspunkt kurz kontrollieren und ggf. neu generieren. +# +# Oberhalb ~20 x f_c war bei der Vorsitzung keine Antwort mehr messbar +# (|H| < 0.03), deshalb endet die Leiter dort statt bei 2 kHz. +# +# Umfang: 231 Recordings, ~224 min reine Aufnahmezeit (inkl. 11 s Overhead je Recording) +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +# +# ========================================================================== +# mean_u = 0.10 erwartetes f_c ~ 0.72 Hz Leiter 0.075 .. 8.7 Hz +# Lokales Plateau: 0.075 und 0.145 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.10 ---------------------------------------- +floor,0.10,0.725,0.02,28,8,background +windows,0.10,0.075,1.70,120,3,pilot +windows,0.10,8.7,1.70,20,3,pilot +ladder,0.10,0.075,0.15,267,3, +ladder,0.10,0.075,0.28,267,3, +ladder,0.10,0.075,0.45,267,3, +ladder,0.10,0.075,0.80,267,3, +ladder,0.10,0.075,1.70,267,3, +ladder,0.10,0.145,1.70,138,3, +ladder,0.10,0.145,0.80,138,3, +ladder,0.10,0.145,0.45,138,3, +ladder,0.10,0.145,0.28,138,3, +ladder,0.10,0.145,0.15,138,3, +ladder,0.10,0.362,0.15,56,3, +ladder,0.10,0.362,0.28,56,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,0.362,0.45,56,3, +ladder,0.10,0.362,0.80,56,3, +ladder,0.10,0.362,1.30,56,3, +ladder,0.10,0.362,1.70,56,3, +ladder,0.10,0.725,1.70,28,3, +ladder,0.10,0.725,1.30,28,3, +ladder,0.10,0.725,0.80,28,3, +ladder,0.10,0.725,0.45,28,3, +ladder,0.10,0.725,0.28,28,3, +ladder,0.10,0.725,0.15,28,3, +ladder,0.10,1.595,0.15,20,3, +ladder,0.10,1.595,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,1.595,0.45,20,3, +ladder,0.10,1.595,0.80,20,3, +ladder,0.10,1.595,1.30,20,3, +ladder,0.10,1.595,1.70,20,3, +ladder,0.10,3.625,1.70,20,3, +ladder,0.10,3.625,1.30,20,3, +ladder,0.10,3.625,0.80,20,3, +ladder,0.10,3.625,0.45,20,3, +ladder,0.10,3.625,0.28,20,3, +ladder,0.10,3.625,0.15,20,3, +ladder,0.10,8.7,0.15,20,3, +ladder,0.10,8.7,0.28,20,3, +ref,0.10,0.145,0.80,120,3, +ladder,0.10,8.7,0.45,20,3, +ladder,0.10,8.7,0.80,20,3, +ladder,0.10,8.7,1.30,20,3, +ladder,0.10,8.7,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.17 erwartetes f_c ~ 1.23 Hz Leiter 0.099 .. 14.79 Hz +# Lokales Plateau: 0.099 und 0.246 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.17 ---------------------------------------- +floor,0.17,1.232,0.02,20,8,background +windows,0.17,0.099,1.70,120,3,pilot +windows,0.17,14.79,1.70,20,3,pilot +ladder,0.17,0.099,0.15,203,3, +ladder,0.17,0.099,0.28,203,3, +ladder,0.17,0.099,0.45,203,3, +ladder,0.17,0.099,0.80,203,3, +ladder,0.17,0.099,1.70,203,3, +ladder,0.17,0.246,1.70,82,3, +ladder,0.17,0.246,0.80,82,3, +ladder,0.17,0.246,0.45,82,3, +ladder,0.17,0.246,0.28,82,3, +ladder,0.17,0.246,0.15,82,3, +ladder,0.17,0.616,0.15,33,3, +ladder,0.17,0.616,0.28,33,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,0.616,0.45,33,3, +ladder,0.17,0.616,0.80,33,3, +ladder,0.17,0.616,1.30,33,3, +ladder,0.17,0.616,1.70,33,3, +ladder,0.17,1.232,1.70,20,3, +ladder,0.17,1.232,1.30,20,3, +ladder,0.17,1.232,0.80,20,3, +ladder,0.17,1.232,0.45,20,3, +ladder,0.17,1.232,0.28,20,3, +ladder,0.17,1.232,0.15,20,3, +ladder,0.17,2.712,0.15,20,3, +ladder,0.17,2.712,0.28,20,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,2.712,0.45,20,3, +ladder,0.17,2.712,0.80,20,3, +ladder,0.17,2.712,1.30,20,3, +ladder,0.17,2.712,1.70,20,3, +ladder,0.17,6.162,1.70,20,3, +ladder,0.17,6.162,1.30,20,3, +ladder,0.17,6.162,0.80,20,3, +ladder,0.17,6.162,0.45,20,3, +ladder,0.17,6.162,0.28,20,3, +ladder,0.17,6.162,0.15,20,3, +ladder,0.17,14.79,0.15,20,3, +ladder,0.17,14.79,0.28,20,3, +ref,0.17,0.246,0.80,81,3, +ladder,0.17,14.79,0.45,20,3, +ladder,0.17,14.79,0.80,20,3, +ladder,0.17,14.79,1.30,20,3, +ladder,0.17,14.79,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.25 erwartetes f_c ~ 1.81 Hz Leiter 0.145 .. 21.75 Hz +# Lokales Plateau: 0.145 und 0.362 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.25 ---------------------------------------- +floor,0.25,1.812,0.02,20,8,background +windows,0.25,0.145,1.70,120,3,pilot +windows,0.25,21.75,1.70,20,3,pilot +ladder,0.25,0.145,0.15,138,3, +ladder,0.25,0.145,0.28,138,3, +ladder,0.25,0.145,0.45,138,3, +ladder,0.25,0.145,0.80,138,3, +ladder,0.25,0.145,1.70,138,3, +ladder,0.25,0.362,1.70,56,3, +ladder,0.25,0.362,0.80,56,3, +ladder,0.25,0.362,0.45,56,3, +ladder,0.25,0.362,0.28,56,3, +ladder,0.25,0.362,0.15,56,3, +ladder,0.25,0.906,0.15,23,3, +ladder,0.25,0.906,0.28,23,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,0.906,0.45,23,3, +ladder,0.25,0.906,0.80,23,3, +ladder,0.25,0.906,1.30,23,3, +ladder,0.25,0.906,1.70,23,3, +ladder,0.25,1.812,1.70,20,3, +ladder,0.25,1.812,1.30,20,3, +ladder,0.25,1.812,0.80,20,3, +ladder,0.25,1.812,0.45,20,3, +ladder,0.25,1.812,0.28,20,3, +ladder,0.25,1.812,0.15,20,3, +ladder,0.25,3.987,0.15,20,3, +ladder,0.25,3.987,0.28,20,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,3.987,0.45,20,3, +ladder,0.25,3.987,0.80,20,3, +ladder,0.25,3.987,1.30,20,3, +ladder,0.25,3.987,1.70,20,3, +ladder,0.25,9.062,1.70,20,3, +ladder,0.25,9.062,1.30,20,3, +ladder,0.25,9.062,0.80,20,3, +ladder,0.25,9.062,0.45,20,3, +ladder,0.25,9.062,0.28,20,3, +ladder,0.25,9.062,0.15,20,3, +ladder,0.25,21.75,0.15,20,3, +ladder,0.25,21.75,0.28,20,3, +ref,0.25,0.362,0.80,55,3, +ladder,0.25,21.75,0.45,20,3, +ladder,0.25,21.75,0.80,20,3, +ladder,0.25,21.75,1.30,20,3, +ladder,0.25,21.75,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.33 erwartetes f_c ~ 2.39 Hz Leiter 0.191 .. 28.71 Hz +# Lokales Plateau: 0.191 und 0.478 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.33 ---------------------------------------- +floor,0.33,2.392,0.02,20,8,background +windows,0.33,0.191,1.70,105,3,pilot +windows,0.33,28.71,1.70,20,3,pilot +ladder,0.33,0.191,0.15,105,3, +ladder,0.33,0.191,0.28,105,3, +ladder,0.33,0.191,0.45,105,3, +ladder,0.33,0.191,0.80,105,3, +ladder,0.33,0.191,1.70,105,3, +ladder,0.33,0.478,1.70,42,3, +ladder,0.33,0.478,0.80,42,3, +ladder,0.33,0.478,0.45,42,3, +ladder,0.33,0.478,0.28,42,3, +ladder,0.33,0.478,0.15,42,3, +ladder,0.33,1.196,0.15,20,3, +ladder,0.33,1.196,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,1.196,0.45,20,3, +ladder,0.33,1.196,0.80,20,3, +ladder,0.33,1.196,1.30,20,3, +ladder,0.33,1.196,1.70,20,3, +ladder,0.33,2.392,1.70,20,3, +ladder,0.33,2.392,1.30,20,3, +ladder,0.33,2.392,0.80,20,3, +ladder,0.33,2.392,0.45,20,3, +ladder,0.33,2.392,0.28,20,3, +ladder,0.33,2.392,0.15,20,3, +ladder,0.33,5.263,0.15,20,3, +ladder,0.33,5.263,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,5.263,0.45,20,3, +ladder,0.33,5.263,0.80,20,3, +ladder,0.33,5.263,1.30,20,3, +ladder,0.33,5.263,1.70,20,3, +ladder,0.33,11.962,1.70,20,3, +ladder,0.33,11.962,1.30,20,3, +ladder,0.33,11.962,0.80,20,3, +ladder,0.33,11.962,0.45,20,3, +ladder,0.33,11.962,0.28,20,3, +ladder,0.33,11.962,0.15,20,3, +ladder,0.33,28.71,0.15,20,3, +ladder,0.33,28.71,0.28,20,3, +ref,0.33,0.478,0.80,42,3, +ladder,0.33,28.71,0.45,20,3, +ladder,0.33,28.71,0.80,20,3, +ladder,0.33,28.71,1.30,20,3, +ladder,0.33,28.71,1.70,20,3, +# +# ========================================================================== +# mean_u = 0.45 erwartetes f_c ~ 3.26 Hz Leiter 0.261 .. 39.15 Hz +# Lokales Plateau: 0.261 und 0.652 Hz; diese Punkte duerfen nicht durch die 0.30-Messung ersetzt werden. +# +# --- Flusspunkt mean_u = 0.45 ---------------------------------------- +floor,0.45,3.262,0.02,20,8,background +windows,0.45,0.261,1.70,77,3,pilot +windows,0.45,39.15,1.70,20,3,pilot +ladder,0.45,0.261,0.15,77,3, +ladder,0.45,0.261,0.28,77,3, +ladder,0.45,0.261,0.45,77,3, +ladder,0.45,0.261,0.80,77,3, +ladder,0.45,0.261,1.70,77,3, +ladder,0.45,0.652,1.70,31,3, +ladder,0.45,0.652,0.80,31,3, +ladder,0.45,0.652,0.45,31,3, +ladder,0.45,0.652,0.28,31,3, +ladder,0.45,0.652,0.15,31,3, +ladder,0.45,1.631,0.15,20,3, +ladder,0.45,1.631,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,1.631,0.45,20,3, +ladder,0.45,1.631,0.80,20,3, +ladder,0.45,1.631,1.30,20,3, +ladder,0.45,1.631,1.70,20,3, +ladder,0.45,3.262,1.70,20,3, +ladder,0.45,3.262,1.30,20,3, +ladder,0.45,3.262,0.80,20,3, +ladder,0.45,3.262,0.45,20,3, +ladder,0.45,3.262,0.28,20,3, +ladder,0.45,3.262,0.15,20,3, +ladder,0.45,7.178,0.15,20,3, +ladder,0.45,7.178,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,7.178,0.45,20,3, +ladder,0.45,7.178,0.80,20,3, +ladder,0.45,7.178,1.30,20,3, +ladder,0.45,7.178,1.70,20,3, +ladder,0.45,16.312,1.70,20,3, +ladder,0.45,16.312,1.30,20,3, +ladder,0.45,16.312,0.80,20,3, +ladder,0.45,16.312,0.45,20,3, +ladder,0.45,16.312,0.28,20,3, +ladder,0.45,16.312,0.15,20,3, +ladder,0.45,39.15,0.15,20,3, +ladder,0.45,39.15,0.28,20,3, +ref,0.45,0.652,0.80,31,3, +ladder,0.45,39.15,0.45,20,3, +ladder,0.45,39.15,0.80,20,3, +ladder,0.45,39.15,1.30,20,3, +ladder,0.45,39.15,1.70,20,3, +# +# Abschluss: Rueckkehr zur lokalen Plateau-Referenz des ersten/dunkelsten +# Flusspunkts. Der Vergleich mit ihrer ersten Wiederholung ist der Driftbefund. +ref,0.10,0.145,0.80,120,8, diff --git a/plugins/stage-a-a1/protocols/a1_triage_90min.csv b/plugins/stage-a-a1/protocols/a1_triage_90min.csv new file mode 100644 index 0000000..e95e634 --- /dev/null +++ b/plugins/stage-a-a1/protocols/a1_triage_90min.csv @@ -0,0 +1,103 @@ +# A1 90-minute triage block — mirrored scout on the bleaching fluorescent sample +# Optical path: stage-a-fluorescence-dualcam-v1; transfer_scope=fluorescence_chain. +# This is A1: J24 phase-0 marker, NOT the A2 photodiode-comparator trigger. +# Runbook: knowledge base/experiments/A1-bode/checklist.md -> "90-minute triage block". +# Design: knowledge base/methodology/a1-bleaching-dual-camera.md +# +# Not generated by build_a1_protocols.py, but VALIDATED as a versioned plugin fixture +# (2026-08-10): shipped as plugins/stage-a-a1/protocols/a1_triage_90min.csv and checked by +# stage-a-modulation and stage-a-photodiode against the production parser, the recorded +# 2026-07-30 lobe/DAC ceiling, the exact mean->frequency->depth retarget order through the +# real service/drive_command path, and the production 500 kSa/s photodiode ring. +# 235/235 tests pass across the three Stage-A suites; the guards were confirmed live by +# mutation — an out-of-range mean_u and a one-cycle row both make the suites fail. +# This file and the shipped fixture must stay byte-identical; a test enforces it. +# +# Hand-checked in addition, because the suites do NOT check it: worst H7 is 200 Hz/a=1.50 +# -> 2fa/C = 2609 1/s against 1/tau_refr = 54945 1/s (provisional C=0.23, tau_refr=18.2 us). +# Recheck H7 against the actual bias readback before arming. +# The photodiode ring caps at 16e6 samples, so at 500 kSa/s the lowest frequency that can +# retain two cycles is 0.0625 Hz. The 0.2 Hz floor below keeps 3.2x margin. +# a=1.70 is the GUI limit, not a service limit — the modulation service accepts more at +# mean_u=0.30. Do not raise it here without re-deriving the H7 grid. +# +# OPERATOR STEPS THAT ARE NOT ROWS IN THIS FILE — do them first: +# 1. Freeze session: bias readback + name bias-vN, read bias_refr, ERC/STC/trail OFF read back, +# sample_id, FOV position/orientation, ROI ~256x256, disk, PD cache >= 30 s. +# 2. ADC dark, light-blocked H14 DAC-active sham, fresh I_tot anchor. The A1 plugin refuses +# the sidecar without a confirmed anchor. Repeat ADC dark and I_tot after the block. +# 3. sCMOS: confocality check, one dark-corrected flat map, freeze the flattest ROI. +# Do NOT flip or refocus between that map and windows_slow below — that pair is the +# offline sCMOS->event registration. +# 4. sCMOS constant-illumination bleach/pre-bleach run at the SAME CYCLE-MEAN flux as these +# rows (not the same mean_u — Bessel I0(a/2)). Stop at <1 % change over 2 min, or 8 min. +# 5. Compute k, then T_block <= 0.05/k and f_min = k/(0.04*0.30). THE LADDER BELOW STARTS AT +# 0.2 Hz. If f_min > 0.2 Hz, delete every row below f_min from BOTH passes symmetrically +# and record that you did. Do not run a frequency below f_min: the within-cycle bleaching +# ramp fakes an OFF excess of k/(2af) there. +# 6. Shutter-closed EVB dark recording (no row here — the camera sees no light). +# 7. After the block: closing sCMOS map + 2 min bleach bracket, closing ADC dark and I_tot. +# +# STRUCTURE. Two mirrored passes over 7 half-decade frequencies x 2 depths. Pass B is the +# EXACT reverse of pass A, so every frequency carries an early and a late acquisition and the +# drift bracket exists at the knee without knowing where the knee is. IF TIME RUNS SHORT, +# DELETE FREQUENCIES FROM BOTH PASSES — NEVER DROP PASS B. A single unbracketed pass +# reproduces the 2026-08-05 failure mode. +# +# DEPTHS 0.30 and 1.50 are far apart on purpose. The bleaching contribution to the ON/OFF +# asymmetry is k/(2af): it must fall as 1/f along the ladder AND as 1/a between the two +# depths, at the magnitude the measured k predicts. That scaling is the discriminator between +# a bleaching artefact and a real ON/OFF bandwidth difference. Optional third depth a=0.70 +# only if pass A finishes early — and then interleaved into BOTH passes, never appended. +# +# REFERENCES are 0.63 Hz / a=1.50. The three identical opening repeats define sigma_ref; +# every A/B difference is judged against it, not against a percentage. +# +# Budget: 40 recordings, 21.9 min acquisition, 2.2 min declared settling, 24.1 min total. +# Verified: pass B is the exact reverse of pass A, and the 7x2 grid is complete with no repeats. +# +# Analysis: background-subtracted events per cycle per valid pixel, ON and OFF never pooled, +# hot pixel (542,299) masked at event level if inside the ROI. The folded-histogram +# fundamental A1 is a DETECTION statistic only — it is shape-biased (2026-07-31). +# +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +floor_slow,0.3,0.63,0.02,60,8,background +floor_fast,0.3,63,0.02,20,3,background +windows_slow,0.3,0.63,1.7,32,3,pilot +windows_fast,0.3,63,1.5,20,3,pilot +ref_open_1,0.3,0.63,1.5,32,3, +ref_open_2,0.3,0.63,1.5,32,3, +ref_open_3,0.3,0.63,1.5,32,3, +scoutA,0.3,2.0,1.5,20,3, +scoutA,0.3,0.2,0.3,100,3, +scoutA,0.3,20,0.3,20,3, +scoutA,0.3,0.63,1.5,32,3, +scoutA,0.3,200,1.5,20,3, +ref_a1,0.3,0.63,1.5,32,3, +scoutA,0.3,6.3,0.3,20,3, +scoutA,0.3,0.63,0.3,32,3, +scoutA,0.3,63,1.5,20,3, +scoutA,0.3,2.0,0.3,20,3, +scoutA,0.3,0.2,1.5,100,3, +ref_a2,0.3,0.63,1.5,32,3, +scoutA,0.3,6.3,1.5,20,3, +scoutA,0.3,200,0.3,20,3, +scoutA,0.3,20,1.5,20,3, +scoutA,0.3,63,0.3,20,3, +scoutB,0.3,63,0.3,20,3, +scoutB,0.3,20,1.5,20,3, +scoutB,0.3,200,0.3,20,3, +scoutB,0.3,6.3,1.5,20,3, +scoutB,0.3,0.2,1.5,100,3, +ref_b1,0.3,0.63,1.5,32,3, +scoutB,0.3,2.0,0.3,20,3, +scoutB,0.3,63,1.5,20,3, +scoutB,0.3,0.63,0.3,32,3, +scoutB,0.3,6.3,0.3,20,3, +scoutB,0.3,200,1.5,20,3, +ref_b2,0.3,0.63,1.5,32,3, +scoutB,0.3,0.63,1.5,32,3, +scoutB,0.3,20,0.3,20,3, +scoutB,0.3,0.2,0.3,100,3, +scoutB,0.3,2.0,1.5,20,3, +ref_close,0.3,0.63,1.5,32,8, diff --git a/plugins/stage-a-a1/protocols/example.csv b/plugins/stage-a-a1/protocols/example.csv new file mode 100644 index 0000000..11899f5 --- /dev/null +++ b/plugins/stage-a-a1/protocols/example.csv @@ -0,0 +1,71 @@ +# Stage-A A1 recording protocol — one row per recording. +# +# Point the A1 plugin's "Protocol file" at a file like this and press "Run the +# protocol". Every row is recorded exactly as written, in the order written, so +# the survey is reproducible from this file alone. +# +# Columns are found BY NAME, so their order does not matter and you can drag +# them around in a spreadsheet. Blank lines and lines starting with # are +# skipped. Errors report the line number you see in your editor. +# +# Required columns +# mean_u normalized cycle-mean lobe point ū — the brightness (I_k) +# axis. 0.01..=1.0. 1.0 is the top of the Pockels lobe; the +# reachable maximum shrinks as depth_a grows, and the +# modulation plugin shows the current limit next to each +# control. A row the drive cannot reach is skipped and named, +# and the rest of the file still runs. +# frequency_hz modulation frequency, 0.01..=2000. +# depth_a optical depth a = ln(I_max / I_min), 0.01..=6. +# +# Optional columns (leave the cell blank on any row to take the default) +# duration_s seconds of camera + photodiode for THIS row. 1..=3600, +# default 10. This is the point of the row-per-recording form: +# low frequencies need several cycles, high ones do not. +# settle_s dwell after retargeting, before recording. 0..=60, default 2. +# role normal (default), pilot, or background. +# pilot — bright reference; freezes the ON/OFF windows the +# rest of the measurement is scored in. Record it +# early. +# background— unmodulated reference; gives the false-response +# floor. Put it first, at a very shallow depth. +# label free text, used in the status line and the sidecar so you can +# tell later which part of the survey a file came from. Quote it +# if it contains a comma. +# +# All three axes are commanded for every row, and the row waits for all three to +# be acknowledged before it starts recording — so a file never records under +# parameters it does not state. One modulation lease covers the whole run and +# your own drive settings are handed back at the end. + +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role + +# --- references first: the whole measurement is scored against these --------- +floor,0.50,10,0.02,20,3,background +windows,0.50,10,2.00,20,3,pilot + +# --- q_p(a) curve at 10 Hz --------------------------------------------------- +curve-10Hz,0.50,10,0.20,10,2, +curve-10Hz,0.50,10,0.45,10,2, +curve-10Hz,0.50,10,0.70,10,2, +curve-10Hz,0.50,10,1.10,10,2, +curve-10Hz,0.50,10,1.60,10,2, + +# --- frequency ladder at one depth ------------------------------------------- +# Longer at the bottom: 1 Hz needs 30 s to cover enough cycles, 200 Hz does not. +ladder,0.40,1,0.80,40,4, +ladder,0.40,5,0.80,25,3, +ladder,0.40,20,0.80,15,2, +ladder,0.40,80,0.80,10,2, +ladder,0.40,200,0.80,10,2, + +# --- brightness series at fixed (f, a) — the I_k axis ------------------------ +# Ascending, so the sensor adapts in one direction only. Longer settle: a change +# in mean illumination is the slowest thing on the bench to settle. +brightness,0.15,50,0.60,15,6, +brightness,0.30,50,0.60,15,6, +brightness,0.45,50,0.60,15,6, +brightness,0.60,50,0.60,15,6, + +# --- a repeat of the first curve point, to expose drift across the run ------- +curve-10Hz-repeat,0.50,10,0.45,10,2, diff --git a/plugins/stage-a-a1/protocols/example.toml b/plugins/stage-a-a1/protocols/example.toml new file mode 100644 index 0000000..6e86f4f --- /dev/null +++ b/plugins/stage-a-a1/protocols/example.toml @@ -0,0 +1,93 @@ +# Stage-A A1 recording protocol — a worked example to copy. +# +# Point the A1 plugin's "Protocol file" setting at a file like this and press +# "Run the protocol". Every point is recorded with the parameters written here, +# so the survey is reproducible from this file alone: nothing is taken from +# whatever the modulation plugin happens to have armed. +# +# Three axes, all named for every point: +# +# mean_u the normalized cycle-mean lobe point ū — the brightness (I_k) +# axis. 1.0 is the top of the Pockels lobe; the achievable +# maximum shrinks as the depth grows, and the modulation plugin +# shows the current limit next to each control. +# frequency_hz the modulation frequency. +# depth_a the optical depth a = ln(I_max / I_min). +# +# Each axis takes either +# an explicit list depth_a = [0.5, 1.0, 2.0] +# a single value mean_u = 0.5 +# or a generated range frequency_hz = { min = 1.0, max = 100.0, points = 5 } +# frequency_hz = { min = 1.0, max = 100.0, points = 5, spacing = "log" } +# +# "linear" is the default spacing; use "log" for anything read per decade — +# a Bode ladder is not read per hertz. +# +# A block records the full product of its three axes. Points run mean_u +# outermost, then frequency_hz, then depth_a, because that settles the +# expensive axis least often: moving the brightness makes the sensor re-adapt, +# a new frequency has to be confirmed against the phase-0 trigger, and changing +# the depth is the cheap innermost step. +# +# One modulation lease is held for the whole file, so nothing can move the +# drive underneath the run, and the operator's own settings are handed back at +# the end. A point whose drive the modulation plugin refuses — usually a +# mean_u/depth_a pair that would run off the top of the lobe — is skipped and +# 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] +duration_s = 10 # seconds of camera + photodiode per recording (1..=3600) +settle_s = 2.0 # dwell after retargeting, before recording starts (0..=60) + + +# --------------------------------------------------------------------------- +# 1. One q_p(a) curve at a single frequency and brightness. +# The everyday depth sweep, written down. +# --------------------------------------------------------------------------- +[[block]] +name = "depth-curve-10Hz" +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = { min = 0.2, max = 2.0, points = 7 } + + +# --------------------------------------------------------------------------- +# 2. The same depth held across a frequency ladder, at two brightnesses. +# Log-spaced, so the ladder is read per decade. +# Longer recordings: the low frequencies need several cycles. +# --------------------------------------------------------------------------- +[[block]] +name = "frequency-ladder" +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 +settle_s = 3.0 + + +# --------------------------------------------------------------------------- +# 3. A small q_p(a, f) surface at one brightness — 3 × 4 = 12 recordings. +# Watch the totals here: the product grows fast, and the plugin reports how +# many recordings and roughly how long the whole file will take before the +# first one starts. +# --------------------------------------------------------------------------- +[[block]] +name = "surface" +mean_u = 0.5 +frequency_hz = { min = 5.0, max = 500.0, points = 3, spacing = "log" } +depth_a = { min = 0.4, max = 1.6, points = 4 } + + +# --------------------------------------------------------------------------- +# 4. Sweeping the brightness itself at a fixed (f, a) — the I_k axis. +# Ascending, so the sensor adapts in one direction only. +# --------------------------------------------------------------------------- +[[block]] +name = "brightness-series" +mean_u = { min = 0.1, max = 0.7, points = 4 } +frequency_hz = 50.0 +depth_a = 0.6 diff --git a/plugins/stage-a-a1/src/lib.rs b/plugins/stage-a-a1/src/lib.rs new file mode 100644 index 0000000..f9a9b67 --- /dev/null +++ b/plugins/stage-a-a1/src/lib.rs @@ -0,0 +1,20 @@ +//! Pure scientific and workflow core for the Stage-A A1 experiment. +//! +//! This crate intentionally contains no serial transport, Teensy client, or +//! PDQ writer. Hardware ownership remains with the Stage-A modulation and +//! photodiode plugins; this code only validates and analyses immutable inputs. + +pub mod phase; +pub mod protocol; +pub mod rates; +pub mod response_curve; +mod runtime; +pub mod types; + +/// Host sensor-telemetry compaction. Shared with A4 through the contract +/// crate, because both workflows gather the same host-written CSV and a second +/// copy would drift the moment the host adds a column. +pub use stage_a_plugin_contract::{csv, telemetry as sensor}; + +pub use runtime::StageAA1Plugin; +pub use types::{CameraEvent, Polarity}; diff --git a/plugins/stage-a-a1/src/phase.rs b/plugins/stage-a-a1/src/phase.rs new file mode 100644 index 0000000..0db0032 --- /dev/null +++ b/plugins/stage-a-a1/src/phase.rs @@ -0,0 +1,382 @@ +//! EXT_TRIGGER marker validation and camera-clock phase folding. + +use crate::types::{CameraEvent, Polarity}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MarkerValidationConfig { + pub expected_frequency_hz: f64, + pub frequency_tolerance_fraction: f64, + pub max_period_jitter_fraction: f64, + /// Expected complete cycles, when the acquisition declared one. + pub expected_cycles: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MarkerValidation { + pub cycle_count: usize, + pub measured_frequency_hz: f64, + pub mean_period_us: f64, + pub max_period_jitter_fraction: f64, + pub first_marker_us: u64, + pub last_marker_us: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MarkerError { + InvalidConfiguration(&'static str), + TooFewMarkers { + count: usize, + }, + NonIncreasing { + index: usize, + }, + CycleCount { + expected: usize, + actual: usize, + }, + FrequencyOutOfTolerance { + expected_hz: f64, + measured_hz: f64, + tolerance_fraction: f64, + }, + JitterOutOfTolerance { + measured_fraction: f64, + tolerance_fraction: f64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FoldedEvent { + pub timestamp_us: u64, + pub x: u16, + pub y: u16, + pub polarity: Polarity, + pub cycle_index: usize, + /// Circular phase in `[0, 1)`. + pub phase: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PhaseFold { + pub markers_us: Vec, + pub validation: MarkerValidation, + pub events: Vec, + pub events_outside_complete_cycles: usize, +} + +impl PhaseFold { + pub fn phase_at(&self, timestamp_us: u64) -> f64 { + let period_us = self.validation.mean_period_us; + (timestamp_us.saturating_sub(self.validation.first_marker_us) as f64 / period_us) + .rem_euclid(1.0) + } +} + +pub fn validate_markers( + markers_us: &[u64], + config: MarkerValidationConfig, +) -> Result { + if !config.expected_frequency_hz.is_finite() || config.expected_frequency_hz <= 0.0 { + return Err(MarkerError::InvalidConfiguration( + "expected frequency must be finite and positive", + )); + } + if !config.frequency_tolerance_fraction.is_finite() + || config.frequency_tolerance_fraction < 0.0 + || !config.max_period_jitter_fraction.is_finite() + || config.max_period_jitter_fraction < 0.0 + { + return Err(MarkerError::InvalidConfiguration( + "marker tolerances must be finite and non-negative", + )); + } + if markers_us.len() < 2 { + return Err(MarkerError::TooFewMarkers { + count: markers_us.len(), + }); + } + + let mut periods = Vec::with_capacity(markers_us.len() - 1); + for (index, pair) in markers_us.windows(2).enumerate() { + if pair[1] <= pair[0] { + return Err(MarkerError::NonIncreasing { index: index + 1 }); + } + periods.push((pair[1] - pair[0]) as f64); + } + + let cycle_count = periods.len(); + if let Some(expected) = config.expected_cycles { + if cycle_count != expected { + return Err(MarkerError::CycleCount { + expected, + actual: cycle_count, + }); + } + } + let mean_period_us = periods.iter().sum::() / cycle_count as f64; + let measured_frequency_hz = 1_000_000.0 / mean_period_us; + let frequency_error = ((measured_frequency_hz - config.expected_frequency_hz) + / config.expected_frequency_hz) + .abs(); + if frequency_error > config.frequency_tolerance_fraction { + return Err(MarkerError::FrequencyOutOfTolerance { + expected_hz: config.expected_frequency_hz, + measured_hz: measured_frequency_hz, + tolerance_fraction: config.frequency_tolerance_fraction, + }); + } + + let max_period_jitter_fraction = periods + .iter() + .map(|period| ((period - mean_period_us) / mean_period_us).abs()) + .fold(0.0_f64, f64::max); + if max_period_jitter_fraction > config.max_period_jitter_fraction { + return Err(MarkerError::JitterOutOfTolerance { + measured_fraction: max_period_jitter_fraction, + tolerance_fraction: config.max_period_jitter_fraction, + }); + } + + Ok(MarkerValidation { + cycle_count, + measured_frequency_hz, + mean_period_us, + max_period_jitter_fraction, + first_marker_us: markers_us[0], + last_marker_us: *markers_us.last().expect("at least two markers"), + }) +} + +/// Folds events against a free-running modulation period, with the phase +/// origin placed at the first event. This is the "phase-0 unanchored" path +/// used until a hardware `EXT_TRIGGER` reaches the camera: bins are relative +/// to the first event, not tied to the drive waveform. Only events inside the +/// whole-cycle span are retained so the rate normalisation matches +/// `cycle_count`. Returns `None` when the period is invalid or the window does +/// not cover at least one whole cycle. +pub fn fold_events_free_running(events: &[CameraEvent], period_us: f64) -> Option { + if !period_us.is_finite() || period_us <= 0.0 || events.is_empty() { + return None; + } + let first = events.iter().map(|event| event.timestamp_us).min()?; + let last = events.iter().map(|event| event.timestamp_us).max()?; + let cycle_count = ((last.saturating_sub(first)) as f64 / period_us).floor() as usize; + if cycle_count == 0 { + return None; + } + + let mut folded = Vec::with_capacity(events.len()); + let mut outside = 0; + for event in events { + let cycles = event.timestamp_us.saturating_sub(first) as f64 / period_us; + let cycle_index = cycles.floor() as usize; + if cycle_index >= cycle_count { + outside += 1; + continue; + } + folded.push(FoldedEvent { + timestamp_us: event.timestamp_us, + x: event.x, + y: event.y, + polarity: event.polarity, + cycle_index, + phase: cycles.fract(), + }); + } + + Some(PhaseFold { + markers_us: Vec::new(), + validation: MarkerValidation { + cycle_count, + measured_frequency_hz: 1_000_000.0 / period_us, + mean_period_us: period_us, + max_period_jitter_fraction: 0.0, + first_marker_us: first, + last_marker_us: first + (cycle_count as f64 * period_us).round() as u64, + }, + events: folded, + events_outside_complete_cycles: outside, + }) +} + +pub fn fold_events( + events: &[CameraEvent], + markers_us: &[u64], + config: MarkerValidationConfig, +) -> Result { + let validation = validate_markers(markers_us, config)?; + let mut folded = Vec::with_capacity(events.len()); + let mut outside = 0; + + for event in events { + let cycle_index = match markers_us.binary_search(&event.timestamp_us) { + Ok(index) if index + 1 < markers_us.len() => index, + Ok(_) => { + outside += 1; + continue; + } + Err(0) => { + outside += 1; + continue; + } + Err(index) if index < markers_us.len() => index - 1, + Err(_) => { + outside += 1; + continue; + } + }; + let start = markers_us[cycle_index]; + let end = markers_us[cycle_index + 1]; + let phase = (event.timestamp_us - start) as f64 / (end - start) as f64; + folded.push(FoldedEvent { + timestamp_us: event.timestamp_us, + x: event.x, + y: event.y, + polarity: event.polarity, + cycle_index, + phase, + }); + } + + Ok(PhaseFold { + markers_us: markers_us.to_vec(), + validation, + events: folded, + events_outside_complete_cycles: outside, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> MarkerValidationConfig { + MarkerValidationConfig { + expected_frequency_hz: 1_000.0, + frequency_tolerance_fraction: 0.01, + max_period_jitter_fraction: 0.02, + expected_cycles: Some(3), + } + } + + #[test] + fn validates_and_folds_against_camera_clock_markers() { + let markers = [10_000, 11_000, 12_000, 13_000]; + let events = [ + CameraEvent { + timestamp_us: 10_250, + x: 1, + y: 2, + polarity: Polarity::On, + }, + CameraEvent { + timestamp_us: 11_750, + x: 3, + y: 4, + polarity: Polarity::Off, + }, + CameraEvent { + timestamp_us: 13_000, + x: 0, + y: 0, + polarity: Polarity::On, + }, + ]; + let fold = fold_events(&events, &markers, config()).expect("valid markers"); + assert_eq!(fold.validation.cycle_count, 3); + assert_eq!(fold.events.len(), 2); + assert_eq!(fold.events_outside_complete_cycles, 1); + assert_eq!(fold.events[0].cycle_index, 0); + assert!((fold.events[0].phase - 0.25).abs() < 1e-12); + assert_eq!(fold.events[1].cycle_index, 1); + assert!((fold.events[1].phase - 0.75).abs() < 1e-12); + } + + #[test] + fn rejects_marker_count_frequency_and_jitter_mismatches() { + let mut wrong_count = config(); + wrong_count.expected_cycles = Some(4); + assert!(matches!( + validate_markers(&[0, 1_000, 2_000, 3_000], wrong_count), + Err(MarkerError::CycleCount { .. }) + )); + + assert!(matches!( + validate_markers(&[0, 2_000, 4_000, 6_000], config()), + Err(MarkerError::FrequencyOutOfTolerance { .. }) + )); + + assert!(matches!( + validate_markers(&[0, 1_000, 2_100, 3_000], config()), + Err(MarkerError::JitterOutOfTolerance { .. }) + )); + } + + #[test] + fn free_running_fold_bins_relative_to_first_event() { + // Period 1000 us; three whole cycles from the first event at 500 us. + let events = [ + CameraEvent { + timestamp_us: 500, + x: 0, + y: 0, + polarity: Polarity::On, + }, + CameraEvent { + timestamp_us: 750, + x: 0, + y: 0, + polarity: Polarity::Off, + }, + CameraEvent { + timestamp_us: 1_750, + x: 0, + y: 0, + polarity: Polarity::On, + }, + // Beyond the last whole cycle -> excluded. + CameraEvent { + timestamp_us: 4_000, + x: 0, + y: 0, + polarity: Polarity::On, + }, + ]; + let fold = fold_events_free_running(&events, 1_000.0).expect("one whole cycle"); + assert_eq!(fold.validation.cycle_count, 3); + assert_eq!(fold.events.len(), 3); + assert_eq!(fold.events_outside_complete_cycles, 1); + assert!((fold.events[0].phase - 0.0).abs() < 1e-12); + assert!((fold.events[1].phase - 0.25).abs() < 1e-12); + assert_eq!(fold.events[1].cycle_index, 0); + assert_eq!(fold.events[2].cycle_index, 1); + assert!((fold.events[2].phase - 0.25).abs() < 1e-12); + } + + #[test] + fn free_running_fold_needs_one_whole_cycle() { + let events = [ + CameraEvent { + timestamp_us: 0, + x: 0, + y: 0, + polarity: Polarity::On, + }, + CameraEvent { + timestamp_us: 400, + x: 0, + y: 0, + polarity: Polarity::On, + }, + ]; + assert!(fold_events_free_running(&events, 1_000.0).is_none()); + } + + #[test] + fn rejects_non_monotonic_markers() { + assert_eq!( + validate_markers(&[0, 1_000, 999, 2_000], config()), + Err(MarkerError::NonIncreasing { index: 2 }) + ); + } +} diff --git a/plugins/stage-a-a1/src/protocol.rs b/plugins/stage-a-a1/src/protocol.rs new file mode 100644 index 0000000..f1062f5 --- /dev/null +++ b/plugins/stage-a-a1/src/protocol.rs @@ -0,0 +1,1292 @@ +//! Declarative recording protocols: a TOML file naming the points to record, +//! expanded into a flat list the runner walks. +//! +//! The buttons in the Record section each sweep exactly one axis (or two, for +//! the `a × f` surface) with whatever is currently armed on the other axes. +//! That is the right shape for exploring, and the wrong shape for a survey +//! that has to run overnight and be reproducible six months later. A protocol +//! is the survey form: it names every axis explicitly — the operating point +//! `ū` (the `I_k` axis), the frequency `f`, and the depth `a` — plus the dwell +//! and duration each point is recorded with, in a file that travels with the +//! results. +//! +//! ## Format +//! +//! ```toml +//! name = "a1-survey" +//! +//! [defaults] +//! duration_s = 10 +//! settle_s = 2.0 +//! +//! [[block]] +//! name = "depth-sweep-at-10Hz" +//! mean_u = [0.5] +//! frequency_hz = [10.0] +//! depth_a = { min = 0.2, max = 2.0, points = 7 } +//! +//! [[block]] +//! name = "frequency-ladder" +//! mean_u = [0.3, 0.5] +//! frequency_hz = { min = 1.0, max = 200.0, points = 5, spacing = "log" } +//! depth_a = [0.8] +//! duration_s = 20 +//! ``` +//! +//! Every axis takes either an explicit list or a `{ min, max, points }` range +//! (`spacing = "linear"` by default, `"log"` for anything read per decade). +//! A block expands to the full product of its three axes. +//! +//! ## Ordering +//! +//! Points come out `ū` outermost, then `f`, then `a`. That is the order of how +//! expensive each change is to settle: the operating point moves the mean +//! illumination the sensor has to re-adapt to, the frequency has to be +//! confirmed against the trigger, and the depth is the cheap innermost step. +//! Any other nesting would spend the whole run settling. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +/// Hard ceiling on the points one protocol may expand to. A three-axis product +/// grows fast, and an operator who typed one zero too many should be told +/// before the bench spends a night on it, not after. +pub const MAX_POINTS: usize = 4_096; + +/// Bounds mirrored from the settings so a protocol cannot ask for a point the +/// plugin would refuse anyway — checked at parse time, where the operator can +/// still see which line was wrong. +const DEPTH_A_RANGE: (f64, f64) = (0.01, 6.0); +const MEAN_U_RANGE: (f64, f64) = (0.01, 1.0); +const FREQUENCY_RANGE: (f64, f64) = ( + stage_a_plugin_contract::DRIVE_FREQUENCY_MIN_MILLIHZ as f64 / 1_000.0, + stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 / 1_000.0, +); +const DURATION_RANGE: (i64, i64) = (1, 3_600); +const SETTLE_RANGE: (f64, f64) = (0.0, 60.0); + +/// What one protocol row records. The same three roles the Record section's +/// buttons offer, so a protocol can carry a complete measurement — its own +/// background reference and pilot, then the points scored against them — +/// instead of needing two button presses before it can be started. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum PointRole { + #[default] + Normal, + /// Bright reference; freezes the ON/OFF windows for the measurement. + Pilot, + /// Unmodulated reference; captures the false-response floor. + Background, +} + +impl PointRole { + fn parse(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "" | "normal" | "point" => Some(Self::Normal), + "pilot" => Some(Self::Pilot), + "background" => Some(Self::Background), + _ => None, + } + } +} + +/// One recording the protocol asks for, with every parameter already resolved. +#[derive(Debug, Clone, PartialEq)] +pub struct ProtocolPoint { + /// Where this row came from — the `[[block]]` name, or a CSV `label` — for + /// the status line and the sidecar. + pub block: String, + /// Normalized cycle-mean lobe point `ū` — the `I_k` axis. + pub mean_u: f64, + pub frequency_hz: f64, + pub depth_a: f64, + pub duration_s: i64, + pub settle_s: f64, + pub role: PointRole, + /// Optional per-point contrast-threshold offsets. These are the host's + /// canonical `diff_on`/`diff_off` values, not absolute sensor codes. + pub diff_on: Option, + pub diff_off: Option, +} + +impl ProtocolPoint { + /// Filename fragment identifying this point inside the measurement folder. + pub fn tag(&self) -> String { + format!( + "u{:.0}m_f{}_a{:.0}m", + self.mean_u * 1_000.0, + frequency_tag(self.frequency_hz), + self.depth_a * 1_000.0, + ) + } +} + +/// A parsed protocol: what to record, in order. +#[derive(Debug, Clone, PartialEq)] +pub struct Protocol { + pub name: String, + /// Optional revision declared by the protocol author. The exact source + /// file is archived separately, so this is a human-facing revision, not a + /// substitute for content identity. + pub version: Option, + pub points: Vec, + pub camera: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CameraSelection { + NamedProfile(String), + Snapshot(augur_plugin_api::CameraConfigurationSnapshotV1), +} + +impl Protocol { + /// Distinct values on each axis, for the summary shown before starting. + pub fn axis_counts(&self) -> (usize, usize, usize) { + let count = |values: Vec| { + let mut keys: Vec = values.into_iter().map(|value| value.to_bits()).collect(); + keys.sort_unstable(); + keys.dedup(); + keys.len() + }; + ( + count(self.points.iter().map(|point| point.mean_u).collect()), + count(self.points.iter().map(|point| point.frequency_hz).collect()), + count(self.points.iter().map(|point| point.depth_a).collect()), + ) + } + + /// Total bench time the protocol asks for, settling included. + pub fn total_seconds(&self) -> f64 { + self.remaining_seconds(0) + } + + /// Bench time the points from `index` onwards still ask for, settling + /// included. The point at `index` counts whole: it is the one in flight, + /// and the recording's own countdown says how far into it the run is. + /// + /// Recording overhead (camera start/stop, the lease handshake, the a₀ + /// search) is not in here, so this is a lower bound on the wall clock — + /// the same quantity [`Self::total_seconds`] announces before the start. + pub fn remaining_seconds(&self, index: usize) -> f64 { + self.points + .iter() + .skip(index) + .map(|point| point.duration_s as f64 + point.settle_s) + .sum() + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ProtocolError { + Toml(String), + /// A named axis, 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 [[block]] with a \ + mean_u, a frequency_hz and a depth_a", + ), + Self::TooManyPoints(count) => write!( + f, + "the protocol expands to {count} recordings, past the {MAX_POINTS} limit — \ + narrow one of the axes or split it into several files" + ), + } + } +} + +impl std::error::Error for ProtocolError {} + +// ---- wire form ------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct ProtocolDoc { + #[serde(default)] + name: Option, + #[serde(default)] + version: Option, + #[serde(default)] + defaults: Defaults, + #[serde(default, rename = "block")] + blocks: Vec, + #[serde(default)] + camera: Option, +} + +#[derive(Debug, Deserialize)] +struct CameraDoc { + #[serde(default)] + profile: Option, + #[serde(default)] + snapshot: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct Defaults { + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + diff_on: Option, + #[serde(default)] + diff_off: Option, +} + +#[derive(Debug, Deserialize)] +struct BlockDoc { + #[serde(default)] + name: Option, + mean_u: Axis, + frequency_hz: Axis, + depth_a: Axis, + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + diff_on: Option, + #[serde(default)] + diff_off: Option, +} + +/// One axis: an explicit list, a single value, or a generated range. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Axis { + One(f64), + List(Vec), + Range { + min: f64, + max: f64, + points: usize, + #[serde(default)] + spacing: Spacing, + }, +} + +#[derive(Debug, Default, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum Spacing { + #[default] + Linear, + Log, +} + +impl Axis { + fn values(&self, what: &str, range: (f64, f64)) -> 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("the list is empty".into())); + } + values.clone() + } + Self::Range { + min, + max, + points, + spacing, + } => { + if *points == 0 { + return Err(invalid("points must be at least 1".into())); + } + if !min.is_finite() || !max.is_finite() { + return Err(invalid("min and max must be numbers".into())); + } + if max < min { + return Err(invalid(format!("max {max} is below min {min}"))); + } + if *spacing == Spacing::Log && *min <= 0.0 { + return Err(invalid( + "log spacing needs a min above 0 — a decade ladder has no zero".into(), + )); + } + if *points == 1 { + vec![*min] + } else { + let last = *points - 1; + (0..*points) + .map(|index| { + let t = index as f64 / last as f64; + match spacing { + Spacing::Linear => min + t * (max - min), + Spacing::Log => (min.ln() + t * (max.ln() - min.ln())).exp(), + } + }) + .collect() + } + } + }; + for value in &values { + if !value.is_finite() || *value < range.0 || *value > range.1 { + return Err(invalid(format!( + "{value} is outside the supported {}..={}", + range.0, range.1 + ))); + } + } + Ok(values) + } +} + +/// Drops a leading UTF-8 byte-order mark. +/// +/// Saving a protocol as "CSV UTF-8" in Excel — the obvious choice on a Windows +/// bench — writes a BOM. Left in place it becomes part of the first header +/// cell, so `mean_u` stops matching `mean_u` and the file is refused for +/// missing a column that is plainly there; in the TOML form it fails the parse +/// outright. Neither message would point at an invisible character. +fn strip_bom(text: &str) -> &str { + text.strip_prefix('\u{feff}').unwrap_or(text) +} + +/// Parses a protocol and expands it into the points to record. +pub fn parse(text: &str) -> Result { + let doc: ProtocolDoc = + toml::from_str(strip_bom(text)).map_err(|error| ProtocolError::Toml(error.to_string()))?; + + let default_duration = doc.defaults.duration_s.unwrap_or(10); + let default_settle = doc.defaults.settle_s.unwrap_or(2.0); + let camera = match doc.camera { + None => None, + Some(CameraDoc { + profile: Some(profile), + snapshot: None, + }) if !profile.trim().is_empty() => Some(CameraSelection::NamedProfile(profile)), + Some(CameraDoc { + profile: None, + snapshot: Some(snapshot), + }) => Some(CameraSelection::Snapshot(snapshot)), + Some(_) => { + return Err(ProtocolError::Invalid { + what: "camera".into(), + detail: "provide exactly one non-empty profile or snapshot".into(), + }); + } + }; + + let mut points = Vec::new(); + // Blocks may be named or not; unnamed ones get a stable positional name so + // 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 = block.duration_s.unwrap_or(default_duration); + if duration_s < DURATION_RANGE.0 || duration_s > DURATION_RANGE.1 { + return Err(ProtocolError::Invalid { + what: format!("block '{name}' duration_s"), + detail: format!( + "{duration_s} is outside the supported {}..={}", + DURATION_RANGE.0, DURATION_RANGE.1 + ), + }); + } + let settle_s = block.settle_s.unwrap_or(default_settle); + if !settle_s.is_finite() || settle_s < SETTLE_RANGE.0 || settle_s > SETTLE_RANGE.1 { + return Err(ProtocolError::Invalid { + what: format!("block '{name}' settle_s"), + detail: format!( + "{settle_s} is outside the supported {}..={}", + SETTLE_RANGE.0, SETTLE_RANGE.1 + ), + }); + } + let diff_on = block.diff_on.or(doc.defaults.diff_on); + let diff_off = block.diff_off.or(doc.defaults.diff_off); + + let mean_u = block + .mean_u + .values(&format!("block '{name}' mean_u"), MEAN_U_RANGE)?; + let frequency_hz = block + .frequency_hz + .values(&format!("block '{name}' frequency_hz"), FREQUENCY_RANGE)?; + let depth_a = block + .depth_a + .values(&format!("block '{name}' depth_a"), DEPTH_A_RANGE)?; + + // `ū` outermost, `a` innermost — see the module docs. + for mean_u in &mean_u { + for frequency_hz in &frequency_hz { + for depth_a in &depth_a { + points.push(ProtocolPoint { + block: name.clone(), + mean_u: *mean_u, + frequency_hz: *frequency_hz, + depth_a: *depth_a, + duration_s, + settle_s, + role: PointRole::Normal, + diff_on, + diff_off, + }); + if points.len() > MAX_POINTS { + return Err(ProtocolError::TooManyPoints(points.len())); + } + } + } + } + } + + 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()), + version: doc.version.filter(|version| !version.trim().is_empty()), + points, + camera, + }) +} + +/// Compact frequency fragment for a filename: `10Hz`, `1500mHz`, `2k5Hz`. +fn frequency_tag(hz: f64) -> String { + if hz < 1.0 { + format!("{:.0}mHz", hz * 1_000.0) + } else if hz < 1_000.0 { + let rounded = (hz * 10.0).round() / 10.0; + if (rounded - rounded.round()).abs() < f64::EPSILON { + format!("{rounded:.0}Hz") + } else { + format!("{rounded:.1}Hz").replace('.', "p") + } + } else { + format!("{:.0}Hz", hz.round()) + } +} + +/// Reads a protocol from a file, choosing the reader by extension. +/// +/// `.csv` is the row-per-recording form and the one to reach for: one line is +/// one recording, every parameter is a column, and it opens in a spreadsheet +/// or comes straight out of a script. `.toml` is the block/range form — more +/// compact for a dense regular sweep, and kept because it expresses one. +/// +/// Both produce the same flat list, so nothing downstream knows which was used. +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 { + let mut protocol = parse_csv(text)?; + protocol.name = std::path::Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.is_empty()) + .unwrap_or("protocol") + .to_owned(); + Ok(protocol) + } else { + parse(text) + } +} + +/// Columns a protocol CSV may carry. `mean_u`, `frequency_hz` and `depth_a` are +/// required; the rest fall back to their defaults. +const CSV_REQUIRED: [&str; 3] = ["mean_u", "frequency_hz", "depth_a"]; +const CSV_OPTIONAL: [&str; 7] = [ + "duration_s", + "settle_s", + "label", + "role", + "camera_profile", + "diff_on", + "diff_off", +]; + +/// 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 — the same rule the sensor readout follows, +/// and the reason a file edited in a spreadsheet keeps working after someone +/// drags a column. +/// +/// Blank lines and `#` comments are skipped, so a file can explain itself. +/// Errors carry the **file line number**, not the row index, 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(); + let mut camera_profile: Option = None; + + // `lines()` already absorbs CRLF; the BOM is the part it leaves behind. + for (offset, raw) in strip_bom(text).lines().enumerate() { + let line_no = offset + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields = crate::csv::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: {}", + 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 number = |name: &str, range: (f64, f64)| -> Result { + let raw = cell(name).unwrap_or(""); + let invalid = |detail: String| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail, + }; + if raw.is_empty() { + return Err(invalid("is empty".into())); + } + let value: f64 = raw + .parse() + .map_err(|_| invalid(format!("'{raw}' is not a number")))?; + if !value.is_finite() || value < range.0 || value > range.1 { + return Err(invalid(format!( + "{value} is outside the supported {}..={}", + range.0, range.1 + ))); + } + Ok(value) + }; + + let mean_u = number("mean_u", MEAN_U_RANGE)?; + let frequency_hz = number("frequency_hz", FREQUENCY_RANGE)?; + let depth_a = number("depth_a", DEPTH_A_RANGE)?; + + // Absent column *or* empty cell falls back, so a file can carry a + // duration column that only some rows fill in. + let duration_s = match cell("duration_s").unwrap_or("") { + "" => 10, + raw => { + let value: i64 = raw.parse().map_err(|_| ProtocolError::Invalid { + what: format!("line {line_no}: duration_s"), + detail: format!("'{raw}' is not a whole number of seconds"), + })?; + if value < DURATION_RANGE.0 || value > DURATION_RANGE.1 { + return Err(ProtocolError::Invalid { + what: format!("line {line_no}: duration_s"), + detail: format!( + "{value} is outside the supported {}..={}", + DURATION_RANGE.0, DURATION_RANGE.1 + ), + }); + } + value + } + }; + let settle_s = match cell("settle_s").unwrap_or("") { + "" => 2.0, + _ => number("settle_s", SETTLE_RANGE)?, + }; + let role = + PointRole::parse(cell("role").unwrap_or("")).ok_or_else(|| ProtocolError::Invalid { + what: format!("line {line_no}: role"), + detail: format!( + "'{}' is not one of normal, pilot, background", + cell("role").unwrap_or("") + ), + })?; + let label = cell("label").unwrap_or("").trim().to_owned(); + let row_profile = cell("camera_profile").unwrap_or("").trim(); + if !row_profile.is_empty() { + match &camera_profile { + Some(existing) if existing != row_profile => { + return Err(ProtocolError::Invalid { + what: format!("line {line_no}: camera_profile"), + detail: format!( + "'{row_profile}' differs from the series profile '{existing}'" + ), + }); + } + None => camera_profile = Some(row_profile.to_owned()), + _ => {} + } + } + let parse_bias = |name: &str| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + raw.parse::() + .map(Some) + .map_err(|_| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail: format!("'{raw}' is not a signed integer offset"), + }) + }; + let diff_on = parse_bias("diff_on")?; + let diff_off = parse_bias("diff_off")?; + + points.push(ProtocolPoint { + block: if label.is_empty() { + format!("row{}", points.len() + 1) + } else { + label + }, + mean_u, + frequency_hz, + depth_a, + duration_s, + settle_s, + role, + diff_on, + diff_off, + }); + if points.len() > MAX_POINTS { + return Err(ProtocolError::TooManyPoints(points.len())); + } + } + + 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: {}", + CSV_REQUIRED.join(", ") + ), + }); + } + if points.is_empty() { + return Err(ProtocolError::Empty); + } + Ok(Protocol { + name: "protocol".to_owned(), + version: None, + points, + camera: camera_profile.map(CameraSelection::NamedProfile), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" +name = "sample" +version = "2026-08-13" + +[defaults] +duration_s = 5 +settle_s = 1.5 + +[[block]] +name = "flat" +mean_u = 0.5 +frequency_hz = [10.0, 20.0] +depth_a = { min = 0.5, max = 1.5, points = 3 } + +[[block]] +name = "ladder" +mean_u = [0.3, 0.6] +frequency_hz = { min = 1.0, max = 100.0, points = 3, spacing = "log" } +depth_a = 0.8 +duration_s = 30 +"#; + + #[test] + fn a_spreadsheet_bom_does_not_hide_the_first_column() { + // Excel's "CSV UTF-8" writes a BOM. Without stripping it, `mean_u` + // reads as `\u{feff}mean_u` and the file is refused for missing the + // column it visibly has. + let csv = "\u{feff}mean_u,frequency_hz,depth_a\n0.5,10,1.0\n"; + let protocol = parse_file("survey.csv", csv).expect("BOM-prefixed CSV"); + assert_eq!(protocol.points.len(), 1); + assert_eq!(protocol.points[0].mean_u, 0.5); + } + + #[test] + fn a_bom_does_not_break_the_toml_form_either() { + let protocol = parse(&format!("\u{feff}{SAMPLE}")).expect("BOM-prefixed TOML"); + assert_eq!(protocol.name, "sample"); + assert_eq!(protocol.version.as_deref(), Some("2026-08-13")); + } + + #[test] + fn a_spreadsheet_crlf_file_parses() { + let csv = "mean_u,frequency_hz,depth_a\r\n0.5,10,1.0\r\n"; + let protocol = parse_file("survey.csv", csv).expect("CRLF CSV"); + assert_eq!(protocol.points.len(), 1); + assert_eq!(protocol.points[0].frequency_hz, 10.0); + } + + #[test] + fn a_protocol_expands_to_the_product_of_its_axes() { + let protocol = parse(SAMPLE).expect("valid protocol"); + assert_eq!(protocol.name, "sample"); + // 1×2×3 + 2×3×1 + assert_eq!(protocol.points.len(), 6 + 6); + assert_eq!(protocol.axis_counts(), (3, 5, 4)); + } + + #[test] + fn points_run_mean_u_outermost_and_depth_innermost() { + // The nesting is the whole reason the protocol is worth having over + // three nested button presses: it settles the expensive axis least + // often. Assert the actual emitted order, not just the count. + let protocol = parse(SAMPLE).expect("valid protocol"); + let flat: Vec<(f64, f64, f64)> = protocol + .points + .iter() + .filter(|point| point.block == "flat") + .map(|point| (point.mean_u, point.frequency_hz, point.depth_a)) + .collect(); + assert_eq!( + flat, + vec![ + (0.5, 10.0, 0.5), + (0.5, 10.0, 1.0), + (0.5, 10.0, 1.5), + (0.5, 20.0, 0.5), + (0.5, 20.0, 1.0), + (0.5, 20.0, 1.5), + ] + ); + } + + #[test] + fn defaults_apply_unless_the_block_overrides_them() { + let protocol = parse(SAMPLE).expect("valid protocol"); + let flat = protocol + .points + .iter() + .find(|point| point.block == "flat") + .expect("flat block"); + assert_eq!(flat.duration_s, 5); + assert!((flat.settle_s - 1.5).abs() < f64::EPSILON); + + let ladder = protocol + .points + .iter() + .find(|point| point.block == "ladder") + .expect("ladder block"); + assert_eq!(ladder.duration_s, 30); + // settle_s was not overridden, so the default still applies. + assert!((ladder.settle_s - 1.5).abs() < f64::EPSILON); + assert_eq!(protocol.camera, None); + assert!(protocol + .points + .iter() + .all(|point| point.diff_on.is_none() && point.diff_off.is_none())); + } + + #[test] + fn a_named_camera_profile_and_canonical_bias_offsets_are_parsed() { + let protocol = parse( + r#" +name = "camera-series" + +[camera] +profile = "A1 low noise" + +[defaults] +diff_on = 12 +diff_off = -7 + +[[block]] +name = "first" +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 + +[[block]] +name = "override" +mean_u = 0.5 +frequency_hz = 20.0 +depth_a = 0.5 +diff_on = 20 +"#, + ) + .expect("camera protocol"); + assert_eq!( + protocol.camera, + Some(CameraSelection::NamedProfile("A1 low noise".into())) + ); + assert_eq!(protocol.points[0].diff_on, Some(12)); + assert_eq!(protocol.points[0].diff_off, Some(-7)); + assert_eq!(protocol.points[1].diff_on, Some(20)); + assert_eq!(protocol.points[1].diff_off, Some(-7)); + } + + #[test] + fn sensor_specific_bias_ranges_are_left_to_the_host_backend() { + let protocol = parse( + r#" +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 +diff_on = 141 +diff_off = 191 +"#, + ) + .expect("the active camera backend owns its supported ranges"); + assert_eq!(protocol.points[0].diff_on, Some(141)); + assert_eq!(protocol.points[0].diff_off, Some(191)); + } + + #[test] + fn an_inline_camera_snapshot_roundtrips_through_toml() { + let protocol = parse( + r#" +[camera.snapshot] +schema_version = 1 +masked_pixels = [[3, 4]] + +[camera.snapshot.biases] +diff_on = 12 +diff_off = -7 +fo = 0 +hpf = 0 +refr = 0 + +[camera.snapshot.roi] +x = 0 +y = 0 +width = 1280 +height = 720 + +[camera.snapshot.digital_filter] +stc_enabled = false +stc_threshold_us = 0 +trail_enabled = false + +[camera.snapshot.external_trigger] +enabled = false +channel = 0 + +[camera.snapshot.global] +nm_per_pixel = 1000.0 +pixel_scale_calibrated = true +sensor_width = 1280 +sensor_height = 720 +acq_time_ms = 1 +event_store_budget_mib = 512 +preview_interval_ms = 16 +point_cloud_interval_ms = 50 +disk_writer_buffer_mib = 64 +record_sensor_telemetry = true + +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 0.5 +"#, + ) + .expect("inline snapshot protocol"); + let Some(CameraSelection::Snapshot(snapshot)) = protocol.camera else { + panic!("inline snapshot was not selected"); + }; + assert_eq!(snapshot.biases.diff_on, 12); + assert!(snapshot.global.record_sensor_telemetry); + assert_eq!(snapshot.masked_pixels, vec![(3, 4)]); + } + + #[test] + fn log_spacing_is_geometric() { + let protocol = parse(SAMPLE).expect("valid protocol"); + let ladder: Vec = protocol + .points + .iter() + .filter(|point| point.block == "ladder" && point.mean_u == 0.3) + .map(|point| point.frequency_hz) + .collect(); + assert_eq!(ladder.len(), 3); + assert!((ladder[0] - 1.0).abs() < 1e-9); + assert!((ladder[1] - 10.0).abs() < 1e-9); + assert!((ladder[2] - 100.0).abs() < 1e-9); + } + + #[test] + fn total_seconds_counts_settling_too() { + let protocol = parse(SAMPLE).expect("valid protocol"); + // 6 × (5 + 1.5) + 6 × (30 + 1.5) + assert!((protocol.total_seconds() - (6.0 * 6.5 + 6.0 * 31.5)).abs() < 1e-9); + } + + #[test] + fn remaining_seconds_drops_the_points_already_done() { + let protocol = parse(SAMPLE).expect("valid protocol"); + // The point in flight counts whole, so after six 6.5 s points only the + // six 31.5 s ones are left. + assert!((protocol.remaining_seconds(6) - 6.0 * 31.5).abs() < 1e-9); + // Past the end nothing is left, rather than an index panic. + assert_eq!(protocol.remaining_seconds(protocol.points.len()), 0.0); + assert!((protocol.remaining_seconds(0) - protocol.total_seconds()).abs() < 1e-9); + } + + #[test] + fn out_of_range_values_name_the_axis_that_is_wrong() { + let error = parse( + r#" +[[block]] +name = "too-deep" +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 99.0 +"#, + ) + .expect_err("a depth of 99 is not drivable"); + let text = error.to_string(); + assert!(text.contains("too-deep"), "{text}"); + assert!(text.contains("depth_a"), "{text}"); + } + + #[test] + fn log_spacing_from_zero_is_refused_rather_than_producing_infinities() { + let error = parse( + r#" +[[block]] +mean_u = 0.5 +depth_a = 1.0 +frequency_hz = { min = 0.0, max = 100.0, points = 3, spacing = "log" } +"#, + ) + .expect_err("log from zero"); + assert!(error.to_string().contains("no zero"), "{error}"); + } + + #[test] + fn an_empty_protocol_says_so_instead_of_running_nothing() { + assert_eq!( + parse("name = \"nothing\"").unwrap_err(), + ProtocolError::Empty + ); + } + + #[test] + fn a_runaway_product_is_refused_before_the_bench_spends_a_night_on_it() { + let error = parse( + r#" +[[block]] +mean_u = { min = 0.1, max = 1.0, points = 20 } +frequency_hz = { min = 1.0, max = 100.0, points = 20 } +depth_a = { min = 0.1, max = 2.0, points = 20 } +"#, + ) + .expect_err("8000 points"); + assert!( + matches!(error, ProtocolError::TooManyPoints(_)), + "{error:?}" + ); + } + + #[test] + fn unnamed_and_repeated_blocks_stay_distinguishable() { + let protocol = parse( + r#" +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 1.0 + +[[block]] +name = "dup" +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 1.0 + +[[block]] +name = "dup" +mean_u = 0.5 +frequency_hz = 20.0 +depth_a = 1.0 +"#, + ) + .expect("valid"); + let names: Vec<&str> = protocol + .points + .iter() + .map(|point| point.block.as_str()) + .collect(); + assert_eq!(names, vec!["block1", "dup", "dup-2"]); + } + + #[test] + fn a_point_tag_is_stable_and_filename_safe() { + let point = ProtocolPoint { + block: "b".into(), + mean_u: 0.5, + frequency_hz: 12.5, + depth_a: 0.75, + duration_s: 5, + settle_s: 1.0, + role: PointRole::Normal, + diff_on: None, + diff_off: None, + }; + assert_eq!(point.tag(), "u500m_f12p5Hz_a750m"); + assert!(!point.tag().contains('.')); + } +} + +#[cfg(test)] +mod csv_tests { + use super::*; + + const SAMPLE: &str = "\ +label,mean_u,frequency_hz,depth_a,duration_s,settle_s,role +floor,0.5,10,0.02,20,3,background +curve,0.5,10,0.5,,, +slow,0.4,1,0.8,40,4, +"; + + #[test] + fn one_row_is_one_recording_in_file_order() { + let protocol = parse_csv(SAMPLE).expect("valid CSV"); + assert_eq!(protocol.points.len(), 3); + let order: Vec<(f64, f64)> = protocol + .points + .iter() + .map(|point| (point.frequency_hz, point.depth_a)) + .collect(); + assert_eq!(order, vec![(10.0, 0.02), (10.0, 0.5), (1.0, 0.8)]); + } + + #[test] + fn csv_protocol_identity_comes_from_its_source_filename() { + let protocol = parse_file("a1_fc_flux_discriminator.csv", SAMPLE).expect("valid CSV"); + assert_eq!(protocol.name, "a1_fc_flux_discriminator"); + assert_eq!(protocol.version, None); + } + + /// The reason for the row-per-recording form: a low frequency needs longer + /// than a high one, with no block gymnastics to express it. + #[test] + fn each_row_carries_its_own_duration_and_settle() { + let protocol = parse_csv(SAMPLE).expect("valid CSV"); + assert_eq!(protocol.points[0].duration_s, 20); + assert_eq!(protocol.points[2].duration_s, 40); + assert!((protocol.points[2].settle_s - 4.0).abs() < f64::EPSILON); + // Blank cells fall back rather than failing the row. + assert_eq!(protocol.points[1].duration_s, 10); + assert!((protocol.points[1].settle_s - 2.0).abs() < f64::EPSILON); + } + + #[test] + fn csv_carries_one_series_profile_and_per_point_diff_offsets() { + let csv = "camera_profile,mean_u,frequency_hz,depth_a,diff_on,diff_off\n\ +A1 low noise,0.5,10,0.5,12,-7\n\ +A1 low noise,0.5,20,0.5,20,-8\n"; + let protocol = parse_csv(csv).expect("camera CSV"); + assert_eq!( + protocol.camera, + Some(CameraSelection::NamedProfile("A1 low noise".into())) + ); + assert_eq!(protocol.points[0].diff_on, Some(12)); + assert_eq!(protocol.points[1].diff_off, Some(-8)); + } + + #[test] + fn csv_refuses_profile_changes_within_one_measurement_series() { + let csv = "camera_profile,mean_u,frequency_hz,depth_a\n\ +profile-a,0.5,10,0.5\n\ +profile-b,0.5,20,0.5\n"; + let error = parse_csv(csv).expect_err("two series profiles"); + assert!(error.to_string().contains("profile-b"), "{error}"); + assert!(error.to_string().contains("profile-a"), "{error}"); + } + + #[test] + fn a_row_can_name_its_role_so_a_file_carries_its_own_references() { + let protocol = parse_csv(SAMPLE).expect("valid CSV"); + assert_eq!(protocol.points[0].role, PointRole::Background); + assert_eq!(protocol.points[1].role, PointRole::Normal); + } + + #[test] + fn columns_are_found_by_name_not_by_position() { + // Someone drags a column in a spreadsheet; the file must still mean the + // same thing. + let reordered = "\ +depth_a,role,frequency_hz,label,mean_u +0.02,background,10,floor,0.5 +"; + let protocol = parse_csv(reordered).expect("valid CSV"); + assert_eq!(protocol.points[0].depth_a, 0.02); + assert_eq!(protocol.points[0].mean_u, 0.5); + assert_eq!(protocol.points[0].role, PointRole::Background); + assert_eq!(protocol.points[0].block, "floor"); + } + + #[test] + fn comments_and_blank_lines_are_skipped_so_a_file_can_explain_itself() { + let commented = "\ +# a survey +mean_u,frequency_hz,depth_a + +# the only point +0.5,10,0.5 +"; + assert_eq!(parse_csv(commented).expect("valid").points.len(), 1); + } + + #[test] + fn errors_name_the_line_number_the_editor_shows() { + // Not a row index: the operator is looking at a spreadsheet. + let bad = "\ +# comment +mean_u,frequency_hz,depth_a +0.5,10,0.5 +0.5,10,99 +"; + let error = parse_csv(bad).expect_err("a depth of 99 is not drivable"); + let text = error.to_string(); + // Line 4 counting the comment and the header, which is what an editor + // and a spreadsheet both show. + assert!(text.contains("line 4"), "{text}"); + assert!(text.contains("depth_a"), "{text}"); + } + + #[test] + fn a_missing_required_column_says_which_one_and_lists_the_rest() { + let error = parse_csv("mean_u,frequency_hz\n0.5,10\n").expect_err("no depth_a"); + let text = error.to_string(); + assert!(text.contains("depth_a"), "{text}"); + assert!( + text.contains("duration_s"), + "optional columns unlisted: {text}" + ); + } + + #[test] + fn a_header_only_or_empty_file_is_refused_rather_than_running_nothing() { + assert_eq!( + parse_csv("mean_u,frequency_hz,depth_a\n").unwrap_err(), + ProtocolError::Empty + ); + assert!(matches!( + parse_csv("# nothing but a comment\n").unwrap_err(), + ProtocolError::Invalid { .. } + )); + } + + #[test] + fn a_quoted_label_may_contain_a_comma() { + let quoted = "label,mean_u,frequency_hz,depth_a\n\"ladder, low end\",0.5,1,0.8\n"; + let protocol = parse_csv(quoted).expect("valid"); + assert_eq!(protocol.points[0].block, "ladder, low end"); + } + + #[test] + fn an_unlabelled_row_still_gets_a_stable_name() { + let protocol = + parse_csv("mean_u,frequency_hz,depth_a\n0.5,10,0.5\n0.5,20,0.5\n").expect("valid"); + assert_eq!(protocol.points[0].block, "row1"); + assert_eq!(protocol.points[1].block, "row2"); + } + + #[test] + fn an_unknown_role_is_refused_rather_than_silently_recorded_as_normal() { + let error = parse_csv("mean_u,frequency_hz,depth_a,role\n0.5,10,0.5,piolt\n") + .expect_err("typo in role"); + assert!(error.to_string().contains("pilot"), "{error}"); + } + + #[test] + fn the_reader_is_chosen_by_extension() { + let csv = "mean_u,frequency_hz,depth_a\n0.5,10,0.5\n"; + assert_eq!(parse_file("survey.csv", csv).expect("csv").points.len(), 1); + assert_eq!(parse_file("SURVEY.CSV", csv).expect("csv").points.len(), 1); + // A .toml path goes to the block reader, and the CSV text is not TOML. + assert!(parse_file("survey.toml", csv).is_err()); + } +} + +#[cfg(test)] +mod example_file_tests { + use super::*; + + /// The shipped example is documentation the operator copies, so it has to + /// stay valid as the format moves — a stale example is worse than none. + /// The shipped CSV is what an operator copies, so it has to stay valid as + /// the format moves — a stale example is worse than none. + #[test] + fn the_shipped_example_csv_parses_and_exercises_every_column() { + let text = include_str!("../protocols/example.csv"); + let protocol = parse_csv(text).expect("the shipped CSV example must parse"); + assert!(protocol.points.len() > 10); + assert!(protocol + .points + .iter() + .any(|point| point.role == PointRole::Background)); + assert!(protocol + .points + .iter() + .any(|point| point.role == PointRole::Pilot)); + // The whole reason for the row form: durations genuinely differ. + let durations: std::collections::BTreeSet = protocol + .points + .iter() + .map(|point| point.duration_s) + .collect(); + assert!(durations.len() > 2, "{durations:?}"); + // And every axis is exercised. + let (means, frequencies, depths) = protocol.axis_counts(); + assert!(means > 1 && frequencies > 1 && depths > 1); + } + + #[test] + fn the_shipped_example_protocol_parses() { + let text = include_str!("../protocols/example.toml"); + let protocol = parse(text).expect("the shipped example must parse"); + assert_eq!(protocol.name, "a1-example-survey"); + // 1×1×7 + 2×6×1 + 1×3×4 + 4×1×1 + assert_eq!(protocol.points.len(), 7 + 12 + 12 + 4); + // Every axis is genuinely exercised, so the example demonstrates what + // it claims to. + let (means, frequencies, depths) = protocol.axis_counts(); + assert!(means > 1 && frequencies > 1 && depths > 1); + assert!(protocol.total_seconds() > 0.0); + } +} diff --git a/plugins/stage-a-a1/src/rates.rs b/plugins/stage-a-a1/src/rates.rs new file mode 100644 index 0000000..605a910 --- /dev/null +++ b/plugins/stage-a-a1/src/rates.rs @@ -0,0 +1,294 @@ +//! Phase-bin event rates and rolling half-period operator quicklooks. + +use crate::phase::PhaseFold; +use crate::types::Polarity; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RateLayer { + pub count: u64, + /// Events per valid pixel per second. + pub rate_per_pixel_s: f64, + /// Poisson standard error in the same units as `rate_per_pixel_s`. + pub standard_error: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PhaseRateBin { + pub phase_start: f64, + pub phase_end: f64, + pub run: RateLayer, + pub background: Option, + /// Run minus background. Negative values are intentionally preserved. + pub net_rate_per_pixel_s: Option, + /// Independent Poisson uncertainty propagated in quadrature. + pub net_standard_error: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PolarityPhaseRates { + pub polarity: Polarity, + pub valid_pixels: usize, + pub run_cycles: usize, + pub background_cycles: Option, + pub bins: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PhaseRateSet { + pub on: PolarityPhaseRates, + pub off: PolarityPhaseRates, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RateError { + ZeroValidPixels, + InvalidBinCount, + EmptyCycles, +} + +pub fn phase_bin_rates( + run: &PhaseFold, + background: Option<&PhaseFold>, + valid_pixels: usize, + bin_count: usize, +) -> Result { + if valid_pixels == 0 { + return Err(RateError::ZeroValidPixels); + } + if bin_count == 0 { + return Err(RateError::InvalidBinCount); + } + if run.validation.cycle_count == 0 + || background.is_some_and(|fold| fold.validation.cycle_count == 0) + { + return Err(RateError::EmptyCycles); + } + + Ok(PhaseRateSet { + on: rates_for_polarity(run, background, valid_pixels, bin_count, Polarity::On), + off: rates_for_polarity(run, background, valid_pixels, bin_count, Polarity::Off), + }) +} + +fn rates_for_polarity( + run: &PhaseFold, + background: Option<&PhaseFold>, + valid_pixels: usize, + bin_count: usize, + polarity: Polarity, +) -> PolarityPhaseRates { + let mut run_counts = vec![0_u64; bin_count]; + let mut background_counts = vec![0_u64; bin_count]; + for event in run.events.iter().filter(|event| event.polarity == polarity) { + run_counts[phase_bin(event.phase, bin_count)] += 1; + } + if let Some(background) = background { + for event in background + .events + .iter() + .filter(|event| event.polarity == polarity) + { + background_counts[phase_bin(event.phase, bin_count)] += 1; + } + } + + let run_bin_s = run.validation.mean_period_us / 1_000_000.0 / bin_count as f64; + let run_exposure = valid_pixels as f64 * run.validation.cycle_count as f64 * run_bin_s; + let background_exposure = background.map(|fold| { + valid_pixels as f64 + * fold.validation.cycle_count as f64 + * (fold.validation.mean_period_us / 1_000_000.0 / bin_count as f64) + }); + + let bins = (0..bin_count) + .map(|index| { + let run_layer = poisson_layer(run_counts[index], run_exposure); + let background_layer = background_exposure + .map(|exposure| poisson_layer(background_counts[index], exposure)); + let (net, net_error) = background_layer.map_or((None, None), |background| { + ( + Some(run_layer.rate_per_pixel_s - background.rate_per_pixel_s), + Some( + (run_layer.standard_error.powi(2) + background.standard_error.powi(2)) + .sqrt(), + ), + ) + }); + PhaseRateBin { + phase_start: index as f64 / bin_count as f64, + phase_end: (index + 1) as f64 / bin_count as f64, + run: run_layer, + background: background_layer, + net_rate_per_pixel_s: net, + net_standard_error: net_error, + } + }) + .collect(); + + PolarityPhaseRates { + polarity, + valid_pixels, + run_cycles: run.validation.cycle_count, + background_cycles: background.map(|fold| fold.validation.cycle_count), + bins, + } +} + +fn phase_bin(phase: f64, bin_count: usize) -> usize { + ((phase.rem_euclid(1.0) * bin_count as f64).floor() as usize).min(bin_count - 1) +} + +fn poisson_layer(count: u64, exposure_pixel_s: f64) -> RateLayer { + RateLayer { + count, + rate_per_pixel_s: count as f64 / exposure_pixel_s, + standard_error: (count as f64).sqrt() / exposure_pixel_s, + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RollingResponsePoint { + pub timestamp_us: u64, + /// Events in `(t - T/2, t]` per valid pixel. + pub run_per_pixel: f64, + /// Integral of the periodic phase-resolved background rate, when enabled. + pub background_per_pixel: Option, + pub net_per_pixel: Option, +} + +pub fn rolling_half_period_response( + run: &PhaseFold, + polarity: Polarity, + valid_pixels: usize, + sample_times_us: &[u64], + background_model: Option<&PolarityPhaseRates>, +) -> Result, RateError> { + if valid_pixels == 0 { + return Err(RateError::ZeroValidPixels); + } + let half_period_us = run.validation.mean_period_us / 2.0; + let period_s = run.validation.mean_period_us / 1_000_000.0; + Ok(sample_times_us + .iter() + .map(|×tamp_us| { + let window_start = timestamp_us as f64 - half_period_us; + let count = run + .events + .iter() + .filter(|event| { + event.polarity == polarity + && event.timestamp_us as f64 > window_start + && event.timestamp_us <= timestamp_us + }) + .count(); + let run_per_pixel = count as f64 / valid_pixels as f64; + let background_per_pixel = background_model.map(|model| { + let start_phase = run.phase_at(timestamp_us.saturating_sub(half_period_us as u64)); + integrate_periodic_rates(model, start_phase, 0.5) * period_s + }); + RollingResponsePoint { + timestamp_us, + run_per_pixel, + background_per_pixel, + net_per_pixel: background_per_pixel.map(|bg| run_per_pixel - bg), + } + }) + .collect()) +} + +/// Integrates rates over a circular phase span and returns rate × phase. +fn integrate_periodic_rates(model: &PolarityPhaseRates, start_phase: f64, phase_span: f64) -> f64 { + let mut total = 0.0; + let start = start_phase.rem_euclid(1.0); + let end = start + phase_span; + for bin in &model.bins { + for offset in [0.0, 1.0] { + let bin_start = bin.phase_start + offset; + let bin_end = bin.phase_end + offset; + let overlap = (end.min(bin_end) - start.max(bin_start)).max(0.0); + let layer = bin.background.unwrap_or(bin.run); + total += overlap * layer.rate_per_pixel_s; + } + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::phase::{fold_events, MarkerValidationConfig}; + use crate::types::CameraEvent; + + fn fold(events: &[CameraEvent]) -> PhaseFold { + fold_events( + events, + &[0, 1_000, 2_000], + MarkerValidationConfig { + expected_frequency_hz: 1_000.0, + frequency_tolerance_fraction: 0.0, + max_period_jitter_fraction: 0.0, + expected_cycles: Some(2), + }, + ) + .unwrap() + } + + fn event(timestamp_us: u64, polarity: Polarity) -> CameraEvent { + CameraEvent { + timestamp_us, + x: 0, + y: 0, + polarity, + } + } + + #[test] + fn computes_raw_background_and_negative_net_rates_per_polarity() { + let run = fold(&[ + event(100, Polarity::On), + event(1_100, Polarity::On), + event(600, Polarity::Off), + ]); + let background = fold(&[ + event(100, Polarity::On), + event(200, Polarity::On), + event(1_100, Polarity::On), + event(1_200, Polarity::On), + ]); + let rates = phase_bin_rates(&run, Some(&background), 10, 2).unwrap(); + let on_first = &rates.on.bins[0]; + assert_eq!(on_first.run.count, 2); + assert_eq!(on_first.background.unwrap().count, 4); + assert!(on_first.net_rate_per_pixel_s.unwrap() < 0.0); + assert!(on_first.net_standard_error.unwrap() > 0.0); + assert_eq!(rates.off.bins[1].run.count, 1); + } + + #[test] + fn rolling_quicklook_uses_open_left_closed_right_window_and_background_integral() { + let run = fold(&[ + event(500, Polarity::On), + event(750, Polarity::On), + event(1_000, Polarity::On), + ]); + let background = fold(&[ + event(100, Polarity::On), + event(600, Polarity::On), + event(1_100, Polarity::On), + event(1_600, Polarity::On), + ]); + let background_rates = phase_bin_rates(&run, Some(&background), 1, 2).unwrap(); + let points = rolling_half_period_response( + &run, + Polarity::On, + 1, + &[1_000], + Some(&background_rates.on), + ) + .unwrap(); + // Event at exactly t-T/2 is excluded; 750 and 1000 are included. + assert_eq!(points[0].run_per_pixel, 2.0); + assert!((points[0].background_per_pixel.unwrap() - 1.0).abs() < 1e-12); + assert!((points[0].net_per_pixel.unwrap() - 1.0).abs() < 1e-12); + } +} diff --git a/plugins/stage-a-a1/src/response_curve.rs b/plugins/stage-a-a1/src/response_curve.rs new file mode 100644 index 0000000..b4540a2 --- /dev/null +++ b/plugins/stage-a-a1/src/response_curve.rs @@ -0,0 +1,293 @@ +//! Auto-windowed Bernoulli response probability `q_p(a, f)`. +//! +//! With the firmware phase-0 `EXT_TRIGGER` anchoring the camera phase, ON and OFF +//! events fall in opposite half-cycles, so the ON/OFF phase windows can be found +//! directly from the current fold — no separate bright "pilot" capture is needed. +//! +//! For each polarity we anchor a window on its phase-histogram peak and grow it +//! outward while the histogram stays above a floor (a fraction of the peak) **and** +//! that polarity still dominates the opposite one. The window therefore ends out in +//! the opposite half-cycle, where the polarity's events have died away, and can +//! never bleed into the other polarity's cluster. +//! +//! The response probability is then, per pixel `i` and cycle `c`: +//! +//! ```text +//! z_{i,c,p} = 1 if pixel i fires at least once in W_p during cycle c, else 0 +//! q_p(a,f) = (1 / (N_valid · M)) · Σ_i Σ_c z_{i,c,p} +//! ``` +//! +//! computed independently for ON and OFF, where `M` is the number of complete +//! valid cycles and `N_valid` is the ROI minus masked pixels. +//! +//! This is the **live quicklook** definition. The authoritative `q_p(a, f)` fit +//! freezes the windows once (from the brightest recording) and applies them to all +//! amplitudes offline — auto-windowing per fold is deliberately not amplitude-frozen. + +use std::collections::HashSet; + +use crate::phase::PhaseFold; +use crate::types::Polarity; + +/// Phase-histogram resolution used for window detection. +pub const HIST_BINS: usize = 64; + +/// Circular phase window `[start, end)` in cycle fraction. When `start > end` +/// the window wraps past 1.0. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PhaseWindow { + pub start: f64, + pub end: f64, +} + +impl PhaseWindow { + pub fn contains(&self, phase: f64) -> bool { + let p = phase.rem_euclid(1.0); + if self.start <= self.end { + p >= self.start && p < self.end + } else { + p >= self.start || p < self.end + } + } +} + +/// Region of interest in pixel coordinates; `x1`/`y1` are exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Roi { + pub x0: u16, + pub y0: u16, + pub x1: u16, + pub y1: u16, +} + +impl Roi { + pub fn contains(&self, x: u16, y: u16) -> bool { + x >= self.x0 && x < self.x1 && y >= self.y0 && y < self.y1 + } + + pub fn area(&self) -> usize { + usize::from(self.x1.saturating_sub(self.x0)) * usize::from(self.y1.saturating_sub(self.y0)) + } +} + +/// One recorded response-curve point. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResponsePoint { + pub measured_a: f64, + pub q_on: f64, + pub q_off: f64, + pub cycles: usize, + pub valid_pixels: usize, +} + +/// ON/OFF phase histogram of a fold. +pub fn phase_histogram(fold: &PhaseFold, polarity: Polarity) -> Vec { + let mut histogram = vec![0.0; HIST_BINS]; + for event in fold + .events + .iter() + .filter(|event| event.polarity == polarity) + { + let phase = event.phase.rem_euclid(1.0); + let bin = ((phase * HIST_BINS as f64) as usize).min(HIST_BINS - 1); + histogram[bin] += 1.0; + } + histogram +} + +/// Grows a circular window out from `hist`'s peak while the peak-relative floor is +/// met and this polarity keeps dominating `other`. Returns `None` on an empty +/// histogram. +fn grow_window(hist: &[f64], other: &[f64], floor_fraction: f64) -> Option { + let bins = hist.len(); + let peak = hist.iter().copied().fold(0.0_f64, f64::max); + if peak <= 0.0 || bins == 0 { + return None; + } + let floor = peak * floor_fraction; + let peak_bin = hist + .iter() + .enumerate() + .max_by(|a, b| a.1.total_cmp(b.1)) + .map(|(index, _)| index)?; + + // A bin belongs to this window when it clears the floor and this polarity is + // at least as strong as the opposite one there. + let keep = |index: usize| hist[index] >= floor && hist[index] >= other[index]; + + // The peak anchors the window; grow right then left until a bin fails. + let mut right = peak_bin; + for step in 1..bins { + let index = (peak_bin + step) % bins; + if keep(index) { + right = index; + } else { + break; + } + } + let mut left = peak_bin; + for step in 1..bins { + let index = (peak_bin + bins - step) % bins; + if keep(index) { + left = index; + } else { + break; + } + } + + Some(PhaseWindow { + start: left as f64 / bins as f64, + end: ((right + 1) % bins) as f64 / bins as f64, + }) +} + +/// Detects the ON and OFF phase windows directly from a fold's histograms. +pub fn auto_windows(fold: &PhaseFold, floor_fraction: f64) -> Option<(PhaseWindow, PhaseWindow)> { + let on = phase_histogram(fold, Polarity::On); + let off = phase_histogram(fold, Polarity::Off); + let window_on = grow_window(&on, &off, floor_fraction)?; + let window_off = grow_window(&off, &on, floor_fraction)?; + Some((window_on, window_off)) +} + +/// Computes the ON/OFF Bernoulli response probabilities for one fold against the +/// given windows. `masked` holds pixels excluded inside the ROI. Returns `None` +/// when there are no complete cycles or no valid pixels. +pub fn response_probability( + fold: &PhaseFold, + window_on: PhaseWindow, + window_off: PhaseWindow, + roi: Roi, + masked: &HashSet<(u16, u16)>, +) -> Option<(f64, f64, usize, usize)> { + let cycles = fold.validation.cycle_count; + if cycles == 0 { + return None; + } + let masked_in_roi = masked.iter().filter(|(x, y)| roi.contains(*x, *y)).count(); + let valid_pixels = roi.area().saturating_sub(masked_in_roi); + if valid_pixels == 0 { + return None; + } + + let mut on_hits: HashSet<(usize, u16, u16)> = HashSet::new(); + let mut off_hits: HashSet<(usize, u16, u16)> = HashSet::new(); + for event in &fold.events { + if !roi.contains(event.x, event.y) || masked.contains(&(event.x, event.y)) { + continue; + } + match event.polarity { + Polarity::On if window_on.contains(event.phase) => { + on_hits.insert((event.cycle_index, event.x, event.y)); + } + Polarity::Off if window_off.contains(event.phase) => { + off_hits.insert((event.cycle_index, event.x, event.y)); + } + _ => {} + } + } + + let denom = valid_pixels as f64 * cycles as f64; + Some(( + on_hits.len() as f64 / denom, + off_hits.len() as f64 / denom, + cycles, + valid_pixels, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::phase::fold_events_free_running; + use crate::types::CameraEvent; + + fn event(timestamp_us: u64, x: u16, y: u16, polarity: Polarity) -> CameraEvent { + CameraEvent { + timestamp_us, + x, + y, + polarity, + } + } + + /// Builds a fold: every pixel in an N-wide ROI fires ON near phase 0.2 and + /// OFF near phase 0.7, for `cycles` cycles at period 1000 us. + fn respond_fold(cycles: u64, pixels: u16) -> PhaseFold { + let mut events = Vec::new(); + for cycle in 0..cycles { + let base = cycle * 1_000; + for x in 0..pixels { + events.push(event(base + 200, x, 0, Polarity::On)); + events.push(event(base + 700, x, 0, Polarity::Off)); + } + } + // One trailing event so the free-running fold spans `cycles` whole cycles. + events.push(event(cycles * 1_000 + 10, 0, 0, Polarity::On)); + fold_events_free_running(&events, 1_000.0).expect("whole cycles") + } + + fn windows_disjoint(on: &PhaseWindow, off: &PhaseWindow) -> bool { + (0..HIST_BINS).all(|bin| { + let phase = (bin as f64 + 0.5) / HIST_BINS as f64; + !(on.contains(phase) && off.contains(phase)) + }) + } + + #[test] + fn auto_windows_are_separated_and_classify_a_full_response() { + let fold = respond_fold(20, 4); + let (on, off) = auto_windows(&fold, 0.1).expect("windows"); + assert!(windows_disjoint(&on, &off)); + assert_ne!(on, off); + // Free-running fold anchors phase 0 to the first event (an ON), so the ON + // cluster sits at phase 0.0 and the OFF cluster half a cycle later at 0.5. + assert!(on.contains(0.0) && off.contains(0.5)); + assert!(!on.contains(0.5) && !off.contains(0.0)); + + let roi = Roi { + x0: 0, + y0: 0, + x1: 4, + y1: 1, + }; + let (q_on, q_off, _, valid) = + response_probability(&fold, on, off, roi, &HashSet::new()).expect("counts"); + assert_eq!(valid, 4); + assert!(q_on > 0.98 && q_off > 0.98, "q_on={q_on} q_off={q_off}"); + } + + #[test] + fn partial_pixel_response_gives_proportional_probability() { + let roi = Roi { + x0: 0, + y0: 0, + x1: 4, + y1: 1, + }; + let (on, off) = auto_windows(&respond_fold(20, 4), 0.1).expect("windows"); + + // Only 2 of 4 ROI pixels respond every cycle -> q_on ~ 0.5. + let weak = respond_fold(20, 2); + let (q_on_weak, _, _, valid) = + response_probability(&weak, on, off, roi, &HashSet::new()).expect("counts"); + assert_eq!(valid, 4); + assert!((q_on_weak - 0.5).abs() < 0.05, "q_on_weak={q_on_weak}"); + } + + #[test] + fn masked_pixels_are_subtracted_from_valid_count() { + let roi = Roi { + x0: 0, + y0: 0, + x1: 4, + y1: 1, + }; + let mut masked = HashSet::new(); + masked.insert((3_u16, 0_u16)); + let fold = respond_fold(10, 4); + let (on, off) = auto_windows(&fold, 0.1).expect("windows"); + let (_, _, _, valid) = response_probability(&fold, on, off, roi, &masked).expect("counts"); + assert_eq!(valid, 3); + } +} diff --git a/plugins/stage-a-a1/src/runtime.rs b/plugins/stage-a-a1/src/runtime.rs new file mode 100644 index 0000000..10a894d --- /dev/null +++ b/plugins/stage-a-a1/src/runtime.rs @@ -0,0 +1,12668 @@ +//! Live A1 recording coordinator. +//! +//! A1 has two jobs on the Stage-A bench, both deliberately thin: +//! +//! 1. **Recording coordinator.** One *Start recording* button records, for a fixed +//! duration, the camera **RAW** stream (host recording) and the photodiode **PDQ** +//! stream (leased `stage-a.photodiode` service) together, grouped under a +//! per-`(I_k, f)` measurement **id** and a shared `_` file stem, and +//! writes an A1 **config sidecar** (`.toml`) linking the two files with the +//! modulation settings, the photodiode-measured modulation depth `a`, the ROI, and +//! the trigger info needed to reproduce and analyse the run offline. A1 owns no +//! hardware and, outside the leased sweep below, never drives the Teensy — the +//! optical drive is armed in the modulation plugin; A1 only *reads* its published +//! settings into the sidecar. The **amplitude sweep** (ADR 010) is the one scoped +//! exception: per sweep point it retargets the armed drive's *depth* through the +//! leased modulation service (`SetOpticalDepth`), waits for the photodiode-measured +//! `a` to settle, and records the point through the same coordinator. The +//! **exact-event-count workflow** (ADR 013) reuses that path the other way round: +//! the `a₀` **lock** trims the *commanded* depth closed-loop until the photodiode +//! *measures* the one frozen depth `a₀`, and an **event-count point** replays that +//! trimmed depth under the same lease so one atomic frequency point is recorded at +//! exactly `a₀`. +//! +//! Where that `a` comes from is one operator setting, [`DepthSource`] (ADR 020). The +//! photodiode's measurement is the default and the source of record; it is also +//! fail-closed on firmware phase-0 markers, so a bench that never receives them can +//! fall back to the modulation owner's *commanded* calibrated depth and run the same +//! workflow open loop. Every artefact that carries an `a` carries which source +//! produced it. +//! +//! 2. **Live sanity quicklooks.** Folding the camera event stream on the modulation +//! period `T` (defined by the firmware phase-0 `EXT_TRIGGER`), it renders the +//! **rolling half-period response** `S_p(t)` (a live "are events appearing, is the +//! ON/OFF timing sane?" indicator) and the **response probability** `q_p` curve +//! (frozen-window Bernoulli statistic vs the measured `a`). The authoritative +//! `q_p(a, f)` fit is computed offline from the recordings; the live plot is a +//! quicklook. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use augur_plugin_api::{ + 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, 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}; +use crate::protocol; +use crate::rates::{rolling_half_period_response, RollingResponsePoint}; +use crate::response_curve::{auto_windows, response_probability, PhaseWindow, ResponsePoint, Roi}; +use crate::sensor; +use crate::types::{CameraEvent, Polarity}; + +const MODULATION_PLUGIN_ID: &str = "stage-a.modulation"; +const PHOTODIODE_PLUGIN_ID: &str = "stage-a.photodiode"; +const A1_PLUGIN_ID: &str = "stage-a.a1"; + +const STATUS_DATASET_ID: &str = "stage-a-a1.status"; +const STATUS_VIEW_ID: &str = "stage-a-a1.status.view"; +const ROLLING_DATASET_ID: &str = "stage-a-a1.rolling-response"; +const ROLLING_VIEW_ID: &str = "stage-a-a1.rolling-response.view"; +const RESPONSE_CURVE_DATASET_ID: &str = "stage-a-a1.response-curve"; +const RESPONSE_CURVE_VIEW_ID: &str = "stage-a-a1.response-curve.view"; +const A0_LOCK_DATASET_ID: &str = "stage-a-a1.a0-locks"; +const A0_LOCK_VIEW_ID: &str = "stage-a-a1.a0-locks.view"; + +/// Camera events retained for the live fold. At the bench event rates this is a +/// few seconds of history and keeps the fold cost bounded. +const MAX_EVENTS: usize = 4_000_000; +/// Sample points on the rolling half-period trace. +const ROLLING_SAMPLES: u64 = 256; +/// Default `q_p` window floor: grow each ON/OFF window until it falls to this +/// fraction of its histogram peak (or the opposite polarity takes over). +const DEFAULT_WINDOW_FLOOR: f64 = 0.10; +/// Default analysis window (ms) pulled from the retained EventStore each frame. +const DEFAULT_ANALYSIS_WINDOW_MS: i64 = 2_000; +/// Give up waiting for a control-plane reply after this many milliseconds. +const REPLY_TIMEOUT_MS: u64 = 15_000; +const CAMERA_RESTORE_MAX_ATTEMPTS: u8 = 3; +/// Upper bound on retained phase-0 markers in the no-EventStore fallback path. +const MAX_MARKERS: usize = 65_536; +/// Give up waiting for the photodiode-measured `a` to reach a sweep target +/// after this long and record anyway (the sidecar stores the measured value). +const SWEEP_SETTLE_TIMEOUT_MS: u64 = 30_000; + +/// Closed-loop trials the `a₀` lock spends on one frequency before it gives up +/// and reports the best commanded depth it reached. +const A0_LOCK_MAX_TRIALS: u32 = 8; +/// Per-trial cap on the multiplicative correction of the commanded depth, so one +/// noisy photodiode reading cannot slam the drive across its whole range. +const A0_LOCK_MAX_STEP_RATIO: f64 = 2.0; +/// Independent photodiode readings taken per trial (fewer only when the +/// measurement deadline hits first). Their *median* is the trial's value and +/// their spread is the stability check — one estimator window already averages +/// many cycles, so repeating it is about catching drift, not reducing noise. +const A0_LOCK_SAMPLES: usize = 3; +/// Fraction of one estimator window that must pass between two readings for +/// them to count as independent. Consecutive `service_revision`s share almost +/// their whole window, so sampling per revision alone measures the publisher's +/// tick rate rather than the drive. +const A0_LOCK_SAMPLE_SPACING: f64 = 0.5; +/// Spread across a trial's readings, relative to its tolerance, above which the +/// operating point is called unstable instead of locked. A drifting `a` that +/// happens to cross the target on one reading is not a lock. +const A0_LOCK_MAX_SPREAD_TOLERANCES: f64 = 2.0; +/// Clipping fraction above which a lock's measured `a` is called out as +/// unreliable in the operator message. +/// +/// Deliberately far below the estimator's own `MAX_CLIP_FRACTION` (1 ‰, above +/// which it withholds `a` altogether): a threshold at or above that one could +/// never fire, because a published summary has already passed it. +const A0_LOCK_CLIP_WARNING: f64 = 0.000_2; +/// Closed range of commanded optical depths the modulation owner accepts. +const COMMANDED_A_MIN: f64 = 0.01; +const COMMANDED_A_MAX: f64 = 6.0; +/// Relative distance within which two frequencies are the same sweep point. +const FREQUENCY_MATCH_FRACTION: f64 = 0.01; +/// Lock table persisted in the output folder, so found depths survive a restart. +const A0_LOCK_FILE: &str = "a0_locks.json"; +/// Frequency points a single run may visit, before the interleaved references. +const FREQ_SWEEP_MAX_POINTS: usize = 64; +/// How long the frequency sweep waits for the phase-0 trigger to report the +/// frequency it just commanded, before it gives that point up. +/// +/// The drive is a firmware table rebuild plus however long the camera takes to +/// deliver two markers at the new period — at 0.1 Hz that is 20 s on its own. +const FREQ_CONFIRM_BASE_MS: u64 = 20_000; +/// Marker periods that must elapse at the *new* frequency before the sweep +/// believes the measured period. Below this the mean spacing is still a mixture +/// 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) +} + +/// Commanded optical depth clamped to what the modulation owner accepts. +fn clamp_commanded_a(depth_a: f64) -> f64 { + if depth_a.is_finite() { + depth_a.clamp(COMMANDED_A_MIN, COMMANDED_A_MAX) + } else { + COMMANDED_A_MIN + } +} + +/// Wire encoding of a commanded optical depth for `SetOpticalDepth`. +fn depth_a_milli(depth_a: f64) -> u32 { + (depth_a * 1_000.0).round().clamp(0.0, u32::MAX as f64) as u32 +} + +/// Whether two frequencies name the same sweep point (drive vs trigger readback +/// never agree to the last digit). +fn same_frequency(left: f64, right: f64) -> bool { + let scale = left.abs().max(right.abs()); + (left - right).abs() <= (scale * FREQUENCY_MATCH_FRACTION).max(1e-6) +} + +fn frequency_label(hz: f64) -> String { + format!("{hz:.3} Hz") +} + +/// A stretch of bench time in the largest unit that still reads as a number an +/// operator can act on: seconds below two minutes, then minutes, then hours. +fn format_bench_time(seconds: f64) -> String { + let seconds = seconds.max(0.0); + if seconds < 120.0 { + format!("{seconds:.0} s") + } else if seconds < 5_400.0 { + format!("{:.0} min", seconds / 60.0) + } else { + format!("{:.1} h", seconds / 3_600.0) + } +} + +/// Upper-cases the first character, so a blocker written as a sentence fragment +/// ("the total power …") can also stand as its own sentence in the status panel. +fn capitalize_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// Compact file-safe frequency tag for an event-count point's stem: +/// `50 Hz → f50Hz`, `0.5 Hz → f0p5Hz`. +fn frequency_tag(hz: f64) -> String { + let mut text = format!("{hz:.3}"); + while text.ends_with('0') { + text.pop(); + } + if text.ends_with('.') { + text.pop(); + } + format!("f{}Hz", text.replace('.', "p")) +} + +trait RecordingControl { + fn request_service(&mut self, request: &PluginServiceRequest); + fn request_host(&mut self, request: &HostCommandRequest); +} + +impl RecordingControl for PluginControlContext<'_> { + fn request_service(&mut self, request: &PluginServiceRequest) { + let _ = PluginControlContext::request_service(self, request); + } + + fn request_host(&mut self, request: &HostCommandRequest) { + let _ = PluginControlContext::request_host(self, request); + } +} + +/// Forwards momentary button presses across the host's UI-mirror → live-worker +/// settings snapshot. A click arrives as `true` on the clicked instance; the +/// other instance only ever sees the snapshot value from `get_setting`, so the +/// press is transported as a monotonic counter and a counter advance counts as +/// one press edge. 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 { + /// Interprets a settings write to this button; returns true on a press edge. + 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) + } +} + +/// Where the coordinated recording is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecPhase { + Idle, + /// Camera start sent; waiting until the host has switched into recording. + StartingCamera, + /// Camera is running; reconnecting the photodiode after the pipeline switch. + ConnectingPhotodiode, + /// AcquireLease sent to the photodiode; waiting for the grant. + AcquiringLease, + /// Camera is running; waiting for the photodiode PDQ start receipt. + StartingPhotodiode, + /// Camera RAW + photodiode PDQ recording are both in flight. + Running, + /// Photodiode finalize sent; camera keeps recording until PDQ is closed. + StoppingPhotodiode, + /// PDQ is closed; waiting for the host camera finalize receipt. + StoppingCamera, +} + +/// What a recording is for within one `(I_k, f)` measurement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecRole { + /// One amplitude point of the sweep. + Normal, + /// Bright reference that freezes the ON/OFF windows for the whole row. + Pilot, + /// Unmodulated (`a≈0`) reference that gives the false-response floor. + Background, + /// One atomic frequency point of the exact-event-count workflow, recorded at + /// the one frozen depth `a₀` the lock found for that frequency. + EventCount, +} + +impl RecRole { + /// Filename-stem suffix, empty for a normal sweep point. + fn suffix(self) -> &'static str { + match self { + RecRole::Normal => "", + RecRole::Pilot => "_pilot", + RecRole::Background => "_background", + RecRole::EventCount => "_ec", + } + } + + fn label(self) -> &'static str { + match self { + RecRole::Normal => "point", + RecRole::Pilot => "pilot", + RecRole::Background => "background", + RecRole::EventCount => "event-count point", + } + } +} + +/// One coordinated `(camera RAW + photodiode PDQ + sidecar)` recording. +struct Recording { + phase: RecPhase, + role: RecRole, + id: String, + stem: String, + folder: String, + duration_s: u64, + start_unix_ms: u64, + last_activity_ms: u64, + lease_id: LeaseId, + stop_requested: bool, + // outstanding request-id correlation + connect_req: u64, + lease_req: u64, + cam_start_req: u64, + cam_stop_req: u64, + pd_begin_req: u64, + pd_finalize_req: u64, + // captured receipts + connect_accepted: bool, + lease_granted: bool, + cam_raw_path: Option, + cam_finalized_path: Option, + /// True only for a complete host finalization receipt, not a partial file. + cam_complete: bool, + /// The host rejected StartRecording — skip the stop and don't wait for a + /// finalize receipt. + cam_rejected: bool, + pd_pdq_path: Option, + pd_sidecar_path: Option, + /// Compacted sensor readout written into the measurement folder, if the + /// host produced any telemetry for this run. + sensor_readout_path: Option, + pd_finalized: bool, + pd_valid: bool, + /// The photodiode rejected BeginRecording — skip the finalize and don't + /// wait for its receipt. + pd_rejected: bool, + /// First thing that went wrong, kept verbatim so the closing message names + /// the cause instead of only reporting that the run was incomplete. + failure: Option, + /// The newest optical summary seen while this recording was running. + /// + /// The sidecar's optical section describes the light *during the recording*, + /// so it is latched here rather than re-read live when the metadata is + /// written. Between the last sample and that write sit the photodiode + /// finalize, the camera finalize and a gather that may copy a multi-gigabyte + /// RAW across volumes — all of it blocking this plugin's own control tick, + /// so no snapshot arrives while it runs. Read live, the owner's 2 s + /// freshness budget then expires against wall-clock time that the recording + /// spent finalizing, and a finished recording lost its sidecar for having + /// been *large* (ADR 034). + optical: Option, +} + +/// Where the amplitude sweep is within its per-point cycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SweepPhase { + /// AcquireLease sent to the modulation owner; waiting for the grant. + AcquiringLease, + /// SetOpticalDepth for the current point sent; waiting for Applied. + SettingDepth, + /// Waiting for the photodiode-measured `a` to settle at the target. + Settling, + /// The per-point recording coordinator owns this phase. + Recording, +} + +/// What a leased sweep is for: the amplitude sweep of one `(I_k, f)` row, or one +/// atomic frequency point of the exact-event-count workflow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SweepKind { + Amplitude, + EventCount, +} + +impl SweepKind { + fn role(self) -> RecRole { + match self { + SweepKind::Amplitude => RecRole::Normal, + SweepKind::EventCount => RecRole::EventCount, + } + } +} + +/// One sweep point: what the drive is *commanded* to, and the +/// photodiode-measured `a` that point is supposed to produce. +/// +/// The amplitude sweep asks for its own value open-loop, trusting the Pockels +/// calibration, so both are equal. An event-count point replays a commanded +/// depth the `a₀` lock already trimmed closed-loop against the *measured* depth, +/// so there its commanded depth is deliberately **not** the depth it expects to +/// measure — that difference is the drive roll-off the lock absorbed. +#[derive(Debug, Clone, Copy, PartialEq)] +struct SweepPoint { + commanded_a: f64, + expected_a: f64, +} + +/// One "record every point of the amplitude range" run: per point the sweep +/// retargets the leased modulation drive, waits for the photodiode-measured +/// `a` to settle, and hands off to the normal recording coordinator. +struct Sweep { + phase: SweepPhase, + kind: SweepKind, + /// The points to record, in order. + points: Vec, + /// The `a₀` lock an event-count point replays; `None` for the amplitude sweep. + lock: Option, + index: usize, + lease_id: LeaseId, + lease_granted: bool, + lease_req: u64, + /// False when the lease belongs to an enclosing run (the frequency sweep): + /// then this run neither acquires nor releases it, so the operator's drive + /// settings stay locked out across the whole ladder rather than only + /// between its points. + owns_lease: bool, + depth_req: u64, + depth_applied: bool, + /// Instant the measured `a` first satisfied the tolerance, for the dwell. + settled_since_ms: Option, + /// Give-up deadline for the settle phase. + settle_deadline_ms: u64, + /// Whether the current point's recording actually started (vs. was + /// refused by validation before it began). + point_started: bool, + /// Set only on the branch that runs out of points with every one recorded. + /// + /// An enclosing frequency ladder has to know whether the inner run it + /// handed a rung to *finished* or gave up, and it cannot tell from the + /// recording coordinator: a sweep that aborts on point 4 of 5 leaves + /// `recording_completed_ok` true from point 3. + completed_ok: bool, + last_activity_ms: u64, + stop_requested: bool, +} + +impl Sweep { + fn point(&self) -> SweepPoint { + self.points.get(self.index).copied().unwrap_or(SweepPoint { + commanded_a: 0.0, + expected_a: 0.0, + }) + } + + /// The photodiode-measured `a` this point must settle at. + fn target_a(&self) -> f64 { + self.point().expected_a + } + + /// The depth the drive is commanded to for this point. + fn commanded_a(&self) -> f64 { + self.point().commanded_a + } + + fn total(&self) -> usize { + self.points.len() + } +} + +/// Where the `a₀` lock is within its current closed-loop trial. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum A0LockPhase { + /// AcquireLease sent to the modulation owner; waiting for the grant. + AcquiringLease, + /// SetOpticalDepth for the current trial sent; waiting for Applied. + SettingDepth, + /// Settling, then averaging fresh photodiode readings for this trial. + Measuring, +} + +/// One "find the commanded depth that makes the photodiode measure `a₀` at this +/// frequency" run. Iterates `commanded ← commanded · a₀/measured` under a +/// modulation lease and never records anything itself. +struct A0Lock { + phase: A0LockPhase, + /// The photodiode-measured log contrast the operator froze for the sweep. + target_a: f64, + /// Convergence band on `|measured − target|`. + tolerance: f64, + /// The depth the current trial commands. + commanded_a: f64, + /// Frequency this lock belongs to, captured when it started. + frequency_hz: f64, + /// 1-based trial counter, bounded by `A0_LOCK_MAX_TRIALS`. + trial: u32, + /// Independent photodiode readings collected for the current trial. + samples: Vec, + /// `service_revision` of the newest photodiode summary already sampled, so a + /// slow publisher is not sampled once per control tick. + sampled_revision: Option, + /// Earliest instant the next reading may be taken: the settle dwell before + /// the first, then one sample spacing after each. + measure_from_ms: u64, + /// Estimator window length (ms) the photodiode reported when this trial + /// commanded its depth. Both the dwell and the sample spacing derive from + /// it, because a reading taken sooner still contains the previous depth. + window_ms: u64, + /// Give-up deadline for the current trial's measurement. + deadline_ms: u64, + lease_id: LeaseId, + lease_granted: bool, + lease_req: u64, + /// See [`Sweep::owns_lease`]. + owns_lease: bool, + depth_req: u64, + depth_applied: bool, + last_activity_ms: u64, + stop_requested: bool, +} + +/// Where the modulation depth `a` that A1 works from comes from. +/// +/// `a = ln(I_max / I_min)` is a property of the *light*, so the photodiode is +/// the only source that can state it (ADR 011, and the estimator's own module +/// docs). That is the default and stays the source of record. +/// +/// The bench cannot always deliver it, though. The photodiode withholds `a` +/// whenever its estimator window cannot be proven to cover whole modulation +/// cycles, which needs firmware phase-0 markers on the stream port; without +/// them — no trigger cable, a firmware build that does not stamp them, a +/// frequency low enough that two cycles do not fit in the ring — every gate +/// that needs `a` refuses, and the whole a₀/sweep workflow is unreachable even +/// though the drive is calibrated and running. +/// +/// [`DepthSource::Commanded`] is the pragmatic way through: the modulation +/// owner already inverts a *measured* Pockels transfer curve (`V_null`, `Vπ`) +/// to command a depth, and publishes that depth as +/// `OpticalDriveStateV1::depth_a_milli`. Taking `a` from there is open loop — +/// it is what the drive asked the cell for, not what the light did, so it +/// carries the calibration's error and any drift since — but it is a +/// calibrated number, not a datasheet one, and it lets the workflow run. Every +/// artefact that records an `a` records which source produced it, so a run +/// taken this way is never mistaken for a measured one. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum DepthSource { + /// The photodiode's measured excitation log-contrast. + #[default] + Photodiode, + /// The depth the modulation owner's calibrated optical drive is commanding. + Commanded, +} + +impl DepthSource { + fn from_index(index: u64) -> Self { + match index { + 1 => Self::Commanded, + _ => Self::Photodiode, + } + } + + fn index(self) -> u64 { + match self { + Self::Photodiode => 0, + Self::Commanded => 1, + } + } + + /// Machine-readable tag written into sidecars and the lock table. + fn label(self) -> &'static str { + match self { + Self::Photodiode => "photodiode_measured", + Self::Commanded => "modulation_commanded", + } + } + + /// The verb the panel uses for a depth from this source: "measured a" is a + /// claim about the light and must not be printed for a commanded one. + fn verb(self) -> &'static str { + match self { + Self::Photodiode => "measured", + Self::Commanded => "commanded", + } + } + + /// Whether holding one `a₀` across frequencies requires the closed-loop + /// search (ADR 013), or whether commanding it is enough (ADR 021). + /// + /// The lock exists for one reason: the Pockels inversion is measured once + /// and is therefore *static*, so the depth it actually delivers rolls off + /// as `f` rises. Holding a **measured** `a₀` across a frequency ladder + /// means re-finding the commanded depth that produces it at every point — + /// `a_cmd ← a_cmd · a₀/a_measured`, a couple of trials per frequency. + /// + /// None of that applies to a **commanded** depth, because it *is* the + /// number being commanded. A search would command `a₀`, read back `a₀`, + /// converge on trial one, and store one identical row per frequency: pure + /// ceremony between the operator and a recording, and worse than nothing + /// once a stale row from a measured run warm-starts it (`begin_a0_lock`) + /// and drags a closed-loop number into an open-loop point. + fn needs_a0_lock(self) -> bool { + matches!(self, Self::Photodiode) + } +} + +/// The result of one lock: the commanded depth that produced the frozen `a₀` at +/// one frequency. Persisted in `a0_locks.json` and replayed by event-count points. +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +struct A0LockPoint { + frequency_hz: f64, + /// The frozen `a₀` the lock aimed at. + target_a: f64, + /// What the drive must be commanded to in order to *measure* `target_a`. + commanded_a: f64, + /// The `a` observed over the final trial, from `depth_source`. + measured_a: f64, + trials: u32, + /// False when the lock ran out of trials or hit a drive limit; such a row is + /// kept for the record but never arms an event-count recording. + converged: bool, + locked_at_unix_ms: u64, + low_clip_fraction: Option, + high_clip_fraction: Option, + /// Which source produced `measured_a`. Lock tables written before the + /// setting existed were all photodiode-measured, which is the default. + #[serde(default)] + depth_source: DepthSource, +} + +/// Order the planned frequencies are actually visited in. +/// +/// A Bode ladder recorded strictly low-to-high confounds frequency with +/// everything that drifts monotonically during the block — bleaching, thermal +/// drift of the Pockels bias, source ageing. The A1 checklist therefore asks +/// for a randomised or alternating schedule, and for the seed to be part of the +/// frozen session plan; both are reproduced in the sidecar. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum FreqOrder { + #[default] + Ascending, + Descending, + /// Lowest, highest, second lowest, second highest, … — a deterministic + /// alternation that decorrelates frequency from time without a seed. + Alternating, + /// Seeded shuffle; the seed is an operator setting and is recorded. + Random, +} + +impl FreqOrder { + fn from_index(index: u64) -> Self { + match index { + 1 => Self::Descending, + 2 => Self::Alternating, + 3 => Self::Random, + _ => Self::Ascending, + } + } + + fn index(self) -> u64 { + match self { + Self::Ascending => 0, + Self::Descending => 1, + Self::Alternating => 2, + Self::Random => 3, + } + } + + fn label(self) -> &'static str { + match self { + Self::Ascending => "ascending", + Self::Descending => "descending", + Self::Alternating => "alternating", + Self::Random => "random", + } + } +} + +/// What the frequency ladder records at each of its frequencies. +/// +/// The ladder is an outer loop over `f` that leases the drive once and hands +/// each confirmed frequency to an inner run. What that inner run *is* is the +/// only thing separating the bench's two multi-frequency experiments, so it is +/// one enum rather than two copies of the ladder. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum FreqSweepMode { + /// One event-count point at the frozen depth `a₀` (ADR 013 / ADR 014): + /// the same depth everywhere, so a change in the event count is a + /// frequency effect. + #[default] + A0Point, + /// The whole depth sweep over `[min_a, max_a]` at every frequency + /// (ADR 023) — one `q_p(a)` curve per `f`, i.e. the `q_p(a, f)` surface + /// the offline `a50(f)` fit is read from. + DepthSweep, +} + +impl FreqSweepMode { + fn label(self) -> &'static str { + match self { + Self::A0Point => "a₀ point", + Self::DepthSweep => "depth sweep", + } + } + + /// Whether a rung has to find a depth before it can record one. + /// + /// Only the `a₀` experiment does: it replays a single depth that something + /// has to have chosen. A depth sweep commands every `a` in its range + /// itself and settles on each, so there is nothing for a lock to add — in + /// either depth source. + fn needs_armed_depth(self) -> bool { + matches!(self, Self::A0Point) + } +} + +/// One stop of the frequency sweep. +#[derive(Debug, Clone, Copy, PartialEq)] +struct FreqSweepPoint { + frequency_hz: f64, + /// True for the interleaved low-frequency reference repeats, which exist to + /// expose drift across the block rather than to add a new frequency. + is_reference: bool, +} + +/// Where the multi-frequency run is within its per-point cycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FreqSweepPhase { + /// AcquireLease sent to the modulation owner; waiting for the grant. + AcquiringLease, + /// SetDriveFrequency for the current point sent; waiting for Applied. + SettingFrequency, + /// Waiting for the phase-0 trigger to actually report the new period. + ConfirmingFrequency, + /// The `a₀` lock owns this phase. + Locking, + /// The one-point event-count sweep owns this phase. + Recording, +} + +/// One "find a₀ and record a point at every frequency" run. +/// +/// It is a supervisor, not a third copy of the machinery: per point it +/// retargets the leased drive's frequency, waits for the trigger to confirm it, +/// then hands off to the unchanged `a₀` lock and the unchanged event-count +/// point — both running on *this* run's lease, so the operator's drive settings +/// stay locked out from the first frequency to the last. +struct FreqSweep { + phase: FreqSweepPhase, + /// What each rung records — see [`FreqSweepMode`]. + mode: FreqSweepMode, + points: Vec, + index: usize, + lease_id: LeaseId, + lease_granted: bool, + lease_req: u64, + freq_req: u64, + freq_applied: bool, + /// Give-up deadline for the trigger to confirm the commanded frequency. + confirm_deadline_ms: u64, + /// Why the current point is being given up, when that was decided in a + /// service reply rather than in the tick. Carries the owner's own wording + /// through to the skip message instead of replacing it with a timeout. + skip_reason: Option, + /// Points whose `a₀` could not be locked or whose recording failed. Kept + /// and reported rather than aborting the ladder: the remaining frequencies + /// are still worth having, and the lock table already carries the detail. + failed: Vec, + recorded: usize, + order: FreqOrder, + seed: u64, + last_activity_ms: u64, + stop_requested: bool, +} + +impl FreqSweep { + fn point(&self) -> Option { + self.points.get(self.index).copied() + } + + fn frequency_hz(&self) -> f64 { + self.point().map(|point| point.frequency_hz).unwrap_or(0.0) + } +} + +/// 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 + /// them to come back Applied. + Retargeting, + /// Dwelling for the point's own settle time before the recording starts. + 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 +/// each, on a single lease held for the whole file. +/// +/// It is a supervisor like [`FreqSweep`], not a fourth copy of the recording +/// machinery: it moves the drive and then hands off to the same +/// `begin_recording` every button uses. The difference from the sweep buttons +/// is that a protocol names *every* axis for *every* point, so nothing is left +/// implicitly at whatever the operator last armed. +struct ProtocolRun { + plan: protocol::Protocol, + source_path: String, + source_sha256: String, + source_text: String, + phase: ProtocolPhase, + index: usize, + lease_id: LeaseId, + 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, + 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 + /// parameters the bench was not actually at. + 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 + /// having — and kept *with the reason*, because the per-point message is + /// overwritten by the next point within the same tick. Without this the + /// only thing an unattended run could report at the end was a count. + failed: Vec<(usize, String)>, + recorded: usize, + last_activity_ms: u64, + stop_requested: bool, + /// Why the current point is being given up, when that was decided in a + /// service reply rather than in the tick. + skip_reason: Option, +} + +impl ProtocolRun { + 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 { + locks: Vec, +} + +pub struct StageAA1Plugin { + enabled: bool, + runtime_role: PluginRuntimeRole, + /// While true, camera events are folded into the live quicklooks. This does + /// not record anything — recording is the separate coordinator below. + live: bool, + modulation: Option, + photodiode: Option, + camera_events: Vec, + /// Reusable buffer for exact events pulled from the retained EventStore. + event_scratch: Vec, + /// Sliding analysis window (ms) for the live fold. + analysis_window_ms: i64, + /// Rising `EXT_TRIGGER` timestamps (firmware phase-0 sync). When present these + /// anchor the fold to the drive on the camera clock; empty falls back to the + /// free-running fold on `T`. + camera_markers_us: Vec, + frame_width: u16, + frame_height: u16, + /// Memoised [`StageAA1Plugin::current_fold`], keyed on its inputs. + fold_cache: RefCell)>>, + // -- 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. + /// + /// Provenance only — never an input to any result. The host publishes it + /// solely while streaming from a camera with a monitoring block, so replay + /// and offline re-runs of the same data carry `None`, and a plugin whose + /// *answers* depended on it would disagree with itself between the two. + sensor: Option, + /// [`Self::sensor`] frozen when the current recording started. + /// + /// Written into the sidecar in preference to the live value: these drift + /// (the die warms, the room lights change), so the number that belongs to a + /// run is the one that held when it began, not the one that happens to be + /// current when the file is finalized seconds later. + sensor_at_start: Option, + // -- response curve (auto-windowed Bernoulli q_p) -- + /// Window floor as a fraction of the ON/OFF histogram peak (see `auto_windows`). + window_floor: f64, + response_points: Vec, + /// ON/OFF windows frozen from the pilot for the current measurement row. When + /// set they override the per-fold auto-windows so the row's `q_p` is + /// consistent; loaded from the pilot's sidecar in the measurement folder. + pilot_windows: Option<(PhaseWindow, PhaseWindow)>, + /// Background floor `(q0_on, q0_off)` from the `a≈0` reference. + background_floor: Option<(f64, f64)>, + // -- recording coordinator -- + output_folder: String, + measurement_id: String, + /// Where the depth `a` comes from — the photodiode's measurement, or the + /// modulation owner's commanded depth. See [`DepthSource`]. + depth_source: DepthSource, + /// Sweep range `[min_a, max_a]` for this `(I_k, f)` row (automation template). + min_a: f64, + max_a: f64, + duration_s: i64, + recording: Recording, + /// Whether the most recent recording reached its finalize path (vs. being + /// aborted); the sweep uses this to decide between advancing and stopping. + recording_completed_ok: bool, + request_seq: u64, + pd_revision_seq: u64, + /// Role latched by the Start/Pilot/Background buttons, consumed next tick. + pending_role: Option, + /// `(folder, id)` last scanned for pilot/background sidecars, so the folder is + /// re-read only when the measurement changes. + loaded_key: Option<(String, String)>, + /// One-line operator feedback about the most recent recording action. + message: String, + dataset_generation: u64, + // -- amplitude sweep -- + /// Number of sweep points across `[min_a, max_a]`. + sweep_count: i64, + /// Dwell the measured `a` must hold the target tolerance before recording. + settle_s: f64, + /// Latched by the Start sweep button, consumed next control tick. + sweep_pending: bool, + sweep: Option, + /// Whether the most recent inner sweep ran out of points with all of them + /// recorded, as opposed to giving up. Read by the frequency ladder to + /// decide between advancing and skipping the rung. See [`Sweep::completed_ok`]. + last_sweep_completed_ok: bool, + // -- exact event-count depth a₀ (ADR 013) -- + /// The one photodiode-measured log contrast held across the frequency sweep. + a0_target: f64, + /// Convergence band on `|measured a − a₀|` for the lock and for an + /// event-count point's settle check. + a0_tolerance: f64, + /// Latched by the Find a₀ button, consumed next control tick. + a0_lock_pending: bool, + /// Latched by the Record a₀ point button, consumed next control tick. + a0_point_pending: bool, + a0_lock: Option, + // -- multi-frequency run over the a₀ ladder -- + /// Frequency range and resolution of the planned ladder. Log-spaced: a Bode + /// ladder is read per decade, not per hertz. + min_f: f64, + max_f: f64, + freq_count: u32, + freq_order: FreqOrder, + freq_seed: u64, + /// Insert the lowest planned frequency again after every N points, so drift + /// across the block shows up as a disagreement between its repeats. 0 = off. + freq_reference_every: u32, + /// Latched by whichever frequency-ladder button was pressed, carrying what + /// that button asked for. Consumed next control tick. + freq_sweep_pending: Option, + freq_sweep: Option, + // -- declarative protocol runs -- + /// Path of the TOML protocol file to run. + protocol_path: String, + /// Latched by the Run protocol button, consumed next control tick. + protocol_pending: bool, + /// Recording length for the *next* run, overriding the panel's setting. + /// + /// The protocol's own `duration_s` has to win, or a survey's lengths would + /// silently come from the UI and the file would not describe what it + /// produced. It cannot be written into `duration_s` itself: the host + /// re-applies the whole settings snapshot from the UI mirror on every pass, + /// so an operator setting assigned on the worker is reverted within the + /// frame — the same trap that made the modulation Apply button look dead. + pending_duration_s: Option, + protocol: Option, + /// One converged (or attempted) lock per frequency, newest per frequency + /// wins; mirrored to `a0_locks.json` in the output folder. + a0_locks: Vec, + /// 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, + press_background: PressLatch, + press_stop: PressLatch, + press_sweep: PressLatch, + press_clear: PressLatch, + press_record_point: PressLatch, + press_clear_curve: PressLatch, + press_find_a0: PressLatch, + press_freq_sweep: PressLatch, + press_freq_depth_sweep: PressLatch, + press_run_protocol: PressLatch, + press_record_a0: PressLatch, + press_clear_a0: PressLatch, +} + +impl Default for StageAA1Plugin { + fn default() -> Self { + Self { + enabled: false, + runtime_role: PluginRuntimeRole::UiMirror, + live: false, + modulation: None, + photodiode: None, + camera_events: Vec::new(), + event_scratch: Vec::new(), + analysis_window_ms: DEFAULT_ANALYSIS_WINDOW_MS, + camera_markers_us: Vec::new(), + fold_cache: RefCell::new(None), + frame_width: 0, + 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, + response_points: Vec::new(), + pilot_windows: None, + background_floor: None, + output_folder: String::new(), + measurement_id: generate_measurement_id(), + depth_source: DepthSource::Photodiode, + min_a: 0.0, + max_a: 2.0, + duration_s: 10, + recording: Recording::idle(), + recording_completed_ok: false, + request_seq: 0, + pd_revision_seq: 0, + pending_role: None, + loaded_key: None, + message: String::new(), + dataset_generation: 1, + sweep_count: 5, + settle_s: 2.0, + sweep_pending: false, + sweep: None, + last_sweep_completed_ok: false, + // No numerical a₀ is frozen in the repository: this default is a + // placeholder the operator replaces with the scout result. + a0_target: 0.5, + a0_tolerance: 0.02, + a0_lock_pending: false, + a0_point_pending: false, + a0_lock: None, + min_f: 1.0, + max_f: 100.0, + freq_count: 7, + freq_order: FreqOrder::Alternating, + freq_seed: 1, + freq_reference_every: 0, + freq_sweep_pending: None, + freq_sweep: None, + protocol_path: String::new(), + protocol_pending: false, + pending_duration_s: None, + 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(), + press_stop: PressLatch::default(), + press_sweep: PressLatch::default(), + press_clear: PressLatch::default(), + press_record_point: PressLatch::default(), + press_clear_curve: PressLatch::default(), + press_find_a0: PressLatch::default(), + press_freq_sweep: PressLatch::default(), + press_freq_depth_sweep: PressLatch::default(), + press_run_protocol: PressLatch::default(), + press_record_a0: PressLatch::default(), + press_clear_a0: PressLatch::default(), + } + } +} + +impl Recording { + fn idle() -> Self { + Self { + phase: RecPhase::Idle, + role: RecRole::Normal, + id: String::new(), + stem: String::new(), + folder: String::new(), + duration_s: 0, + start_unix_ms: 0, + last_activity_ms: 0, + lease_id: LeaseId::new(String::new()), + stop_requested: false, + connect_req: 0, + lease_req: 0, + cam_start_req: 0, + cam_stop_req: 0, + pd_begin_req: 0, + pd_finalize_req: 0, + connect_accepted: false, + lease_granted: false, + cam_raw_path: None, + cam_finalized_path: None, + sensor_readout_path: None, + cam_complete: false, + cam_rejected: false, + pd_pdq_path: None, + pd_sidecar_path: None, + pd_finalized: false, + pd_valid: false, + pd_rejected: false, + failure: None, + optical: None, + } + } + + /// Records the first failure only: later fallout (a stop that finds nothing + /// to finalize) must not mask the reason the run went wrong. + fn fail(&mut self, reason: impl Into) { + if self.failure.is_none() { + self.failure = Some(reason.into()); + } + } + + fn is_active(&self) -> bool { + self.phase != RecPhase::Idle + } + + fn state_label(&self) -> &'static str { + match self.phase { + RecPhase::Idle => "not recording", + RecPhase::StartingCamera => "starting the camera", + RecPhase::ConnectingPhotodiode => "connecting the photodiode", + RecPhase::AcquiringLease => "reserving the photodiode", + RecPhase::StartingPhotodiode => "starting the photodiode", + RecPhase::Running => "recording", + RecPhase::StoppingPhotodiode => "saving the photodiode data", + RecPhase::StoppingCamera => "saving the camera data", + } + } + + /// Seconds remaining in the fixed-duration window, when running. + fn remaining_s(&self, now_ms: u64) -> Option { + if self.phase != RecPhase::Running { + return None; + } + let elapsed_ms = now_ms.saturating_sub(self.start_unix_ms); + let total_ms = self.duration_s.saturating_mul(1_000); + Some(total_ms.saturating_sub(elapsed_ms) / 1_000) + } +} + +/// Fingerprint of everything the phase fold is computed from. Cheap to build +/// (no scan of the event buffer) and exact enough that a stale fold cannot +/// survive a change to any input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FoldKey { + period_us_bits: u64, + event_count: usize, + first_event_us: Option, + last_event_us: Option, + marker_count: usize, + first_marker_us: Option, + last_marker_us: Option, + roi: Option, + masked_count: usize, +} + +impl StageAA1Plugin { + fn bump(&mut self) { + self.dataset_generation = self.dataset_generation.wrapping_add(1); + } + + /// Releases the live analysis buffers and the fold memoised from them. + /// Returns whether anything was actually held. + /// + /// Assigning fresh `Vec`s rather than calling `clear()` is deliberate: the + /// event buffer reaches millions of entries, and `clear()` keeps every byte + /// of that capacity reserved. + /// + /// This is what switching Live analysis off has to do. Merely stopping the + /// *filling* left the last window's events live for every later + /// `current_fold()`, and the control tick re-folds them — so turning the + /// toggle off left the plugin folding millions of stale events on every + /// tick, forever. That is the lag that outlived the switch. + fn drop_live_buffers(&mut self) -> bool { + let held = !self.camera_events.is_empty() || !self.camera_markers_us.is_empty(); + self.camera_events = Vec::new(); + self.event_scratch = Vec::new(); + self.camera_markers_us = Vec::new(); + self.fold_cache.replace(None); + held + } + + /// One Stop for everything the Record section can start. + /// + /// Whatever is in flight — a single recording, a depth sweep, an `a₀` + /// lock, a frequency ladder or a protocol — asks it to wind down at its + /// next safe point, and any latched-but-not-yet-started press is dropped so + /// the stop is not immediately undone by a queued start. + fn request_stop(&mut self) { + if self.recording.is_active() { + self.recording.stop_requested = true; + } + if let Some(sweep) = self.sweep.as_mut() { + sweep.stop_requested = true; + self.message = "Sweep stop requested".into(); + } + if let Some(lock) = self.a0_lock.as_mut() { + lock.stop_requested = true; + self.message = "a₀ lock stop requested".into(); + } + // Before the protocol, so the outer runner's wording wins over the + // child it happens to be driving. + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.stop_requested = true; + self.message = "Frequency sweep stop requested".into(); + } + if let Some(protocol) = self.protocol.as_mut() { + protocol.stop_requested = true; + self.message = "Protocol stop requested".into(); + } + self.sweep_pending = false; + self.a0_lock_pending = false; + self.a0_point_pending = false; + self.freq_sweep_pending = None; + 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(); + self.bump(); + } + + /// Reports a failure and remembers it as the run's cause, so the closing + /// message can name it after the coordinator has unwound. The first cause + /// wins: it is the specific one, and later arms only see the fallout. + fn note_failure(&mut self, message: impl Into) { + if self.recording.failure.is_some() { + return; + } + let message = message.into(); + self.recording.fail(message.clone()); + self.note(message); + } + + /// The modulation period `T` in microseconds: measured from the phase-0 + /// markers when present (the trigger *defines* the frequency, latency- + /// invariant), otherwise the modulation plugin's acknowledged waveform. + fn period_us(&self) -> Option { + if let Some(period) = self.measured_period_us() { + return Some(period); + } + let hz = self.acknowledged_frequency_hz()?; + (hz > 0.0).then(|| 1_000_000.0 / hz) + } + + /// Modulation frequency implied by [`Self::period_us`]. + fn frequency_hz(&self) -> Option { + self.period_us().map(|period| 1_000_000.0 / period) + } + + /// Modulation period measured from the phase-0 markers (mean spacing). + fn measured_period_us(&self) -> Option { + if self.camera_markers_us.len() < 2 { + return None; + } + let first = *self.camera_markers_us.first()?; + let last = *self.camera_markers_us.last()?; + let spans = (self.camera_markers_us.len() - 1) as f64; + let period = last.saturating_sub(first) as f64 / spans; + (period > 0.0).then_some(period) + } + + /// Frequency (Hz) from the modulation plugin's acknowledged periodic waveform. + fn acknowledged_frequency_hz(&self) -> Option { + let target = self.modulation.as_ref()?.acknowledged.as_ref()?; + match target.waveform.as_ref()? { + WaveformV1::Periodic { + frequency_millihz, .. + } => (*frequency_millihz > 0).then(|| *frequency_millihz as f64 / 1_000.0), + _ => None, + } + } + + fn is_marker_anchored(&self) -> bool { + self.camera_markers_us.len() >= 2 + } + + /// Why there is no modulation frequency to work from, phrased as the + /// operator action that fixes it. `None` means the frequency is known. + /// + /// A frequency comes from two independent places — the phase-0 trigger + /// markers, or the drive the modulation plugin has *acknowledged*. Neither + /// has anything to do with whether that plugin is connected, which is a + /// separate check ([`Self::modulation_connected`]). Reporting "connect the + /// modulation plugin" for a missing frequency told operators their bench + /// was unplugged when it was not: the usual cause is simply that no + /// periodic drive has been armed yet. + fn frequency_blocker(&self) -> Option { + if self.frequency_hz().is_some() { + return None; + } + let Some(state) = self.modulation.as_ref() else { + return Some( + "the modulation plugin is not reporting status — enable it in the plugin list" + .into(), + ); + }; + if !matches!(state.connection, ConnectionStateV1::Connected { .. }) { + return Some(format!( + "the modulation plugin is {} — connect it", + connection_label(&state.connection) + )); + } + // Connected, so the question is what it is being asked to drive. + let Some(target) = state.acknowledged.as_ref() else { + return Some( + "the modulation plugin has not applied a drive yet — set one up there and apply it" + .into(), + ); + }; + match target.waveform.as_ref() { + Some(WaveformV1::Periodic { + frequency_millihz, .. + }) if *frequency_millihz == 0 => { + Some("the modulation drive frequency is set to 0 — raise it".into()) + } + Some(WaveformV1::Periodic { .. }) => None, + Some(_) => Some( + "the modulation drive is not a repeating waveform, so it has no frequency — \ + choose a periodic one" + .into(), + ), + None => Some( + "the modulation plugin has not applied a waveform yet — set one up there and \ + apply it" + .into(), + ), + } + } + + fn frequency_source(&self) -> &'static str { + if self.measured_period_us().is_some() { + "trigger" + } else { + "modulation" + } + } + + /// The current phase fold, memoised. + /// + /// Called several times per repaint (`rolling_dataset`, `latest_rolling` + /// from both `status_dataset` and `status_entries`, `current_windows`, + /// `current_response`). Each fold allocates a `Vec` over up to + /// `MAX_EVENTS` events, so refolding per call threw away hundreds of + /// megabytes per repaint at bench event rates. The cache is keyed on a + /// cheap fingerprint of everything the fold reads, so it invalidates + /// exactly when the inputs move rather than on every `bump()`. + fn current_fold(&self) -> Option { + let key = self.fold_key()?; + if let Ok(cache) = self.fold_cache.try_borrow() { + if let Some((cached_key, fold)) = cache.as_ref() { + if *cached_key == key { + return fold.clone(); + } + } + } + let fold = self.compute_fold(); + if let Ok(mut cache) = self.fold_cache.try_borrow_mut() { + *cache = Some((key, fold.clone())); + } + fold + } + + /// Fingerprint of every input [`Self::compute_fold`] reads. `None` when + /// there is no period, i.e. no fold to compute. + fn fold_key(&self) -> Option { + let period_us = self.period_us()?; + Some(FoldKey { + period_us_bits: period_us.to_bits(), + event_count: self.camera_events.len(), + first_event_us: self.camera_events.first().map(|event| event.timestamp_us), + last_event_us: self.camera_events.last().map(|event| event.timestamp_us), + marker_count: self.camera_markers_us.len(), + first_marker_us: self.camera_markers_us.first().copied(), + last_marker_us: self.camera_markers_us.last().copied(), + roi: self.roi(), + masked_count: self.masked_pixels.len(), + }) + } + + fn compute_fold(&self) -> Option { + let period_us = self.period_us()?; + // Fold only the events the analysis is normalised over. `q_p` already + // restricts to ROI minus masked pixels; the rolling response divides by + // the same count, so its numerator has to be restricted too or it + // counts events from outside the ROI against an ROI-sized denominator. + let events = self.roi_filtered_events(); + let marker_fold = self.is_marker_anchored().then(|| { + let expected_hz = 1_000_000.0 / period_us; + fold_events( + &events, + &self.camera_markers_us, + MarkerValidationConfig { + expected_frequency_hz: expected_hz, + // Live quicklook: accept real-world drift/jitter rather than + // rejecting the whole fold. + frequency_tolerance_fraction: 0.5, + max_period_jitter_fraction: 0.75, + expected_cycles: None, + }, + ) + .ok() + }); + // A marker glitch (dropped trigger, out-of-tolerance jitter) must not + // blank the live plots — fall back to the free-running fold on T. + marker_fold + .flatten() + .or_else(|| fold_events_free_running(&events, period_us)) + } + + /// The analysis-window events restricted to the ROI, masked pixels removed. + /// Without an ROI the whole frame is the ROI, so this is a clone. + fn roi_filtered_events(&self) -> Vec { + let Some(roi) = self.roi() else { + return self.camera_events.clone(); + }; + if roi.area() == usize::from(self.frame_width) * usize::from(self.frame_height) + && self.masked_pixels.is_empty() + { + return self.camera_events.clone(); + } + self.camera_events + .iter() + .filter(|event| { + roi.contains(event.x, event.y) && !self.masked_pixels.contains(&(event.x, event.y)) + }) + .copied() + .collect() + } + + /// Fresh optical summary from a connected photodiode owner. + fn fresh_optical_summary(&self) -> Option<&PhotodiodeOpticalSummaryV1> { + let state = self.photodiode.as_ref()?; + if !matches!(state.connection, ConnectionStateV1::Connected { .. }) + || state.freshness.is_stale_at(now_unix_ms()) + { + return None; + } + state.optical_summary.as_ref() + } + + /// Fresh optical modulation depth `a` published by the photodiode plugin. + fn photodiode_a(&self) -> Option { + self.fresh_optical_summary() + .map(|summary| summary.measured_log_contrast) + } + + /// The depth `a` the modulation owner's calibrated optical drive is + /// currently commanding, from its published inversion provenance. + /// + /// `None` unless the owner is connected and has a calibrated optical drive + /// armed: `optical_drive` is published only for `OPTICAL_LOG_SINE` / + /// `OPTICAL_LINEAR_SINE` under an identified transfer calibration, which is + /// exactly the case in which a commanded `a` means anything at all. A + /// manual DAC band or a constant level publishes nothing here, and must not + /// be turned into a depth. + /// + /// Deliberately not freshness-gated. The published depth is what this + /// plugin's own `SetOpticalDepth` wrote into the owner a moment ago, not a + /// reading off the bench, so it does not go stale the way a measurement + /// does — and gating it on the device poll would make the sweep's settle + /// check flap between "settled" and "no a" on a slow reply. + fn commanded_a(&self) -> Option { + let state = self.modulation.as_ref()?; + if !matches!(state.connection, ConnectionStateV1::Connected { .. }) { + return None; + } + let depth = f64::from(state.optical_drive.as_ref()?.depth_a_milli) / 1_000.0; + (depth > 0.0).then_some(depth) + } + + /// The modulation depth `a` A1 works from, per the operator's chosen + /// [`DepthSource`]. Every gate, sweep settle check, plot and sidecar reads + /// this one accessor, so the source is chosen in exactly one place. + fn depth_a(&self) -> Option { + match self.depth_source { + DepthSource::Photodiode => self.photodiode_a(), + DepthSource::Commanded => self.commanded_a(), + } + } + + /// Why there is no `a` to work from, phrased as the operator action that + /// fixes it. + /// + /// Every gate that needs `a` — the a₀ lock, the amplitude sweep, the + /// frequency ladder — used to refuse with one fixed sentence naming the two + /// most common causes. When the real cause was a third thing (a railed + /// window, too few trigger markers, a stale snapshot) that sentence sent the + /// operator to re-check an anchor that was already fine. Ask the owner + /// instead, and only fall back to the local view of the snapshot. + /// + /// `None` means `a` is available. + fn depth_a_blocker(&self) -> Option { + match self.depth_source { + DepthSource::Photodiode => self.photodiode_a_blocker(), + DepthSource::Commanded => self.commanded_a_blocker(), + } + } + + fn photodiode_a_blocker(&self) -> Option { + // 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 reason = self.optical_summary_blocker()?; + Some(format!( + "{reason} (or switch \"Depth a source\" to the commanded drive to work open loop)" + )) + } + + /// Why the photodiode is publishing no optical summary, in the owner's own + /// words where it has any. `None` means it is publishing one. + /// + /// Separate from [`Self::photodiode_a_blocker`] because not every caller can + /// offer the open-loop way out: the sidecar needs this summary whichever + /// depth source is selected, so telling the operator to switch sources there + /// would name an escape that does not exist. + fn optical_summary_blocker(&self) -> Option { + if self.fresh_optical_summary().is_some() { + return None; + } + let Some(state) = self.photodiode.as_ref() else { + return Some( + "the photodiode plugin is not reporting status — enable it and connect the \ + detector" + .into(), + ); + }; + if !matches!(state.connection, ConnectionStateV1::Connected { .. }) { + return Some(format!( + "the photodiode is {} — connect it", + connection_label(&state.connection) + )); + } + if state.freshness.is_stale_at(now_unix_ms()) { + 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(reason.to_owned()); + } + Some("the photodiode is streaming no samples yet — start the stream".into()) + } + + fn commanded_a_blocker(&self) -> Option { + if self.commanded_a().is_some() { + return None; + } + let Some(state) = self.modulation.as_ref() else { + return Some( + "the modulation plugin is not reporting status — enable it to read the commanded \ + depth" + .into(), + ); + }; + if !matches!(state.connection, ConnectionStateV1::Connected { .. }) { + return Some(format!( + "the modulation plugin is {} — connect it", + connection_label(&state.connection) + )); + } + Some( + "the modulation plugin is not running a calibrated optical drive — apply a Pockels \ + calibration and arm OPTICAL_LOG_SINE, or a commanded depth means nothing" + .into(), + ) + } + + /// Current ROI from the host camera config, clamped to the frame. + fn roi(&self) -> Option { + if self.frame_width == 0 || self.frame_height == 0 { + return None; + } + let host = self.host_roi.unwrap_or_default(); + let x0 = host.x.min(self.frame_width); + let y0 = host.y.min(self.frame_height); + let x1 = if host.width == 0 { + self.frame_width + } else { + host.x.saturating_add(host.width).min(self.frame_width) + }; + let y1 = if host.height == 0 { + self.frame_height + } else { + host.y.saturating_add(host.height).min(self.frame_height) + }; + (x1 > x0 && y1 > y0).then_some(Roi { x0, y0, x1, y1 }) + } + + /// Number of valid pixels: ROI area minus masked pixels inside it. + fn valid_pixel_count(&self) -> Option { + let roi = self.roi()?; + let masked = self + .masked_pixels + .iter() + .filter(|(x, y)| roi.contains(*x, *y)) + .count(); + Some(roi.area().saturating_sub(masked)) + } + + /// ON/OFF phase windows for `q_p`: the pilot-frozen windows when a pilot has + /// been recorded for this row, otherwise the per-fold auto-windows. + fn current_windows(&self) -> Option<(PhaseWindow, PhaseWindow)> { + if let Some(windows) = self.pilot_windows { + return Some(windows); + } + auto_windows(&self.current_fold()?, self.window_floor) + } + + /// Whether the `q_p` windows are frozen from a pilot (vs live auto-windows). + fn windows_are_frozen(&self) -> bool { + self.pilot_windows.is_some() + } + + /// ON/OFF response probability for the current fold against `current_windows`. + fn current_response(&self) -> Option<(f64, f64, usize, usize)> { + let fold = self.current_fold()?; + let roi = self.roi()?; + let (window_on, window_off) = self.current_windows()?; + response_probability(&fold, window_on, window_off, roi, &self.masked_pixels) + } + + /// Freezes the ON/OFF windows for this row from the current fold (a pilot). + fn freeze_pilot_windows(&mut self) { + match self + .current_fold() + .and_then(|fold| auto_windows(&fold, self.window_floor)) + { + Some(windows) => { + self.pilot_windows = Some(windows); + self.note("Pilot windows frozen from the live signal"); + } + None => { + // Drop whatever was loaded for this measurement: leaving it in + // place let `write_sidecar` record windows from an *earlier* + // pilot as if they had just been frozen from this run. + self.pilot_windows = None; + self.note("No live signal to freeze windows — enable Live analysis first"); + } + } + } + + /// Captures the background floor `(q0_on, q0_off)` from the current fold. + fn capture_background_floor(&mut self) { + match self.current_response() { + Some((q_on, q_off, _, _)) => { + self.background_floor = Some((q_on, q_off)); + self.note(format!("Background floor captured (q0_on={q_on:.3})")); + } + None => { + self.note("No valid background window yet (need events and a valid ROI)"); + } + } + } + + /// Records one response-curve point at the current depth `a`. + fn record_response_point(&mut self) -> Result<(), String> { + let measured_a = self.depth_a().ok_or_else(|| { + format!( + "no modulation depth a available: {}", + self.depth_a_blocker() + .unwrap_or_else(|| "no depth source is reporting".into()) + ) + })?; + let (q_on, q_off, cycles, valid_pixels) = self + .current_response() + .ok_or("no valid response window yet (need trigger-anchored events and a valid ROI)")?; + self.response_points.push(ResponsePoint { + measured_a, + q_on, + q_off, + cycles, + valid_pixels, + }); + Ok(()) + } + + fn response_curve_dataset(&self) -> Series1dV1 { + let line = |select: fn(&ResponsePoint) -> f64| { + let mut points: Vec = self + .response_points + .iter() + .map(|point| Series1dPoint { + x: point.measured_a, + y: select(point), + }) + .collect(); + points.sort_by(|a, b| a.x.total_cmp(&b.x)); + points + }; + Series1dV1 { + x_label: format!( + "Modulation depth a = ln(I_max / I_min) ({})", + self.depth_source.verb() + ), + y_label: "Response probability q_p = fraction of pixel-cycles that fired".into(), + lines: vec![ + Series1dLine { + name: "ON".into(), + points: line(|point| point.q_on), + }, + Series1dLine { + name: "OFF".into(), + points: line(|point| point.q_off), + }, + ], + } + } + + fn rolling_dataset(&self) -> Series1dV1 { + const X: &str = "Camera time since first event (s)"; + const Y: &str = "Events per valid pixel in the trailing half-cycle T/2"; + let empty = || Series1dV1 { + x_label: X.into(), + y_label: Y.into(), + lines: vec![ + Series1dLine { + name: "ON".into(), + points: Vec::new(), + }, + Series1dLine { + name: "OFF".into(), + points: Vec::new(), + }, + ], + }; + let Some(fold) = self.current_fold() else { + return empty(); + }; + let first = fold.validation.first_marker_us; + let last = fold.validation.last_marker_us; + let samples = ROLLING_SAMPLES.min(last.saturating_sub(first).saturating_add(1)); + if samples < 2 { + return empty(); + } + let sample_times: Vec = (0..samples) + .map(|index| first + (last - first) * index / (samples - 1)) + .collect(); + // Same denominator as `q_p` (ROI minus masked), against the ROI-filtered + // fold — the two are shown side by side and must mean the same thing. + let Some(valid_pixels) = self.valid_pixel_count() else { + return empty(); + }; + let line = |polarity: Polarity| { + rolling_half_period_response(&fold, polarity, valid_pixels, &sample_times, None) + .map(|points| points_for(&points, first)) + .unwrap_or_default() + }; + Series1dV1 { + x_label: X.into(), + y_label: Y.into(), + lines: vec![ + Series1dLine { + name: "ON".into(), + points: line(Polarity::On), + }, + Series1dLine { + name: "OFF".into(), + points: line(Polarity::Off), + }, + ], + } + } + + /// Latest rolling half-period value per polarity, for the status readout. + fn latest_rolling(&self) -> Option<(f64, f64)> { + let fold = self.current_fold()?; + let at = [fold.validation.last_marker_us]; + let valid_pixels = self.valid_pixel_count()?; + let value = |polarity| { + rolling_half_period_response(&fold, polarity, valid_pixels, &at, None) + .ok() + .and_then(|points| points.first().map(|point| point.run_per_pixel)) + }; + Some((value(Polarity::On)?, value(Polarity::Off)?)) + } + + fn status_dataset(&self) -> TableDatasetV1 { + let now_ms = now_unix_ms(); + let period_us = self.period_us(); + let frequency = period_us.map(|t| 1_000_000.0 / t); + let source = self.frequency_source(); + let (on_now, off_now) = self + .latest_rolling() + .map_or((None, None), |(on, off)| (Some(on), Some(off))); + let cell = |id: &str, value: String| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(vec![value]), + }; + TableDatasetV1 { + columns: vec![ + cell("state", self.recording.state_label().into()), + cell( + "measurement_id", + if self.recording.is_active() { + self.recording.id.clone() + } else { + self.measurement_id.clone() + }, + ), + cell( + "remaining", + self.recording + .remaining_s(now_ms) + .map_or_else(|| "—".into(), |s| format!("{s} s")), + ), + cell( + "frequency", + frequency.map_or_else(|| "—".into(), |hz| format!("{hz:.3} Hz ({source})")), + ), + cell( + "a", + self.depth_a().map_or_else( + || "—".into(), + |a| format!("{a:.3} ({})", self.depth_source.verb()), + ), + ), + cell( + "s_on", + on_now.map_or_else(|| "—".into(), |v| format!("{v:.4}")), + ), + cell( + "s_off", + off_now.map_or_else(|| "—".into(), |v| format!("{v:.4}")), + ), + cell("events", self.camera_events.len().to_string()), + cell( + "message", + // While idle, anything that would refuse the next recording + // is worth more than the previous run's result: the operator + // sees it before pressing Record, not after. + match (self.recording.is_active(), self.photodiode_blocker()) { + (false, Some(blocker)) => blocker, + _ if self.message.is_empty() => "—".into(), + _ => self.message.clone(), + }, + ), + ], + } + } + + fn update_snapshots(&mut self, inbox: &PluginControlInbox) { + for snapshot in &inbox.snapshots { + match (snapshot.plugin_id.as_str(), snapshot.topic.as_str()) { + (MODULATION_PLUGIN_ID, CTX_STAGE_A_MODULATION_STATE_V1) => { + if let Ok(state) = serde_json::from_value(snapshot.payload.clone()) { + self.modulation = Some(state); + } + } + (PHOTODIODE_PLUGIN_ID, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1) => { + if let Ok(summary) = serde_json::from_value(snapshot.payload.clone()) { + self.photodiode = Some(summary); + } + } + _ => {} + } + } + } + + fn next_request_id(&mut self) -> u64 { + self.request_seq = self.request_seq.wrapping_add(1); + self.request_seq + } + + /// Wraps a photodiode command in the routed service request A1 emits. + fn photodiode_request(&mut self, command: PhotodiodeCommandV1) -> PluginServiceRequest { + let request_id = self.next_request_id(); + let needs_revision = matches!( + command, + PhotodiodeCommandV1::BeginRecording { .. } + | PhotodiodeCommandV1::FinalizeRecording { .. } + | PhotodiodeCommandV1::AbortRecording { .. } + ); + let mut envelope = + PhotodiodeRequestV1::new(RequestId(request_id), ClientId::new(A1_PLUGIN_ID), command); + envelope.lease_id = Some(self.recording.lease_id.clone()); + if !self.recording.stem.is_empty() { + envelope.run_id = Some(RunId::new(self.recording.stem.clone())); + } + if needs_revision { + let observed = self + .photodiode + .as_ref() + .map(|summary| { + summary + .requested_revision + .into_iter() + .chain(summary.acknowledged_revision) + .map(|revision| revision.0) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + self.pd_revision_seq = self + .pd_revision_seq + .saturating_add(1) + .max(observed.saturating_add(1)); + envelope.requested_revision = Some(SemanticRevision(self.pd_revision_seq)); + } + envelope.target_owner_instance = self + .photodiode + .as_ref() + .map(|summary| summary.owner_instance.clone()); + envelope.issued_at_unix_ms = now_unix_ms(); + PluginServiceRequest { + request_id, + source_plugin_id: A1_PLUGIN_ID.into(), + target_plugin_id: PHOTODIODE_PLUGIN_ID.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + payload: serde_json::to_value(&envelope).unwrap_or(Value::Null), + } + } + + /// String metadata embedded in both recorders' own sidecars. + fn recording_metadata(&self) -> BTreeMap { + let mut meta = BTreeMap::new(); + meta.insert("a1_measurement_id".into(), self.recording.id.clone()); + meta.insert("a1_stem".into(), self.recording.stem.clone()); + meta.insert("a1_role".into(), self.recording.role.label().into()); + meta.insert( + "a1_duration_s".into(), + self.recording.duration_s.to_string(), + ); + meta.insert("sweep_min_a".into(), format!("{:.6}", self.min_a)); + meta.insert("sweep_max_a".into(), format!("{:.6}", self.max_a)); + if let Some(sweep) = self + .sweep + .as_ref() + .filter(|sweep| sweep.phase == SweepPhase::Recording) + { + meta.insert( + "sweep_requested_a".into(), + format!("{:.6}", sweep.target_a()), + ); + meta.insert( + "sweep_commanded_a".into(), + format!("{:.6}", sweep.commanded_a()), + ); + meta.insert("sweep_point_index".into(), (sweep.index + 1).to_string()); + meta.insert("sweep_point_total".into(), sweep.total().to_string()); + } + if let Some(lock) = self.sweep.as_ref().and_then(|sweep| sweep.lock.as_ref()) { + meta.insert("a0_target".into(), format!("{:.6}", lock.target_a)); + meta.insert("a0_commanded_a".into(), format!("{:.6}", lock.commanded_a)); + meta.insert( + "a0_lock_measured_a".into(), + format!("{:.6}", lock.measured_a), + ); + meta.insert( + "a0_lock_depth_source".into(), + lock.depth_source.label().into(), + ); + meta.insert( + "a0_lock_frequency_hz".into(), + format!("{:.6}", lock.frequency_hz), + ); + } + // The depth this run was driven and judged by, always tagged with where + // 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_analysis_source".into(), + self.depth_source.label().into(), + ); + if let Some(a) = self.depth_a() { + 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("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. + if let Some(sensor) = self.recorded_sensor() { + if let Some(celsius) = sensor.temperature_c { + meta.insert("sensor_temperature_c".into(), format!("{celsius:.2}")); + } + if let Some(dead_time_us) = sensor.pixel_dead_time_us { + meta.insert( + "sensor_pixel_dead_time_us".into(), + format!("{dead_time_us:.3}"), + ); + } + if let Some(lux) = sensor.illumination_lux { + meta.insert("sensor_illumination_lux".into(), format!("{lux:.3}")); + } + meta.insert( + "sensor_reading_age_s".into(), + format!("{:.3}", sensor.age_s), + ); + } + meta + } + + /// The sensor reading that belongs to the run being written: the one frozen + /// when it started, falling back to the latest if the recording began + /// before any frame carried one. + fn recorded_sensor(&self) -> Option { + self.sensor_at_start.or(self.sensor) + } + + /// The measurement id to file this run under, generating one when the + /// operator has not typed anything. + /// + /// A blank id used to refuse the recording. It never had to: the id only + /// names a folder and a file stem, and the plugin already ships a generated + /// default for exactly that reason. Filling it in here (and writing it back, + /// so the panel shows what was used) means an operator who wants their data + /// grouped can say so, and one who just wants to record can press record. + /// Tested on the raw field, not on `sanitize_stem`'s output: the sanitizer + /// substitutes `A1` for anything that reduces to nothing, so asking it + /// whether the id was blank always answers no — and every unnamed run would + /// silently share one folder called `A1`. + fn ensure_measurement_id(&mut self) -> String { + if self.measurement_id.trim().is_empty() { + self.measurement_id = generate_measurement_id(); + self.bump(); + } + sanitize_stem(self.measurement_id.trim()) + } + + /// Why the photodiode cannot record right now, phrased as the operator + /// action that fixes it. `None` means the PDQ leg is expected to succeed. + fn photodiode_blocker(&self) -> Option { + let Some(photodiode) = self.photodiode.as_ref() else { + return Some( + "The photodiode plugin is not reporting status — enable it before recording".into(), + ); + }; + if !matches!(photodiode.connection, ConnectionStateV1::Connected { .. }) { + return Some(format!( + "The photodiode is {} — connect it before recording", + connection_label(&photodiode.connection) + )); + } + // The photodiode's own Data directory is deliberately *not* checked: A1 + // names the destination root in the start spec, so a recording started + // here does not depend on the owner's folder setting at all. + // + // A lease held by anyone else means the PDQ is already committed. + if let Some(lease) = photodiode.lease.as_ref() { + if lease.holder.as_str() != A1_PLUGIN_ID { + return Some(format!( + "The photodiode is leased by {} — release it before recording", + lease.holder.as_str() + )); + } + } + 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) { + if self.recording.is_active() { + return; + } + if self.output_folder.trim().is_empty() { + self.note("Pick an output folder first — that is where the files go"); + return; + } + // Checked before the camera starts: every one of these used to surface + // as a PDQ rejection *after* the host was already recording, which left + // a stub RAW behind and no photodiode data. + if let Some(blocker) = self.photodiode_blocker() { + 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. + self.sensor_at_start = self.sensor; + let id = self.ensure_measurement_id(); + // Sweep points get a stable per-point tag so the row's files sort by + // sweep order as well as by timestamp. Event-count points instead carry + // their frequency, because one measurement id spans the whole frequency + // sweep at the single frozen depth a₀. + let live_hz = self.frequency_hz(); + // A depth sweep nested inside the frequency ladder repeats its point + // indices at every rung, so `_p03` alone would collide across + // frequencies within one measurement id. Prefix the ladder's frequency + // so the whole q_p(a, f) surface sorts by f, then by depth. + let nested_freq_tag = self + .freq_sweep + .as_ref() + .filter(|sweep| sweep.mode == FreqSweepMode::DepthSweep) + .map(|sweep| format!("_{}", frequency_tag(sweep.frequency_hz()))) + .unwrap_or_default(); + let sweep_tag = self + .sweep + .as_ref() + .filter(|sweep| sweep.phase == SweepPhase::Recording) + .map(|sweep| match sweep.kind { + SweepKind::Amplitude => { + format!("{nested_freq_tag}_p{:02}", sweep.index + 1) + } + SweepKind::EventCount => { + let hz = sweep + .lock + .as_ref() + .map(|lock| lock.frequency_hz) + .or(live_hz) + .unwrap_or_default(); + format!("_{}", frequency_tag(hz)) + } + }) + .unwrap_or_default(); + let stem = format!( + "{id}_{}{}{sweep_tag}", + format_compact_utc(now_ms / 1_000), + role.suffix() + ); + let lease_id = LeaseId::new(format!("a1-{stem}")); + self.recording_completed_ok = false; + let mut recording = Recording::idle(); + recording.role = role; + recording.id = id; + recording.stem = stem; + recording.folder = self.output_folder.trim().to_string(); + recording.duration_s = self + .pending_duration_s + .take() + .unwrap_or(self.duration_s) + .max(1) as u64; + // The measurement clock starts only after both recorders acknowledge + // that they are running. + recording.start_unix_ms = 0; + recording.last_activity_ms = now_ms; + recording.lease_id = lease_id; + self.recording = recording; + + // Capture the science reference now (after any folder scan this tick), + // from the live signal at the current drive amplitude. + match role { + RecRole::Pilot => self.freeze_pilot_windows(), + RecRole::Background => self.capture_background_floor(), + // Both keep the row's pilot-frozen windows and background floor. + RecRole::Normal | RecRole::EventCount => {} + } + + self.start_camera(context); + } + + /// Re-reads pilot/background sidecars from the measurement folder when the + /// measurement (folder + id) changes, so the `q_p` plot reuses them. + fn scan_measurement_folder(&mut self) { + let folder = self.output_folder.trim().to_string(); + let id = sanitize_stem(self.measurement_id.trim()); + let key = (folder.clone(), id.clone()); + if self.loaded_key.as_ref() == Some(&key) { + return; + } + self.loaded_key = Some(key); + self.pilot_windows = None; + self.background_floor = None; + if folder.is_empty() || id.is_empty() { + return; + } + let measurement_dir = Path::new(&folder).join(&id); + let measurement_dir = measurement_dir.to_string_lossy(); + if let Some((on, off)) = load_row_windows(&measurement_dir, &id, "_pilot") { + self.pilot_windows = Some((on, off)); + } + self.background_floor = load_row_background(&measurement_dir, &id, "_background"); + } + + /// Start the host camera recorder first. Starting it switches the host from + /// preview into recording and briefly revokes plugin effects, so the PDQ + /// stream must not be opened until the host acknowledges this transition. + fn start_camera(&mut self, context: &mut impl RecordingControl) { + let subdir = self.recording.id.clone(); + let stem = self.recording.stem.clone(); + let metadata = self.recording_metadata(); + + let cam_req = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id: cam_req, + command: HostCommand::StartRecording { + run_id: stem.clone(), + base_path: format!("{subdir}/{stem}.raw"), + metadata, + }, + }); + self.recording.cam_start_req = cam_req; + self.recording.phase = RecPhase::StartingCamera; + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!("Recording {}: starting camera…", self.recording.id)); + } + + fn connect_photodiode(&mut self, context: &mut impl RecordingControl) { + let request = self.photodiode_request(PhotodiodeCommandV1::Connect); + self.recording.connect_req = request.request_id; + context.request_service(&request); + self.recording.phase = RecPhase::ConnectingPhotodiode; + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!( + "Recording {}: connecting photodiode…", + self.recording.id + )); + } + + /// 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) + } + + 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); + self.recording.phase = RecPhase::AcquiringLease; + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!( + "Recording {}: preparing photodiode…", + self.recording.id + )); + } + + /// Start the PDQ only after the camera recorder is running. + fn start_photodiode(&mut self, context: &mut impl RecordingControl) { + let subdir = self.recording.id.clone(); + let stem = self.recording.stem.clone(); + let spec = PdqStartSpecV1 { + pdq_path: format!("{subdir}/{stem}_pd.pdq"), + sidecar_path: format!("{subdir}/{stem}_pd.json"), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: self.recording_metadata(), + // Write the PDQ straight into this measurement's folder rather than + // the photodiode's own data directory: for a recording started here, + // this plugin's output folder is the one that decides where files go. + root_dir: Some(self.recording.folder.clone()), + }; + let pd_request = self.photodiode_request(PhotodiodeCommandV1::BeginRecording { + specification: spec, + }); + self.recording.pd_begin_req = pd_request.request_id; + context.request_service(&pd_request); + self.recording.phase = RecPhase::StartingPhotodiode; + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!( + "Recording {}: starting photodiode…", + self.recording.id + )); + } + + /// Atomically close the PDQ and release its lease while the camera + /// pipeline is still live. + fn stop_photodiode(&mut self, context: &mut impl RecordingControl) { + if self.recording.lease_granted { + let pd_request = self.photodiode_request(PhotodiodeCommandV1::ReleaseLease { + finalize_recording: true, + reason: "a1 recording complete".into(), + }); + self.recording.pd_finalize_req = pd_request.request_id; + context.request_service(&pd_request); + self.recording.phase = RecPhase::StoppingPhotodiode; + } else { + self.stop_camera(context); + return; + } + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!( + "Recording {}: saving photodiode data…", + self.recording.id + )); + } + + /// The photodiode leg failed while the camera was already recording. The + /// camera RAW is the primary measurement, so it keeps running for its full + /// duration instead of being cut short — a truncated file that reports + /// itself as finalized is worse than a complete camera-only one. Any lease + /// still held is released by the normal stop path at the end. + fn continue_without_photodiode(&mut self, context: &mut impl RecordingControl) { + let camera_running = self.recording.cam_raw_path.is_some() && !self.recording.cam_rejected; + if !camera_running || self.recording.stop_requested { + self.stop_camera(context); + return; + } + self.recording.phase = RecPhase::Running; + self.recording.start_unix_ms = now_unix_ms(); + self.recording.last_activity_ms = self.recording.start_unix_ms; + let reason = self + .recording + .failure + .clone() + .unwrap_or_else(|| "the photodiode did not start".into()); + self.note(format!( + "{reason} — recording camera only for {} s", + self.recording.duration_s + )); + } + + /// Stop the host recorder after the PDQ has been safely finalized. + fn stop_camera(&mut self, context: &mut impl RecordingControl) { + if self.recording.cam_raw_path.is_some() && !self.recording.cam_rejected { + let cam_req = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id: cam_req, + command: HostCommand::StopRecording, + }); + self.recording.cam_stop_req = cam_req; + self.recording.phase = RecPhase::StoppingCamera; + self.recording.last_activity_ms = now_unix_ms(); + self.note(format!( + "Recording {}: saving camera data…", + self.recording.id + )); + } else { + self.finish_recording(context); + } + } + + fn finish_recording(&mut self, context: &mut impl RecordingControl) { + let clean = 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(); + // Gather the RAW/PDQ next to the sidecar before writing it, so the + // recorded paths are the final ones. + self.gather_into_measurement_folder(); + let sidecar = self.write_sidecar(); + let reason = self + .recording + .failure + .clone() + .unwrap_or_else(|| "not every file was finalized".into()); + let message = match (sidecar, clean) { + (Ok(path), true) => format!("Saved recording {} → {path}", self.recording.id), + (Ok(path), false) => format!( + "Recording {} incomplete: {reason} — metadata saved to {path}", + self.recording.id + ), + (Err(err), _) => format!( + "Recording {} finished, metadata save failed: {err}", + self.recording.id + ), + }; + self.recording_completed_ok = clean; + 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. + 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() { + return; + } + let raw = self + .recording + .cam_finalized_path + .clone() + .or_else(|| self.recording.cam_raw_path.clone()); + // The host writes the bias/config sidecar as a sibling of the RAW; it + // travels with it so the recording stays self-describing. + if let Some(raw) = raw { + if let Some(moved) = move_into(&dir, &raw) { + if self.recording.cam_finalized_path.is_some() { + self.recording.cam_finalized_path = Some(moved.clone()); + } + self.recording.cam_raw_path = Some(moved); + } + if let Some(bias) = sibling_toml(&raw) { + move_into(&dir, &bias); + } + // The host's sensor telemetry is written as another sibling of the + // RAW and used to be left behind entirely, which separated a run + // 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 + // record the absolute path either way. + if let Some(pdq) = self.resolved_photodiode_path(self.recording.pd_pdq_path.as_deref()) { + self.recording.pd_pdq_path = Some(move_into(&dir, &pdq).unwrap_or(pdq)); + } + if let Some(sidecar) = + self.resolved_photodiode_path(self.recording.pd_sidecar_path.as_deref()) + { + self.recording.pd_sidecar_path = Some(move_into(&dir, &sidecar).unwrap_or(sidecar)); + } + } + + /// Compacts the host's sensor-telemetry CSV into the measurement folder, + /// under the recording's own stem, and removes the original. + /// + /// Best-effort throughout: a missing or unreadable telemetry file is normal + /// (replay, a camera with no monitoring block, a host that did not poll) + /// and must not cost the operator the recording that has just finished. + fn gather_sensor_readout(&self, dir: &Path, raw: &str) -> Option { + let source = Path::new(raw) + .file_stem() + .map(|stem| { + Path::new(raw) + .parent() + .unwrap_or(Path::new(".")) + .join(format!("{}.sensor-monitoring.csv", stem.to_string_lossy())) + }) + .filter(|path| path.exists())?; + let text = std::fs::read_to_string(&source).ok()?; + let readout = sensor::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 None; + } + let destination = dir.join(format!("{}.sensor.json", 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; + } + let _ = std::fs::remove_file(&source); + Some(destination.display().to_string()) + } + + /// Absolute location of a photodiode-reported recording path. Receipts name + /// the *label* A1 asked for, which is relative to whichever root the owner + /// used: the folder A1 named in the start spec, or — for an owner too old to + /// honour it — the owner's own data directory. Resolve against both and + /// prefer the one that exists. + fn resolved_photodiode_path(&self, reported: Option<&str>) -> Option { + let reported = reported?; + let path = Path::new(reported); + if path.is_absolute() { + return Some(reported.to_owned()); + } + let requested = Path::new(&self.recording.folder).join(path); + if requested.exists() { + return Some(requested.display().to_string()); + } + let owner_root = self + .photodiode + .as_ref() + .and_then(|photodiode| photodiode.data_dir.as_deref()) + .map(|root| Path::new(root).join(path)); + match owner_root { + Some(owner_root) if owner_root.exists() => Some(owner_root.display().to_string()), + _ => Some(requested.display().to_string()), + } + } + + /// Release the photodiode lease (only if we actually hold it) and return to idle. + fn release_and_idle(&mut self, context: &mut impl RecordingControl, message: String) { + if self.recording.lease_granted { + let request = self.photodiode_request(PhotodiodeCommandV1::ReleaseLease { + finalize_recording: true, + reason: "a1 recording complete".into(), + }); + context.request_service(&request); + } + self.recording = Recording::idle(); + self.note(message); + } + + /// Wraps a modulation command in the routed service request A1 emits. + fn modulation_request( + &mut self, + command: ModulationCommandV1, + lease_id: &LeaseId, + ) -> PluginServiceRequest { + let request_id = self.next_request_id(); + let mut envelope = + ModulationRequestV1::new(RequestId(request_id), ClientId::new(A1_PLUGIN_ID), command); + envelope.lease_id = Some(lease_id.clone()); + envelope.target_owner_instance = self + .modulation + .as_ref() + .map(|state| state.owner_instance.clone()); + envelope.issued_at_unix_ms = now_unix_ms(); + 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), + } + } + + fn modulation_connected(&self) -> bool { + matches!( + self.modulation.as_ref().map(|state| &state.connection), + Some(ConnectionStateV1::Connected { .. }) + ) + } + + /// The requested `a` per sweep point, ascending and inclusive of both ends. + /// The amplitude sweep trusts the calibration, so each point commands the + /// very depth it expects to measure. + fn sweep_points(&self) -> Vec { + let count = self.sweep_count.clamp(2, 64) as usize; + let span = self.max_a - self.min_a; + (0..count) + .map(|index| { + let depth_a = self.min_a + span * index as f64 / (count - 1) as f64; + SweepPoint { + commanded_a: depth_a, + expected_a: depth_a, + } + }) + .collect() + } + + /// Worst-case sweep duration, used as the modulation lease TTL. + fn sweep_lease_ttl_ms(&self, remaining_points: usize) -> u64 { + let per_point_ms = (self.duration_s.max(1) as u64) + .saturating_mul(1_000) + .saturating_add(SWEEP_SETTLE_TIMEOUT_MS) + .saturating_add(30_000); + (remaining_points as u64) + .saturating_mul(per_point_ms) + .saturating_add(60_000) + } + + /// Kick off the amplitude sweep: validate, then lease the modulation owner. + fn begin_sweep(&mut self, context: &mut impl RecordingControl) { + if self.recording.is_active() || self.sweep.is_some() { + self.message = "A recording or sweep is already running".into(); + return; + } + if self.output_folder.trim().is_empty() { + self.message = "Pick an output folder first — that is where the files go".into(); + return; + } + if !self.modulation_connected() { + self.message = "The modulation plugin is not connected — connect it to drive the \ + depth" + .into(); + return; + } + if self + .modulation + .as_ref() + .and_then(|state| state.calibration_id.as_deref()) + .is_none() + { + self.message = "Run the Pockels calibration in the modulation plugin first — without \ + it a commanded depth means nothing" + .into(); + return; + } + // Same wording every other gate uses, from the same helper: the owner + // knows which estimator gate withheld `a`, and a fixed sentence here + // used to send the operator after the wrong thing. + if let Some(reason) = self.depth_a_blocker() { + self.message = format!( + "The sweep needs a {} depth a, but {reason}", + self.depth_source.verb() + ); + return; + } + // The sweep ends in a recording, so ask the recording's own question now + // rather than after the drive has already moved to point 1. + if let Some(blocker) = self.photodiode_blocker() { + self.message = blocker; + return; + } + if self.min_a.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { + self.message = "Set Sweep min a above 0 — a = 0 is the background reference, which \ + has its own button" + .into(); + return; + } + if self.max_a.partial_cmp(&self.min_a) != Some(std::cmp::Ordering::Greater) { + self.message = "Sweep max a must be larger than Sweep min a".into(); + return; + } + let points = self.sweep_points(); + let message = format!( + "Sweep: acquiring modulation lease for {} points…", + points.len() + ); + self.begin_leased_sweep(context, SweepKind::Amplitude, points, None, None, message); + } + + /// Shared entry point for both leased recording runs (amplitude sweep and + /// single event-count point): validate the destination and the owner, then + /// acquire the modulation lease that holds the drive for the whole run. + fn begin_leased_sweep( + &mut self, + context: &mut impl RecordingControl, + kind: SweepKind, + points: Vec, + lock: Option, + inherited_lease: Option, + message: String, + ) { + if self.recording.is_active() || self.sweep.is_some() || self.a0_lock.is_some() { + self.message = "A recording, sweep or a₀ lock is already running".into(); + return; + } + if self.output_folder.trim().is_empty() { + self.message = "Pick an output folder first — that is where the files go".into(); + return; + } + if !self.modulation_connected() { + self.message = + "The modulation plugin is not connected — connect it to drive the depth".into(); + return; + } + if points.is_empty() { + self.message = "Nothing to record: the run has no points".into(); + return; + } + let now_ms = now_unix_ms(); + // A verdict belongs to the run that produced it. Clearing it here means + // an enclosing ladder can never read the previous rung's outcome if + // this one ends without reaching `finish_sweep`. + self.last_sweep_completed_ok = false; + let owns_lease = inherited_lease.is_none(); + let lease_id = inherited_lease.unwrap_or_else(|| { + LeaseId::new(format!("a1-sweep-{}", format_compact_utc(now_ms / 1_000))) + }); + let mut lease_req = 0; + if owns_lease { + let ttl_ms = self.sweep_lease_ttl_ms(points.len()); + let request = + self.modulation_request(ModulationCommandV1::AcquireLease { ttl_ms }, &lease_id); + lease_req = request.request_id; + context.request_service(&request); + } + self.sweep = Some(Sweep { + phase: SweepPhase::AcquiringLease, + kind, + points, + lock, + index: 0, + lease_id, + // An inherited lease is already granted; the first tick goes + // straight to retargeting the depth. + lease_granted: !owns_lease, + lease_req, + owns_lease, + depth_req: 0, + depth_applied: false, + settled_since_ms: None, + settle_deadline_ms: 0, + point_started: false, + completed_ok: false, + last_activity_ms: now_ms, + stop_requested: false, + }); + self.message = message; + } + + /// Record one atomic frequency point of the exact-event-count workflow. + /// + /// The armed lock's commanded depth is re-applied under a modulation lease — + /// which also locks the operator's drive settings out for the whole point, so + /// the amplitude provably cannot change during the recorded interval — and the + /// point is then recorded through the same coordinator as every other run. + fn begin_a0_point( + &mut self, + context: &mut impl RecordingControl, + inherited_lease: Option, + ) { + let Some(lock) = self.armed_a0() else { + self.message = format!( + "Cannot record the a₀ point: {}", + self.armed_a0_blocker() + .unwrap_or_else(|| "no depth is armed".into()) + ); + return; + }; + let points = vec![SweepPoint { + commanded_a: lock.commanded_a, + expected_a: lock.target_a, + }]; + let message = format!( + "Event-count point at {}: leasing the drive at commanded a = {:.3} (a₀ = {:.3})…", + frequency_label(lock.frequency_hz), + lock.commanded_a, + lock.target_a + ); + self.begin_leased_sweep( + context, + SweepKind::EventCount, + points, + Some(lock), + inherited_lease, + message, + ); + } + + /// Release the modulation lease (if held) and clear the sweep. + fn finish_sweep(&mut self, context: &mut impl RecordingControl, message: String) { + if let Some(sweep) = self.sweep.take() { + self.last_sweep_completed_ok = sweep.completed_ok; + if sweep.owns_lease && sweep.lease_granted { + let request = self.modulation_request( + ModulationCommandV1::ReleaseLease { + safe_off: false, + reason: "a1 sweep finished".into(), + }, + &sweep.lease_id, + ); + context.request_service(&request); + } + } + self.message = message; + } + + /// Renew the modulation lease and retarget the drive at the current point. + fn send_sweep_depth(&mut self, context: &mut impl RecordingControl) { + let Some(sweep) = self.sweep.as_ref() else { + return; + }; + let lease_id = sweep.lease_id.clone(); + let remaining = sweep.total().saturating_sub(sweep.index); + let commanded_a = sweep.commanded_a(); + let target_a = sweep.target_a(); + let index = sweep.index; + let total = sweep.total(); + + let ttl_ms = self.sweep_lease_ttl_ms(remaining); + let renew = self.modulation_request(ModulationCommandV1::RenewLease { ttl_ms }, &lease_id); + context.request_service(&renew); + + let depth = self.modulation_request( + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: depth_a_milli(commanded_a), + }, + &lease_id, + ); + let depth_req = depth.request_id; + context.request_service(&depth); + + let now_ms = now_unix_ms(); + if let Some(sweep) = self.sweep.as_mut() { + sweep.phase = SweepPhase::SettingDepth; + sweep.depth_req = depth_req; + sweep.depth_applied = false; + sweep.settled_since_ms = None; + sweep.point_started = false; + sweep.last_activity_ms = now_ms; + } + self.message = if commanded_a == target_a { + format!( + "Sweep point {}/{total}: retargeting drive to a = {target_a:.3}…", + index + 1 + ) + } else { + format!( + "Event-count point: commanding a = {commanded_a:.3} for a {} a₀ = {target_a:.3}…", + self.depth_source.verb() + ) + }; + } + + /// Advance the amplitude sweep one control tick. Runs before + /// `drive_recording`, so a point's recording starts on the same tick. + fn drive_sweep(&mut self, context: &mut impl RecordingControl) { + if self.sweep.is_none() { + if std::mem::take(&mut self.sweep_pending) { + self.begin_sweep(context); + } else if std::mem::take(&mut self.a0_point_pending) { + self.begin_a0_point(context, None); + } + return; + } + self.sweep_pending = false; + self.a0_point_pending = false; + let now_ms = now_unix_ms(); + let ( + phase, + kind, + stop_requested, + lease_granted, + depth_applied, + last_activity_ms, + index, + total, + ) = { + let sweep = self.sweep.as_ref().expect("sweep checked above"); + ( + sweep.phase, + sweep.kind, + sweep.stop_requested, + sweep.lease_granted, + sweep.depth_applied, + sweep.last_activity_ms, + sweep.index, + sweep.total(), + ) + }; + if stop_requested && phase != SweepPhase::Recording { + let message = if self.message.is_empty() { + "Sweep stopped".into() + } else { + self.message.clone() + }; + self.finish_sweep(context, message); + return; + } + match phase { + SweepPhase::AcquiringLease => { + if lease_granted { + self.send_sweep_depth(context); + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_sweep( + context, + "Sweep aborted: timed out acquiring the modulation lease".into(), + ); + } + } + SweepPhase::SettingDepth => { + if depth_applied { + let target = self + .sweep + .as_mut() + .map(|sweep| { + sweep.phase = SweepPhase::Settling; + sweep.settled_since_ms = None; + sweep.settle_deadline_ms = now_ms + SWEEP_SETTLE_TIMEOUT_MS; + sweep.target_a() + }) + .unwrap_or_default(); + self.message = format!( + "Sweep point {}/{}: waiting for a to settle at {target:.3}…", + index + 1, + total, + ); + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_sweep( + context, + "Sweep aborted: timed out retargeting the modulation drive".into(), + ); + } + } + SweepPhase::Settling => { + let target = self.sweep.as_ref().map(Sweep::target_a).unwrap_or_default(); + // The amplitude sweep drives open-loop and accepts the coarse + // calibration band; an event-count point replays a depth that was + // already trimmed against `a₀`, so it holds the lock's band. + let tolerance = match kind { + SweepKind::Amplitude => sweep_tolerance(target), + SweepKind::EventCount => self.a0_tolerance.max(1e-3), + }; + // With `DepthSource::Commanded` this compares the commanded + // depth against itself and settles as soon as the owner has + // applied it — which is the honest answer for an open-loop + // sweep: nothing on the bench can contradict the command. The + // operator's settle dwell below still applies, so the drive + // gets its physical time to move either way. + let settled = self + .depth_a() + .is_some_and(|measured| (measured - target).abs() <= tolerance); + let dwell_ms = (self.settle_s.max(0.0) * 1_000.0) as u64; + let mut start_recording = false; + let mut settle_timed_out = false; + if let Some(sweep) = self.sweep.as_mut() { + if settled { + let since = *sweep.settled_since_ms.get_or_insert(now_ms); + if now_ms.saturating_sub(since) >= dwell_ms { + start_recording = true; + } + } else { + sweep.settled_since_ms = None; + } + if !start_recording && now_ms >= sweep.settle_deadline_ms { + settle_timed_out = true; + } + if start_recording { + sweep.phase = SweepPhase::Recording; + } + } + if settle_timed_out { + self.finish_sweep( + context, + format!( + "Sweep aborted at point {}/{}: no fresh, settled optical a at \ + target {target:.3} before timeout", + index + 1, + total + ), + ); + return; + } + if start_recording { + self.pending_role = Some(kind.role()); + } + } + SweepPhase::Recording => { + if self.pending_role.is_some() || self.recording.is_active() { + if self.recording.is_active() { + if let Some(sweep) = self.sweep.as_mut() { + sweep.point_started = true; + } + if stop_requested { + self.recording.stop_requested = true; + } + } + return; + } + // The recording coordinator is idle again: the point either + // finished, failed, or was refused before starting. + let point_started = self.sweep.as_ref().is_some_and(|sweep| sweep.point_started); + if stop_requested { + let message = self.message.clone(); + self.finish_sweep(context, message); + } else if !point_started || !self.recording_completed_ok { + let message = format!("Sweep aborted: {}", self.message); + self.finish_sweep(context, message); + } else if index + 1 >= total { + let message = match kind { + SweepKind::Amplitude => format!("Sweep complete: {total} points recorded"), + SweepKind::EventCount => self.message.clone(), + }; + if let Some(sweep) = self.sweep.as_mut() { + sweep.completed_ok = true; + } + self.finish_sweep(context, message); + } else { + if let Some(sweep) = self.sweep.as_mut() { + sweep.index += 1; + } + self.send_sweep_depth(context); + } + } + } + } + + /// Routes modulation-service replies belonging to the sweep. Returns true + /// when the reply was consumed. + fn on_sweep_reply(&mut self, reply: &PluginServiceReply) -> bool { + let Some((lease_req, depth_req)) = self + .sweep + .as_ref() + .map(|sweep| (sweep.lease_req, sweep.depth_req)) + else { + return false; + }; + let abort = |this: &mut Self, message: String| { + this.message = message; + if let Some(sweep) = this.sweep.as_mut() { + sweep.stop_requested = true; + } + }; + if reply.request_id == lease_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(sweep) = self.sweep.as_mut() { + sweep.lease_granted = true; + sweep.last_activity_ms = now_unix_ms(); + } + } + PluginServiceOutcome::Rejected { message, .. } => { + abort( + self, + format!("Sweep aborted: modulation lease rejected: {message}"), + ); + } + } + true + } else if reply.request_id == depth_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(sweep) = self.sweep.as_mut() { + sweep.depth_applied = true; + sweep.last_activity_ms = now_unix_ms(); + } + } + PluginServiceOutcome::Rejected { message, .. } => { + abort( + self, + format!("Sweep aborted: drive retarget rejected: {message}"), + ); + } + } + true + } else { + false + } + } + + // ---- exact event-count depth a₀ (ADR 013) ------------------------------ + + /// The lock stored for `hz`, whether or not it converged. + fn lock_for_frequency(&self, hz: f64) -> Option<&A0LockPoint> { + self.a0_locks + .iter() + .find(|lock| same_frequency(lock.frequency_hz, hz)) + } + + /// The lock that applies to the drive right now: same frequency, converged, + /// and aimed at the `a₀` currently entered. + /// + /// "Aimed at the same `a₀`" is judged against the operator's own convergence + /// tolerance, not on exact equality. The a₀ field is a drag control with a + /// 0.01 step, so a strict comparison disarmed a lock the operator had just + /// found the moment they nudged the slider — and then asked them to press + /// Find a₀ again, which is what they had done. + fn armed_lock(&self) -> Option<&A0LockPoint> { + let hz = self.frequency_hz()?; + let tolerance = self.a0_tolerance.max(1e-3); + self.lock_for_frequency(hz) + .filter(|lock| lock.converged && (lock.target_a - self.a0_target).abs() <= tolerance) + } + + /// The depth an `a₀` recording would be made at right now — the one + /// question every consumer of the lock table actually asks. + /// + /// With a measured depth source this is a stored, converged lock: the + /// commanded depth that was *found* to produce `a₀` at this frequency, and + /// there is no answer until [`Self::begin_a0_lock`] has found one. + /// + /// With a commanded depth source there is nothing to look up. `a₀` is + /// commanded directly, at every frequency, so the answer is always + /// available and is synthesised here rather than round-tripped through a + /// table of identical rows ([`DepthSource::needs_a0_lock`]). `trials: 0` + /// records honestly that no search happened. + fn armed_a0(&self) -> Option { + if self.depth_source.needs_a0_lock() { + return self.armed_lock().cloned(); + } + let hz = self.frequency_hz()?; + let target = self.a0_target; + (COMMANDED_A_MIN..=COMMANDED_A_MAX) + .contains(&target) + .then(|| A0LockPoint { + frequency_hz: hz, + target_a: target, + commanded_a: clamp_commanded_a(target), + measured_a: target, + trials: 0, + converged: true, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: None, + high_clip_fraction: None, + depth_source: self.depth_source, + }) + } + + /// Why no `a₀` recording can be made right now, phrased as the operator + /// action that fixes it. `None` means [`Self::armed_a0`] has an answer. + fn armed_a0_blocker(&self) -> Option { + if self.depth_source.needs_a0_lock() { + return self.armed_lock_blocker(); + } + if self.armed_a0().is_some() { + return None; + } + if self.frequency_hz().is_none() { + return Some(format!( + "there is no modulation frequency yet: {}", + self.frequency_blocker() + .unwrap_or_else(|| "no drive is armed".into()) + )); + } + Some(format!( + "a₀ = {:.3} is outside the drivable {COMMANDED_A_MIN}..={COMMANDED_A_MAX}", + self.a0_target + )) + } + + /// Why the stored locks do not arm a recording at the current frequency, + /// phrased as the operator action that fixes it. + /// + /// The three causes — no lock at this frequency, a lock that did not + /// converge, a lock aimed at a different a₀ — used to share one sentence + /// telling the operator to press Find a₀, which only helps for the first. + fn armed_lock_blocker(&self) -> Option { + if self.armed_lock().is_some() { + return None; + } + let Some(hz) = self.frequency_hz() else { + return Some(format!( + "there is no modulation frequency yet: {}", + self.frequency_blocker() + .unwrap_or_else(|| "no drive is armed".into()) + )); + }; + let label = frequency_label(hz); + let Some(lock) = self.lock_for_frequency(hz) else { + return Some(format!( + "no depth has been found for {label} yet — press Find a₀ at this frequency" + )); + }; + if !lock.converged { + return Some(format!( + "the last Find a₀ at {label} did not reach a₀ (it stopped at a measured {:.3}) — \ + press Find a₀ again, or widen the a₀ tolerance", + lock.measured_a + )); + } + Some(format!( + "the depth found for {label} was aimed at a₀ = {:.3}, and a₀ is now {:.3} — press \ + Find a₀ again at the new a₀", + lock.target_a, self.a0_target + )) + } + + fn a0_locks_path(&self) -> Option { + let folder = self.output_folder.trim(); + (!folder.is_empty()).then(|| Path::new(folder).join(A0_LOCK_FILE)) + } + + /// Store a finished lock, replacing any earlier one at the same frequency, + /// and mirror the table to disk. Returns a save failure for the caller to + /// append to its own message. + fn store_lock(&mut self, lock: A0LockPoint) -> Result<(), String> { + self.a0_locks + .retain(|existing| !same_frequency(existing.frequency_hz, lock.frequency_hz)); + self.a0_locks.push(lock); + self.a0_locks + .sort_by(|left, right| left.frequency_hz.total_cmp(&right.frequency_hz)); + self.save_a0_locks() + } + + /// Persist the lock table next to the recordings, so the found depths survive + /// a restart and can be cited offline. + /// + /// Returns the failure so the caller can append it to its own message: a + /// lock the operator can see on screen but that never reached disk is a + /// lock they will not have after a restart. + fn save_a0_locks(&mut self) -> Result<(), String> { + let Some(path) = self.a0_locks_path() else { + return Ok(()); + }; + let table = A0LockTable { + locks: self.a0_locks.clone(), + }; + let written = serde_json::to_string_pretty(&table) + .map_err(|error| error.to_string()) + .and_then(|text| { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write(&path, text).map_err(|error| error.to_string()) + }); + written.map_err(|error| format!("a₀ lock table save failed: {error}")) + } + + /// Re-read the lock table when the experiment folder changes. + fn load_a0_locks(&mut self) { + let folder = self.output_folder.trim().to_string(); + if self.loaded_locks_folder.as_deref() == Some(folder.as_str()) { + return; + } + self.loaded_locks_folder = Some(folder); + self.a0_locks.clear(); + let Some(path) = self.a0_locks_path() else { + return; + }; + if let Some(table) = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + { + self.a0_locks = table.locks; + } + } + + /// Worst-case lock duration, used as the modulation lease TTL. + fn a0_lock_lease_ttl_ms(&self) -> u64 { + let per_trial_ms = (self.settle_s.max(0.0) * 1_000.0) as u64 + SWEEP_SETTLE_TIMEOUT_MS; + u64::from(A0_LOCK_MAX_TRIALS) + .saturating_mul(per_trial_ms) + .saturating_add(60_000) + } + + /// Kick off the closed-loop `a₀` lock at the current frequency. + fn begin_a0_lock( + &mut self, + context: &mut impl RecordingControl, + inherited_lease: Option, + ) { + if self.recording.is_active() || self.sweep.is_some() || self.a0_lock.is_some() { + self.message = "A recording, sweep or a₀ lock is already running".into(); + return; + } + // There is nothing to search for when `a` *is* the command: the search + // would command a₀, read back a₀ and stop. Say so instead of spending + // a lease and a trial to arrive back where the operator already is. + if !self.depth_source.needs_a0_lock() { + self.message = format!( + "No search needed: with the depth coming from the commanded drive, a₀ = {:.3} is \ + simply commanded at every frequency. Press \"Record a₀ point\", or \"Record all \ + frequencies\" for the whole ladder.", + self.a0_target + ); + return; + } + if !self.modulation_connected() { + self.message = "Modulation owner is not connected — cannot find a₀".into(); + return; + } + // The lock table belongs to the experiment folder, and it is re-read + // whenever that folder changes: without one, a lock found now would be + // dropped the moment the operator picks the destination. + if self.output_folder.trim().is_empty() { + self.message = "Set an output folder before finding a₀".into(); + return; + } + let Some(hz) = self.frequency_hz() else { + self.message = format!( + "Cannot find a₀ without a modulation frequency: {}", + self.frequency_blocker() + .unwrap_or_else(|| "no drive is armed".into()) + ); + return; + }; + if let Some(reason) = self.depth_a_blocker() { + self.message = format!( + "Cannot find a₀ without a {} a: {reason}", + self.depth_source.verb() + ); + return; + } + // Refuse before touching the drive, not after eight trials of chasing a + // truncated estimate upwards. + if let Err(reason) = self.optical_window_covers_a_cycle(hz) { + self.message = format!("Cannot find a₀ at {}: {reason}", frequency_label(hz)); + return; + } + let target = self.a0_target; + if !(COMMANDED_A_MIN..=COMMANDED_A_MAX).contains(&target) { + self.message = format!( + "a₀ = {target:.3} is outside the drivable {COMMANDED_A_MIN}..={COMMANDED_A_MAX}" + ); + return; + } + // Warm start from an earlier lock at this frequency; otherwise trust the + // Pockels calibration for the first guess (command exactly `a₀`). + let start = self + .lock_for_frequency(hz) + .map(|lock| lock.commanded_a) + .unwrap_or(target); + let now_ms = now_unix_ms(); + let owns_lease = inherited_lease.is_none(); + let lease_id = inherited_lease.unwrap_or_else(|| { + LeaseId::new(format!("a1-a0-{}", format_compact_utc(now_ms / 1_000))) + }); + let mut lease_req = 0; + if owns_lease { + let ttl_ms = self.a0_lock_lease_ttl_ms(); + let request = + self.modulation_request(ModulationCommandV1::AcquireLease { ttl_ms }, &lease_id); + lease_req = request.request_id; + context.request_service(&request); + } + self.a0_lock = Some(A0Lock { + phase: A0LockPhase::AcquiringLease, + target_a: target, + tolerance: self.a0_tolerance.max(1e-3), + commanded_a: clamp_commanded_a(start), + frequency_hz: hz, + trial: 1, + samples: Vec::new(), + sampled_revision: None, + measure_from_ms: 0, + window_ms: 0, + deadline_ms: 0, + lease_id, + lease_granted: !owns_lease, + lease_req, + owns_lease, + depth_req: 0, + depth_applied: false, + last_activity_ms: now_ms, + stop_requested: false, + }); + self.message = if owns_lease { + format!( + "a₀ lock at {}: acquiring the modulation lease…", + frequency_label(hz) + ) + } else { + format!( + "a₀ lock at {}: trimming the drive depth…", + frequency_label(hz) + ) + }; + } + + /// Renew the lease and command the current trial's depth. + fn send_a0_depth(&mut self, context: &mut impl RecordingControl) { + let Some(lock) = self.a0_lock.as_ref() else { + return; + }; + let lease_id = lock.lease_id.clone(); + let commanded = lock.commanded_a; + let trial = lock.trial; + let target = lock.target_a; + + let ttl_ms = self.a0_lock_lease_ttl_ms(); + let renew = self.modulation_request(ModulationCommandV1::RenewLease { ttl_ms }, &lease_id); + context.request_service(&renew); + let depth = self.modulation_request( + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: depth_a_milli(commanded), + }, + &lease_id, + ); + let depth_req = depth.request_id; + context.request_service(&depth); + + let now_ms = now_unix_ms(); + if let Some(lock) = self.a0_lock.as_mut() { + lock.phase = A0LockPhase::SettingDepth; + lock.depth_req = depth_req; + lock.depth_applied = false; + lock.samples.clear(); + lock.sampled_revision = None; + lock.last_activity_ms = now_ms; + } + self.message = format!( + "a₀ lock trial {trial}/{A0_LOCK_MAX_TRIALS}: commanding a = {commanded:.3} for a {} \ + a₀ = {target:.3}…", + self.depth_source.verb() + ); + } + + /// Release the modulation lease and clear the lock. + /// + /// Never `safe_off`: the drive must stay exactly where the lock left it, so + /// the event-count point that follows records at `a₀`. + fn finish_a0_lock(&mut self, context: &mut impl RecordingControl, message: String) { + if let Some(lock) = self.a0_lock.take() { + if lock.owns_lease && lock.lease_granted { + let request = self.modulation_request( + ModulationCommandV1::ReleaseLease { + safe_off: false, + reason: "a1 a0 lock finished".into(), + }, + &lock.lease_id, + ); + context.request_service(&request); + } + } + self.message = message; + } + + /// Length of the photodiode's contrast estimator window, in milliseconds. + /// + /// This is the time a commanded depth needs to fully replace the previous + /// one inside the estimate. Owners that predate the field do not publish + /// it; then only the operator's settle dwell is available. + fn optical_window_seconds(&self) -> Option { + // A window only bounds a depth that is read out of it. A commanded + // depth is not, so there is nothing here to wait for or to check. + if self.depth_source != DepthSource::Photodiode { + return None; + } + self.photodiode + .as_ref()? + .optical_summary + .as_ref()? + .window_seconds + .filter(|seconds| seconds.is_finite() && *seconds > 0.0) + } + + /// [`Self::optical_window_seconds`] rounded up to the millisecond the lock's + /// timers work in. + fn optical_window_ms(&self) -> Option { + self.optical_window_seconds() + .map(|seconds| (seconds * 1_000.0).ceil() as u64) + } + + /// Whether the photodiode's estimator window spans at least one full + /// modulation cycle at `hz`, i.e. whether the published `a` can be a + /// peak-to-peak measurement at all. + /// + /// The owner refuses on its own when its markers can prove the window is + /// too short. It cannot when it has no marker stream — but A1 always knows + /// the frequency, from its own phase-0 triggers or the armed drive, so the + /// check is repeated here where the knowledge is. Getting this wrong is not + /// a small error: a sub-cycle window *under*-reports `a`, and the lock + /// divides by it, so it would drive the depth up until it rails. + fn optical_window_covers_a_cycle(&self, hz: f64) -> Result<(), String> { + let Some(window_seconds) = self.optical_window_seconds() else { + return Ok(()); + }; + let cycles = window_seconds * hz; + if cycles >= 1.0 { + return Ok(()); + } + Err(format!( + "the photodiode estimates a over {window_seconds:.4} s, only {cycles:.2} cycles at \ + {} — a is a peak-to-peak quantity and would be under-reported. Raise the photodiode \ + cache length to at least {:.0} s", + frequency_label(hz), + (2.0 / hz).ceil().max(1.0), + )) + } + + /// Take one reading per *independent* photodiode window. + /// + /// Two constraints, both about the estimator window rather than the + /// publisher: a reading must come from a summary that did not exist when + /// the depth was commanded (`sampled_revision`), and consecutive readings + /// must be at least [`A0_LOCK_SAMPLE_SPACING`] of a window apart — + /// otherwise they share nearly all their samples and three of them say no + /// more than one. + fn sample_a0_measurement(&mut self, now_ms: u64) { + let Some((revision, measured)) = self.depth_reading() else { + return; + }; + let spacing_ms = self.a0_sample_spacing_ms(); + // The stale-window rule exists because the photodiode's estimate mixes + // samples from before and after the depth changed. A commanded depth is + // not read out of a window at all — it is the value that was just + // applied — so holding it to the same rule would only make the trial + // depend on the modulation owner's device-poll cadence, and time out + // whenever that owner had nothing new to say. + let requires_new_revision = self.depth_source == DepthSource::Photodiode; + let Some(lock) = self.a0_lock.as_mut() else { + return; + }; + if now_ms < lock.measure_from_ms + || (requires_new_revision && lock.sampled_revision == Some(revision)) + { + return; + } + lock.sampled_revision = Some(revision); + lock.samples.push(measured); + lock.measure_from_ms = now_ms.saturating_add(spacing_ms); + } + + /// One depth reading from the active [`DepthSource`], tagged with the + /// publishing owner's service revision. + /// + /// The revision is what makes a reading *independent*: the lock only counts + /// values published after it commanded the depth, so a trial never averages + /// in the previous one. Both owners bump their revision on every state + /// change, so the same rule works for either source. + fn depth_reading(&self) -> Option<(u64, f64)> { + match self.depth_source { + DepthSource::Photodiode => self.photodiode.as_ref().and_then(|summary| { + summary + .optical_summary + .as_ref() + .map(|optical| (summary.service_revision, optical.measured_log_contrast)) + }), + DepthSource::Commanded => self.commanded_a().map(|a| { + ( + self.modulation.as_ref().map_or(0, |s| s.service_revision), + a, + ) + }), + } + } + + /// Minimum gap between two readings of one trial. + fn a0_sample_spacing_ms(&self) -> u64 { + let window_ms = self + .a0_lock + .as_ref() + .map(|lock| lock.window_ms) + .unwrap_or_default(); + ((window_ms as f64) * A0_LOCK_SAMPLE_SPACING).ceil() as u64 + } + + /// Photodiode clipping note for a lock message, empty when the windows are clean. + fn clip_warning(&self) -> String { + // A clipped detector window says nothing about a commanded depth, and + // appending it to that lock's message would suggest it did. + if self.depth_source != DepthSource::Photodiode { + return String::new(); + } + let Some(optical) = self + .photodiode + .as_ref() + .and_then(|summary| summary.optical_summary.as_ref()) + else { + return String::new(); + }; + if optical.low_clip_fraction.max(optical.high_clip_fraction) <= A0_LOCK_CLIP_WARNING { + return String::new(); + } + format!( + " — warning: photodiode clipping (low {:.1} %, high {:.1} %), the measured a is a \ + truncated estimate", + optical.low_clip_fraction * 100.0, + optical.high_clip_fraction * 100.0 + ) + } + + /// Close out one trial: converged, out of trials, at a drive limit, or one + /// more multiplicative correction. + fn evaluate_a0_trial(&mut self, context: &mut impl RecordingControl) { + let Some(lock) = self.a0_lock.as_ref() else { + return; + }; + let (target, tolerance, commanded, trial, hz) = ( + lock.target_a, + lock.tolerance, + lock.commanded_a, + lock.trial, + lock.frequency_hz, + ); + let mut readings = lock.samples.clone(); + if readings.is_empty() { + // The owner withholds `a` for a stated reason (clipping, no + // 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 + .depth_a_blocker() + .unwrap_or_else(|| "it published nothing while the lock was measuring".into()); + self.finish_a0_lock( + context, + format!("a₀ lock aborted: no depth a arrived while measuring — {reason}"), + ); + return; + } + readings.sort_by(f64::total_cmp); + let measured = readings[readings.len() / 2]; + let spread = readings[readings.len() - 1] - readings[0]; + if measured <= 0.0 { + self.finish_a0_lock( + context, + format!( + "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() + ), + ); + return; + } + // A drifting `a` that happens to cross the target on one reading is not + // a lock: the next action would record at whatever it drifted to. + if readings.len() > 1 && spread > tolerance * A0_LOCK_MAX_SPREAD_TOLERANCES { + self.finish_a0_lock( + context, + 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 placement-specific \ + photodiode reference", + frequency_label(hz), + readings.len(), + A0_LOCK_MAX_SPREAD_TOLERANCES, + ), + ); + return; + } + + let converged = (measured - target).abs() <= tolerance; + // The delivered optical depth is proportional to the commanded one to + // first order, so one gain correction per trial converges in a couple of + // steps even where the drive rolls off at high frequency. + let ratio = (target / measured).clamp(1.0 / A0_LOCK_MAX_STEP_RATIO, A0_LOCK_MAX_STEP_RATIO); + let next = clamp_commanded_a(commanded * ratio); + let railed = !converged && (next - commanded).abs() < 1e-9; + let exhausted = trial >= A0_LOCK_MAX_TRIALS; + + if !converged && !railed && !exhausted { + if let Some(lock) = self.a0_lock.as_mut() { + lock.commanded_a = next; + lock.trial += 1; + } + self.message = format!( + "a₀ lock trial {trial}: {} a = {measured:.3} vs a₀ = {target:.3} — correcting the \ + commanded depth to {next:.3}", + self.depth_source.verb() + ); + self.send_a0_depth(context); + return; + } + + let optical = self + .photodiode + .as_ref() + .and_then(|summary| summary.optical_summary.as_ref()); + let saved = self.store_lock(A0LockPoint { + frequency_hz: hz, + target_a: target, + commanded_a: commanded, + measured_a: measured, + trials: trial, + converged, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: optical.map(|optical| optical.low_clip_fraction), + high_clip_fraction: optical.map(|optical| optical.high_clip_fraction), + depth_source: self.depth_source, + }); + let label = frequency_label(hz); + // "measures" is a claim about the light. Open loop the lock has only + // confirmed that the drive accepted the depth, so say that instead. + let verb = match self.depth_source { + DepthSource::Photodiode => "measures", + DepthSource::Commanded => "is commanded as", + }; + let message = if converged { + format!( + "a₀ locked at {label}: commanded a = {commanded:.3} {verb} a = {measured:.3} \ + (a₀ = {target:.3}, {trial} trial(s)){}", + self.clip_warning() + ) + } else if railed { + format!( + "a₀ lock stopped at {label}: commanded a = {commanded:.3} is at the drivable limit \ + and only {verb} a = {measured:.3} — lower a₀ or the operating point I_k" + ) + } else { + format!( + "a₀ lock did not converge at {label}: best commanded a = {commanded:.3} {verb} \ + a = {measured:.3} after {trial} trials — widen the tolerance or check the drive" + ) + }; + // A lock the operator can see but that never reached disk is a lock + // they will not have after a restart — say so on the same line. + let message = match saved { + Ok(()) => message, + Err(error) => format!("{message} — {error}"), + }; + self.finish_a0_lock(context, message); + } + + /// Advance the `a₀` lock one control tick. + fn drive_a0_lock(&mut self, context: &mut impl RecordingControl) { + if self.a0_lock.is_none() { + if std::mem::take(&mut self.a0_lock_pending) { + self.begin_a0_lock(context, None); + } + return; + } + self.a0_lock_pending = false; + let now_ms = now_unix_ms(); + let (phase, stop_requested, lease_granted, depth_applied, last_activity_ms) = { + let lock = self.a0_lock.as_ref().expect("lock checked above"); + ( + lock.phase, + lock.stop_requested, + lock.lease_granted, + lock.depth_applied, + lock.last_activity_ms, + ) + }; + if stop_requested { + let message = if self.message.is_empty() { + "a₀ lock stopped".into() + } else { + self.message.clone() + }; + self.finish_a0_lock(context, message); + return; + } + match phase { + A0LockPhase::AcquiringLease => { + if lease_granted { + self.send_a0_depth(context); + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_a0_lock( + context, + "a₀ lock aborted: timed out acquiring the modulation lease".into(), + ); + } + } + A0LockPhase::SettingDepth => { + if depth_applied { + // The drive settles for the operator's dwell, and the + // photodiode's own estimator window has to roll over before + // the published `a` is free of the previous depth. Waiting + // for only the shorter of the two silently measures a + // mixture — with the 0.82 s default window that is every + // settle below ~1 s, and it gets worse at low frequency + // where the window grows to cover whole cycles. + let window_ms = self.optical_window_ms().unwrap_or_default(); + let dwell_ms = ((self.settle_s.max(0.0) * 1_000.0) as u64).max(window_ms); + // Only summaries published *after* this depth was commanded + // count, so the trial never averages the previous depth. + let published = self.depth_reading().map(|(revision, _)| revision); + if let Some(lock) = self.a0_lock.as_mut() { + lock.phase = A0LockPhase::Measuring; + lock.window_ms = window_ms; + lock.measure_from_ms = now_ms.saturating_add(dwell_ms); + // The deadline has to outlast the readings it is + // waiting for, or a low-frequency point times out + // before its first independent sample can exist. + let sampling_ms = + (window_ms as f64 * A0_LOCK_SAMPLE_SPACING * A0_LOCK_SAMPLES as f64) + .ceil() as u64; + lock.deadline_ms = lock + .measure_from_ms + .saturating_add(SWEEP_SETTLE_TIMEOUT_MS.max(sampling_ms * 2)); + lock.samples.clear(); + lock.sampled_revision = published; + } + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_a0_lock( + context, + "a₀ lock aborted: timed out retargeting the modulation drive".into(), + ); + } + } + A0LockPhase::Measuring => { + self.sample_a0_measurement(now_ms); + let ready = self.a0_lock.as_ref().is_some_and(|lock| { + lock.samples.len() >= A0_LOCK_SAMPLES || now_ms >= lock.deadline_ms + }); + if ready { + self.evaluate_a0_trial(context); + } + } + } + } + + /// Routes modulation-service replies belonging to the `a₀` lock. Returns true + /// when the reply was consumed. + fn on_a0_lock_reply(&mut self, reply: &PluginServiceReply) -> bool { + let Some((lease_req, depth_req)) = self + .a0_lock + .as_ref() + .map(|lock| (lock.lease_req, lock.depth_req)) + else { + return false; + }; + let abort = |this: &mut Self, message: String| { + this.message = message; + if let Some(lock) = this.a0_lock.as_mut() { + lock.stop_requested = true; + } + }; + if reply.request_id == lease_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(lock) = self.a0_lock.as_mut() { + lock.lease_granted = true; + lock.last_activity_ms = now_unix_ms(); + } + } + PluginServiceOutcome::Rejected { message, .. } => abort( + self, + format!("a₀ lock aborted: modulation lease rejected: {message}"), + ), + } + true + } else if reply.request_id == depth_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(lock) = self.a0_lock.as_mut() { + lock.depth_applied = true; + lock.last_activity_ms = now_unix_ms(); + } + } + // The owner refuses a depth its calibrated drive cannot express + // (lobe ceiling, DAC limit) — that *is* the "a₀ unreachable at + // this operating point" answer, so surface its wording verbatim. + PluginServiceOutcome::Rejected { message, .. } => abort( + self, + format!("a₀ lock aborted: the drive rejected the commanded depth: {message}"), + ), + } + true + } else { + false + } + } + + // ---- multi-frequency a₀ ladder ----------------------------------------- + + /// The planned frequency ladder, log-spaced and inclusive of both ends. + /// + /// Log spacing because `|H(f)|` is read per decade: a linear ladder spends + /// most of its points where the response is flat and none where it rolls + /// off. + fn planned_frequencies(&self) -> Vec { + let count = self.freq_count.clamp(1, FREQ_SWEEP_MAX_POINTS as u32) as usize; + if count == 1 { + return vec![self.min_f]; + } + let (low, high) = (self.min_f.ln(), self.max_f.ln()); + (0..count) + .map(|index| (low + (high - low) * index as f64 / (count - 1) as f64).exp()) + .collect() + } + + /// The planned ladder in the order it will actually be visited, with the + /// interleaved low-frequency reference repeats inserted. + fn freq_sweep_points(&self) -> Vec { + let mut ladder = self.planned_frequencies(); + match self.freq_order { + FreqOrder::Ascending => {} + FreqOrder::Descending => ladder.reverse(), + FreqOrder::Alternating => { + // Lowest, highest, second lowest, second highest, … + let mut out = Vec::with_capacity(ladder.len()); + let (mut low, mut high) = (0usize, ladder.len()); + while low < high { + out.push(ladder[low]); + low += 1; + if low < high { + high -= 1; + out.push(ladder[high]); + } + } + ladder = out; + } + FreqOrder::Random => { + // A seeded Fisher-Yates with a small xorshift, so the executed + // order is reproducible from the seed recorded in the sidecar. + let mut state = self.freq_seed.max(1); + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for index in (1..ladder.len()).rev() { + ladder.swap(index, (next() % (index as u64 + 1)) as usize); + } + } + } + let reference_hz = self.planned_frequencies().first().copied(); + let every = self.freq_reference_every as usize; + let mut points = Vec::with_capacity(ladder.len() * 2); + for (visited, frequency_hz) in ladder.into_iter().enumerate() { + points.push(FreqSweepPoint { + frequency_hz, + is_reference: false, + }); + // Interleave the low-frequency reference so drift across the block + // shows up as a disagreement between its repeats (A1 checklist, + // "interleave a low-frequency reference to expose drift"). + if let Some(reference_hz) = reference_hz.filter(|_| every > 0) { + if (visited + 1) % every == 0 { + points.push(FreqSweepPoint { + frequency_hz: reference_hz, + is_reference: true, + }); + } + } + } + points + } + + /// Lease TTL for the whole ladder: what one rung costs, times the rungs + /// left. A depth-sweep rung is a whole inner sweep, so it is the expensive + /// one by a factor of the point count — a TTL sized for an `a₀` point would + /// expire mid-curve and hand the drive back to the operator's settings. + fn freq_sweep_lease_ttl_ms(&self, remaining_points: usize, mode: FreqSweepMode) -> u64 { + let inner_ms = match mode { + FreqSweepMode::A0Point => self + .a0_lock_lease_ttl_ms() + .saturating_add(self.sweep_lease_ttl_ms(1)), + FreqSweepMode::DepthSweep => { + self.sweep_lease_ttl_ms(self.sweep_count.clamp(2, 64) as usize) + } + }; + let per_point_ms = inner_ms.saturating_add(FREQ_CONFIRM_BASE_MS); + (remaining_points as u64) + .saturating_mul(per_point_ms) + .saturating_add(60_000) + } + + /// Kick off the multi-frequency run: validate the whole plan, then lease. + /// + /// Everything checkable is checked *here*, before the drive moves: a plan + /// that cannot work at its lowest frequency should say so in a message, not + /// two hours into a block. + fn begin_freq_sweep(&mut self, context: &mut impl RecordingControl, mode: FreqSweepMode) { + if self.recording.is_active() + || self.sweep.is_some() + || self.a0_lock.is_some() + || self.freq_sweep.is_some() + { + self.message = "A recording, sweep or a₀ lock is already running".into(); + return; + } + if self.output_folder.trim().is_empty() { + self.message = "Pick an output folder first — that is where the files go".into(); + return; + } + if !self.modulation_connected() { + self.message = "The modulation plugin is not connected — connect it to drive the \ + frequency" + .into(); + return; + } + // Every point of the ladder ends in a recording, so ask the recording's + // own question here. It used to be asked for the first time three stages + // in, at point 1, after the drive had already been retargeted — which is + // how the panel came to read "Recording: idle" mid-ladder. + if let Some(blocker) = self.photodiode_blocker() { + self.message = blocker; + return; + } + // Written through `partial_cmp` so a NaN from the settings drag is + // rejected rather than silently passing a negated comparison. + let range_ok = self.min_f.partial_cmp(&0.0) == Some(std::cmp::Ordering::Greater) + && matches!( + self.max_f.partial_cmp(&self.min_f), + Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal) + ); + if !range_ok { + self.message = "Set Sweep min f above 0 and Sweep max f at or above it".into(); + return; + } + if let Some(reason) = self.depth_a_blocker() { + self.message = format!( + "The frequency sweep needs a {} depth a, but {reason}", + self.depth_source.verb() + ); + return; + } + // Each mode reads a different depth setting, so each validates its own. + match mode { + FreqSweepMode::A0Point => { + let target = self.a0_target; + if !(COMMANDED_A_MIN..=COMMANDED_A_MAX).contains(&target) { + self.message = format!( + "a₀ = {target:.3} is outside the drivable \ + {COMMANDED_A_MIN}..={COMMANDED_A_MAX}" + ); + return; + } + } + FreqSweepMode::DepthSweep => { + // Same two questions `begin_sweep` asks of the depth range, + // asked here before the drive moves rather than at the first + // rung — a ladder that cannot record its inner sweep should say + // so on the button press. + if self.min_a.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { + self.message = "Set Sweep min a above 0 — a = 0 is the background reference, \ + which has its own button" + .into(); + return; + } + if self.max_a.partial_cmp(&self.min_a) != Some(std::cmp::Ordering::Greater) { + self.message = "Sweep max a must be larger than Sweep min a".into(); + return; + } + } + } + // The photodiode estimates `a` over one window for all frequencies, so + // the *lowest* planned frequency decides whether the ladder is + // measurable at all. Refuse the plan, not its 9th point. + if let Err(reason) = self.optical_window_covers_a_cycle(self.min_f) { + self.message = format!("Frequency sweep refused at its lowest point: {reason}"); + return; + } + // Only the measured source needs the camera trigger: it is what confirms + // each commanded frequency and what anchors the fold the point is scored + // in. Commanded mode confirms against the modulation owner instead + // (ADR 021), so requiring markers here would refuse a ladder that can + // run perfectly well. + if self.depth_source.needs_a0_lock() && !self.is_marker_anchored() { + // Without the phase-0 trigger there is nothing that can confirm the + // drive actually reached a commanded frequency, and the fold has no + // anchor either. Live analysis off and a missing trigger cable look + // identical from the marker count, so name whichever one it is. + self.message = if self.live { + "No phase-0 trigger markers — the sweep cannot confirm a commanded frequency. \ + Check the EXT_TRIGGER wiring from the Teensy to the camera" + .into() + } else { + "Live analysis is off, so no phase-0 markers are ingested and the sweep cannot \ + confirm a commanded frequency. Enable Live analysis" + .into() + }; + return; + } + let points = self.freq_sweep_points(); + if points.is_empty() { + self.message = "Nothing to sweep: the frequency ladder has no points".into(); + return; + } + let now_ms = now_unix_ms(); + let lease_id = LeaseId::new(format!("a1-fsweep-{}", format_compact_utc(now_ms / 1_000))); + let ttl_ms = self.freq_sweep_lease_ttl_ms(points.len(), mode); + let request = + self.modulation_request(ModulationCommandV1::AcquireLease { ttl_ms }, &lease_id); + let lease_req = request.request_id; + context.request_service(&request); + let total = points.len(); + self.freq_sweep = Some(FreqSweep { + phase: FreqSweepPhase::AcquiringLease, + mode, + points, + index: 0, + lease_id, + lease_granted: false, + lease_req, + freq_req: 0, + freq_applied: false, + confirm_deadline_ms: 0, + skip_reason: None, + failed: Vec::new(), + recorded: 0, + order: self.freq_order, + seed: self.freq_seed, + last_activity_ms: now_ms, + stop_requested: false, + }); + self.message = match mode { + FreqSweepMode::A0Point => format!( + "Frequency sweep: acquiring the modulation lease for {total} points ({} order)…", + self.freq_order.label() + ), + FreqSweepMode::DepthSweep => format!( + "Depth sweep at every frequency: acquiring the modulation lease for {total} × {} \ + recordings ({} order)…", + self.sweep_count.clamp(2, 64), + self.freq_order.label() + ), + }; + } + + /// Release the ladder's lease (if this run holds it) and clear the sweep. + /// + /// `safe_off = false` as everywhere else: stopping the drive is the owner's + /// lease-expiry job, not a sweep's. Releasing does hand the operator's own + /// frequency and depth back, because the owner parks them on the first + /// retarget. + fn finish_freq_sweep(&mut self, context: &mut impl RecordingControl, message: String) { + if let Some(sweep) = self.freq_sweep.take() { + if sweep.lease_granted { + let request = self.modulation_request( + ModulationCommandV1::ReleaseLease { + safe_off: false, + reason: "a1 frequency sweep finished".into(), + }, + &sweep.lease_id, + ); + context.request_service(&request); + } + } + self.message = message; + } + + /// Renew the ladder's lease and retarget the drive at the current point. + fn send_freq_sweep_frequency(&mut self, context: &mut impl RecordingControl) { + let Some(sweep) = self.freq_sweep.as_ref() else { + return; + }; + let lease_id = sweep.lease_id.clone(); + let remaining = sweep.points.len().saturating_sub(sweep.index); + let hz = sweep.frequency_hz(); + let (index, total) = (sweep.index, sweep.points.len()); + let is_reference = sweep.point().is_some_and(|point| point.is_reference); + let mode = sweep.mode; + + let ttl_ms = self.freq_sweep_lease_ttl_ms(remaining, mode); + let renew = self.modulation_request(ModulationCommandV1::RenewLease { ttl_ms }, &lease_id); + context.request_service(&renew); + let request = self.modulation_request( + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: (hz * 1_000.0).round().max(0.0) as u64, + }, + &lease_id, + ); + let freq_req = request.request_id; + context.request_service(&request); + + // The retained markers and events belong to the *previous* frequency: + // the measured period is their mean spacing, so leaving them in place + // would confirm the new frequency against a mixture of the two. The + // pilot windows are frozen at a phase of the old period and are not + // transferable either — a point recorded against them would be scored + // in the wrong window. + self.camera_markers_us.clear(); + self.camera_events.clear(); + self.fold_cache.replace(None); + self.pilot_windows = None; + + let now_ms = now_unix_ms(); + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.phase = FreqSweepPhase::SettingFrequency; + sweep.freq_req = freq_req; + sweep.freq_applied = false; + sweep.skip_reason = None; + sweep.last_activity_ms = now_ms; + } + self.message = format!( + "Frequency sweep {}/{total}: retargeting the drive to {}{}…", + index + 1, + frequency_label(hz), + if is_reference { " (reference)" } else { "" }, + ); + } + + /// Hand the current ladder point to the one-point event-count recording. + /// + /// Reached either from the finished `a₀` search (measured depth source) or + /// straight from the confirmed frequency (commanded source, where there is + /// no search). Both need the same question answered — *what depth is armed + /// at this frequency?* — so both ask [`Self::armed_a0`] and neither knows + /// which regime it is in. + fn start_freq_sweep_recording(&mut self, context: &mut impl RecordingControl) { + let Some((mode, hz, index, total)) = self.freq_sweep.as_ref().map(|sweep| { + ( + sweep.mode, + sweep.frequency_hz(), + sweep.index, + sweep.points.len(), + ) + }) else { + return; + }; + // A non-converged lock is stored but never arms a recording, so this is + // the single question worth asking for an `a₀` rung. + if mode.needs_armed_depth() && self.armed_a0().is_none() { + let reason = self + .armed_a0_blocker() + .unwrap_or_else(|| self.message.clone()); + self.fail_freq_sweep_point(context, reason); + return; + } + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.phase = FreqSweepPhase::Recording; + } + let lease = self.freq_sweep.as_ref().map(|sweep| sweep.lease_id.clone()); + let label = frequency_label(hz); + match mode { + FreqSweepMode::A0Point => self.begin_a0_point(context, lease), + // The inner run is the *unchanged* amplitude sweep, handed this + // ladder's lease so the operator's drive settings stay locked out + // from the first frequency to the last rather than being handed + // back between rungs. + FreqSweepMode::DepthSweep => { + let points = self.sweep_points(); + let message = format!( + "Depth sweep {}/{total} at {label}: {} depths…", + index + 1, + points.len() + ); + self.begin_leased_sweep( + context, + SweepKind::Amplitude, + points, + None, + lease, + message, + ); + } + } + if self.sweep.is_none() { + let reason = self.message.clone(); + self.fail_freq_sweep_point(context, reason); + return; + } + if mode == FreqSweepMode::A0Point { + self.message = format!( + "Frequency sweep {}/{total}: recording the a₀ point at {label}…", + index + 1 + ); + } + } + + /// Give up on the current point and move to the next one. + /// + /// A frequency that cannot be locked or recorded does not end the ladder: + /// the remaining points are still worth having, and the failure is already + /// in the lock table. It is reported in the final summary. + fn fail_freq_sweep_point(&mut self, context: &mut impl RecordingControl, reason: String) { + let hz = self + .freq_sweep + .as_ref() + .map(FreqSweep::frequency_hz) + .unwrap_or_default(); + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.failed.push(hz); + } + self.message = format!( + "Frequency sweep: skipping {} — {reason}", + frequency_label(hz) + ); + self.advance_freq_sweep(context); + } + + /// Move to the next ladder point, or finish with a summary. + fn advance_freq_sweep(&mut self, context: &mut impl RecordingControl) { + let done = match self.freq_sweep.as_mut() { + Some(sweep) => { + sweep.index += 1; + sweep.index >= sweep.points.len() + } + None => return, + }; + if !done { + self.send_freq_sweep_frequency(context); + return; + } + let Some((mode, recorded, failed, total, order, seed)) = + self.freq_sweep.as_ref().map(|sweep| { + ( + sweep.mode, + sweep.recorded, + sweep.failed.clone(), + sweep.points.len(), + sweep.order, + sweep.seed, + ) + }) + else { + return; + }; + let mut message = match mode { + FreqSweepMode::A0Point => format!( + "Frequency sweep complete: {recorded}/{total} points recorded ({} order, seed \ + {seed})", + order.label() + ), + FreqSweepMode::DepthSweep => format!( + "Depth sweep at every frequency complete: {recorded}/{total} frequencies × {} \ + depths recorded ({} order, seed {seed})", + self.sweep_count.clamp(2, 64), + order.label() + ), + }; + if !failed.is_empty() { + let list = failed + .iter() + .map(|hz| frequency_label(*hz)) + .collect::>() + .join(", "); + message.push_str(&format!( + " — {} skipped: {list}{}", + failed.len(), + if mode.needs_armed_depth() && self.depth_source.needs_a0_lock() { + ". See the a₀ lock table" + } else { + "" + } + )); + } + self.finish_freq_sweep(context, message); + } + + // ---- declarative protocol runs ------------------------------------- + + /// 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: 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() + .map(|point| point.duration_s as f64 + point.settle_s) + .sum(); + // Doubled: every point also spends time on the start/finalize + // handshake, which is not in the protocol's own numbers. + ((remaining * 2_000.0) as u64).saturating_add(60_000) + } + + /// Load, validate and start the protocol named in the settings. + /// + /// Everything checkable is checked here, before the drive moves — the + /// whole point of a protocol is that it runs unattended, so a file that + /// cannot work should say so on the button press rather than at 3 a.m. + fn begin_protocol(&mut self, context: &mut impl RecordingControl) { + if self.recording.is_active() + || self.sweep.is_some() + || self.a0_lock.is_some() + || self.freq_sweep.is_some() + || self.protocol.is_some() + { + self.message = "A recording, sweep or protocol is already running".into(); + return; + } + if self.output_folder.trim().is_empty() { + self.message = "Pick an output folder first — that is where the files go".into(); + return; + } + let path = self.protocol_path.trim().to_owned(); + if path.is_empty() { + 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) => { + self.message = format!("Cannot read {path}: {error}"); + return; + } + }; + let plan = match protocol::parse_file(&path, &text) { + Ok(plan) => plan, + Err(error) => { + self.message = format!("Protocol rejected — {error}"); + return; + } + }; + let source_sha256 = format!("{:x}", Sha256::digest(text.as_bytes())); + // The photodiode measures `a` over one window for every frequency, so + // the lowest frequency in the file decides whether the survey is + // measurable at all. Refuse the plan, not its 40th point. + let lowest = 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) { + self.message = format!( + "Protocol refused at its lowest frequency ({}): {reason}", + frequency_label(lowest) + ); + return; + } + } + let highest = plan + .points + .iter() + .map(|point| point.frequency_hz) + .fold(0.0_f64, f64::max); + if let Some(blocker) = self.photodiode_measurement_blocker(highest) { + self.message = format!( + "Protocol refused at its highest frequency ({}): {blocker}", + frequency_label(highest) + ); + return; + } + + let controls_camera = plan.camera.is_some() + || plan + .points + .iter() + .any(|point| point.diff_on.is_some() || point.diff_off.is_some()); + + let now_ms = now_unix_ms(); + let lease_id = LeaseId::new(format!( + "a1-protocol-{}", + format_compact_utc(now_ms / 1_000) + )); + 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 bench_time = format_bench_time(plan.total_seconds()); + self.message = format!( + "Protocol '{}': {total} recordings ({means} × ū, {frequencies} × f, {depths} × a), \ + about {bench_time} of bench time — preparing the camera and modulation lease…", + plan.name + ); + self.protocol = Some(ProtocolRun { + plan, + source_path: path, + source_sha256, + source_text: text, + phase, + index: 0, + lease_id, + lease_granted: false, + 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, + 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(); + } + } + + /// 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) { + if let Some(run) = self.protocol.take() { + if run.lease_granted { + let request = self.modulation_request( + ModulationCommandV1::ReleaseLease { + safe_off: false, + reason: "a1 protocol finished".into(), + }, + &run.lease_id, + ); + context.request_service(&request); + } + } + self.message = message; + } + + /// Renew the lease and retarget all three axes at the current point. + fn send_protocol_point(&mut self, context: &mut impl RecordingControl) { + let Some(run) = self.protocol.as_ref() else { + return; + }; + let Some(point) = run.point().cloned() else { + return; + }; + let lease_id = run.lease_id.clone(); + 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); + + // All three axes, every point. A protocol states the whole operating + // condition, so nothing is left at whatever the previous point or the + // operator happened to leave behind. + let mut pending = Vec::with_capacity(3); + for command in [ + ModulationCommandV1::SetOperatingPoint { + mean_u_milli: (point.mean_u * 1_000.0).round().clamp(0.0, 1_000.0) as u32, + }, + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: (point.frequency_hz * 1_000.0).round().max(0.0) as u64, + }, + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: depth_a_milli(point.depth_a), + }, + ] { + let request = self.modulation_request(command, &lease_id); + pending.push(request.request_id); + 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 + // windows are frozen at a phase of the old period and do not transfer. + self.camera_markers_us.clear(); + self.camera_events.clear(); + self.fold_cache.replace(None); + self.pilot_windows = None; + + let now_ms = now_unix_ms(); + if let Some(run) = self.protocol.as_mut() { + run.phase = ProtocolPhase::Retargeting; + run.pending_reqs = pending; + run.bias_req = bias_request; + run.bias_confirmation = None; + run.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!( + "Protocol {}/{total} [{}]: ū={:.2}, f={}, a={:.2}…", + index + 1, + point.block, + point.mean_u, + frequency_label(point.frequency_hz), + point.depth_a, + ); + } + + /// Give up on the current point and move to the next. + fn fail_protocol_point(&mut self, context: &mut impl RecordingControl, reason: String) { + let Some(run) = self.protocol.as_mut() else { + return; + }; + let index = run.index; + run.failed.push((index, reason.clone())); + let point = run.plan.points[index].clone(); + self.message = format!( + "Protocol point {} (ū={:.2}, f={}, a={:.2}) skipped: {reason}", + index + 1, + point.mean_u, + frequency_label(point.frequency_hz), + point.depth_a, + ); + self.advance_protocol(context); + } + + /// Step to the next point, or finish. + fn advance_protocol(&mut self, context: &mut impl RecordingControl) { + let Some(run) = self.protocol.as_mut() else { + return; + }; + run.index += 1; + run.last_activity_ms = now_unix_ms(); + if run.index < run.plan.points.len() && !run.stop_requested { + self.send_protocol_point(context); + return; + } + let (name, recorded, failed, total) = ( + run.plan.name.clone(), + run.recorded, + run.failed.clone(), + run.plan.points.len(), + ); + let stopped = run.stop_requested; + let mut message = format!( + "Protocol '{name}' {}: {recorded}/{total} recorded", + if stopped { "stopped" } else { "finished" } + ); + if !failed.is_empty() { + // Name the reasons, not just the count: an unattended run's whole + // report is this one line. + let mut reasons: Vec = failed + .iter() + .map(|(_, reason)| reason.clone()) + .collect::>() + .into_iter() + .collect(); + reasons.truncate(3); + message.push_str(&format!( + " — {} skipped ({})", + failed.len(), + reasons.join("; ") + )); + } + self.finish_protocol(context, message); + } + + /// Advance a protocol run one control tick. Runs outermost: a point it + /// starts hands off to the recording coordinator on the same tick. + fn drive_protocol(&mut self, context: &mut impl RecordingControl) { + if self.protocol.is_none() { + if self.protocol_pending { + self.protocol_pending = false; + self.begin_protocol(context); + } + return; + } + if self.protocol_pending { + // Say so rather than swallowing the press: Stop is a different + // button, and a silently ignored one reads as a dead control. + self.protocol_pending = false; + 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, + 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, + ) + }; + + // 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 && 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( + context, + "Protocol aborted: the modulation plugin did not grant the lease" + .into(), + ); + } + return; + } + self.send_protocol_point(context); + } + ProtocolPhase::Retargeting => { + if let Some(reason) = self + .protocol + .as_mut() + .and_then(|run| run.skip_reason.take()) + { + self.fail_protocol_point(context, reason); + return; + } + if retargets_left > 0 || bias_pending { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + self.fail_protocol_point( + context, + "the requested drive or camera biases were not confirmed".into(), + ); + } + return; + } + let settle_ms = self + .protocol + .as_ref() + .and_then(|run| run.point()) + .map(|point| (point.settle_s * 1_000.0).round().max(0.0) as u64) + .unwrap_or(0); + 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; + } + } + ProtocolPhase::Settling => { + if now_ms < settle_until_ms { + return; + } + let duration_s = self + .protocol + .as_ref() + .and_then(|run| run.point()) + .map(|point| point.duration_s) + .unwrap_or(self.duration_s); + // The point's own duration wins over the panel's: the file says + // how long each point runs, and a survey whose lengths silently + // came from the UI would not be reproducible from the protocol + // alone. Through the override, not `duration_s` — see + // `pending_duration_s`. + self.pending_duration_s = Some(duration_s); + if let Some(run) = self.protocol.as_mut() { + run.phase = ProtocolPhase::Recording; + run.last_activity_ms = now_ms; + } + // The row says what it is: a protocol can carry its own + // background and pilot, so a survey does not need two button + // presses before it can be started. + let role = self + .protocol + .as_ref() + .and_then(|run| run.point()) + .map(|point| match point.role { + protocol::PointRole::Normal => RecRole::Normal, + protocol::PointRole::Pilot => RecRole::Pilot, + protocol::PointRole::Background => RecRole::Background, + }) + .unwrap_or(RecRole::Normal); + self.begin_recording(context, role); + // `begin_recording` refuses through `message` rather than a + // result, so a point that never started has to be caught here + // or the run would wait on a recording that does not exist. + if !self.recording.is_active() { + let reason = self.message.clone(); + self.fail_protocol_point(context, reason); + } + } + ProtocolPhase::Recording => { + if self.recording.is_active() { + return; + } + if self.recording_completed_ok { + if let Some(run) = self.protocol.as_mut() { + run.recorded += 1; + } + self.advance_protocol(context); + } else { + let reason = self.message.clone(); + self.fail_protocol_point(context, reason); + } + } + 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 message = self + .protocol + .as_mut() + .and_then(|run| run.finish_message.take()) + .unwrap_or_else(|| "Protocol ended".into()); + self.complete_protocol( + context, + format!( + "{message} — ERROR: pre-run camera settings were not confirmed restored after {restore_attempts} attempts ({})", + restore_error.unwrap_or_else(|| "unknown restore failure".into()) + ), + ); + } + } + } + } + + /// 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 { + return false; + }; + let lease_req = run.lease_req; + let is_retarget = run.pending_reqs.contains(&reply.request_id); + if reply.request_id == lease_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(run) = self.protocol.as_mut() { + run.lease_granted = true; + run.last_activity_ms = now_unix_ms(); + } + } + PluginServiceOutcome::Rejected { message, .. } => { + self.message = + format!("Protocol aborted: modulation lease rejected: {message}"); + if let Some(run) = self.protocol.as_mut() { + run.stop_requested = true; + } + } + } + return true; + } + if !is_retarget { + return false; + } + let now_ms = now_unix_ms(); + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(run) = self.protocol.as_mut() { + run.pending_reqs.retain(|id| *id != reply.request_id); + run.last_activity_ms = now_ms; + } + } + PluginServiceOutcome::Rejected { message, .. } => { + // Carry the owner's own wording through to the skip message: + // "ū=0.90 rejected: peak exceeds the lobe ceiling" tells the + // operator which line of the file to fix, "retarget failed" + // does not. + if let Some(run) = self.protocol.as_mut() { + run.pending_reqs.clear(); + run.skip_reason = Some(message.clone()); + run.last_activity_ms = now_ms; + } + } + } + true + } + + /// Advance the multi-frequency run one control tick. Runs before the lock + /// and the point sweep, so a child it starts runs on the same tick. + fn drive_freq_sweep(&mut self, context: &mut impl RecordingControl) { + if self.freq_sweep.is_none() { + if let Some(mode) = self.freq_sweep_pending.take() { + self.begin_freq_sweep(context, mode); + } + return; + } + self.freq_sweep_pending = None; + let now_ms = now_unix_ms(); + let (phase, stop_requested, lease_granted, freq_applied, last_activity_ms, index, total) = { + let sweep = self.freq_sweep.as_ref().expect("sweep checked above"); + ( + sweep.phase, + sweep.stop_requested, + sweep.lease_granted, + sweep.freq_applied, + sweep.last_activity_ms, + sweep.index, + sweep.points.len(), + ) + }; + // A stop propagates into whichever child is running; the ladder ends + // once that child has let go. + if stop_requested { + if let Some(lock) = self.a0_lock.as_mut() { + lock.stop_requested = true; + return; + } + if let Some(sweep) = self.sweep.as_mut() { + sweep.stop_requested = true; + return; + } + let message = if self.message.is_empty() { + "Frequency sweep stopped".into() + } else { + self.message.clone() + }; + self.finish_freq_sweep(context, message); + return; + } + match phase { + FreqSweepPhase::AcquiringLease => { + if lease_granted { + self.send_freq_sweep_frequency(context); + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_freq_sweep( + context, + "Frequency sweep aborted: timed out acquiring the modulation lease".into(), + ); + } + } + FreqSweepPhase::SettingFrequency => { + // A refused frequency is a property of this point, not of the + // ladder; the owner already said why. + if let Some(reason) = self + .freq_sweep + .as_mut() + .and_then(|sweep| sweep.skip_reason.take()) + { + self.fail_freq_sweep_point(context, reason); + } else if freq_applied { + let hz = self + .freq_sweep + .as_ref() + .map(FreqSweep::frequency_hz) + .unwrap_or_default(); + // Confirming needs whole cycles at the *new* period, so the + // budget has to scale with it: 4 cycles at 0.1 Hz is 40 s. + let cycles_ms = if hz > 0.0 { + (FREQ_CONFIRM_CYCLES / hz * 1_000.0).ceil() as u64 + } else { + 0 + }; + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.phase = FreqSweepPhase::ConfirmingFrequency; + sweep.confirm_deadline_ms = + now_ms.saturating_add(FREQ_CONFIRM_BASE_MS.max(cycles_ms * 3)); + } + self.message = format!( + "Frequency sweep {}/{total}: waiting for the trigger to report {}…", + index + 1, + frequency_label(hz), + ); + } else if now_ms.saturating_sub(last_activity_ms) > REPLY_TIMEOUT_MS { + self.finish_freq_sweep( + context, + "Frequency sweep aborted: timed out retargeting the drive frequency".into(), + ); + } + } + FreqSweepPhase::ConfirmingFrequency => { + let hz = self + .freq_sweep + .as_ref() + .map(FreqSweep::frequency_hz) + .unwrap_or_default(); + // Which side is entitled to say the drive really reached the new + // frequency. + // + // Measured mode holds out for the camera's phase-0 markers: they + // *define* the period, the fold that scores the point is anchored + // on them, and an ACK from the firmware only says the table was + // accepted, not that the light is modulating at that rate. Enough + // markers must have arrived at the new period for their mean + // spacing to mean anything. + // + // Commanded mode asks the modulation owner instead. That is the + // same contract it already relies on for the depth — if the + // owner's acknowledged waveform is trusted to state `a`, it is + // trusted to state `f` — and it does not strand a bench whose + // camera trigger is not wired, which is the whole reason the + // commanded source exists (ADR 021). The live fold goes + // free-running without markers; the recorded RAW and PDQ, which + // are what the offline fit reads, are unaffected. + let confirmed = if self.depth_source.needs_a0_lock() { + self.camera_markers_us.len() as f64 >= FREQ_CONFIRM_CYCLES + && self + .frequency_hz() + .is_some_and(|measured| same_frequency(measured, hz)) + } else { + self.acknowledged_frequency_hz() + .is_some_and(|acknowledged| same_frequency(acknowledged, hz)) + }; + let deadline = self + .freq_sweep + .as_ref() + .map(|sweep| sweep.confirm_deadline_ms) + .unwrap_or_default(); + if confirmed { + if let Err(reason) = self.optical_window_covers_a_cycle(hz) { + self.fail_freq_sweep_point(context, reason); + return; + } + // The search stands between the frequency and the recording + // in exactly one case: an `a₀` rung whose depth is measured. + // A depth sweep commands and settles every `a` in its range + // itself, so there is nothing for a lock to contribute at + // any frequency, in either depth source. + let needs_search = self + .freq_sweep + .as_ref() + .is_some_and(|sweep| sweep.mode.needs_armed_depth()) + && self.depth_source.needs_a0_lock(); + if !needs_search { + self.start_freq_sweep_recording(context); + return; + } + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.phase = FreqSweepPhase::Locking; + } + let lease = self.freq_sweep.as_ref().map(|sweep| sweep.lease_id.clone()); + self.begin_a0_lock(context, lease); + if self.a0_lock.is_none() { + // `begin_a0_lock` refused and said why; keep its wording. + let reason = self.message.clone(); + self.fail_freq_sweep_point(context, reason); + } + } else if now_ms >= deadline { + let reason = if self.depth_source.needs_a0_lock() { + let measured = self + .frequency_hz() + .map_or_else(|| "—".into(), frequency_label); + format!( + "the trigger never reported it (measured {measured} from {} markers)", + self.camera_markers_us.len() + ) + } else { + let acknowledged = self + .acknowledged_frequency_hz() + .map_or_else(|| "—".into(), frequency_label); + format!( + "the modulation plugin never acknowledged it (its armed drive still \ + reads {acknowledged})" + ) + }; + self.fail_freq_sweep_point(context, reason); + } + } + FreqSweepPhase::Locking => { + if self.a0_lock.is_some() { + return; + } + self.start_freq_sweep_recording(context); + } + FreqSweepPhase::Recording => { + if self.sweep.is_some() || self.recording.is_active() { + return; + } + // The inner run's own verdict, not the last recording's. A + // depth sweep that gives up on point 4 of 5 leaves + // `recording_completed_ok` true from point 3, which would have + // counted a half-recorded curve as a finished rung. + if self.last_sweep_completed_ok { + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.recorded += 1; + } + self.advance_freq_sweep(context); + } else { + let reason = self.message.clone(); + self.fail_freq_sweep_point(context, reason); + } + } + } + } + + /// Routes modulation-service replies belonging to the frequency sweep. + fn on_freq_sweep_reply(&mut self, reply: &PluginServiceReply) -> bool { + let Some((lease_req, freq_req)) = self + .freq_sweep + .as_ref() + .map(|sweep| (sweep.lease_req, sweep.freq_req)) + else { + return false; + }; + let abort = |this: &mut Self, message: String| { + this.message = message; + if let Some(sweep) = this.freq_sweep.as_mut() { + sweep.stop_requested = true; + } + }; + if reply.request_id == lease_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.lease_granted = true; + sweep.last_activity_ms = now_unix_ms(); + } + } + PluginServiceOutcome::Rejected { message, .. } => abort( + self, + format!("Frequency sweep aborted: modulation lease rejected: {message}"), + ), + } + true + } else if reply.request_id == freq_req { + match &reply.outcome { + PluginServiceOutcome::Accepted { .. } => { + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.freq_applied = true; + sweep.last_activity_ms = now_unix_ms(); + } + } + // A refused frequency is a property of this point, not of the + // ladder: skip it and keep the remaining decades. The skip runs + // on the next tick, through the one path that advances the + // ladder, carrying the owner's wording. + PluginServiceOutcome::Rejected { message, .. } => { + if let Some(sweep) = self.freq_sweep.as_mut() { + sweep.skip_reason = + Some(format!("the drive rejected the frequency: {message}")); + } + } + } + true + } else { + false + } + } + + fn a0_locks_dataset(&self) -> TableDatasetV1 { + let column = |id: &str, values: Vec| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(values), + }; + let map = |select: fn(&A0LockPoint) -> String| { + self.a0_locks.iter().map(select).collect::>() + }; + TableDatasetV1 { + columns: vec![ + column("frequency", map(|lock| frequency_label(lock.frequency_hz))), + column("target_a", map(|lock| format!("{:.3}", lock.target_a))), + column( + "commanded_a", + map(|lock| format!("{:.3}", lock.commanded_a)), + ), + column("measured_a", map(|lock| format!("{:.3}", lock.measured_a))), + column("depth_source", map(|lock| lock.depth_source.verb().into())), + column("trials", map(|lock| lock.trials.to_string())), + column( + "state", + map(|lock| { + if lock.converged { + "locked".into() + } else { + "not converged".into() + } + }), + ), + column( + "locked_at", + map(|lock| format_iso_utc(lock.locked_at_unix_ms / 1_000)), + ), + ], + } + } + + 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 { + actual_raw_path, .. + } => { + self.recording.cam_raw_path = Some(actual_raw_path.clone()); + self.recording.last_activity_ms = now_unix_ms(); + } + HostCommandOutcome::Rejected { code, message } => { + // Stop the rest of the recording; drive_recording resolves the + // abort from the current phase on the next tick. + self.note_failure(format!("Camera recording rejected ({code}): {message}")); + self.recording.cam_rejected = true; + self.recording.stop_requested = true; + } + _ => {} + } + } else if reply.request_id == self.recording.cam_stop_req { + match &reply.outcome { + HostCommandOutcome::RecordingFinalized { + actual_raw_path, .. + } => { + self.recording.cam_finalized_path = Some(actual_raw_path.clone()); + self.recording.cam_complete = true; + self.recording.last_activity_ms = now_unix_ms(); + } + HostCommandOutcome::RecordingPartial { + actual_raw_path, .. + } => { + self.recording.cam_finalized_path = Some(actual_raw_path.clone()); + self.recording.last_activity_ms = now_unix_ms(); + } + HostCommandOutcome::Rejected { code, message } => { + self.note_failure(format!("Camera stop failed ({code}): {message}")); + self.recording.cam_rejected = true; + self.recording.last_activity_ms = now_unix_ms(); + } + _ => {} + } + } + } + + fn on_service_reply(&mut self, reply: &PluginServiceReply) { + if self.on_sweep_reply(reply) + || self.on_a0_lock_reply(reply) + || self.on_freq_sweep_reply(reply) + || self.on_protocol_reply(reply) + { + return; + } + let response = match &reply.outcome { + PluginServiceOutcome::Accepted { payload } => { + serde_json::from_value::(payload.clone()).ok() + } + PluginServiceOutcome::Rejected { code, message } => { + if reply.request_id == self.recording.connect_req + || reply.request_id == self.recording.lease_req + || reply.request_id == self.recording.pd_begin_req + { + // Not `stop_requested`: that flag means the operator asked + // to stop. A photodiode fault leaves the camera running to + // its full duration (see `continue_without_photodiode`). + self.note_failure(format!("Photodiode start failed ({code}): {message}")); + self.recording.pd_rejected = true; + } else if reply.request_id == self.recording.pd_finalize_req { + self.note_failure(format!("Photodiode save failed ({code}): {message}")); + self.recording.pd_rejected = true; + self.recording.lease_granted = false; + self.recording.last_activity_ms = now_unix_ms(); + } + None + } + }; + let Some(response) = response else { + return; + }; + if reply.request_id == self.recording.connect_req { + self.recording.connect_accepted = true; + self.recording.last_activity_ms = now_unix_ms(); + } else if reply.request_id == self.recording.lease_req { + self.recording.lease_granted = true; + self.recording.last_activity_ms = now_unix_ms(); + } else if reply.request_id == self.recording.pd_begin_req { + if let Some(PdqReceiptV1::Started(started)) = &response.receipt { + self.recording.pd_pdq_path = Some(started.pdq_path.clone()); + self.recording.pd_sidecar_path = Some(started.sidecar_path.clone()); + self.recording.last_activity_ms = now_unix_ms(); + } + } else if reply.request_id == self.recording.pd_finalize_req { + self.recording.pd_finalized = true; + if let Some(PdqReceiptV1::Finalized(finalized)) = &response.receipt { + self.recording.pd_pdq_path = Some(finalized.pdq_path.clone()); + self.recording.pd_sidecar_path = Some(finalized.sidecar_path.clone()); + self.recording.pd_valid = finalized.valid; + } + self.recording.lease_granted = false; + self.recording.last_activity_ms = now_unix_ms(); + } + } + + /// Advance the recording state machine one control tick. + fn drive_recording(&mut self, context: &mut impl RecordingControl) { + let now_ms = now_unix_ms(); + // Latch the light while the recording can still see it. Everything after + // the last sample — both finalizes, the gather — blocks this tick, so a + // summary read afterwards is judged stale for time the recording itself + // spent being written out. + if self.recording.is_active() { + if let Some(optical) = self.fresh_optical_summary() { + self.recording.optical = Some(optical.clone()); + } + } + match self.recording.phase { + RecPhase::Idle => { + if let Some(role) = self.pending_role.take() { + self.begin_recording(context, role); + } + } + RecPhase::StartingCamera => { + if self.recording.cam_rejected { + let message = self.message.clone(); + self.release_and_idle(context, message); + } else if self.recording.cam_raw_path.is_some() { + if self.recording.stop_requested { + self.stop_camera(context); + } else { + self.connect_photodiode(context); + } + } else if now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + self.recording.cam_rejected = true; + self.note_failure("Timed out starting camera recording"); + let message = self.message.clone(); + self.release_and_idle(context, message); + } + } + RecPhase::ConnectingPhotodiode => { + if self.recording.pd_rejected { + self.continue_without_photodiode(context); + } else if self.recording.stop_requested && !self.recording.connect_accepted { + self.stop_camera(context); + } else if self.recording.connect_accepted { + if self.recording.stop_requested { + self.stop_camera(context); + } else { + self.acquire_photodiode(context); + } + } else if now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + self.recording.pd_rejected = true; + self.note_failure("Timed out connecting the photodiode"); + self.continue_without_photodiode(context); + } + } + RecPhase::AcquiringLease => { + if self.recording.pd_rejected { + self.continue_without_photodiode(context); + } else if self.recording.lease_granted { + if self.recording.stop_requested { + self.stop_photodiode(context); + } else { + self.start_photodiode(context); + } + } else if now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + self.recording.pd_rejected = true; + self.note_failure("Timed out preparing the photodiode"); + self.continue_without_photodiode(context); + } + } + RecPhase::StartingPhotodiode => { + if self.recording.pd_pdq_path.is_some() && self.recording.pd_sidecar_path.is_some() + { + self.recording.phase = RecPhase::Running; + self.recording.start_unix_ms = now_ms; + if self.recording.stop_requested { + self.stop_photodiode(context); + } else { + self.note(format!( + "Recording {} for {} s…", + self.recording.id, self.recording.duration_s + )); + } + } else if self.recording.pd_rejected + || now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + self.recording.pd_rejected = true; + self.note_failure("The photodiode did not open its PDQ file"); + self.continue_without_photodiode(context); + } + } + RecPhase::Running => { + let elapsed_ms = now_ms.saturating_sub(self.recording.start_unix_ms); + let over = elapsed_ms >= self.recording.duration_s.saturating_mul(1_000); + if over || self.recording.stop_requested { + self.stop_photodiode(context); + } + } + RecPhase::StoppingPhotodiode => { + if self.recording.pd_finalized || self.recording.pd_rejected { + self.stop_camera(context); + } else if now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + self.recording.pd_rejected = true; + self.recording.lease_granted = false; + self.note_failure("Timed out saving photodiode data"); + self.stop_camera(context); + } + } + RecPhase::StoppingCamera => { + if self.recording.cam_finalized_path.is_some() + || self.recording.cam_rejected + || now_ms.saturating_sub(self.recording.last_activity_ms) > REPLY_TIMEOUT_MS + { + if self.recording.cam_finalized_path.is_none() && !self.recording.cam_rejected { + self.recording.cam_rejected = true; + self.note_failure("Timed out saving camera data"); + } + self.finish_recording(context); + } + } + } + } + + /// 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()); + // The recording's own conditions first, and only then a live read for a + // sidecar written outside one. + let optical = self + .recording + .optical + .as_ref() + .or_else(|| self.fresh_optical_summary()); + if optical.is_none() && self.depth_source == DepthSource::Photodiode { + // The refusal used to stop at "no fresh summary", which reads as a + // missing anchor and sends the operator to re-confirm one that was + // already fine. The owner knows which estimator gate rejected the + // window — a railed detector, too few whole cycles, a stopped + // stream — so hand its sentence on. This is the whole report an + // unattended protocol run leaves behind for the point it lost. + return Err(format!( + "cannot write a quantitative A1 sidecar without a fresh photodiode optical \ + summary that passed the selected placement's optical gates: {}", + self.optical_summary_blocker() + .unwrap_or_else(|| "the photodiode gave no reason".into()) + )); + } + let raw_path = self + .recording + .cam_finalized_path + .clone() + .or_else(|| self.recording.cam_raw_path.clone()); + let camera_bias_sidecar = raw_path.as_deref().and_then(sibling_toml); + + let protocol = self + .protocol + .as_ref() + .map(|run| { + let extension = Path::new(&run.source_path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("txt"); + let short_hash = &run.source_sha256[..12.min(run.source_sha256.len())]; + let archive_name = format!("a1_protocol_{short_hash}.{extension}"); + let archive_path = dir.join(&archive_name); + if !archive_path.exists() { + std::fs::write(&archive_path, &run.source_text) + .map_err(|error| format!("archiving protocol source failed: {error}"))?; + } + let point = run.point(); + Ok::<_, String>(ProtocolSidecar { + name: run.plan.name.clone(), + version: run.plan.version.clone(), + source_file: Path::new(&run.source_path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| run.source_path.clone()), + source_sha256: run.source_sha256.clone(), + archived_file: archive_name, + point_index: run.index + 1, + point_total: run.plan.points.len(), + point_label: point.map(|point| point.block.clone()), + point_role: point.map(|point| { + match point.role { + protocol::PointRole::Normal => "normal", + protocol::PointRole::Pilot => "pilot", + protocol::PointRole::Background => "background", + } + .to_owned() + }), + requested_mean_u: point.map(|point| point.mean_u), + requested_frequency_hz: point.map(|point| point.frequency_hz), + requested_depth_a: point.map(|point| point.depth_a), + requested_diff_on: point.and_then(|point| point.diff_on), + requested_diff_off: point.and_then(|point| point.diff_off), + }) + }) + .transpose()?; + let direct_dark = optical + .and_then(|summary| summary.calibration.dark_reference.as_ref()) + .or_else(|| { + self.photodiode + .as_ref() + .and_then(|summary| summary.dark_reference.as_ref()) + }); + + let doc = SidecarDoc { + schema: "stage-a.a1.sidecar.v2".into(), + measurement_id: self.recording.id.clone(), + file_stem: self.recording.stem.clone(), + role: self.recording.role.label().into(), + recorded_at_utc: format_iso_utc( + if self.recording.start_unix_ms == 0 { + now_ms + } else { + self.recording.start_unix_ms + } / 1_000, + ), + finalized_at_utc: format_iso_utc(now_ms / 1_000), + duration_s: self.recording.duration_s, + 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 + .as_ref() + .filter(|sweep| sweep.phase == SweepPhase::Recording); + SweepSidecar { + min_a: self.min_a, + max_a: self.max_a, + requested_a: point.map(Sweep::target_a), + commanded_a: point.map(Sweep::commanded_a), + point_index: point.map(|sweep| sweep.index + 1), + point_total: point.map(Sweep::total), + } + }, + a0_lock: self + .sweep + .as_ref() + .and_then(|sweep| sweep.lock.as_ref()) + .map(|lock| A0LockSidecar { + target_a: lock.target_a, + commanded_a: lock.commanded_a, + measured_a_at_lock: lock.measured_a, + depth_source: lock.depth_source.label().into(), + frequency_hz_at_lock: lock.frequency_hz, + trials: lock.trials, + converged: lock.converged, + locked_at_utc: format_iso_utc(lock.locked_at_unix_ms / 1_000), + }), + frequency_sweep: self.freq_sweep.as_ref().and_then(|sweep| { + sweep.point().map(|point| FreqSweepSidecar { + min_f: self.min_f, + max_f: self.max_f, + planned_points: self.freq_count as usize, + point_index: sweep.index + 1, + point_total: sweep.points.len(), + order: sweep.order.label().into(), + seed: sweep.seed, + is_reference: point.is_reference, + requested_frequency_hz: point.frequency_hz, + }) + }), + pilot: (self.recording.role == RecRole::Pilot) + .then_some(self.pilot_windows) + .flatten() + .map(|(on, off)| PilotSidecar { + window_on_start: on.start, + window_on_end: on.end, + window_off_start: off.start, + window_off_end: off.end, + }), + background: (self.recording.role == RecRole::Background) + .then_some(self.background_floor) + .flatten() + .map(|(q_on, q_off)| BackgroundSidecar { q_on, q_off }), + modulation: ModulationSidecar { + frequency_hz: self.period_us().map(|t| 1_000_000.0 / t), + frequency_source: self.frequency_source().into(), + calibration_id: self + .modulation + .as_ref() + .and_then(|state| state.calibration_id.clone()), + optical_target: mod_optical.map(|drive| match drive.target { + OpticalTargetV1::LogSine => "log_sine".into(), + OpticalTargetV1::LinearSine => "linear_sine".into(), + }), + requested_mean_u: mod_optical + .map(|drive| f64::from(drive.requested_mean_u_milli) / 1_000.0), + 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), + 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), + }, + 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_detector_volts: optical + .map(|o| (o.excitation_min_volts * o.excitation_max_volts).sqrt()), + 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()) + .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| 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(), + marker_count: self.camera_markers_us.len(), + measured_period_us: self.measured_period_us(), + }, + files: FilesSidecar { + camera_raw: raw_path, + camera_config_sidecar: camera_bias_sidecar, + photodiode_pdq: self.recording.pd_pdq_path.clone(), + photodiode_sidecar: self.recording.pd_sidecar_path.clone(), + sensor_readout: self.recording.sensor_readout_path.clone(), + }, + }; + + let toml = toml::to_string_pretty(&doc).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()) + } +} + +// ---- sidecar document ------------------------------------------------------ + +#[derive(Serialize)] +struct SidecarDoc { + schema: String, + measurement_id: String, + file_stem: String, + role: String, + recorded_at_utc: String, + finalized_at_utc: String, + duration_s: u64, + #[serde(skip_serializing_if = "Option::is_none")] + protocol: Option, + depth: DepthSidecar, + sweep: SweepSidecar, + /// Present on **event-count** points: the `a₀` lock this point replayed. + #[serde(skip_serializing_if = "Option::is_none")] + a0_lock: Option, + /// Present on points recorded by the automatic frequency ladder. + #[serde(skip_serializing_if = "Option::is_none")] + frequency_sweep: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pilot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + background: Option, + modulation: ModulationSidecar, + 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")] + sensor: Option, + trigger: TriggerSidecar, + 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, + max_a: f64, + /// The `a` this sweep point asked the drive for (measured `a` is in + /// `[optical]`); absent on manual recordings. + #[serde(skip_serializing_if = "Option::is_none")] + requested_a: Option, + /// The depth the drive was *commanded* to for this point. Equal to + /// `requested_a` on the amplitude sweep; on an event-count point it is the + /// `a₀`-locked depth, which differs by the drive roll-off at that frequency. + #[serde(skip_serializing_if = "Option::is_none")] + commanded_a: Option, + /// 1-based point position within the sweep; absent on manual recordings. + #[serde(skip_serializing_if = "Option::is_none")] + point_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + point_total: Option, +} + +/// The `a₀` lock an **event-count** point replayed: the closed-loop trim that +/// made the photodiode measure the frozen `a₀` at this frequency. +#[derive(Serialize)] +struct A0LockSidecar { + target_a: f64, + commanded_a: f64, + measured_a_at_lock: f64, + /// Which source `measured_a_at_lock` came from — see `depth_a_source`. + depth_source: String, + frequency_hz_at_lock: f64, + trials: u32, + converged: bool, + locked_at_utc: String, +} + +/// The automatic frequency ladder this point belongs to. +/// +/// The executed order and its seed are part of the frozen session schedule the +/// A1 checklist asks for, so they belong in every point rather than only in an +/// operator's notebook: a block is only interpretable if you can tell which +/// frequency was recorded when. +#[derive(Serialize)] +struct FreqSweepSidecar { + min_f: f64, + max_f: f64, + planned_points: usize, + /// Position in the *executed* order, references included. + point_index: usize, + point_total: usize, + order: String, + seed: u64, + /// True for the interleaved low-frequency reference repeats. + is_reference: bool, + /// The ladder asked for this frequency; `[trigger] measured_frequency_hz` + /// is what the phase-0 markers reported when the point was recorded. + requested_frequency_hz: f64, +} + +/// Frozen ON/OFF windows written into a **pilot** recording's sidecar and read +/// back to reuse them across the row. +#[derive(Serialize, serde::Deserialize)] +struct PilotSidecar { + window_on_start: f64, + window_on_end: f64, + window_off_start: f64, + window_off_end: f64, +} + +/// False-response floor written into a **background** recording's sidecar. +#[derive(Serialize, serde::Deserialize)] +struct BackgroundSidecar { + q_on: f64, + q_off: f64, +} + +/// Partial view of a config sidecar for reading the pilot/background sections +/// back; every other section is ignored. +#[derive(serde::Deserialize)] +struct RowSidecar { + #[serde(default)] + pilot: Option, + #[serde(default)] + background: Option, +} + +#[derive(Serialize)] +struct ModulationSidecar { + #[serde(skip_serializing_if = "Option::is_none")] + frequency_hz: Option, + frequency_source: String, + #[serde(skip_serializing_if = "Option::is_none")] + calibration_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + optical_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + requested_mean_u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + resolved_mean_u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + internal_u: 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 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_detector_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detector_min_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detector_max_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detector_headroom_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + low_clip_fraction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + high_clip_fraction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + measured_frequency_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + adc_calibration_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dark_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dark_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dark_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dark_captured_at_unix_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dark_age_s: Option, +} + +/// Bench conditions the sensor measured for itself at the start of the run. +/// +/// Provenance, never an input: the `q_p(a, f)` response depends on the pixel +/// dead time and on the scene illumination, and the die temperature moves the +/// biases, so a row that cannot be compared to another has to be identifiable +/// as such afterwards. Present only when the host was streaming from a camera +/// with a monitoring block — an offline re-run of the same RAW has no sensor to +/// ask, and every field stays absent rather than becoming zero. +#[derive(Serialize)] +struct SensorSidecar { + /// Sensor die temperature, °C. + #[serde(skip_serializing_if = "Option::is_none")] + temperature_c: Option, + /// Measured pixel dead time (refractory period), µs. + #[serde(skip_serializing_if = "Option::is_none")] + pixel_dead_time_us: Option, + /// Scene illumination integrated by the sensor, lux. + #[serde(skip_serializing_if = "Option::is_none")] + illumination_lux: Option, + /// 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, +} + +#[derive(Serialize)] +struct TriggerSidecar { + marker_anchored: bool, + marker_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + measured_period_us: Option, +} + +#[derive(Serialize)] +struct FilesSidecar { + #[serde(skip_serializing_if = "Option::is_none")] + camera_raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + camera_config_sidecar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + photodiode_pdq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + 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 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, +} + +// ---- free functions -------------------------------------------------------- + +fn ffi_to_camera_event(event: &FfiCdEvent) -> CameraEvent { + CameraEvent { + x: event.x, + y: event.y, + timestamp_us: event.timestamp_us(), + polarity: if event.is_on() { + Polarity::On + } else { + Polarity::Off + }, + } +} + +fn points_for(points: &[RollingResponsePoint], first: u64) -> Vec { + points + .iter() + .map(|point| Series1dPoint { + x: point.timestamp_us.saturating_sub(first) as f64 / 1_000_000.0, + y: point.run_per_pixel, + }) + .collect() +} + +fn waveform_label(waveform: &WaveformV1) -> String { + match waveform { + WaveformV1::Off => "off".into(), + WaveformV1::Constant { level_dac } => format!("constant({level_dac})"), + WaveformV1::Periodic { + waveform, + min_dac, + max_dac, + frequency_millihz, + } => format!( + "periodic({waveform:?}, {min_dac}..{max_dac}, {:.3} Hz)", + *frequency_millihz as f64 / 1_000.0 + ), + } +} + +/// 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()) +} + +/// 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. +fn load_row_sidecar(folder: &str, id: &str, role_tag: &str) -> Option { + let prefix = format!("{id}_"); + let mut best: Option = None; + for entry in std::fs::read_dir(folder).ok()?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with(&prefix) + && name.contains(role_tag) + && name.ends_with("_config.toml") + && best.as_ref().is_none_or(|current| name > *current) + { + best = Some(name); + } + } + let text = std::fs::read_to_string(Path::new(folder).join(best?)).ok()?; + toml::from_str::(&text).ok() +} + +fn load_row_windows(folder: &str, id: &str, role_tag: &str) -> Option<(PhaseWindow, PhaseWindow)> { + let pilot = load_row_sidecar(folder, id, role_tag)?.pilot?; + Some(( + PhaseWindow { + start: pilot.window_on_start, + end: pilot.window_on_end, + }, + PhaseWindow { + start: pilot.window_off_start, + end: pilot.window_off_end, + }, + )) +} + +fn load_row_background(folder: &str, id: &str, role_tag: &str) -> Option<(f64, f64)> { + let background = load_row_sidecar(folder, id, role_tag)?.background?; + Some((background.q_on, background.q_off)) +} + +/// 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 ch in input.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + out.push(ch); + } else if !out.ends_with('_') { + out.push('_'); + } + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "A1".into() + } else { + trimmed + } +} + +fn generate_measurement_id() -> String { + let ms = now_unix_ms(); + format!( + "A1-{}-{:04x}", + format_compact_date(ms / 1_000), + (ms & 0xffff) + ) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.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 (y, m, d) = civil_from_days(days); + (y, m, d, 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 StageAA1Plugin { + fn name(&self) -> &'static str { + "Stage-A A1 Analysis" + } + + fn description(&self) -> &'static str { + "Stage-A A1 recording coordinator: one-button synchronized camera RAW + photodiode PDQ recording with a config sidecar, plus live rolling-response and response-probability quicklooks." + } + + 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.camera_events.clear(); + self.event_scratch.clear(); + self.camera_markers_us.clear(); + self.response_points.clear(); + self.pilot_windows = None; + self.background_floor = None; + self.loaded_key = None; + self.bump(); + } + + fn on_discontinuity(&mut self, reason: PluginDiscontinuity) { + match reason { + // The host raises SettingsChanged on *every* settings sync of any + // plugin (including our own button presses). The fold window + // rebuilds itself each frame, and the response curve, pilot + // windows, and background floor are operator-owned science state — + // wiping them here made "Record point" appear dead. + PluginDiscontinuity::SettingsChanged => {} + PluginDiscontinuity::Seek + | PluginDiscontinuity::SourceChanged + | PluginDiscontinuity::HistoryEvicted => { + // Starting and stopping the host recorder restarts the capture + // pipeline, and the host reports that as SourceChanged. Those + // boundaries are self-inflicted — twice per recording — so they + // 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. + // + // 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(); + self.bump(); + } else { + self.reset(); + } + } + } + } + + fn input_kind(&self) -> PluginInput { + PluginInput::RawEvents + } + + fn capabilities(&self) -> PluginCapabilities { + // Request retained event history so the analysis window comes exactly + // from the EventStore rather than best-effort preview frames. + PluginCapabilities { + retained_event_history: true, + } + } + + fn process_frame( + &mut self, + frame: &PluginFrame<'_>, + _output: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + event_store: &EventStoreHandle<'_>, + ) { + self.frame_width = frame.width(); + self.frame_height = frame.height(); + // ROI and masked pixels are owned by the host camera config, not the + // plugin; mirror the latest snapshot each frame. + if let Some(settings) = context + .get::(CTX_GLOBAL_SETTINGS) + .ok() + .flatten() + { + 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 + // often as with it on. Absent whenever the host has no camera that can + // measure them (replay, imports, a sensor without a monitoring block), + // which stays `None` rather than becoming a zero. + if let Some(monitoring) = context + .get::(CTX_SENSOR_MONITORING) + .ok() + .flatten() + { + self.sensor = Some(monitoring); + } + if !self.live { + // Drop the analysis buffers on the way out, not just stop filling + // them — see `drop_live_buffers`. + if self.drop_live_buffers() { + self.bump(); + } + return; + } + + // Markers (phase-0 sync) only exist on the preview frame, so accumulate + // the rising EXT_TRIGGER edges here regardless of the event source. + // Preview windows overlap, so the same trigger arrives on several + // consecutive frames — dedup after every merge or the duplicate + // timestamps fail marker validation and blank the fold. + self.camera_markers_us.extend( + frame + .external_triggers() + .iter() + .filter(|trigger| trigger.is_rising()) + .map(|trigger| trigger.timestamp_us), + ); + self.camera_markers_us.sort_unstable(); + self.camera_markers_us.dedup(); + if self.camera_markers_us.len() > MAX_MARKERS { + let excess = self.camera_markers_us.len() - MAX_MARKERS; + self.camera_markers_us.drain(..excess); + } + + let window_end = frame.window_end_us(); + let window_us = (self.analysis_window_ms.max(1) as u64).saturating_mul(1_000); + if event_store.frame_count() > 0 { + // Exact path: rebuild the analysis window from the retained event + // history, immune to dropped preview frames. + let window_start = window_end + .saturating_sub(window_us) + .max(event_store.oldest_timestamp_us()); + self.event_scratch.clear(); + event_store.collect_events_in_range(window_start, window_end, &mut self.event_scratch); + self.camera_events.clear(); + self.camera_events + .extend(self.event_scratch.iter().map(ffi_to_camera_event)); + // Keep the marker set on the same window as the events. + self.camera_markers_us + .retain(|&marker| marker >= window_start); + } else { + // Fallback (no retained history available): accumulate the + // best-effort preview-frame events, then trim to the same analysis + // window the exact path uses. Without the trim the buffer grew to + // MAX_EVENTS and then stopped accepting anything at all, so the + // fold silently spanned an ever-widening window and finally froze + // on a stale 4M-event buffer while the plots still looked live. + self.camera_events + .extend(frame.events().iter().map(ffi_to_camera_event)); + let window_start = window_end.saturating_sub(window_us); + let keep_from = self + .camera_events + .partition_point(|event| event.timestamp_us < window_start); + if keep_from > 0 { + self.camera_events.drain(..keep_from); + } + // Hard ceiling as well: a window longer than the event buffer can + // hold must drop the oldest events, not stop taking new ones. + if self.camera_events.len() > MAX_EVENTS { + let excess = self.camera_events.len() - MAX_EVENTS; + self.camera_events.drain(..excess); + } + self.camera_markers_us + .retain(|&marker| marker >= window_start); + } + self.bump(); + } + + fn process_control(&mut self, context: &mut PluginControlContext<'_>) { + let inbox = context.inbox().clone(); + self.update_snapshots(&inbox); + for reply in &inbox.host_replies { + self.on_host_reply(reply); + } + for reply in &inbox.service_replies { + self.on_service_reply(reply); + } + // Reuse the pilot/background captured for this measurement when idle: when + // the folder or id changes, look them up in the folder. + if !self.recording.is_active() { + 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.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 + // at the top, guarded where they begin. + self.drive_protocol(context); + self.drive_freq_sweep(context); + self.drive_a0_lock(context); + self.drive_sweep(context); + self.drive_recording(context); + // The fold reflects live snapshots (T, a) even between frames. + self.bump(); + } + + fn settings_schema(&self) -> SettingsSchema { + // The record buttons stay disabled until the recording has a + // destination, instead of failing with a status message after a click. + let can_record = !self.output_folder.trim().is_empty(); + // Deliberately *not* gated on "is something running": `settings_schema` + // is rendered by the UI mirror, and every run — the recording, the + // sweeps, the ladder, the protocol — 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 each `begin_*` refuses with a message that + // names what is already running (ADR 010, same reason as the modulation + // plugin's `calibration_offered`). + SettingsSchema { + sections: vec![ + SettingsSection { + label: "Live analysis".into(), + description: Some( + "A live look at the camera events folded against the modulation cycle. \ + Nothing is saved from here — but almost everything else reads it, so \ + it belongs at the top rather than buried below the recording controls.\n\n\ + Leave it ON. The frequency sweep and the protocol both need it to see \ + the phase-0 trigger, and the response curve below scores its points \ + out of the same buffer." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "live".into(), + label: "Live analysis".into(), + tooltip: Some( + "On: read incoming events and triggers and update the plots. \ + Off: the buffer is dropped and nothing is read at all — the \ + frequency sweep and the protocol will refuse to start." + .into(), + ), + kind: SettingKind::Bool { default: self.live }, + }, + SettingItem { + key: "analysis_window_ms".into(), + label: "Analysis window (ms)".into(), + tooltip: Some( + "How far back the live plots look. Longer covers more cycles but \ + folds more events on every update — if the plots feel heavy, \ + shorten this first." + .into(), + ), + kind: SettingKind::I64Drag { + min: 1, + max: 120_000, + default: self.analysis_window_ms, + }, + }, + SettingItem { + key: "clear".into(), + label: "Clear captured events".into(), + tooltip: Some("Empties the buffer and resets the live plots.".into()), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "window_floor".into(), + label: "Window width threshold".into(), + tooltip: Some( + "How the bright and dark windows are found automatically: each \ + one widens out from its busiest moment until the event rate \ + drops below this fraction of the peak. 0.10 = stop at 10 %." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.02, + max: 0.5, + speed: 0.01, + default: self.window_floor, + }, + }, + SettingItem { + key: "record_point".into(), + label: "Add response-curve point (at the current depth)".into(), + tooltip: Some( + "Adds one dot to the response curve, using the events in the \ + live buffer and the depth measured right now. A preview to \ + check the shape looks sensible — saves no files, and the real \ + fit is done afterwards from the recorded ones." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "clear_curve".into(), + label: "Clear the response curve".into(), + tooltip: Some("Removes all the dots from the curve.".into()), + kind: SettingKind::Button { enabled: true }, + }, + ], + }, + SettingsSection { + label: "Modulation depth a".into(), + description: Some( + "Where the depth a comes from. Everything that needs a depth — the \ + sweeps, the protocol, the response curve — reads this one setting.\n\n\ + The photodiode is the honest source: it watches the light itself. But \ + it only reports a depth when it can prove its window covers whole \ + modulation cycles, which needs the phase-0 trigger markers on the \ + photodiode's own stream. Without them (no trigger, or a frequency so \ + low that two cycles do not fit in the photodiode's cache) it reports \ + nothing and every button refuses.\n\n\ + The commanded drive gets you running in that case: the modulation \ + plugin already inverts your measured Pockels curve to command a depth, \ + so the number is calibrated — it just is not checked against the light. \ + Runs recorded this way are tagged as such in their description file." + .into(), + ), + default_open: true, + items: vec![SettingItem { + key: "depth_source".into(), + label: "Depth a source".into(), + tooltip: Some( + "Photodiode: use the depth the photodiode measures (accurate, needs \ + the trigger markers). Modulation drive: use the depth the modulation \ + plugin is commanding (works without the photodiode, but open loop — \ + it is not verified against the light)." + .into(), + ), + kind: SettingKind::Enum { + variants: vec![ + "photodiode (measured)".into(), + "modulation drive (commanded, open loop)".into(), + ], + default: self.depth_source.index() as usize, + }, + }], + }, + SettingsSection { + label: "Record".into(), + description: Some( + "Everything that writes files, in one place. Each measurement gets its \ + own folder holding the camera file, the photodiode file, the sensor \ + readout and a description file, all sharing one name.\n\n\ + Four ways to run, all using the settings below and whatever the \ + modulation plugin currently has armed for the axes they do not sweep:\n\ + • Record once — one recording, exactly as the bench stands now.\n\ + • Sweep a — a range of depths at the armed frequency.\n\ + • Sweep f — a range of frequencies at the armed depth.\n\ + • Sweep a × f — every depth at every frequency (the q_p(a, f) surface).\n\n\ + You need an output folder, a connected photodiode, and the modulation \ + plugin running the light. The measurement id is filled in for you if \ + you leave it blank." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "output_folder".into(), + label: "Output folder".into(), + tooltip: Some( + "Where the recordings go. Each measurement gets its own subfolder \ + in here. Required." + .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 subfolder and every file in it. Use one id for all the \ + repeats that belong together. Optional — leave it blank and one \ + is generated when you press record." + .into(), + ), + kind: SettingKind::Text { + default: self.measurement_id.clone(), + }, + }, + SettingItem { + key: "new_id".into(), + label: "New id".into(), + tooltip: Some( + "Put a fresh generated id in the field above, so the next \ + recording starts a new measurement folder." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "duration_s".into(), + label: "Duration (s)".into(), + tooltip: Some( + "How many seconds each recording lasts before it stops and saves \ + itself. Applies to every button here." + .into(), + ), + kind: SettingKind::I64Drag { + min: 1, + max: 3_600, + default: self.duration_s, + }, + }, + SettingItem { + key: "settle_s".into(), + label: "Settle time (s)".into(), + tooltip: Some( + "After changing the depth or the frequency, wait this long with \ + the reading holding steady before recording. Longer is safer if \ + your signal drifts. Ignored by Record once, which records what \ + is already there." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.0, + max: 60.0, + speed: 0.1, + default: self.settle_s, + }, + }, + SettingItem { + key: "min_a".into(), + label: "Depth axis: min a".into(), + tooltip: Some( + "Shallowest depth the a sweep records. Must be above 0 — a = 0 \ + is the background reference, which has its own button." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.0, + max: 10.0, + speed: 0.01, + default: self.min_a, + }, + }, + SettingItem { + key: "max_a".into(), + label: "Depth axis: max a".into(), + tooltip: Some("Deepest depth the a sweep records.".into()), + kind: SettingKind::F64Drag { + min: 0.0, + max: 10.0, + speed: 0.01, + default: self.max_a, + }, + }, + SettingItem { + key: "sweep_count".into(), + label: "Depth axis: points".into(), + tooltip: Some( + "How many depths the a sweep records, spread evenly from min a \ + to max a." + .into(), + ), + kind: SettingKind::I64Drag { + min: 2, + max: 64, + default: self.sweep_count, + }, + }, + SettingItem { + key: "a0_target".into(), + label: "Frequency axis: the depth a to hold".into(), + tooltip: Some( + "Sweep f records every frequency at this one depth, so a change \ + in the event count comes from the frequency and not from the \ + depth. Not used by Sweep a or Sweep a × f, which command each \ + depth themselves.\n\n\ + With the photodiode depth source the drive does not hold a depth \ + by itself as f changes, so it is re-found by measurement at \ + every frequency — see the a₀ section below." + .into(), + ), + kind: SettingKind::F64Drag { + min: COMMANDED_A_MIN, + max: COMMANDED_A_MAX, + speed: 0.01, + default: self.a0_target, + }, + }, + SettingItem { + key: "min_f".into(), + label: "Frequency axis: min f (Hz)".into(), + tooltip: Some( + "Lowest frequency the f sweep records. It also decides whether \ + the run is measurable at all: the photodiode needs whole \ + modulation cycles inside its cache, so a very low value is \ + refused up front rather than mid-run." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.01, + max: stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 + / 1_000.0, + speed: 0.1, + default: self.min_f, + }, + }, + SettingItem { + key: "max_f".into(), + label: "Frequency axis: max f (Hz)".into(), + tooltip: Some( + "Highest frequency to record. Check yourself that the sensor can \ + follow it." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.01, + max: stage_a_plugin_contract::DRIVE_FREQUENCY_MAX_MILLIHZ as f64 + / 1_000.0, + speed: 1.0, + default: self.max_f, + }, + }, + SettingItem { + key: "freq_count".into(), + label: "Frequency axis: points".into(), + tooltip: Some( + "How many frequencies to record, spaced by decade rather than by \ + hertz — a Bode ladder is read per decade." + .into(), + ), + kind: SettingKind::I64Drag { + min: 1, + max: FREQ_SWEEP_MAX_POINTS as i64, + default: i64::from(self.freq_count), + }, + }, + SettingItem { + key: "freq_order".into(), + label: "Frequency axis: order".into(), + tooltip: Some( + "The order the frequencies are visited in. Anything but ascending \ + separates a real frequency effect from slow drift across the \ + block, because neighbouring points are no longer neighbours in \ + time." + .into(), + ), + kind: SettingKind::Enum { + variants: vec![ + "ascending".into(), + "alternating (low, high, low…)".into(), + "shuffled".into(), + ], + default: self.freq_order.index() as usize, + }, + }, + SettingItem { + key: "freq_seed".into(), + label: "Frequency axis: shuffle seed".into(), + tooltip: Some( + "Makes the shuffled order repeatable: the same seed always gives \ + the same order. Ignored unless the order is shuffled." + .into(), + ), + kind: SettingKind::I64Drag { + min: 1, + max: 1_000_000, + default: self.freq_seed as i64, + }, + }, + SettingItem { + key: "freq_reference_every".into(), + label: "Frequency axis: repeat the lowest every N".into(), + tooltip: Some( + "Re-records the lowest frequency after every N points, so drift \ + across the block shows up as a disagreement between its \ + repeats. 0 = off." + .into(), + ), + kind: SettingKind::I64Drag { + min: 0, + max: 10, + default: i64::from(self.freq_reference_every), + }, + }, + SettingItem { + key: "start_recording".into(), + label: "Record once".into(), + tooltip: Some( + "One recording with the light exactly as the modulation plugin \ + has it armed right now. Nothing is retargeted and nothing \ + settles first." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "start_sweep".into(), + label: "Sweep a".into(), + tooltip: Some( + "Records one file at each depth from min a to max a, at the \ + armed frequency. Settles on each depth before recording it." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "start_freq_sweep".into(), + label: "Sweep f".into(), + tooltip: Some( + "Records one file at each frequency from min f to max f, all at \ + the depth set above (\"the depth a to hold\"). With the \ + photodiode depth source that depth is re-found by measurement at \ + every frequency, because the drive does not hold it by itself as \ + f changes; with the commanded source it is simply commanded." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "start_freq_depth_sweep".into(), + label: "Sweep a × f".into(), + tooltip: Some( + "The whole surface: every depth in the a range, at every \ + frequency in the f range. That is (frequency points × depth \ + points) recordings — check both counts and the duration before \ + starting. Files are named …_fHz_pNN so the surface sorts by \ + frequency and then by depth." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "record_pilot".into(), + label: "Record pilot (freezes the ON/OFF windows)".into(), + tooltip: Some( + "A bright reference recording whose ON/OFF windows are reused by \ + every later recording in the same measurement, so the whole row \ + is scored consistently." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "record_background".into(), + label: "Record background (a ≈ 0 reference)".into(), + tooltip: Some( + "An unmodulated reference giving the false-response floor the \ + later points are measured above." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "stop_recording".into(), + label: "Stop".into(), + tooltip: Some( + "Stops whatever is running — a recording, a sweep, a ladder or a \ + protocol — at its next safe point, so the file in flight is \ + still finished and saved." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + ], + }, + SettingsSection { + label: "Protocol (run a survey from a file)".into(), + description: Some( + "The buttons above sweep one axis with the others left wherever they \ + happen to be. A protocol names every axis for every recording instead, \ + in a file that travels with the results.\n\n\ + **CSV — one row per recording.** Columns: mean_u (the brightness / I_k \ + axis), frequency_hz, depth_a, plus optional duration_s, settle_s, role \ + (normal / pilot / background) and label. Columns are found by name so \ + their order does not matter; blank lines and # comments are skipped, \ + and a blank cell falls back to the default. Because each row carries \ + its own duration, a 1 Hz point can record for 40 s and a 200 Hz point \ + for 10 — and a file can start with its own background and pilot.\n\n\ + **TOML — blocks and ranges.** [[block]] with a list or \ + { min, max, points } range on each axis, expanded to their product. \ + More compact for a dense regular sweep. Points run ū outermost, then f, \ + then a, which settles the slow axis least often.\n\n\ + Either way: all three axes are commanded at every point and the point \ + waits for all three to be acknowledged before recording, so nothing is \ + ever filed under parameters the file does not state. One modulation \ + lease covers the whole run and your armed settings are handed back at \ + the end. A point the drive cannot reach is skipped and named rather \ + than stopping the survey.\n\n\ + Commented examples ship with the plugin, at \ + ~/.augur/plugins/stage-a-a1/protocols/ — example.csv and example.toml." + .into(), + ), + default_open: false, + items: vec![ + SettingItem { + key: "protocol_path".into(), + label: "Protocol file".into(), + tooltip: Some( + ".csv (one row per recording) or .toml (blocks and ranges) — the \ + reader is chosen by the extension. Read and fully validated \ + when you press Run, so a bad value is reported with its line \ + number before the drive moves." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::OpenFile, + default: self.protocol_path.clone(), + }, + }, + SettingItem { + key: "run_protocol".into(), + label: "Run the protocol".into(), + tooltip: Some( + "Reads and validates the file, then records every point in it. \ + The status line reports how many recordings and roughly how long \ + it will take before the first one starts, and tracks progress \ + after that.\n\n\ + Use Stop in the Record section to end it early — the recording \ + in flight is still finished and saved." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + ], + }, + SettingsSection { + label: "Advanced: hold one depth across frequencies (a₀)".into(), + description: Some( + "Only needed with the photodiode depth source. The drive does not \ + deliver the same depth at every frequency by itself, so before \ + recording a frequency point the plugin adjusts it until the photodiode \ + measures a₀. That search is what Find a₀ does, and Sweep f runs it \ + automatically at every frequency.\n\n\ + With the commanded depth source there is nothing to search for and \ + none of this is used. The depths found are saved per frequency in the \ + output folder and survive a restart." + .into(), + ), + default_open: false, + items: vec![ + SettingItem { + key: "a0_tolerance".into(), + label: "a₀ tolerance".into(), + tooltip: Some( + "How close the measured depth has to get to a₀ before the search \ + calls it done. Tighter takes longer and can fail on a noisy \ + reading." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.002, + max: 0.5, + speed: 0.002, + default: self.a0_tolerance, + }, + }, + SettingItem { + key: "find_a0".into(), + label: "Find a₀ (at the armed frequency)".into(), + tooltip: Some( + "Searches for the drive depth that makes the photodiode measure \ + a₀ at the frequency currently armed, and remembers it." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "record_a0_point".into(), + label: "Record a₀ point".into(), + tooltip: Some( + "Records one file at the depth Find a₀ found for the armed \ + frequency." + .into(), + ), + kind: SettingKind::Button { + enabled: can_record, + }, + }, + SettingItem { + key: "clear_a0_locks".into(), + label: "Forget saved depths".into(), + tooltip: Some( + "Throws away every depth Find a₀ has found. Do this after \ + changing the illumination, the calibration, or a₀ itself — the \ + old depths no longer apply." + .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)), + "depth_source" => Some(json!(self.depth_source.index())), + "min_a" => Some(json!(self.min_a)), + "max_a" => Some(json!(self.max_a)), + "sweep_count" => Some(json!(self.sweep_count)), + "settle_s" => Some(json!(self.settle_s)), + "duration_s" => Some(json!(self.duration_s)), + "live" => Some(json!(self.live)), + "analysis_window_ms" => Some(json!(self.analysis_window_ms)), + "window_floor" => Some(json!(self.window_floor)), + // Button presses are exported as monotonic counters so the host's + // settings snapshot transports them from the UI mirror to the + // live worker (see PressLatch). + "start_recording" => Some(self.press_start.value()), + "record_pilot" => Some(self.press_pilot.value()), + "record_background" => Some(self.press_background.value()), + "stop_recording" => Some(self.press_stop.value()), + "start_sweep" => Some(self.press_sweep.value()), + "clear" => Some(self.press_clear.value()), + "record_point" => Some(self.press_record_point.value()), + "clear_curve" => Some(self.press_clear_curve.value()), + "a0_target" => Some(json!(self.a0_target)), + "a0_tolerance" => Some(json!(self.a0_tolerance)), + "find_a0" => Some(self.press_find_a0.value()), + "record_a0_point" => Some(self.press_record_a0.value()), + "clear_a0_locks" => Some(self.press_clear_a0.value()), + "min_f" => Some(json!(self.min_f)), + "max_f" => Some(json!(self.max_f)), + "freq_count" => Some(json!(self.freq_count)), + "freq_order" => Some(json!(self.freq_order.index())), + "freq_seed" => Some(json!(self.freq_seed)), + "freq_reference_every" => Some(json!(self.freq_reference_every)), + "start_freq_sweep" => Some(self.press_freq_sweep.value()), + "start_freq_depth_sweep" => Some(self.press_freq_depth_sweep.value()), + "protocol_path" => Some(json!(self.protocol_path)), + "run_protocol" => Some(self.press_run_protocol.value()), + // New id regenerates the measurement id locally; the id itself is + // what synchronizes, so the press must not be forwarded (both + // instances would generate different ids). + "new_id" => Some(json!(false)), + _ => 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(); + } + "new_id" if value.as_bool() == Some(true) => { + self.measurement_id = generate_measurement_id(); + } + "depth_source" => { + self.depth_source = + DepthSource::from_index(value.as_u64().ok_or("depth_source must be an index")?); + } + "min_a" => { + self.min_a = value + .as_f64() + .ok_or("min_a must be a number")? + .clamp(0.0, 10.0); + } + "max_a" => { + self.max_a = value + .as_f64() + .ok_or("max_a must be a number")? + .clamp(0.0, 10.0); + } + "sweep_count" => { + self.sweep_count = value + .as_i64() + .ok_or("sweep_count must be an integer")? + .clamp(2, 64); + } + "settle_s" => { + self.settle_s = value + .as_f64() + .ok_or("settle_s must be a number")? + .clamp(0.0, 60.0); + } + "duration_s" => { + self.duration_s = value + .as_i64() + .ok_or("duration_s must be an integer")? + .clamp(1, 3_600); + } + "start_recording" => { + if self.press_start.accept(&value) { + self.pending_role = Some(RecRole::Normal); + } + } + "record_pilot" => { + if self.press_pilot.accept(&value) { + self.pending_role = Some(RecRole::Pilot); + } + } + "record_background" => { + if self.press_background.accept(&value) { + self.pending_role = Some(RecRole::Background); + } + } + "start_sweep" => { + if self.press_sweep.accept(&value) { + self.sweep_pending = true; + } + } + "start_freq_sweep" => { + if self.press_freq_sweep.accept(&value) { + self.freq_sweep_pending = Some(FreqSweepMode::A0Point); + } + } + "start_freq_depth_sweep" => { + if self.press_freq_depth_sweep.accept(&value) { + self.freq_sweep_pending = Some(FreqSweepMode::DepthSweep); + } + } + "stop_recording" => { + if self.press_stop.accept(&value) { + self.request_stop(); + } + } + "live" => { + self.live = value.as_bool().ok_or("live must be a boolean")?; + if !self.live { + // Immediately, not on the next frame: with no camera + // running there is no next frame, and the operator who just + // switched this off is the one waiting for the plots to + // stop being slow. + self.drop_live_buffers(); + } + } + "analysis_window_ms" => { + self.analysis_window_ms = value + .as_i64() + .ok_or("analysis_window_ms must be an integer")? + .clamp(1, 120_000); + } + "clear" => { + if self.press_clear.accept(&value) { + self.drop_live_buffers(); + } + } + "window_floor" => { + self.window_floor = value + .as_f64() + .ok_or("window_floor must be a number")? + .clamp(0.02, 0.5); + } + "record_point" => { + if self.press_record_point.accept(&value) { + // Report failure via the status message: on the worker the + // press arrives through the settings snapshot, where a + // returned error would be silently dropped. + if let Err(error) = self.record_response_point() { + self.message = format!("Record point failed: {error}"); + } + } + } + "clear_curve" => { + if self.press_clear_curve.accept(&value) { + self.response_points.clear(); + } + } + "a0_target" => { + self.a0_target = value + .as_f64() + .ok_or("a0_target must be a number")? + .clamp(COMMANDED_A_MIN, COMMANDED_A_MAX); + } + "a0_tolerance" => { + self.a0_tolerance = value + .as_f64() + .ok_or("a0_tolerance must be a number")? + .clamp(0.002, 0.5); + } + "find_a0" => { + if self.press_find_a0.accept(&value) { + self.a0_lock_pending = true; + } + } + "record_a0_point" => { + if self.press_record_a0.accept(&value) { + self.a0_point_pending = true; + } + } + "min_f" => { + self.min_f = value.as_f64().ok_or("min_f must be a number")?.max(0.01); + } + "max_f" => { + self.max_f = value.as_f64().ok_or("max_f must be a number")?.max(0.01); + } + "freq_count" => { + self.freq_count = value + .as_u64() + .ok_or("freq_count must be an integer")? + .clamp(1, FREQ_SWEEP_MAX_POINTS as u64) + as u32; + } + "freq_order" => { + self.freq_order = + FreqOrder::from_index(value.as_u64().ok_or("freq_order must be an index")?); + } + "freq_seed" => { + self.freq_seed = value.as_u64().ok_or("freq_seed must be an integer")?.max(1); + } + "freq_reference_every" => { + self.freq_reference_every = value + .as_u64() + .ok_or("freq_reference_every must be an integer")? + .min(10) as u32; + } + "protocol_path" => { + self.protocol_path = value + .as_str() + .ok_or("protocol_path must be a string")? + .to_string(); + } + "run_protocol" => { + if self.press_run_protocol.accept(&value) { + self.protocol_pending = true; + } + } + "clear_a0_locks" => { + if self.press_clear_a0.accept(&value) { + self.a0_locks.clear(); + self.message = match self.save_a0_locks() { + Ok(()) => "a₀ lock table cleared".into(), + Err(error) => error, + }; + } + } + "new_id" => return Ok(()), + _ => return Err(format!("unknown setting '{key}'")), + } + self.bump(); + Ok(()) + } + + fn status_entries(&self) -> Vec { + let mut entries = vec![StatusEntry::LabeledValue { + label: "Recording".into(), + value: self.recording.state_label().into(), + 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, about {} of bench time left", + run.plan.name, + (run.index + 1).min(run.plan.points.len()), + run.plan.points.len(), + run.recorded, + run.failed.len(), + format_bench_time(run.plan.remaining_seconds(run.index)), + ))); + // The per-point message is overwritten within the tick that skips a + // point, so the most recent reason lives here instead of scrolling + // past unread. + if let Some((index, reason)) = run.failed.last() { + entries.push(StatusEntry::Text(format!( + "Last skipped point {}: {reason}", + index + 1 + ))); + } + } + if self.recording.is_active() { + if let Some(remaining) = self.recording.remaining_s(now_unix_ms()) { + entries.push(StatusEntry::Text(format!( + "{} — {remaining} s remaining", + self.recording.id + ))); + } + } + if let Some(sweep) = &self.freq_sweep { + let phase = match sweep.phase { + FreqSweepPhase::AcquiringLease => "taking control of the drive", + FreqSweepPhase::SettingFrequency => "changing the frequency", + FreqSweepPhase::ConfirmingFrequency => "checking the frequency really changed", + FreqSweepPhase::Locking => "finding the depth a₀", + FreqSweepPhase::Recording => match sweep.mode { + // The inner sweep prints its own point-by-point line below, + // so this one only has to say which stage of the *ladder* + // the run is in. + FreqSweepMode::A0Point => "recording", + FreqSweepMode::DepthSweep => "recording the depth curve", + }, + }; + let point = sweep.point(); + entries.push(StatusEntry::Text(format!( + "Frequency ladder ({}) {}/{} at {}{} — {phase} ({} done, {} skipped)", + sweep.mode.label(), + sweep.index + 1, + sweep.points.len(), + frequency_label(sweep.frequency_hz()), + if point.is_some_and(|point| point.is_reference) { + " (reference)" + } else { + "" + }, + sweep.recorded, + sweep.failed.len(), + ))); + } + if let Some(sweep) = &self.sweep { + let phase = match sweep.phase { + SweepPhase::AcquiringLease => "taking control of the drive", + SweepPhase::SettingDepth => "changing the depth", + SweepPhase::Settling => "waiting for the depth to settle", + SweepPhase::Recording => "recording", + }; + let label = match sweep.kind { + SweepKind::Amplitude => "Depth sweep", + SweepKind::EventCount => "a₀ point", + }; + entries.push(StatusEntry::Text(format!( + "{label}: point {}/{}, asking for a = {:.3} to measure {:.3} ({phase})", + sweep.index + 1, + sweep.total(), + sweep.commanded_a(), + sweep.target_a() + ))); + } + if let Some(lock) = &self.a0_lock { + let phase = match lock.phase { + A0LockPhase::AcquiringLease => "taking control of the drive", + A0LockPhase::SettingDepth => "setting the depth", + A0LockPhase::Measuring => "measuring", + }; + entries.push(StatusEntry::Text(format!( + "Find a₀ at {}: try {}/{A0_LOCK_MAX_TRIALS}, asking for a = {:.3} to measure a₀ = \ + {:.3} ({phase}, {} reading(s))", + frequency_label(lock.frequency_hz), + lock.trial, + lock.commanded_a, + lock.target_a, + lock.samples.len() + ))); + } + if !self.message.is_empty() { + entries.push(StatusEntry::Text(self.message.clone())); + } + // Each fact appears once. The panel used to state a missing frequency on + // three separate lines — the transient message, this line, and the a₀ + // readiness line — which reads as three problems instead of one. + let no_frequency = self.frequency_hz().is_none(); + match self.period_us() { + Some(period_us) => { + let source = match self.frequency_source() { + "trigger" => "measured from the trigger", + _ => "as set in the modulation plugin", + }; + entries.push(StatusEntry::Text(format!( + "Frequency: {:.3} Hz ({source}) — one cycle is {:.3} ms", + 1_000_000.0 / period_us, + period_us / 1_000.0, + ))); + } + None => entries.push(StatusEntry::Text(format!( + "Frequency: unknown. {}", + capitalize_first( + &self + .frequency_blocker() + .unwrap_or_else(|| "no drive is armed".into()) + ) + ))), + } + // With Live analysis off nothing is ingested at all, so an event count + // and a pixel count describe the switch rather than the bench. Say only + // what is true. + entries.push(StatusEntry::Text(if !self.live { + "Camera: Live analysis is OFF — no events or triggers are being read".into() + } else { + let anchor = if self.is_marker_anchored() { + format!("{} triggers seen", self.camera_markers_us.len()) + } else { + "no trigger signal — check the cable from the Teensy to the camera".into() + }; + format!( + "Camera: {} events, {} usable pixels; {anchor}", + self.camera_events.len(), + self.valid_pixel_count().unwrap_or(0) + ) + })); + let depth_label = match self.depth_source { + DepthSource::Photodiode => "Measured depth a", + DepthSource::Commanded => "Commanded depth a (open loop, not measured)", + }; + entries.push(StatusEntry::Text(match self.depth_a() { + Some(a) => format!("{depth_label} = {a:.3}"), + // Name the gate, not just its effect: every a₀ and sweep button + // refuses on this value, so the panel has to say what to fix. A + // colon, not a dash: the reason carries a dash of its own. + None => format!( + "{depth_label}: not available. {}", + capitalize_first( + &self + .depth_a_blocker() + .unwrap_or_else(|| "no depth source has sent anything yet".into()) + ) + ), + })); + // 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 { + let mut parts = Vec::new(); + if let Some(celsius) = sensor.temperature_c { + parts.push(format!("{celsius:.1} °C")); + } + if let Some(dead_time_us) = sensor.pixel_dead_time_us { + parts.push(format!("{dead_time_us:.1} µs dead time")); + } + if let Some(lux) = sensor.illumination_lux { + parts.push(format!("{lux:.0} lx")); + } + if !parts.is_empty() { + entries.push(StatusEntry::Text(format!( + "Sensor: {} (read {:.1} s ago; recorded with every run)", + parts.join(", "), + 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!( + "Events per pixel per half-cycle: {on:.4} bright, {off:.4} dark" + ))); + } + // Silent until there is something to report: at rest this line said + // "0 point(s), no bright/dark windows yet", which is just the absence of + // the two facts above it. + let windows = self.current_windows(); + if !self.response_points.is_empty() || windows.is_some() { + let source = if self.windows_are_frozen() { + "from the pilot" + } else { + "found automatically" + }; + let windows = windows.map_or_else( + || "no bright/dark windows yet".into(), + |(on, off)| { + format!( + "bright/dark windows {source} (bright {:.2}–{:.2}, dark {:.2}–{:.2} of a \ + cycle)", + on.start, on.end, off.start, off.end + ) + }, + ); + entries.push(StatusEntry::Text(format!( + "Response curve: {} point(s), {windows}", + self.response_points.len() + ))); + } + if let Some((q0_on, q0_off)) = self.background_floor { + entries.push(StatusEntry::Text(format!( + "Background floor recorded: {q0_on:.3} bright, {q0_off:.3} dark" + ))); + } + // Open loop there is no lock table and nothing was searched for, so the + // line says what will happen rather than reporting a saved-depth count + // that is structurally always zero. + entries.push(StatusEntry::Text(match (self.armed_a0(), no_frequency) { + (Some(lock), _) if !self.depth_source.needs_a0_lock() => format!( + "Ready to record at a₀ = {:.3}: the drive is commanded to a = {:.3} at {} — no \ + search needed, press \"Record a₀ point\" or \"Record all frequencies\".", + lock.target_a, + lock.commanded_a, + frequency_label(lock.frequency_hz), + ), + (Some(lock), _) => format!( + "Ready to record at a₀ = {:.3}: at {} the drive is set to a = {:.3} and the \ + photodiode measures {:.3}. {} depth(s) saved.", + lock.target_a, + frequency_label(lock.frequency_hz), + lock.commanded_a, + lock.measured_a, + self.a0_locks.len() + ), + // Without a frequency nothing about a₀ can be judged yet, and the + // Frequency line above already says what to fix. Point at it rather + // than repeating it. + (None, true) => "a₀ points: waiting for a frequency (above).".into(), + (None, false) => format!( + "Not ready to record an a₀ point — {}.", + self.armed_a0_blocker() + .unwrap_or_else(|| "no depth is armed".into()), + ), + })); + 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: "A1 status".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("state", "Recording"), + column("measurement_id", "Measurement id"), + column("remaining", "Remaining"), + column("frequency", "Frequency"), + column("a", "a (depth)"), + column("s_on", "S_on"), + column("s_off", "S_off"), + column("events", "Events"), + column("message", "Message"), + ], + ..TableSchema::default() + }), + empty_message: "A1 idle".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: ROLLING_DATASET_ID.into(), + title: "A1 rolling response S_p(t) — live sanity check".into(), + kind: HostDatasetKind::Series1dV1, + empty_message: "Enable Live analysis; waiting for events and a period".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: RESPONSE_CURVE_DATASET_ID.into(), + title: "A1 response probability q_p(a) — live quicklook".into(), + kind: HostDatasetKind::Series1dV1, + empty_message: "Capture a pilot, then record points per amplitude".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: A0_LOCK_DATASET_ID.into(), + title: "A1 a₀ locks — commanded depth per frequency".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("frequency", "Frequency"), + column("target_a", "a₀ (target)"), + column("commanded_a", "Commanded a"), + column("measured_a", "Observed a"), + column("depth_source", "a from"), + column("trials", "Trials"), + column("state", "State"), + column("locked_at", "Locked at (UTC)"), + ], + ..TableSchema::default() + }), + empty_message: "No a₀ lock yet — set a₀ and press Find a₀ per frequency".into(), + display: None, + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: STATUS_VIEW_ID.into(), + title: "A1 status".into(), + dataset_id: STATUS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: ROLLING_VIEW_ID.into(), + title: "A1 rolling response S_p (ON/OFF)".into(), + dataset_id: ROLLING_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::LineSeriesWindow, + }, + HostViewDescriptor { + id: RESPONSE_CURVE_VIEW_ID.into(), + title: "A1 response probability q_p (ON/OFF)".into(), + dataset_id: RESPONSE_CURVE_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::LineSeriesWindow, + }, + HostViewDescriptor { + id: A0_LOCK_VIEW_ID.into(), + title: "A1 a₀ locks (commanded depth per frequency)".into(), + dataset_id: A0_LOCK_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(), + ROLLING_DATASET_ID => serde_json::to_vec(&self.rolling_dataset()).ok(), + RESPONSE_CURVE_DATASET_ID => serde_json::to_vec(&self.response_curve_dataset()).ok(), + A0_LOCK_DATASET_ID => serde_json::to_vec(&self.a0_locks_dataset()).ok(), + _ => None, + } + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + matches!( + dataset_id, + STATUS_DATASET_ID | ROLLING_DATASET_ID | RESPONSE_CURVE_DATASET_ID | A0_LOCK_DATASET_ID + ) + .then_some(self.dataset_generation) + .unwrap_or(0) + } +} + +fn connection_label(connection: &ConnectionStateV1) -> &'static str { + match connection { + ConnectionStateV1::Connected { .. } => "connected", + ConnectionStateV1::Connecting => "connecting", + ConnectionStateV1::Disconnected => "disconnected", + ConnectionStateV1::Faulted { .. } => "faulted", + } +} + +export_plugin!(StageAA1Plugin); + +#[cfg(test)] +mod tests { + use stage_a_plugin_contract::{ + FreshnessV1, OwnerInstanceId, PdqFinalizedReceiptV1, PdqStartedReceiptV1, + PhotodiodeCalibrationV1, PhotodiodeStreamV1, RequestOutcomeV1, ResponseCommonV1, Sha256V1, + StreamIntegrityV1, SynchronizationV1, UnsyncedReasonV1, CONTRACT_VERSION_V1, + }; + + use super::*; + + #[derive(Default)] + struct ControlSink { + services: Vec, + hosts: Vec, + } + + impl RecordingControl for ControlSink { + fn request_service(&mut self, request: &PluginServiceRequest) { + self.services.push(request.clone()); + } + + fn request_host(&mut self, request: &HostCommandRequest) { + self.hosts.push(request.clone()); + } + } + + #[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, + inbox: PluginControlInbox, + sink: &mut ControlSink, + ) { + for reply in &inbox.host_replies { + plugin.on_host_reply(reply); + } + for reply in &inbox.service_replies { + plugin.on_service_reply(reply); + } + // 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); + plugin.drive_sweep(sink); + plugin.drive_recording(sink); + } + + /// Bare `Accepted` reply, as the modulation owner answers a lease or depth + /// command (only the outcome variant is routed). + fn accepted(request_id: u64) -> PluginServiceReply { + PluginServiceReply { + request_id, + source_plugin_id: A1_PLUGIN_ID.into(), + target_plugin_id: MODULATION_PLUGIN_ID.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + outcome: PluginServiceOutcome::Accepted { + payload: Value::Null, + }, + } + } + + /// A control inbox carrying just these service replies. + fn inbox_with(service_replies: Vec) -> PluginControlInbox { + PluginControlInbox { + service_replies, + ..PluginControlInbox::default() + } + } + + /// The modulation command inside a routed service request, if it is one. + fn modulation_command(request: &PluginServiceRequest) -> Option { + serde_json::from_value::(request.payload.clone()) + .ok() + .map(|envelope| envelope.command) + } + + fn rejected(request_id: u64, message: &str) -> PluginServiceReply { + PluginServiceReply { + request_id, + source_plugin_id: A1_PLUGIN_ID.into(), + target_plugin_id: MODULATION_PLUGIN_ID.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + outcome: PluginServiceOutcome::Rejected { + code: "invalid_command".into(), + message: message.into(), + }, + } + } + + fn connected_modulation() -> ModulationStateV1 { + ModulationStateV1 { + contract_version: stage_a_plugin_contract::CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("mod-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("0.4.0".into()), + }, + capabilities: Vec::new(), + lease: None, + controller_state: stage_a_plugin_contract::ControllerStateV1::Configured, + active_run_id: None, + requested: None, + acknowledged: None, + synchronization: stage_a_plugin_contract::SynchronizationV1::Unsynced { + reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: stage_a_plugin_contract::FreshnessV1 { + observed_at_unix_ms: now_unix_ms(), + valid_for_ms: 5_000, + }, + calibration_id: Some("pockels-test".into()), + optical_drive: None, + } + } + + /// A connected modulation owner running a calibrated optical drive at + /// `depth_a`, published at `revision`. + fn commanded_modulation(revision: u64, depth_a: f64) -> ModulationStateV1 { + ModulationStateV1 { + service_revision: revision, + optical_drive: Some(stage_a_plugin_contract::OpticalDriveStateV1 { + target: OpticalTargetV1::LogSine, + requested_mean_u_milli: 500, + resolved_mean_u_milli: 500, + internal_u_milli: 500, + depth_a_milli: (depth_a * 1_000.0).round() as u32, + v_null_dac: 100, + v_peak_dac: 800, + }), + ..connected_modulation() + } + } + + /// A photodiode snapshot reporting `measured_a`, published at `revision`. + fn photodiode_measuring(revision: u64, measured_a: f64) -> PhotodiodeSummaryV1 { + PhotodiodeSummaryV1 { + contract_version: stage_a_plugin_contract::CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("pd-test"), + service_revision: revision, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: None, + }, + lease: None, + active_run_id: None, + requested_revision: None, + acknowledged_revision: None, + stream: stage_a_plugin_contract::PhotodiodeStreamV1 { + stream_epoch: 1, + sample_range: None, + sample_rate_hz: Some(20_000), + latest_adc_code: None, + integrity: StreamIntegrityV1::default(), + level: None, + }, + active_recording: None, + last_finalized_recording: None, + data_dir: Some(std::env::temp_dir().display().to_string()), + optical_summary: Some(stage_a_plugin_contract::PhotodiodeOpticalSummaryV1 { + run_id: RunId::new("pd-run"), + calibration: stage_a_plugin_contract::PhotodiodeCalibrationV1 { + adc_calibration_id: "adc".into(), + dark_id: "dark".into(), + anchor_id: Some("anchor".into()), + dark_volts: 0.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, + excitation_max_volts: 0.9, + excitation_headroom_volts: 0.1, + low_clip_fraction: 0.0, + high_clip_fraction: 0.0, + measured_frequency_hz: None, + fundamental_phase_rad: None, + total_harmonic_distortion: None, + // A short window, so the lock's dwell and sample spacing stay + // in the millisecond range the tests tick at. + window_seconds: Some(0.001), + covered_cycles: Some(8.0), + }), + optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, + synchronization: stage_a_plugin_contract::SynchronizationV1::Unsynced { + reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: stage_a_plugin_contract::FreshnessV1 { + observed_at_unix_ms: now_unix_ms(), + valid_for_ms: 5_000, + }, + } + } + + /// The depth carried by the newest `SetOpticalDepth` the plugin emitted. + fn last_commanded_depth(sink: &ControlSink) -> Option { + sink.services.iter().rev().find_map(|request| { + let envelope: ModulationRequestV1 = + serde_json::from_value(request.payload.clone()).ok()?; + match envelope.command { + ModulationCommandV1::SetOpticalDepth { depth_a_milli } => { + Some(f64::from(depth_a_milli) / 1_000.0) + } + _ => None, + } + }) + } + + /// A unique scratch directory for a test's lock table and sidecars. + fn temp_folder(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("a1-{tag}-{}", now_unix_ms())) + } + + /// A plugin wired to a connected drive at 1 kHz (marker-anchored) whose + /// photodiode reports a bench that delivers `gain ×` the commanded depth. + fn plugin_locking(gain: f64, folder: &Path) -> StageAA1Plugin { + let mut plugin = plugin_with_markers(); + plugin.modulation = Some(connected_modulation()); + plugin.photodiode = Some(photodiode_measuring(1, gain)); + plugin.output_folder = folder.display().to_string(); + plugin.measurement_id = "A1-ec".into(); + plugin.settle_s = 0.0; + plugin.a0_tolerance = 0.02; + plugin + } + + /// Answers the lock's outstanding lease/depth request and publishes the + /// photodiode readings the commanded depth produces, until the lock ends. + fn run_lock_to_completion( + plugin: &mut StageAA1Plugin, + sink: &mut ControlSink, + gain: f64, + max_ticks: usize, + ) -> usize { + let mut revision = 1; + // The first tick consumes the latched press and starts the lock. + control_tick(plugin, PluginControlInbox::default(), sink); + for tick in 0..max_ticks { + if plugin.a0_lock.is_none() { + return tick + 1; + } + let (lease_req, depth_req, granted, applied) = { + let lock = plugin.a0_lock.as_ref().expect("lock"); + ( + lock.lease_req, + lock.depth_req, + lock.lease_granted, + lock.depth_applied, + ) + }; + let mut replies = Vec::new(); + if !granted { + replies.push(accepted(lease_req)); + } else if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + // Measuring: publish what the bench delivers for the commanded + // depth as a fresh summary. + let commanded = plugin.a0_lock.as_ref().expect("lock").commanded_a; + revision += 1; + plugin.photodiode = Some(photodiode_measuring(revision, commanded * gain)); + // The lock spaces its readings by a fraction of the photodiode's + // estimator window (1 ms in these fixtures), so a tick loop that + // never advances the wall clock would collect exactly one. + std::thread::sleep(std::time::Duration::from_millis(1)); + } + control_tick( + plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + sink, + ); + } + max_ticks + } + + /// The bench that motivated the commanded source: the photodiode streams + /// fine but withholds `a` because no phase-0 markers ever arrive, so no + /// window can be proven to cover whole modulation cycles. + fn photodiode_without_triggers() -> PhotodiodeSummaryV1 { + 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" + .into(), + ), + ..photodiode_measuring(1, 0.5) + } + } + + #[test] + fn a_withheld_photodiode_depth_names_the_commanded_fallback() { + // The photodiode's own reason has to survive verbatim — it is the only + // side that knows which gate refused — but a bench with no trigger + // markers at all cannot act on it, so the way past must be on the same + // line as the diagnosis. + let mut plugin = plugin_with_markers(); + plugin.photodiode = Some(photodiode_without_triggers()); + + let blocker = plugin.depth_a_blocker().expect("a withheld a has a reason"); + assert!( + blocker.contains("two whole modulation cycles"), + "the owner's own words must survive: {blocker}" + ); + assert!( + blocker.contains("Depth a source"), + "and must name the setting that gets past it: {blocker}" + ); + } + + #[test] + fn the_commanded_source_reports_a_depth_with_no_photodiode_at_all() { + let mut plugin = plugin_with_markers(); + plugin.photodiode = None; + plugin.modulation = Some(commanded_modulation(1, 0.75)); + plugin.depth_source = DepthSource::Commanded; + + assert_eq!(plugin.depth_a(), Some(0.75)); + assert!( + plugin.depth_a_blocker().is_none(), + "the commanded drive is a depth source in its own right" + ); + } + + #[test] + fn the_commanded_source_refuses_a_drive_that_is_not_calibrated() { + // Without a calibrated optical drive the owner publishes no inversion, + // and a "commanded a" would be a DAC number wearing a physical name. + let mut plugin = plugin_with_markers(); + plugin.photodiode = None; + plugin.modulation = Some(connected_modulation()); + plugin.depth_source = DepthSource::Commanded; + + assert!(plugin.depth_a().is_none()); + let blocker = plugin.depth_a_blocker().expect("a reason"); + assert!( + blocker.contains("calibration") && blocker.contains("OPTICAL_LOG_SINE"), + "{blocker}" + ); + } + + /// An acknowledged periodic drive at `hz`, i.e. what the modulation owner + /// publishes once it has really applied a commanded frequency. + fn acknowledged_sine(hz: f64) -> stage_a_plugin_contract::ModulationTargetV1 { + stage_a_plugin_contract::ModulationTargetV1 { + revision: SemanticRevision(1), + waveform: Some(WaveformV1::Periodic { + waveform: stage_a_plugin_contract::PeriodicWaveformV1::Sine, + min_dac: 100, + 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, + } + } + + /// A plugin ready to record a₀ points open loop: calibrated commanded + /// drive, no photodiode, and — deliberately — no camera trigger markers. + fn plugin_commanded_a0(folder: &Path) -> StageAA1Plugin { + StageAA1Plugin { + frame_width: 10, + frame_height: 1, + depth_source: DepthSource::Commanded, + photodiode: Some(fresh_photodiode_summary()), + modulation: Some(commanded_modulation(1, 0.5)), + output_folder: folder.display().to_string(), + measurement_id: "A1-cmd".into(), + settle_s: 0.0, + a0_target: 0.5, + a0_tolerance: 0.02, + ..StageAA1Plugin::default() + } + } + + #[test] + fn find_a0_refuses_to_search_for_a_depth_it_is_commanding() { + // The search commands a₀, reads back a₀ and stops — one identical row + // per frequency and nothing learned. Refuse and say so rather than + // spending a lease and a trial to arrive where the operator already is. + let dir = temp_folder("commanded-find"); + let mut plugin = plugin_commanded_a0(&dir); + let mut sink = ControlSink::default(); + + plugin.set_setting("find_a0", json!(true)).expect("press"); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.a0_lock.is_none(), "no search may start"); + assert!( + sink.services.is_empty(), + "and no lease may be taken for it: {:?}", + sink.services.len() + ); + assert!( + plugin.message.contains("No search needed"), + "{}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_commanded_a0_point_is_armed_without_any_lock() { + // `Record a₀ point` and the ladder both ask one question — what depth + // is armed here — and open loop the answer needs no stored table. + let dir = temp_folder("commanded-armed"); + let mut plugin = plugin_commanded_a0(&dir); + // 1 kHz from the modulation owner's acknowledged drive; no markers. + plugin.modulation = Some(ModulationStateV1 { + acknowledged: Some(acknowledged_sine(1_000.0)), + ..commanded_modulation(1, 0.5) + }); + + assert!( + !plugin.is_marker_anchored(), + "no camera trigger in this test" + ); + assert!( + plugin.armed_a0_blocker().is_none(), + "{:?}", + plugin.armed_a0_blocker() + ); + let armed = plugin.armed_a0().expect("an armed depth"); + assert!((armed.commanded_a - 0.5).abs() < 1e-9); + assert!((armed.frequency_hz - 1_000.0).abs() < 1e-6); + assert_eq!(armed.trials, 0, "no search happened, and it must say so"); + assert_eq!(armed.depth_source, DepthSource::Commanded); + assert!( + plugin.a0_locks.is_empty(), + "and nothing was written to the lock table" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_commanded_ladder_records_every_point_without_a_lock_or_a_trigger() { + // The whole simplification in one test: no photodiode `a`, no camera + // markers, no Find a₀ — the ladder still leases once, walks the + // frequencies confirming each against the modulation owner, and records + // a point at a₀ at every one of them. + let dir = temp_folder("commanded-ladder"); + let mut plugin = plugin_commanded_a0(&dir); + plugin.min_f = 10.0; + plugin.max_f = 1_000.0; + plugin.freq_count = 3; + plugin.freq_order = FreqOrder::Ascending; + plugin.duration_s = 1; + + let mut sink = ControlSink::default(); + plugin + .set_setting("start_freq_sweep", json!(true)) + .expect("press"); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + plugin.freq_sweep.is_some(), + "the ladder must start with no trigger: {}", + plugin.message + ); + + let mut recorded = Vec::new(); + for _ in 0..400 { + let Some((phase, target_hz, lease_req, freq_req, granted, applied)) = + plugin.freq_sweep.as_ref().map(|sweep| { + ( + sweep.phase, + sweep.frequency_hz(), + sweep.lease_req, + sweep.freq_req, + sweep.lease_granted, + sweep.freq_applied, + ) + }) + else { + break; + }; + let mut replies = Vec::new(); + match phase { + FreqSweepPhase::AcquiringLease if !granted => replies.push(accepted(lease_req)), + FreqSweepPhase::SettingFrequency if !applied && freq_req != 0 => { + // The owner acknowledges the new frequency. In commanded + // mode that ack — not the camera trigger — is what confirms + // the point, so nothing here ever writes a marker. + plugin.modulation = Some(ModulationStateV1 { + acknowledged: Some(acknowledged_sine(target_hz)), + ..commanded_modulation(2, 0.5) + }); + replies.push(accepted(freq_req)); + } + FreqSweepPhase::Locking => { + panic!("the commanded ladder must never enter the search phase") + } + FreqSweepPhase::Recording => { + if let Some(sweep) = plugin.sweep.as_ref() { + if !sweep.depth_applied && sweep.depth_req != 0 { + replies.push(accepted(sweep.depth_req)); + } + } + // Short-circuit the recording coordinator once the point has + // started: this test is about the ladder, and the + // coordinator has tests of its own. + if plugin.recording.is_active() + && plugin + .sweep + .as_ref() + .is_some_and(|sweep| sweep.point_started) + { + plugin.recording = Recording::idle(); + plugin.recording_completed_ok = true; + recorded.push(target_hz); + } + } + _ => {} + } + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + &mut sink, + ); + } + + assert!( + plugin.freq_sweep.is_none(), + "the ladder must finish: {}", + plugin.message + ); + assert_eq!( + recorded.len(), + 3, + "every planned point records: {recorded:?}" + ); + assert!( + plugin.message.contains("3/3 points recorded"), + "{}", + plugin.message + ); + assert!( + plugin.a0_locks.is_empty(), + "and the lock table stays empty — nothing was searched for" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_nested_sweep_records_every_depth_at_every_frequency_on_one_lease() { + // The q_p(a, f) surface in one press: the outer ladder walks the + // frequencies, and each rung runs the *whole* inner depth sweep. No a₀ + // and no search are involved at any point. + let dir = temp_folder("nested-sweep"); + let mut plugin = plugin_commanded_a0(&dir); + plugin.min_f = 10.0; + plugin.max_f = 100.0; + plugin.freq_count = 2; + plugin.freq_order = FreqOrder::Ascending; + plugin.min_a = 0.5; + plugin.max_a = 1.5; + plugin.sweep_count = 3; + plugin.duration_s = 1; + + let mut sink = ControlSink::default(); + plugin + .set_setting("start_freq_depth_sweep", json!(true)) + .expect("press"); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + plugin.freq_sweep.is_some(), + "the nested sweep must start: {}", + plugin.message + ); + + // (frequency, depth index) of every recording that actually started. + let mut recorded: Vec<(f64, usize)> = Vec::new(); + for _ in 0..600 { + let Some((phase, mode, target_hz, lease_req, freq_req, granted, applied)) = + plugin.freq_sweep.as_ref().map(|sweep| { + ( + sweep.phase, + sweep.mode, + sweep.frequency_hz(), + sweep.lease_req, + sweep.freq_req, + sweep.lease_granted, + sweep.freq_applied, + ) + }) + else { + break; + }; + assert_eq!(mode, FreqSweepMode::DepthSweep); + assert_ne!( + phase, + FreqSweepPhase::Locking, + "a depth sweep never needs an a₀ search" + ); + let mut replies = Vec::new(); + match phase { + FreqSweepPhase::AcquiringLease if !granted => replies.push(accepted(lease_req)), + FreqSweepPhase::SettingFrequency if !applied && freq_req != 0 => { + plugin.modulation = Some(ModulationStateV1 { + acknowledged: Some(acknowledged_sine(target_hz)), + ..commanded_modulation(2, 0.5) + }); + replies.push(accepted(freq_req)); + } + FreqSweepPhase::Recording => { + if let Some(sweep) = plugin.sweep.as_ref() { + let (depth_req, depth_applied, index, commanded) = ( + sweep.depth_req, + sweep.depth_applied, + sweep.index, + sweep.commanded_a(), + ); + if !depth_applied && depth_req != 0 { + // The owner applies the depth this point asked for, + // which is what the settle check then reads back. + plugin.modulation = Some(ModulationStateV1 { + acknowledged: Some(acknowledged_sine(target_hz)), + ..commanded_modulation(3, commanded) + }); + replies.push(accepted(depth_req)); + } + if plugin.recording.is_active() && sweep.point_started { + plugin.recording = Recording::idle(); + plugin.recording_completed_ok = true; + recorded.push((target_hz, index)); + } + } + } + _ => {} + } + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + &mut sink, + ); + } + + assert!( + plugin.freq_sweep.is_none(), + "the nested sweep must finish: {}", + plugin.message + ); + assert_eq!( + recorded.len(), + 6, + "2 frequencies × 3 depths: {recorded:?} — {}", + plugin.message + ); + // Every frequency saw its whole curve, in depth order. + assert_eq!( + recorded.iter().map(|(_, index)| *index).collect::>(), + vec![0, 1, 2, 0, 1, 2] + ); + assert!((recorded[0].0 - 10.0).abs() < 1e-6, "{recorded:?}"); + assert!((recorded[3].0 - 100.0).abs() < 1e-6, "{recorded:?}"); + assert!( + plugin + .message + .contains("2/2 frequencies × 3 depths recorded"), + "{}", + plugin.message + ); + + // One lease for the whole block: the inner sweeps inherit it, so the + // operator's drive cannot move between rungs. + let leases = sink + .services + .iter() + .filter_map(|request| { + serde_json::from_value::(request.payload.clone()).ok() + }) + .filter(|envelope| matches!(envelope.command, ModulationCommandV1::AcquireLease { .. })) + .count(); + assert_eq!( + leases, 1, + "exactly one lease acquisition for the whole block" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_nested_sweep_point_is_named_by_frequency_and_depth() { + // `_p03` alone repeats at every rung, so the surface would collide + // inside one measurement id. + let dir = temp_folder("nested-name"); + let mut plugin = plugin_commanded_a0(&dir); + plugin.freq_sweep = Some(FreqSweep { + phase: FreqSweepPhase::Recording, + mode: FreqSweepMode::DepthSweep, + points: vec![FreqSweepPoint { + frequency_hz: 50.0, + is_reference: false, + }], + index: 0, + lease_id: LeaseId::new("nested"), + lease_granted: true, + lease_req: 0, + freq_req: 0, + freq_applied: true, + confirm_deadline_ms: 0, + skip_reason: None, + failed: Vec::new(), + recorded: 0, + order: FreqOrder::Ascending, + seed: 1, + last_activity_ms: 0, + stop_requested: false, + }); + plugin.sweep = Some(Sweep { + phase: SweepPhase::Recording, + kind: SweepKind::Amplitude, + points: vec![ + SweepPoint { + commanded_a: 1.0, + expected_a: 1.0, + }; + 3 + ], + lock: None, + index: 2, + lease_id: LeaseId::new("nested"), + lease_granted: true, + lease_req: 0, + owns_lease: false, + depth_req: 0, + depth_applied: true, + settled_since_ms: None, + settle_deadline_ms: 0, + point_started: false, + completed_ok: false, + last_activity_ms: 0, + stop_requested: false, + }); + + let mut sink = ControlSink::default(); + plugin.begin_recording(&mut sink, RecRole::Normal); + let stem = plugin.recording.stem.clone(); + assert!(stem.ends_with("_f50Hz_p03"), "stem: {stem}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_run_records_the_bench_conditions_the_sensor_measured() { + let mut plugin = plugin_with_markers(); + plugin.sensor = Some(SensorMonitoringV1 { + pixel_dead_time_us: Some(102.5), + illumination_lux: Some(742.0), + temperature_c: Some(41.25), + bias_codes: Some(augur_plugin_api::SensorBiasReadbackV1 { + current: augur_plugin_api::SensorBiasCodesV1 { + diff_on: 115, + diff_off: 52, + fo: 55, + hpf: 0, + refr: 20, + }, + factory_default: augur_plugin_api::SensorBiasCodesV1::default(), + }), + age_s: 0.25, + }); + // Frozen at start: the die warms and the room lights move, so what + // belongs to a run is what held when it began. + plugin.sensor_at_start = plugin.sensor; + plugin.sensor = Some(SensorMonitoringV1 { + temperature_c: Some(99.0), + ..plugin.sensor.expect("set above") + }); + + let meta = plugin.recording_metadata(); + assert_eq!( + meta.get("sensor_temperature_c").map(String::as_str), + Some("41.25"), + "the start snapshot wins over the drifted live one" + ); + assert_eq!( + meta.get("sensor_pixel_dead_time_us").map(String::as_str), + Some("102.500") + ); + assert_eq!( + meta.get("sensor_illumination_lux").map(String::as_str), + Some("742.000") + ); + assert_eq!( + meta.get("sensor_reading_age_s").map(String::as_str), + Some("0.250") + ); + + plugin.recording.id = "A1-sensor".into(); + plugin.recording.stem = "A1-sensor_20260731-000000".into(); + plugin.recording.folder = std::env::temp_dir().display().to_string(); + plugin.recording.duration_s = 5; + let doc = plugin.write_sidecar().expect("sidecar path"); + let text = std::fs::read_to_string(&doc).expect("read sidecar"); + assert!(text.contains("[sensor]"), "{text}"); + 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") && !text.contains("factory_diff_on"), + "camera configuration must stay in the host sidecar: {text}" + ); + let _ = std::fs::remove_file(&doc); + } + + #[test] + fn a_quantity_the_sensor_cannot_report_is_absent_rather_than_zero() { + // Replay, decoded imports and cameras without a monitoring block have + // no sensor to ask. A 0 °C die or 0 lx scene would be read downstream + // as a measurement. + let mut plugin = plugin_with_markers(); + assert!(plugin.recorded_sensor().is_none()); + let meta = plugin.recording_metadata(); + assert!(!meta.contains_key("sensor_temperature_c")); + assert!(!meta.contains_key("sensor_illumination_lux")); + + // A sensor that reports only some of the three is equally honest. + plugin.sensor_at_start = Some(SensorMonitoringV1 { + temperature_c: Some(38.0), + age_s: 0.1, + ..SensorMonitoringV1::default() + }); + let meta = plugin.recording_metadata(); + assert_eq!( + meta.get("sensor_temperature_c").map(String::as_str), + Some("38.00") + ); + assert!(!meta.contains_key("sensor_illumination_lux")); + assert!(!meta.contains_key("sensor_pixel_dead_time_us")); + } + + #[test] + fn a_run_records_which_source_its_depth_came_from() { + let mut plugin = plugin_with_markers(); + plugin.photodiode = None; + plugin.modulation = Some(commanded_modulation(1, 0.75)); + plugin.depth_source = DepthSource::Commanded; + + let meta = plugin.recording_metadata(); + assert_eq!( + meta.get("depth_a_analysis_source").map(String::as_str), + Some("modulation_commanded") + ); + 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("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_analysis_source").map(String::as_str), + Some("photodiode_measured") + ); + assert_eq!( + meta.get("depth_a_measured").map(String::as_str), + Some("0.420000") + ); + } + + fn pd_reply(request_id: u64, receipt: Option) -> PluginServiceReply { + let response = PhotodiodeResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: RequestId(request_id), + owner_instance: OwnerInstanceId::new("pd-test"), + run_id: None, + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_unix_ms()), + error: None, + }, + receipt, + }; + PluginServiceReply { + request_id, + source_plugin_id: A1_PLUGIN_ID.into(), + target_plugin_id: PHOTODIODE_PLUGIN_ID.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + outcome: PluginServiceOutcome::Accepted { + payload: serde_json::to_value(response).expect("response"), + }, + } + } + + /// A photodiode summary that passes the pre-flight: connected, unleased, + /// and with somewhere to put the PDQ. + fn ready_photodiode() -> PhotodiodeSummaryV1 { + PhotodiodeSummaryV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("pd-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: None, + }, + lease: None, + active_run_id: None, + requested_revision: None, + acknowledged_revision: None, + stream: PhotodiodeStreamV1 { + stream_epoch: 1, + sample_range: None, + sample_rate_hz: Some(20_000), + latest_adc_code: Some(1_000), + integrity: StreamIntegrityV1::default(), + level: None, + }, + data_dir: Some("/pd".into()), + active_recording: None, + last_finalized_recording: None, + optical_summary: None, + optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, + synchronization: SynchronizationV1::Unsynced { + reason: stage_a_plugin_contract::UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_unix_ms(), + valid_for_ms: 60_000, + }, + } + } + + fn on(timestamp_us: u64) -> CameraEvent { + CameraEvent { + timestamp_us, + x: 0, + y: 0, + polarity: Polarity::On, + } + } + + fn fresh_photodiode_summary() -> PhotodiodeSummaryV1 { + 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(20_000), + latest_adc_code: Some(1_000), + integrity: StreamIntegrityV1::default(), + level: None, + }, + active_recording: None, + last_finalized_recording: None, + data_dir: Some(std::env::temp_dir().display().to_string()), + optical_summary: Some(PhotodiodeOpticalSummaryV1 { + run_id: RunId::from("test-run"), + calibration: PhotodiodeCalibrationV1 { + adc_calibration_id: "adc-test".into(), + dark_id: "dark-test".into(), + anchor_id: Some("itot-test".into()), + dark_volts: 0.05, + 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, + excitation_max_volts: 0.8 * std::f64::consts::E, + excitation_headroom_volts: 0.8, + low_clip_fraction: 0.0, + high_clip_fraction: 0.0, + measured_frequency_hz: Some(1_000.0), + fundamental_phase_rad: None, + total_harmonic_distortion: None, + window_seconds: Some(0.008), + covered_cycles: Some(8.0), + }), + optical_unavailable: None, + placement: stage_a_plugin_contract::PhotodiodePlacementV1::RejectedPort, + splitter_fraction: None, + dark_reference: None, + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_unix_ms(), + valid_for_ms: 60_000, + }, + } + } + + /// A plugin whose period comes from marker spacing (no fallback frequency). + fn plugin_with_markers() -> StageAA1Plugin { + StageAA1Plugin { + // 10 x 1 sensor, no host ROI => valid_pixel_count() == 10. + frame_width: 10, + frame_height: 1, + camera_markers_us: vec![0, 1_000, 2_000, 3_000], + photodiode: Some(fresh_photodiode_summary()), + ..StageAA1Plugin::default() + } + } + + #[test] + fn period_comes_from_the_trigger_marker_spacing() { + let plugin = plugin_with_markers(); + let period = plugin.period_us().expect("measured period"); + assert!((period - 1_000.0).abs() < 1e-6, "period={period}"); + assert_eq!(plugin.frequency_source(), "trigger"); + } + + #[test] + fn no_markers_and_no_modulation_yields_no_period() { + let plugin = StageAA1Plugin::default(); + assert!(plugin.period_us().is_none()); + assert!(plugin.rolling_dataset().lines[0].points.is_empty()); + } + + #[test] + fn external_triggers_anchor_the_fold() { + let mut plugin = plugin_with_markers(); + for cycle in 0..3 { + plugin.camera_events.push(on(cycle * 1_000 + 200)); + } + assert!(plugin.is_marker_anchored()); + let fold = plugin.current_fold().expect("marker fold"); + assert_eq!(fold.validation.cycle_count, 3); + assert!((fold.events[0].phase - 0.2).abs() < 1e-9); + } + + #[test] + fn rolling_dataset_keeps_on_and_off_separate() { + let mut plugin = plugin_with_markers(); + for cycle in 0..3 { + let base = cycle * 1_000; + plugin.camera_events.push(on(base + 100)); + plugin.camera_events.push(CameraEvent { + polarity: Polarity::Off, + ..on(base + 600) + }); + } + let rolling = plugin.rolling_dataset(); + assert_eq!(rolling.lines.len(), 2); + assert_eq!(rolling.lines[0].name, "ON"); + assert!(rolling.lines[0].points.len() >= 2); + } + + #[test] + fn response_curve_auto_windows_without_a_pilot_and_refuses_without_a() { + let mut plugin = plugin_with_markers(); + plugin.frame_width = 8; + plugin.frame_height = 1; + for cycle in 0..20 { + let base = cycle * 1_000; + for x in 0..4 { + plugin.camera_events.push(CameraEvent { + timestamp_us: base + 200, + x, + y: 0, + polarity: Polarity::On, + }); + plugin.camera_events.push(CameraEvent { + timestamp_us: base + 700, + x, + y: 0, + polarity: Polarity::Off, + }); + } + } + plugin.camera_markers_us = (0..=20).map(|c| c * 1_000).collect(); + plugin.host_roi = Some(RoiV1 { + x: 0, + y: 0, + width: 4, + height: 1, + }); + + // Windows come straight from the fold — no pilot capture needed. + let (q_on, q_off, _, valid) = plugin.current_response().expect("response"); + assert_eq!(valid, 4); + assert!(q_on > 0.9 && q_off > 0.9, "q_on={q_on} q_off={q_off}"); + // Recording a point is still refused without a photodiode-measured a. + plugin.photodiode = None; + assert!(plugin.depth_a().is_none()); + assert!(plugin.record_response_point().is_err()); + } + + #[test] + fn the_rolling_response_is_normalised_over_the_roi_not_the_sensor() { + // `q_p` counts ROI-minus-masked pixels; the rolling half-period rate is + // plotted next to it and must agree. Normalising by the whole sensor + // under-reported S_p by the ROI/frame ratio *and* counted events from + // outside the ROI. + let mut plugin = StageAA1Plugin { + frame_width: 10, + frame_height: 10, + camera_markers_us: vec![0, 1_000, 2_000, 3_000], + ..StageAA1Plugin::default() + }; + plugin.host_roi = Some(RoiV1 { + x: 0, + y: 0, + width: 2, + height: 2, + }); + let event = |x: u16, y: u16, timestamp_us: u64| CameraEvent { + timestamp_us, + x, + y, + polarity: Polarity::On, + }; + // Two ON events inside the 2x2 ROI, five well outside it, all inside + // the trailing half period the status readout samples. + plugin.camera_events.push(event(0, 0, 2_800)); + plugin.camera_events.push(event(1, 1, 2_850)); + for x in 5..10_u16 { + plugin.camera_events.push(event(x, 9, 2_900)); + } + + let (on_rate, _) = plugin.latest_rolling().expect("rolling value"); + assert!( + (on_rate - 0.5).abs() < 1e-9, + "expected 2 ROI events over 4 valid pixels, got {on_rate}" + ); + } + + #[test] + fn the_fold_cache_tracks_its_inputs() { + let mut plugin = plugin_with_markers(); + for cycle in 0..8 { + plugin.camera_events.push(on(cycle * 1_000 + 200)); + } + let first = plugin.current_fold().expect("fold"); + // Repeated calls within a repaint must be identical, not merely equal + // to a fresh recomputation. + assert_eq!(plugin.current_fold().as_ref(), Some(&first)); + assert_eq!(plugin.compute_fold().as_ref(), Some(&first)); + + // ...and adding an event inside the marker span must invalidate it. + plugin.camera_events.push(on(2_500)); + plugin.camera_events.sort_by_key(|event| event.timestamp_us); + let second = plugin.current_fold().expect("fold"); + assert_eq!(second.events.len(), first.events.len() + 1); + + // A changed ROI also invalidates, even at identical event counts. + plugin.host_roi = Some(RoiV1 { + x: 0, + y: 0, + width: 1, + height: 1, + }); + let third = plugin.current_fold().expect("fold"); + assert_eq!(third.events.len(), second.events.len()); + plugin.host_roi = Some(RoiV1 { + x: 5, + y: 0, + width: 1, + height: 1, + }); + let fourth = plugin.current_fold().expect("fold"); + assert!( + fourth.events.is_empty(), + "ROI moved off the events but the cache served a stale fold" + ); + } + + #[test] + fn a_failed_pilot_freeze_clears_stale_windows() { + // `scan_measurement_folder` may have loaded windows from an earlier + // pilot for this measurement. If the freeze then fails, the sidecar + // must not record those as if they had come from this run. + let mut plugin = plugin_with_markers(); + plugin.pilot_windows = Some(( + PhaseWindow { + start: 0.0, + end: 0.2, + }, + PhaseWindow { + start: 0.5, + end: 0.7, + }, + )); + // No events => the fold carries no signal => the freeze cannot pick + // windows and must not leave the loaded ones in place. + assert!(plugin.camera_events.is_empty()); + plugin.freeze_pilot_windows(); + assert!( + plugin.pilot_windows.is_none(), + "stale pilot windows survived a failed freeze" + ); + assert!(!plugin.windows_are_frozen()); + } + + #[test] + fn press_latch_distinguishes_clicks_baselines_and_advances() { + let mut latch = PressLatch::default(); + // Direct click on this instance: an edge, and the counter advances. + assert!(latch.accept(&json!(true))); + assert_eq!(latch.value(), json!(1)); + // `false` writes (legacy snapshots) are never edges. + assert!(!latch.accept(&json!(false))); + + // A fresh instance adopts the first forwarded counter silently… + let mut worker = PressLatch::default(); + assert!(!worker.accept(&json!(3))); + // …repeats are not edges… + assert!(!worker.accept(&json!(3))); + // …and only an advance is one press. + assert!(worker.accept(&json!(4))); + assert!(!worker.accept(&json!(4))); + } + + #[test] + fn forwarded_button_counter_latches_the_recording_role() { + let mut plugin = StageAA1Plugin::default(); + // First snapshot after (re)load: adopt the mirror's counter, no press. + plugin + .set_setting("start_recording", json!(2)) + .expect("baseline"); + assert!(plugin.pending_role.is_none()); + // The mirror's counter advances by one click → one press edge. + plugin + .set_setting("start_recording", json!(3)) + .expect("press"); + assert_eq!(plugin.pending_role, Some(RecRole::Normal)); + // Re-applying the same snapshot must not re-press. + plugin.pending_role = None; + plugin + .set_setting("start_recording", json!(3)) + .expect("repeat"); + assert!(plugin.pending_role.is_none()); + } + + #[test] + fn recording_orders_camera_then_pdq_and_saves_inside_the_measurement_folder() { + let folder = std::env::temp_dir().join(format!("a1-lifecycle-{}", now_unix_ms())); + let mut plugin = StageAA1Plugin { + output_folder: folder.display().to_string(), + measurement_id: "A1-row".into(), + photodiode: Some(fresh_photodiode_summary()), + duration_s: 1, + pending_role: Some(RecRole::Normal), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(plugin.recording.phase, RecPhase::StartingCamera); + assert_eq!(sink.hosts.len(), 1); + assert!(sink.services.is_empty(), "PDQ must not start before camera"); + let cam_start_req = sink.hosts[0].request_id; + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: cam_start_req, + outcome: HostCommandOutcome::RecordingStarted { + actual_raw_path: "/camera/A1-row/run.raw".into(), + started_at: "2026-07-23T00:00:00Z".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let connect = sink.services.last().expect("connect request"); + let connect_envelope: PhotodiodeRequestV1 = + serde_json::from_value(connect.payload.clone()).expect("connect envelope"); + assert!(matches!( + connect_envelope.command, + PhotodiodeCommandV1::Connect + )); + + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply(connect.request_id, None)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let acquire = sink.services.last().expect("lease request"); + let acquire_envelope: PhotodiodeRequestV1 = + serde_json::from_value(acquire.payload.clone()).expect("lease envelope"); + assert!(matches!( + acquire_envelope.command, + PhotodiodeCommandV1::AcquireLease { .. } + )); + assert_eq!( + acquire_envelope.run_id.as_ref().map(RunId::as_str), + Some(plugin.recording.stem.as_str()) + ); + + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply(acquire.request_id, None)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let begin = sink.services.last().expect("begin request"); + let begin_envelope: PhotodiodeRequestV1 = + serde_json::from_value(begin.payload.clone()).expect("begin envelope"); + assert!(matches!( + begin_envelope.command, + PhotodiodeCommandV1::BeginRecording { .. } + )); + assert_eq!(begin_envelope.requested_revision, Some(SemanticRevision(1))); + assert_eq!(plugin.recording.start_unix_ms, 0); + + let run_id = begin_envelope.run_id.expect("run id"); + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply( + begin.request_id, + Some(PdqReceiptV1::Started(PdqStartedReceiptV1 { + run_id: run_id.clone(), + pdq_path: "/pd/A1-row/run_pd.pdq".into(), + sidecar_path: "/pd/A1-row/run_pd.json".into(), + opened_at_unix_ms: now_unix_ms(), + stream_epoch: 1, + first_sample_index: Some(0), + })), + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert_eq!(plugin.recording.phase, RecPhase::Running); + assert!(plugin.recording.start_unix_ms > 0); + + plugin.recording.start_unix_ms = now_unix_ms().saturating_sub(1_000); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let release = sink.services.last().expect("release request"); + let release_envelope: PhotodiodeRequestV1 = + serde_json::from_value(release.payload.clone()).expect("release envelope"); + assert!(matches!( + release_envelope.command, + PhotodiodeCommandV1::ReleaseLease { + finalize_recording: true, + .. + } + )); + assert_eq!(sink.hosts.len(), 1, "camera keeps running until PDQ closes"); + + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply( + release.request_id, + Some(PdqReceiptV1::Finalized(PdqFinalizedReceiptV1 { + run_id, + pdq_path: "/pd/A1-row/run_pd.pdq".into(), + sidecar_path: "/pd/A1-row/run_pd.json".into(), + opened_at_unix_ms: now_unix_ms().saturating_sub(1_000), + finalized_at_unix_ms: now_unix_ms(), + file_size_bytes: 64, + sha256: Sha256V1::parse("ab".repeat(32)).expect("sha"), + frames_written: 1, + sample_frames_written: 1, + sample_range: None, + sample_rate_hz: Some(20_000), + segment_count: 1, + integrity: StreamIntegrityV1::default(), + termination: stage_a_plugin_contract::PdqTerminationV1::OperatorStopped, + valid: true, + })), + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert_eq!(plugin.recording.phase, RecPhase::StoppingCamera); + assert_eq!(sink.hosts.len(), 2); + let cam_stop_req = sink.hosts[1].request_id; + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: cam_stop_req, + outcome: HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/camera/A1-row/run.raw".into(), + size: 128, + sha256: "cd".repeat(32), + duration_us: 1_000_000, + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert_eq!(plugin.recording.phase, RecPhase::Idle); + let measurement_dir = folder.join("A1-row"); + let sidecars: Vec<_> = std::fs::read_dir(&measurement_dir) + .expect("measurement folder") + .flatten() + .map(|entry| entry.path()) + .collect(); + assert_eq!(sidecars.len(), 1); + assert!(sidecars[0] + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("_config.toml"))); + assert!(plugin.message.starts_with("Saved recording A1-row")); + + std::fs::remove_dir_all(folder).expect("cleanup"); + } + + #[test] + fn duplicate_or_jittery_markers_still_yield_a_fold() { + // A long trigger dropout leaves a gap far beyond the jitter tolerance: + // marker validation rejects the fold, but the quicklook must fall back + // to the free-running fold instead of blanking. + let mut plugin = StageAA1Plugin { + frame_width: 10, + frame_height: 1, + camera_markers_us: vec![0, 1_000, 2_000, 10_000], + ..StageAA1Plugin::default() + }; + for cycle in 0..10 { + plugin.camera_events.push(on(cycle * 1_000 + 200)); + } + let fold = plugin.current_fold().expect("fallback fold"); + assert!(fold.markers_us.is_empty(), "free-running fold expected"); + assert!(!plugin.rolling_dataset().lines[0].points.is_empty()); + } + + #[test] + fn sweep_points_span_the_range_inclusively() { + let plugin = StageAA1Plugin { + min_a: 0.5, + max_a: 2.5, + sweep_count: 5, + ..StageAA1Plugin::default() + }; + let points = plugin.sweep_points(); + assert_eq!(points.len(), 5); + assert!((points[0].expected_a - 0.5).abs() < 1e-12); + assert!((points[4].expected_a - 2.5).abs() < 1e-12); + assert!((points[2].expected_a - 1.5).abs() < 1e-12); + // The amplitude sweep trusts the calibration: it commands what it expects. + assert!(points + .iter() + .all(|point| point.commanded_a == point.expected_a)); + } + + #[test] + fn sweep_point_recordings_carry_the_requested_a_in_the_sidecar() { + let mut plugin = plugin_with_markers(); + plugin.min_a = 0.5; + plugin.max_a = 1.5; + plugin.sweep_count = 3; + plugin.sweep = Some(Sweep { + phase: SweepPhase::Recording, + kind: SweepKind::Amplitude, + points: plugin.sweep_points(), + lock: None, + index: 1, + lease_id: LeaseId::new("a1-sweep-test"), + lease_granted: true, + lease_req: 0, + owns_lease: true, + depth_req: 0, + depth_applied: true, + settled_since_ms: None, + settle_deadline_ms: 0, + point_started: true, + completed_ok: false, + last_activity_ms: 0, + stop_requested: false, + }); + plugin.recording.id = "A1-sweeprow".into(); + plugin.recording.stem = "A1-sweeprow_20260723-000000_p02".into(); + plugin.recording.folder = std::env::temp_dir().display().to_string(); + plugin.recording.duration_s = 5; + plugin.recording.start_unix_ms = 1_774_224_000_000; + let path = plugin.write_sidecar().expect("sidecar path"); + let text = std::fs::read_to_string(&path).expect("read sidecar"); + assert!(text.contains("requested_a = 1.0"), "sidecar: {text}"); + assert!(text.contains("point_index = 2")); + assert!(text.contains("point_total = 3")); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn a0_lock_trims_the_commanded_depth_until_the_photodiode_measures_a0() { + // A bench that delivers 60 % of the commanded depth (drive roll-off): + // commanding a₀ directly would record a = 0.30 instead of 0.50. + let folder = temp_folder("lock"); + let mut plugin = plugin_locking(0.6, &folder); + plugin.a0_target = 0.5; + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + + let ticks = run_lock_to_completion(&mut plugin, &mut sink, 0.6, 64); + assert!(ticks < 64, "lock never finished"); + + let lock = plugin + .a0_locks + .first() + .expect("the converged lock is stored"); + assert!(lock.converged, "message: {}", plugin.message); + assert!( + (lock.measured_a - 0.5).abs() <= plugin.a0_tolerance, + "measured {}", + lock.measured_a + ); + assert!( + (lock.commanded_a - 0.5 / 0.6).abs() < 0.01, + "commanded {}", + lock.commanded_a + ); + assert!(lock.trials >= 2, "trials {}", lock.trials); + assert!((lock.frequency_hz - 1_000.0).abs() < 1.0); + // The drive is left at the depth the lock found, and the lease is + // released without a safe-off so it stays there for the recording. + assert!((last_commanded_depth(&sink).expect("depth") - lock.commanded_a).abs() < 0.002); + let release: ModulationRequestV1 = + serde_json::from_value(sink.services.last().expect("release").payload.clone()) + .expect("envelope"); + assert!(matches!( + release.command, + ModulationCommandV1::ReleaseLease { + safe_off: false, + .. + } + )); + // The lock arms the event-count recording for this frequency. + assert!(plugin.armed_lock().is_some()); + // …and the table is on disk next to the recordings. + assert!(folder.join(A0_LOCK_FILE).exists()); + let _ = std::fs::remove_dir_all(&folder); + } + + /// Widens the fixture photodiode's contrast window, so it covers a whole + /// cycle at every frequency a ladder test visits (the lock refuses below + /// one cycle, which is the point of a different test). + fn photodiode_window(plugin: &mut StageAA1Plugin, seconds: f64) { + if let Some(summary) = plugin.photodiode.as_mut() { + if let Some(optical) = summary.optical_summary.as_mut() { + optical.window_seconds = Some(seconds); + } + } + } + + /// Rewrites the plugin's phase-0 markers so the trigger reports `hz`, the + /// way the camera would once the drive has really moved. + fn trigger_reports(plugin: &mut StageAA1Plugin, hz: f64) { + let period_us = (1_000_000.0 / hz).round() as u64; + plugin.camera_markers_us = (0..8).map(|index| index * period_us).collect(); + plugin.fold_cache.replace(None); + } + + /// Drives a whole frequency ladder to completion against a bench that + /// delivers `gain ×` the commanded depth, answering every lease/depth/ + /// frequency request and letting the trigger confirm each commanded + /// frequency. Returns the frequencies whose points were recorded, in order. + fn run_freq_sweep_to_completion( + plugin: &mut StageAA1Plugin, + sink: &mut ControlSink, + gain: f64, + max_ticks: usize, + ) -> Vec { + let mut revision = 1; + let mut recorded = Vec::new(); + control_tick(plugin, PluginControlInbox::default(), sink); + for _ in 0..max_ticks { + let Some((phase, target_hz, lease_req, freq_req, granted, applied)) = + plugin.freq_sweep.as_ref().map(|sweep| { + ( + sweep.phase, + sweep.frequency_hz(), + sweep.lease_req, + sweep.freq_req, + sweep.lease_granted, + sweep.freq_applied, + ) + }) + else { + break; + }; + let mut replies = Vec::new(); + match phase { + FreqSweepPhase::AcquiringLease if !granted => replies.push(accepted(lease_req)), + FreqSweepPhase::SettingFrequency if !applied && freq_req != 0 => { + replies.push(accepted(freq_req)); + } + FreqSweepPhase::ConfirmingFrequency => trigger_reports(plugin, target_hz), + FreqSweepPhase::Locking => { + if let Some(lock) = plugin.a0_lock.as_ref() { + let (depth_req, applied, commanded) = + (lock.depth_req, lock.depth_applied, lock.commanded_a); + if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + revision += 1; + plugin.photodiode = + Some(photodiode_measuring(revision, commanded * gain)); + photodiode_window(plugin, 0.02); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + } + FreqSweepPhase::Recording => { + if let Some(sweep) = plugin.sweep.as_ref() { + let (depth_req, applied, expected) = + (sweep.depth_req, sweep.depth_applied, sweep.target_a()); + if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + revision += 1; + plugin.photodiode = Some(photodiode_measuring(revision, expected)); + photodiode_window(plugin, 0.02); + } + } + // Short-circuit the recording coordinator once the sweep + // has seen the point start: this test is about the ladder, + // and the coordinator has tests of its own. + if plugin.recording.is_active() + && plugin + .sweep + .as_ref() + .is_some_and(|sweep| sweep.point_started) + { + plugin.recording = Recording::idle(); + plugin.recording_completed_ok = true; + recorded.push(target_hz); + } + } + _ => {} + } + control_tick( + plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + sink, + ); + } + recorded + } + + #[test] + fn the_frequency_ladder_is_log_spaced_and_ordered_reproducibly() { + let mut plugin = plugin_with_markers(); + plugin.min_f = 1.0; + plugin.max_f = 100.0; + plugin.freq_count = 3; + + plugin.freq_order = FreqOrder::Ascending; + let ladder = plugin.planned_frequencies(); + // Log-spaced: |H(f)| is read per decade, so a decade per step. + assert_eq!(ladder.len(), 3); + assert!((ladder[0] - 1.0).abs() < 1e-9); + assert!((ladder[1] - 10.0).abs() < 1e-6, "middle {}", ladder[1]); + assert!((ladder[2] - 100.0).abs() < 1e-6); + + // Alternating decorrelates frequency from time without a seed. + plugin.freq_order = FreqOrder::Alternating; + let order: Vec = plugin + .freq_sweep_points() + .iter() + .map(|point| point.frequency_hz) + .collect(); + assert!((order[0] - 1.0).abs() < 1e-9 && (order[1] - 100.0).abs() < 1e-6); + assert!((order[2] - 10.0).abs() < 1e-6); + + // A seeded random order is reproducible — the seed is in the sidecar. + plugin.freq_order = FreqOrder::Random; + plugin.freq_count = 8; + plugin.freq_seed = 42; + let first: Vec = plugin + .freq_sweep_points() + .iter() + .map(|point| point.frequency_hz) + .collect(); + let again: Vec = plugin + .freq_sweep_points() + .iter() + .map(|point| point.frequency_hz) + .collect(); + assert_eq!(first, again, "the seeded order must be reproducible"); + plugin.freq_seed = 43; + let other: Vec = plugin + .freq_sweep_points() + .iter() + .map(|point| point.frequency_hz) + .collect(); + assert_ne!(first, other, "a different seed must shuffle differently"); + let mut sorted = first.clone(); + sorted.sort_by(f64::total_cmp); + let mut planned = plugin.planned_frequencies(); + planned.sort_by(f64::total_cmp); + assert_eq!(sorted.len(), planned.len(), "the shuffle is a permutation"); + } + + #[test] + fn the_low_frequency_reference_is_interleaved_into_the_ladder() { + let mut plugin = plugin_with_markers(); + plugin.min_f = 1.0; + plugin.max_f = 1_000.0; + plugin.freq_count = 4; + plugin.freq_order = FreqOrder::Ascending; + plugin.freq_reference_every = 2; + + let points = plugin.freq_sweep_points(); + let flags: Vec = points.iter().map(|point| point.is_reference).collect(); + assert_eq!(flags, [false, false, true, false, false, true]); + for point in points.iter().filter(|point| point.is_reference) { + assert!( + (point.frequency_hz - 1.0).abs() < 1e-9, + "the reference repeats the lowest planned frequency" + ); + } + } + + #[test] + fn the_frequency_sweep_locks_and_records_every_point_on_one_lease() { + let folder = temp_folder("fsweep"); + let mut plugin = plugin_locking(0.6, &folder); + plugin.a0_target = 0.5; + plugin.min_f = 100.0; + plugin.max_f = 1_000.0; + plugin.freq_count = 2; + plugin.freq_order = FreqOrder::Ascending; + photodiode_window(&mut plugin, 0.02); + plugin.freq_sweep_pending = Some(FreqSweepMode::A0Point); + let mut sink = ControlSink::default(); + + let recorded = run_freq_sweep_to_completion(&mut plugin, &mut sink, 0.6, 4_000); + + assert_eq!(recorded.len(), 2, "message: {}", plugin.message); + assert!((recorded[0] - 100.0).abs() < 1.0 && (recorded[1] - 1_000.0).abs() < 10.0); + assert!(plugin.freq_sweep.is_none(), "the ladder must finish"); + assert!( + plugin.message.contains("2/2 points recorded"), + "message: {}", + plugin.message + ); + + // One lease for the whole ladder: the operator's drive settings are + // locked out from the first frequency to the last, so the amplitude + // provably cannot move between a lock and the point that replays it. + let commands: Vec = sink + .services + .iter() + .filter_map(|request| { + serde_json::from_value::(request.payload.clone()) + .ok() + .map(|envelope| envelope.command) + }) + .collect(); + let acquired = commands + .iter() + .filter(|command| matches!(command, ModulationCommandV1::AcquireLease { .. })) + .count(); + let released = commands + .iter() + .filter(|command| matches!(command, ModulationCommandV1::ReleaseLease { .. })) + .count(); + assert_eq!(acquired, 1, "one lease for the ladder, not one per child"); + assert_eq!(released, 1, "released exactly once, at the end"); + assert!(commands + .iter() + .any(|command| matches!(command, ModulationCommandV1::SetDriveFrequency { .. }))); + + // Both frequencies are locked, each at the depth its own roll-off needs. + assert_eq!(plugin.a0_locks.len(), 2); + for lock in &plugin.a0_locks { + assert!( + lock.converged, + "lock at {} did not converge", + lock.frequency_hz + ); + assert!((lock.measured_a - 0.5).abs() <= plugin.a0_tolerance); + } + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a_frequency_the_trigger_never_confirms_is_skipped_not_fatal() { + // The firmware ACKs a table it accepted, not light that is modulating. + // A point whose trigger never reports the commanded period is skipped + // and named; the rest of the ladder is still worth having. + let folder = temp_folder("fskip"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_target = 0.5; + plugin.min_f = 100.0; + plugin.max_f = 1_000.0; + plugin.freq_count = 2; + plugin.freq_order = FreqOrder::Ascending; + photodiode_window(&mut plugin, 0.02); + plugin.freq_sweep_pending = Some(FreqSweepMode::A0Point); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + let mut revision = 1; + let mut recorded = Vec::new(); + for _ in 0..4_000 { + let Some((phase, target_hz, lease_req, freq_req, granted, applied)) = + plugin.freq_sweep.as_ref().map(|sweep| { + ( + sweep.phase, + sweep.frequency_hz(), + sweep.lease_req, + sweep.freq_req, + sweep.lease_granted, + sweep.freq_applied, + ) + }) + else { + break; + }; + let mut replies = Vec::new(); + match phase { + FreqSweepPhase::AcquiringLease if !granted => replies.push(accepted(lease_req)), + FreqSweepPhase::SettingFrequency if !applied && freq_req != 0 => { + replies.push(accepted(freq_req)); + } + FreqSweepPhase::ConfirmingFrequency => { + // The trigger confirms 100 Hz but never moves to 1 kHz. + if target_hz < 500.0 { + trigger_reports(&mut plugin, target_hz); + } else if let Some(sweep) = plugin.freq_sweep.as_mut() { + sweep.confirm_deadline_ms = 1; + } + } + FreqSweepPhase::Locking => { + if let Some(lock) = plugin.a0_lock.as_ref() { + let (depth_req, applied, commanded) = + (lock.depth_req, lock.depth_applied, lock.commanded_a); + if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + revision += 1; + plugin.photodiode = Some(photodiode_measuring(revision, commanded)); + photodiode_window(&mut plugin, 0.02); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + } + FreqSweepPhase::Recording => { + if let Some(sweep) = plugin.sweep.as_ref() { + let (depth_req, applied, expected) = + (sweep.depth_req, sweep.depth_applied, sweep.target_a()); + if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + revision += 1; + plugin.photodiode = Some(photodiode_measuring(revision, expected)); + photodiode_window(&mut plugin, 0.02); + } + } + if plugin.recording.is_active() + && plugin + .sweep + .as_ref() + .is_some_and(|sweep| sweep.point_started) + { + plugin.recording = Recording::idle(); + plugin.recording_completed_ok = true; + recorded.push(target_hz); + } + } + _ => {} + } + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + &mut sink, + ); + } + + assert_eq!(recorded.len(), 1, "message: {}", plugin.message); + assert!(plugin.freq_sweep.is_none()); + assert!( + plugin.message.contains("1/2 points recorded") && plugin.message.contains("1 skipped"), + "message: {}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn the_frequency_sweep_refuses_a_ladder_its_photodiode_cannot_measure() { + // The estimator window is one window for the whole ladder, so the + // *lowest* point decides measurability. Refuse the plan, not its + // ninth point two hours in. + let folder = temp_folder("fladder"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_target = 0.5; + plugin.min_f = 0.1; + plugin.max_f = 100.0; + plugin.freq_count = 4; + if let Some(summary) = plugin.photodiode.as_mut() { + if let Some(optical) = summary.optical_summary.as_mut() { + optical.window_seconds = Some(1.0); // 0.1 cycles at 0.1 Hz + } + } + plugin.freq_sweep_pending = Some(FreqSweepMode::A0Point); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.freq_sweep.is_none(), "the ladder must not start"); + assert!(sink.services.is_empty(), "no lease may be requested"); + assert!( + plugin.message.contains("lowest point") && plugin.message.contains("cache length"), + "message: {}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn changing_frequency_drops_the_previous_period_s_markers_and_windows() { + // The measured period is the mean marker spacing, so markers from the + // old drive would confirm the new frequency against a mixture. Pilot + // windows are frozen at a phase of the old period and do not transfer. + let folder = temp_folder("fflush"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.pilot_windows = Some(( + PhaseWindow { + start: 0.0, + end: 0.2, + }, + PhaseWindow { + start: 0.5, + end: 0.7, + }, + )); + plugin.freq_sweep = Some(FreqSweep { + phase: FreqSweepPhase::AcquiringLease, + mode: FreqSweepMode::A0Point, + points: vec![FreqSweepPoint { + frequency_hz: 50.0, + is_reference: false, + }], + index: 0, + lease_id: LeaseId::new("a1-fsweep-test"), + lease_granted: true, + lease_req: 0, + freq_req: 0, + freq_applied: false, + confirm_deadline_ms: 0, + skip_reason: None, + failed: Vec::new(), + recorded: 0, + order: FreqOrder::Ascending, + seed: 1, + last_activity_ms: now_unix_ms(), + stop_requested: false, + }); + let mut sink = ControlSink::default(); + plugin.send_freq_sweep_frequency(&mut sink); + + assert!(plugin.camera_markers_us.is_empty()); + assert!(plugin.camera_events.is_empty()); + assert!( + plugin.pilot_windows.is_none(), + "windows frozen at another period must not carry over" + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a0_lock_refuses_a_photodiode_window_shorter_than_one_cycle() { + // `a` is peak-to-peak. Under one cycle the photodiode under-reports it, + // and the lock divides by it — so it would inflate the drive until it + // railed. Refuse before touching the drive, and say what to change. + let folder = temp_folder("subcycle"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_target = 0.5; + // 1 kHz markers give the plugin its frequency; make the estimator + // window 0.4 ms, i.e. 0.4 of a cycle. + if let Some(summary) = plugin.photodiode.as_mut() { + if let Some(optical) = summary.optical_summary.as_mut() { + optical.window_seconds = Some(0.000_4); + } + } + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.a0_lock.is_none(), "the lock must not start"); + assert!(sink.services.is_empty(), "no lease may be requested"); + assert!( + plugin.message.contains("0.40 cycles") && plugin.message.contains("cache length"), + "message: {}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a0_gates_quote_the_owners_reason_for_withholding_a() { + // The old refusal named the anchor and the cable whatever the real cause + // was, which sent the operator to re-check a calibration that was + // already fine. Whatever gate the owner closed has to reach the panel. + let folder = temp_folder("blocker"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_target = 0.5; + if let Some(summary) = plugin.photodiode.as_mut() { + summary.optical_summary = None; + summary.optical_unavailable = Some("ADC clipping: 307‰ low / 0‰ high".into()); + } + + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.a0_lock.is_none(), "the lock must not start"); + assert!( + plugin.message.contains("307‰ low"), + "the a₀ refusal must quote the owner: {}", + plugin.message + ); + // And without pressing anything: the resting panel says the same thing. + let status = plugin + .status_entries() + .into_iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + assert!( + status.contains("307‰ low"), + "the status panel must name the gate: {status}" + ); + 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 sidecar refusal is the whole report an unattended protocol run leaves + /// behind for a point it lost — and it arrives after the recording has + /// already run. Naming only the anchor sent the operator to re-confirm one + /// that was fine while the real gate (too few whole cycles at a sub-hertz + /// rung) went unnamed for a whole survey. + #[test] + fn a_refused_sidecar_quotes_the_owners_reason_not_just_the_anchor() { + let folder = temp_folder("sidecar-blocker"); + let mut plugin = plugin_locking(1.0, &folder); + if let Some(summary) = plugin.photodiode.as_mut() { + summary.optical_summary = None; + summary.optical_unavailable = Some( + "no stretch of samples covers two whole modulation cycles between triggers \ + (2 trigger(s) in the last 10000000 samples)" + .into(), + ); + } + + let error = plugin + .write_sidecar() + .expect_err("no optical summary must refuse the sidecar"); + assert!( + error.contains("two whole modulation cycles"), + "the refusal must quote the owner: {error}" + ); + // And it must not offer the open-loop escape here: the sidecar needs + // this summary whichever depth source is selected. + assert!( + !error.contains("Depth a source"), + "the sidecar refusal must not name an escape that does not exist: {error}" + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn the_status_panel_names_live_analysis_when_it_is_off() { + // "0 events, free-running" describes the toggle, not the bench, and the + // frequency sweep refuses on the marker count it produces. + let folder = temp_folder("liveoff"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.live = false; + plugin.camera_markers_us.clear(); + // Clear the gates that sit *before* the marker check, so the refusal + // under test is the marker one and not the optical-window one. + plugin.min_f = 1.0; + plugin.max_f = 1.0; + photodiode_window(&mut plugin, 4.0); + + let status = plugin + .status_entries() + .into_iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(status.contains("Live analysis is OFF"), "status: {status}"); + + plugin.freq_sweep_pending = Some(FreqSweepMode::A0Point); + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!(plugin.freq_sweep.is_none(), "the sweep must not start"); + assert!( + plugin.message.contains("Live analysis"), + "the sweep refusal must name the toggle: {}", + plugin.message + ); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a0_lock_refuses_to_lock_onto_an_unsettled_operating_point() { + // Readings that walk across the target are not a lock: the next action + // would record at wherever the drive drifted to, not at a₀. + let folder = temp_folder("unsettled"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_target = 0.5; + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + let mut revision = 1; + let mut drift = 0.30; + for _ in 0..64 { + if plugin.a0_lock.is_none() { + break; + } + let (lease_req, depth_req, granted, applied) = { + let lock = plugin.a0_lock.as_ref().expect("lock"); + ( + lock.lease_req, + lock.depth_req, + lock.lease_granted, + lock.depth_applied, + ) + }; + let mut replies = Vec::new(); + if !granted { + replies.push(accepted(lease_req)); + } else if !applied && depth_req != 0 { + replies.push(accepted(depth_req)); + } else { + revision += 1; + drift += 0.20; // 0.50, 0.70, 0.90 — straddling a₀ = 0.50 + plugin.photodiode = Some(photodiode_measuring(revision, drift)); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: replies, + ..PluginControlInbox::default() + }, + &mut sink, + ); + } + + assert!(plugin.a0_lock.is_none(), "the lock must end"); + assert!( + plugin.message.contains("not settled"), + "message: {}", + plugin.message + ); + // Nothing is stored, so nothing can arm a recording. + assert!(plugin.a0_locks.is_empty()); + assert!(plugin.armed_lock().is_none()); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a0_lock_reports_an_unreachable_depth_instead_of_arming_a_recording() { + // The bench delivers 5 % of the commanded depth: a₀ = 0.5 would need a + // commanded depth far beyond what the owner accepts. + let folder = temp_folder("unreachable"); + let mut plugin = plugin_locking(0.05, &folder); + plugin.a0_target = 0.5; + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + + assert!(run_lock_to_completion(&mut plugin, &mut sink, 0.05, 256) < 256); + let lock = plugin.a0_locks.first().expect("the attempt is recorded"); + assert!(!lock.converged); + assert!((lock.commanded_a - COMMANDED_A_MAX).abs() < 1e-9); + assert!( + plugin.message.contains("drivable limit") + || plugin.message.contains("did not converge"), + "message: {}", + plugin.message + ); + // A non-converged lock must never arm an event-count recording. + assert!(plugin.armed_lock().is_none()); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn a0_lock_surfaces_a_drive_rejection_verbatim() { + let folder = temp_folder("reject"); + let mut plugin = plugin_locking(1.0, &folder); + plugin.a0_lock_pending = true; + let mut sink = ControlSink::default(); + // Tick 1: begin and lease. + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let lease_req = plugin.a0_lock.as_ref().expect("lock").lease_req; + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![accepted(lease_req)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let depth_req = plugin.a0_lock.as_ref().expect("lock").depth_req; + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![rejected( + depth_req, + "calibrated optical peak u = 1.2 exceeds the lobe ceiling", + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + assert!(plugin.a0_lock.is_none(), "the lock must not keep trying"); + assert!( + plugin.message.contains("lobe ceiling"), + "message: {}", + plugin.message + ); + assert!(plugin.a0_locks.is_empty(), "a rejected lock stores nothing"); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn event_count_point_commands_the_locked_depth_not_a0() { + let folder = temp_folder("ecpoint"); + let mut plugin = plugin_locking(0.6, &folder); + plugin.a0_target = 0.5; + plugin.a0_locks.push(A0LockPoint { + frequency_hz: 1_000.0, + target_a: 0.5, + commanded_a: 0.8333, + measured_a: 0.5, + trials: 2, + converged: true, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: Some(0.0), + high_clip_fraction: Some(0.0), + depth_source: DepthSource::Photodiode, + }); + let mut sink = ControlSink::default(); + + plugin + .set_setting("record_a0_point", json!(true)) + .expect("press"); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let sweep = plugin.sweep.as_ref().expect("event-count sweep"); + assert_eq!(sweep.kind, SweepKind::EventCount); + assert_eq!(sweep.total(), 1); + let lease_req = sweep.lease_req; + + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![accepted(lease_req)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + // The drive is commanded to the locked depth, *not* to a₀ itself. + let commanded = last_commanded_depth(&sink).expect("commanded depth"); + assert!((commanded - 0.833).abs() < 0.002, "commanded {commanded}"); + assert!((plugin.sweep.as_ref().expect("sweep").target_a() - 0.5).abs() < 1e-9); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn event_count_stems_and_sidecars_carry_the_frequency_and_the_lock() { + let folder = temp_folder("ecstem"); + let mut plugin = plugin_locking(0.6, &folder); + plugin.measurement_id = "A1-ecrow".into(); + plugin.frame_width = 4; + plugin.frame_height = 1; + let lock = A0LockPoint { + frequency_hz: 1_000.0, + target_a: 0.5, + commanded_a: 0.8333, + measured_a: 0.5, + trials: 2, + converged: true, + locked_at_unix_ms: 1_784_764_800_000, + low_clip_fraction: Some(0.0), + high_clip_fraction: Some(0.0), + depth_source: DepthSource::Photodiode, + }; + plugin.sweep = Some(Sweep { + phase: SweepPhase::Recording, + kind: SweepKind::EventCount, + points: vec![SweepPoint { + commanded_a: 0.8333, + expected_a: 0.5, + }], + lock: Some(lock), + index: 0, + lease_id: LeaseId::new("a1-sweep-test"), + lease_granted: true, + lease_req: 0, + owns_lease: true, + depth_req: 0, + depth_applied: true, + settled_since_ms: None, + settle_deadline_ms: 0, + point_started: true, + completed_ok: false, + last_activity_ms: 0, + stop_requested: false, + }); + + // The stem carries the frequency instead of a sweep-point index. + let mut sink = ControlSink::default(); + plugin.begin_recording(&mut sink, RecRole::EventCount); + let stem = plugin.recording.stem.clone(); + assert!(stem.ends_with("_ec_f1000Hz"), "stem: {stem}"); + + plugin.recording.duration_s = 5; + plugin.recording.start_unix_ms = 1_784_764_800_000; + let path = plugin.write_sidecar().expect("sidecar path"); + let text = std::fs::read_to_string(&path).expect("read sidecar"); + assert!(text.contains("role = \"event-count point\""), "{text}"); + assert!(text.contains("[a0_lock]"), "{text}"); + assert!(text.contains("target_a = 0.5"), "{text}"); + assert!(text.contains("commanded_a = 0.8333"), "{text}"); + assert!(text.contains("converged = true"), "{text}"); + let _ = std::fs::remove_dir_all(&folder); + } + + #[test] + fn frequency_tags_are_file_safe() { + assert_eq!(frequency_tag(50.0), "f50Hz"); + assert_eq!(frequency_tag(0.5), "f0p5Hz"); + assert_eq!(frequency_tag(1_200.0), "f1200Hz"); + assert_eq!(frequency_tag(12.345), "f12p345Hz"); + assert_eq!(sanitize_stem(&frequency_tag(0.5)), frequency_tag(0.5)); + } + + #[test] + fn locks_are_one_per_frequency_and_round_trip_through_the_folder() { + let dir = std::env::temp_dir().join(format!("a1-a0-{}", now_unix_ms())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let folder = dir.display().to_string(); + + let mut plugin = StageAA1Plugin { + output_folder: folder.clone(), + ..StageAA1Plugin::default() + }; + let point = |hz: f64, commanded_a: f64| A0LockPoint { + frequency_hz: hz, + target_a: 0.5, + commanded_a, + measured_a: 0.5, + trials: 2, + converged: true, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: None, + high_clip_fraction: None, + depth_source: DepthSource::Photodiode, + }; + plugin.store_lock(point(1_000.0, 0.83)).expect("saved"); + plugin.store_lock(point(50.0, 0.52)).expect("saved"); + // Re-locking the same frequency replaces the row rather than appending. + plugin.store_lock(point(1_000.5, 0.86)).expect("saved"); + assert_eq!(plugin.a0_locks.len(), 2); + assert!( + (plugin.a0_locks[0].frequency_hz - 50.0).abs() < 1e-9, + "sorted by frequency" + ); + + let mut other = StageAA1Plugin { + output_folder: folder.clone(), + ..StageAA1Plugin::default() + }; + other.load_a0_locks(); + assert_eq!(other.a0_locks.len(), 2); + let reloaded = other.lock_for_frequency(1_000.0).expect("reloaded lock"); + assert!((reloaded.commanded_a - 0.86).abs() < 1e-9); + assert_eq!(other.a0_locks_dataset().columns.len(), 8); + // A table written before the depth-source setting existed loads as the + // photodiode-measured rows it was: the field defaults, it is not lost. + assert_eq!(reloaded.depth_source, DepthSource::Photodiode); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn settings_discontinuities_keep_the_response_curve() { + let mut plugin = StageAA1Plugin::default(); + plugin.response_points.push(ResponsePoint { + measured_a: 1.0, + q_on: 0.5, + q_off: 0.1, + cycles: 10, + valid_pixels: 4, + }); + plugin.on_discontinuity(PluginDiscontinuity::SettingsChanged); + assert_eq!(plugin.response_points.len(), 1); + plugin.on_discontinuity(PluginDiscontinuity::SourceChanged); + assert!(plugin.response_points.is_empty()); + } + + #[test] + fn measurement_id_generation_is_file_safe_and_prefixed() { + let id = generate_measurement_id(); + assert!(id.starts_with("A1-")); + assert_eq!(sanitize_stem(&id), id); + assert_eq!(sanitize_stem("I_k 3 / f=10Hz"), "I_k_3_f_10Hz"); + } + + #[test] + fn compact_utc_formats_a_known_epoch() { + // 2026-07-23T00:00:00Z = 1_784_764_800 s; +3661 s = 01:01:01. + assert_eq!(format_compact_date(1_784_764_800), "20260723"); + assert_eq!(format_iso_utc(1_784_764_800), "2026-07-23T00:00:00Z"); + assert_eq!(format_compact_utc(1_784_764_800), "20260723-000000"); + assert_eq!( + format_iso_utc(1_784_764_800 + 3_661), + "2026-07-23T01:01:01Z" + ); + } + + /// Turning Live analysis off used to leave the last analysis window in + /// place — up to millions of events — and the control tick keeps folding + /// whatever is in the buffer. So the plots stayed slow after the switch was + /// off again, which is exactly what the operator reported. + #[test] + fn turning_live_analysis_off_releases_the_event_buffer() { + let mut plugin = plugin_with_markers(); + plugin.live = true; + plugin.camera_events = (0..50_000) + .map(|index| CameraEvent { + x: 0, + y: 0, + timestamp_us: index, + polarity: Polarity::On, + }) + .collect(); + plugin.camera_markers_us = (0..100).map(|index| index * 1_000).collect(); + // Prime the memoised fold so the stale one cannot survive either. + let _ = plugin.current_fold(); + + plugin.set_setting("live", json!(false)).expect("live off"); + + assert!(plugin.camera_events.is_empty()); + assert!(plugin.camera_markers_us.is_empty()); + assert!( + plugin.camera_events.capacity() == 0, + "the buffer kept {} events' worth of capacity reserved", + plugin.camera_events.capacity() + ); + assert!( + plugin.fold_cache.borrow().is_none(), + "a stale fold survived" + ); + } + + /// The Clear button and the off switch must leave the plugin in the same + /// state — they are the same operation. + #[test] + fn clearing_captured_events_releases_the_same_buffers() { + let mut plugin = plugin_with_markers(); + plugin.live = true; + plugin.camera_events = vec![CameraEvent { + x: 0, + y: 0, + timestamp_us: 1, + polarity: Polarity::On, + }]; + plugin.camera_markers_us = vec![0, 1_000]; + + plugin.set_setting("clear", json!(true)).expect("clear"); + + assert!(plugin.camera_events.is_empty()); + assert!(plugin.camera_markers_us.is_empty()); + assert!(plugin.fold_cache.borrow().is_none()); + } + + /// `settings_schema` is rendered by the UI mirror, which never runs + /// `process_control` — so every run lives on an instance the panel cannot + /// see. Gating a button on "is something running" therefore disables + /// nothing and lies to the next reader; the interlocks belong worker-side. + #[test] + fn buttons_are_not_gated_on_state_the_ui_mirror_cannot_see() { + let mut mirror = StageAA1Plugin { + output_folder: "/tmp/a1-mirror".into(), + ..StageAA1Plugin::default() + }; + mirror.set_runtime_role(PluginRuntimeRole::UiMirror); + + let enabled_of = |plugin: &StageAA1Plugin, key: &str| { + plugin + .settings_schema() + .sections + .iter() + .flat_map(|section| section.items.iter()) + .find(|item| item.key == key) + .and_then(|item| match item.kind { + SettingKind::Button { enabled } => Some(enabled), + _ => None, + }) + .unwrap_or_else(|| panic!("missing button {key}")) + }; + + let buttons = [ + "start_recording", + "start_sweep", + "start_freq_sweep", + "start_freq_depth_sweep", + "run_protocol", + ]; + let before: Vec = buttons.iter().map(|key| enabled_of(&mirror, key)).collect(); + assert!(before.iter().all(|enabled| *enabled), "{before:?}"); + + // Now make the *worker-side* state look busy. The mirror renders the + // same either way, because it never sees any of this. + mirror.recording.phase = RecPhase::StartingCamera; + let after: Vec = buttons.iter().map(|key| enabled_of(&mirror, key)).collect(); + assert_eq!(before, after, "a button was gated on worker-only state"); + + // Without an output folder they *are* disabled — that is mirrored + // state, so it is a legitimate gate. + mirror.output_folder = String::new(); + for key in buttons { + assert!(!enabled_of(&mirror, key), "{key} ignored the output folder"); + } + } + + /// The Sweep f button and the depth it holds have to be in the same place: + /// the button used to be in Record while `a₀` sat in a collapsed section. + #[test] + fn the_depth_sweep_f_holds_sits_beside_the_frequency_axis() { + let schema = StageAA1Plugin::default().settings_schema(); + let record = schema + .sections + .iter() + .find(|section| section.label == "Record") + .expect("a single Record section"); + for key in ["a0_target", "min_f", "max_f", "start_freq_sweep"] { + assert!( + record.items.iter().any(|item| item.key == key), + "{key} is not in the Record section" + ); + } + } + + /// The protocol's own duration must not be written into the panel setting: + /// the host re-applies the mirror's snapshot every pass, so it would be + /// reverted within the frame and the operator's value would flicker. + #[test] + fn a_protocol_duration_overrides_without_touching_the_panel_setting() { + let folder = temp_folder("protocol-duration-override"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.duration_s = 999; + 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(); + control_tick( + &mut plugin, + inbox_with(retargets.into_iter().map(accepted).collect()), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert_eq!( + plugin.recording.duration_s, 3, + "the protocol's duration lost" + ); + assert_eq!( + plugin.duration_s, 999, + "the protocol overwrote the operator's own duration setting" + ); + // And it is consumed, so the next hand-driven recording is the panel's. + assert!(plugin.pending_duration_s.is_none()); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// A protocol file on disk, and a plugin ready to run it. + fn protocol_plugin(folder: &Path, body: &str) -> (StageAA1Plugin, PathBuf) { + std::fs::create_dir_all(folder).expect("protocol folder"); + let path = folder.join("protocol.toml"); + std::fs::write(&path, body).expect("write protocol"); + let mut plugin = plugin_with_markers(); + plugin.modulation = Some(connected_modulation()); + plugin.photodiode = Some(ready_photodiode()); + plugin.output_folder = folder.display().to_string(); + 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 +settle_s = 0.0 + +[[block]] +name = "pair" +mean_u = [0.4, 0.6] +frequency_hz = 25.0 +depth_a = 0.7 +"#; + + #[test] + fn protocol_identity_and_exact_source_are_archived_with_each_point() { + let folder = temp_folder("protocol-provenance"); + let (mut plugin, source) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.photodiode = Some(fresh_photodiode_summary()); + if let Some(optical) = plugin + .photodiode + .as_mut() + .and_then(|summary| summary.optical_summary.as_mut()) + { + optical.window_seconds = Some(1.0); + optical.covered_cycles = Some(25.0); + } + let mut sink = ControlSink::default(); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + plugin.recording.id = "A1-proto".into(); + plugin.recording.stem = "A1-proto_20260813-120000".into(); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.duration_s = 3; + plugin.recording.start_unix_ms = now_unix_ms(); + let sidecar = plugin.write_sidecar().expect("v2 sidecar"); + let text = std::fs::read_to_string(&sidecar).expect("sidecar text"); + + assert!(text.contains("[protocol]")); + assert!(text.contains("name = \"two-point\"")); + assert!(text.contains("version = \"test-v2\"")); + assert!(text.contains("source_file = \"protocol.toml\"")); + assert!(text.contains("source_sha256 = \"")); + assert!(text.contains("point_label = \"pair\"")); + let archived = std::fs::read_dir(folder.join("A1-proto")) + .expect("measurement folder") + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("a1_protocol_")) + }) + .expect("archived protocol"); + assert_eq!( + std::fs::read_to_string(archived).expect("archived source"), + std::fs::read_to_string(source).expect("original source") + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + const BIAS_PROTOCOL: &str = r#" +name = "bias-point" + +[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_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 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() + .filter(|request| { + matches!( + modulation_command(request), + Some( + ModulationCommandV1::SetOperatingPoint { .. } + | ModulationCommandV1::SetDriveFrequency { .. } + | ModulationCommandV1::SetOpticalDepth { .. } + ) + ) + }) + .map(|request| request.request_id) + .collect(); + 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; + + control_tick( + &mut plugin, + inbox_with(retargets.into_iter().map(accepted).collect()), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert!( + !plugin.recording.is_active(), + "recording started before the host confirmed its sensor readback" + ); + + let mut point_snapshot = initial_snapshot; + point_snapshot.biases.diff_on = 12; + point_snapshot.biases.diff_off = -7; + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![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(), + "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); + } + + #[test] + 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_none()); + assert!(plugin.message.contains("ERROR"), "{}", plugin.message); + assert!( + !plugin.message.ends_with("pre-run camera settings restored"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// The reason a protocol exists rather than three nested button presses: + /// every point states its whole operating condition, so all three axes are + /// commanded at every point instead of being left wherever the last one + /// 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(); + + 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 first_retarget = sink + .services + .iter() + .find(|request| { + matches!( + modulation_command(request), + Some(ModulationCommandV1::SetOperatingPoint { .. }) + ) + }) + .expect("operating point retarget") + .request_id; + + sink.services.clear(); + control_tick( + &mut plugin, + inbox_with(vec![rejected( + first_retarget, + "operating point ū=0.400 rejected: peak exceeds the lobe ceiling", + )]), + &mut sink, + ); + + let run = plugin + .protocol + .as_ref() + .expect("the run abandoned the survey"); + assert_eq!(run.index, 1, "the run did not move on to the second point"); + let (index, reason) = run.failed.last().expect("the skip was recorded"); + assert_eq!(*index, 0); + assert!( + reason.contains("lobe ceiling"), + "the owner's reason was replaced: {reason}" + ); + // And it is on the status pane, because the per-point message has + // already been overwritten by the next point. + let status = plugin + .status_entries() + .iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" | "); + assert!(status.contains("lobe ceiling"), "{status}"); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// A protocol whose file is wrong must say so on the button press, before + /// the drive has moved — the whole point is that it runs unattended. + #[test] + fn an_invalid_protocol_is_refused_before_the_drive_moves() { + let folder = temp_folder("protocol-invalid"); + let (mut plugin, _) = protocol_plugin( + &folder, + r#" +[[block]] +mean_u = 0.5 +frequency_hz = 10.0 +depth_a = 99.0 +"#, + ); + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert!(plugin.protocol.is_none(), "a bad protocol started anyway"); + assert!( + sink.services.is_empty(), + "the drive was touched before the file was validated: {:?}", + sink.services + ); + assert!( + plugin.message.contains("depth_a"), + "the message does not name the offending axis: {}", + plugin.message + ); + + 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. + #[test] + fn a_point_records_for_the_duration_the_file_asks_for() { + let folder = temp_folder("protocol-duration"); + let (mut plugin, _) = protocol_plugin(&folder, TWO_POINT_PROTOCOL); + plugin.duration_s = 999; + 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(); + control_tick( + &mut plugin, + inbox_with(retargets.into_iter().map(accepted).collect()), + &mut sink, + ); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert_eq!( + plugin.recording.duration_s, 3, + "the panel's duration overrode the protocol's" + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// The host writes its sensor telemetry beside the RAW and A1 moves the + /// recording somewhere else, so the conditions a run was taken under used + /// to be separated from the run itself at the first gather. It has to + /// arrive in the measurement folder under the measurement's own name. + #[test] + fn the_sensor_readout_lands_in_the_measurement_folder_under_the_run_name() { + let capture = temp_folder("sensor-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"); + std::fs::write( + capture.join("host-capture.sensor-monitoring.csv"), + "schema_version,sample_id,poll_kind,host_elapsed_start_us,host_elapsed_end_us,\ +raw_data_offset_before_bytes,raw_data_offset_after_bytes,illumination_lux,temperature_c,\ +pixel_dead_time_us,bias_diff_on_code,bias_diff_off_code,bias_fo_code,bias_hpf_code,\ +bias_refr_code,status,error\n\ +1,1,full,1000,1200,0,0,140.0,41.5,12.7,10,20,30,40,50,ok,\n\ +1,2,fast,2000,2200,0,0,,,12.8,,,,,,ok,\n", + ) + .expect("telemetry"); + + let output = temp_folder("sensor-output"); + let mut plugin = StageAA1Plugin { + output_folder: output.display().to_string(), + ..StageAA1Plugin::default() + }; + plugin.recording.folder = output.display().to_string(); + plugin.recording.id = "A1-sensor".into(); + plugin.recording.stem = "A1-sensor_20260731-120000".into(); + plugin.recording.cam_finalized_path = Some(raw.display().to_string()); + + plugin.gather_into_measurement_folder(); + + let written = plugin + .recording + .sensor_readout_path + .as_deref() + .expect("a sensor readout was written"); + assert!( + written.ends_with("A1-sensor_20260731-120000.sensor.json"), + "{written}" + ); + assert!( + Path::new(written).starts_with(output.join("A1-sensor")), + "{written} is outside the measurement folder" + ); + let parsed: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(written).expect("read")).expect("JSON"); + assert_eq!(parsed["measurement_id"], "A1-sensor"); + assert_eq!(parsed["channels"]["pixel_dead_time_us"]["value"][1], 12.8); + 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); + } + + #[test] + fn sidecar_serializes_the_expected_sections() { + let mut plugin = plugin_with_markers(); + plugin.frame_width = 4; + plugin.frame_height = 1; + plugin.recording.id = "A1-test".into(); + plugin.recording.stem = "A1-test_20260723-000000".into(); + plugin.recording.folder = std::env::temp_dir().display().to_string(); + plugin.recording.duration_s = 5; + plugin.recording.start_unix_ms = 1_774_224_000_000; + plugin.recording.cam_finalized_path = Some("/data/A1-test/A1-test.raw".into()); + plugin.recording.pd_pdq_path = Some("/pd/A1-test/A1-test_pd.pdq".into()); + let doc = plugin.write_sidecar().expect("sidecar path"); + let text = std::fs::read_to_string(&doc).expect("read sidecar"); + 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("schema = \"stage-a.a1.sidecar.v2\"")); + assert!(text.contains("analysis_source = \"photodiode_measured\"")); + assert!(text.contains("[depth]")); + assert!(text.contains("[modulation]")); + assert!(text.contains("[photodiode]")); + assert!(!text.contains("[camera_control]")); + assert!(!text.contains("total_power_volts")); + assert!(text.contains("[files]")); + assert!(text.contains("camera_config_sidecar = \"/data/A1-test/A1-test.toml\"")); + let _ = std::fs::remove_file(&doc); + } + + #[test] + fn commanded_depth_sidecar_does_not_require_a_measured_optical_summary() { + let folder = temp_folder("commanded-sidecar"); + let mut photodiode = ready_photodiode(); + photodiode.placement = stage_a_plugin_contract::PhotodiodePlacementV1::EmissionPath; + photodiode.splitter_fraction = Some(0.5); + photodiode.dark_reference = Some(stage_a_plugin_contract::PhotodiodeDarkReferenceV1 { + dark_id: "lamp-off@sample-42@1774223990000".into(), + source: stage_a_plugin_contract::PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: 0.012, + captured_at_unix_ms: 1_774_223_990_000, + age_s: 10.0, + }); + let mut plugin = StageAA1Plugin { + depth_source: DepthSource::Commanded, + modulation: Some(commanded_modulation(1, 0.75)), + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + plugin.recording.id = "A1-commanded".into(); + plugin.recording.stem = "A1-commanded_20260813-120000".into(); + plugin.recording.folder = folder.display().to_string(); + plugin.recording.duration_s = 5; + + let path = plugin.write_sidecar().expect("commanded sidecar"); + let text = std::fs::read_to_string(path).expect("sidecar text"); + assert!(text.contains("analysis_source = \"modulation_commanded\"")); + assert!(text.contains("commanded_a = 0.75")); + assert!(!text.contains("measured_a")); + assert!(text.contains("placement = \"emission_path\"")); + assert!(text.contains("splitter_fraction = 0.5")); + assert!(text.contains("dark_id = \"lamp-off@sample-42@1774223990000\"")); + assert!(text.contains("dark_source = \"measured_lamp_off\"")); + assert!(text.contains("dark_volts = 0.012")); + assert!(text.contains("dark_captured_at_unix_ms = 1774223990000")); + assert!(text.contains("dark_age_s = ")); + assert!(!text.contains("total_power")); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn pilot_windows_round_trip_through_the_folder() { + let dir = std::env::temp_dir().join(format!("a1-pilot-{}", now_unix_ms())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let folder = dir.display().to_string(); + + let mut plugin = plugin_with_markers(); + plugin.output_folder = folder.clone(); + plugin.measurement_id = "A1-row".into(); + plugin.pilot_windows = Some(( + PhaseWindow { + start: 0.10, + end: 0.30, + }, + PhaseWindow { + start: 0.55, + end: 0.80, + }, + )); + // Write a pilot sidecar for the row. + plugin.recording.role = RecRole::Pilot; + plugin.recording.id = "A1-row".into(); + plugin.recording.stem = "A1-row_20260723-000000_pilot".into(); + plugin.recording.folder = folder.clone(); + plugin.write_sidecar().expect("pilot sidecar"); + + // A fresh plugin on the same folder+id auto-loads the frozen windows. + let mut other = StageAA1Plugin { + output_folder: folder.clone(), + measurement_id: "A1-row".into(), + ..StageAA1Plugin::default() + }; + other.scan_measurement_folder(); + let (on, off) = other.pilot_windows.expect("loaded windows"); + assert!((on.start - 0.10).abs() < 1e-9 && (off.end - 0.80).abs() < 1e-9); + assert!(other.windows_are_frozen()); + + let _ = std::fs::remove_dir_all(&dir); + } + + fn pd_rejection(request_id: u64, code: &str, message: &str) -> PluginServiceReply { + PluginServiceReply { + request_id, + source_plugin_id: A1_PLUGIN_ID.into(), + target_plugin_id: PHOTODIODE_PLUGIN_ID.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + outcome: PluginServiceOutcome::Rejected { + code: code.into(), + message: message.into(), + }, + } + } + + /// A photodiode that cannot record is caught before the host is recording, + /// so a misconfigured bench no longer leaves a stub RAW behind. + #[test] + fn a_disconnected_photodiode_is_refused_before_the_camera_starts() { + let mut photodiode = ready_photodiode(); + photodiode.connection = ConnectionStateV1::Disconnected; + let mut plugin = StageAA1Plugin { + output_folder: "/tmp/a1-preflight".into(), + measurement_id: "A1-row".into(), + duration_s: 10, + pending_role: Some(RecRole::Normal), + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert_eq!(plugin.recording.phase, RecPhase::Idle); + assert!( + sink.hosts.is_empty(), + "the camera must not start when the PDQ cannot follow" + ); + assert!( + plugin.message.contains("connect it"), + "message={}", + plugin.message + ); + } + + #[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. + /// + /// The frequency comes from the phase-0 trigger markers or from the + /// *acknowledged* drive; neither is the connection state, which is a + /// separate check. Conflating them told operators to plug in a bench that + /// was already plugged in. + #[test] + fn a_missing_frequency_is_not_reported_as_a_disconnected_plugin() { + let mut plugin = StageAA1Plugin { + // Connected, but nothing applied yet, and no markers (Live off). + modulation: Some(connected_modulation()), + photodiode: Some(fresh_photodiode_summary()), + frame_width: 10, + frame_height: 1, + ..StageAA1Plugin::default() + }; + assert!(plugin.modulation_connected()); + assert!(plugin.frequency_hz().is_none()); + + let blocker = plugin.frequency_blocker().expect("a reason"); + assert!( + blocker.contains("has not applied a drive yet"), + "an armed-nothing bench must be named as such: {blocker}" + ); + assert!( + !blocker.contains("connect"), + "a connected plugin must not be reported as needing connecting: {blocker}" + ); + + // A genuinely absent owner still says so. + plugin.modulation = None; + let absent = plugin.frequency_blocker().expect("a reason"); + assert!(absent.contains("not reporting status"), "{absent}"); + } + + /// One fact, one line. A missing frequency used to be stated three times. + #[test] + fn the_status_panel_states_a_missing_frequency_exactly_once() { + let plugin = StageAA1Plugin { + modulation: Some(connected_modulation()), + photodiode: Some(fresh_photodiode_summary()), + frame_width: 10, + frame_height: 1, + ..StageAA1Plugin::default() + }; + let lines: Vec = plugin + .status_entries() + .into_iter() + .map(|entry| match entry { + StatusEntry::Text(text) => text, + StatusEntry::LabeledValue { label, value, .. } => format!("{label}: {value}"), + _ => String::new(), + }) + .collect(); + + let explaining = lines + .iter() + .filter(|line| line.contains("has not applied a drive yet")) + .count(); + assert_eq!( + explaining, 1, + "the cause belongs on one line, not three: {lines:#?}" + ); + // The a₀ line points at that line instead of restating it. + assert!( + lines + .iter() + .any(|line| line.contains("waiting for a frequency")), + "{lines:#?}" + ); + // Nothing to say about the response curve at rest. + assert!( + !lines.iter().any(|line| line.starts_with("Response curve")), + "an empty response curve must not take a line: {lines:#?}" + ); + } + + /// An output folder is the only thing an operator must type before they can + /// record. The measurement id names a folder and the flux point id is + /// provenance; neither has ever been a reason to refuse the run, and every + /// fixture in this file used to pre-fill both, which is how the refusal + /// survived. Nothing here sets them. + #[test] + fn recording_needs_only_an_output_folder_not_the_optional_ids() { + let mut plugin = StageAA1Plugin { + output_folder: "/tmp/a1-no-ids".into(), + measurement_id: String::new(), + duration_s: 10, + pending_role: Some(RecRole::Normal), + photodiode: Some(ready_photodiode()), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert_eq!( + plugin.recording.phase, + RecPhase::StartingCamera, + "blank ids must not refuse the recording; message={}", + plugin.message + ); + // The generated id is written back, so the panel shows what was used + // rather than filing the run under a name the operator cannot see. + assert!( + !plugin.measurement_id.trim().is_empty(), + "a generated id must land in the field the operator reads" + ); + assert_eq!(plugin.recording.id, sanitize_stem(&plugin.measurement_id)); + assert!(plugin.recording.stem.starts_with(&plugin.recording.id)); + } + + /// An operator id that is present is kept exactly as it was. + #[test] + fn a_typed_measurement_id_is_never_replaced_by_a_generated_one() { + let mut plugin = StageAA1Plugin { + output_folder: "/tmp/a1-typed-id".into(), + measurement_id: "row-7".into(), + pending_role: Some(RecRole::Normal), + photodiode: Some(ready_photodiode()), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + + assert_eq!(plugin.measurement_id, "row-7"); + assert_eq!(plugin.recording.id, "row-7"); + } + + /// The whole unattended ladder, with nothing typed in but the folder. + /// + /// Each point ends in a recording, and `begin_recording` used to refuse a + /// blank id — three stages after the ladder had already taken the lease and + /// moved the drive. The panel showed the ladder running and the recording + /// idle, and every point was skipped. + #[test] + fn the_frequency_sweep_runs_with_no_ids_typed_in() { + let folder = temp_folder("fsweep-no-ids"); + let mut plugin = plugin_locking(0.6, &folder); + plugin.measurement_id = String::new(); + plugin.a0_target = 0.5; + plugin.min_f = 100.0; + plugin.max_f = 1_000.0; + plugin.freq_count = 2; + plugin.freq_order = FreqOrder::Ascending; + photodiode_window(&mut plugin, 0.02); + plugin.freq_sweep_pending = Some(FreqSweepMode::A0Point); + let mut sink = ControlSink::default(); + + let recorded = run_freq_sweep_to_completion(&mut plugin, &mut sink, 0.6, 4_000); + + assert_eq!( + recorded.len(), + 2, + "every ladder point must record; message: {}", + plugin.message + ); + assert!(plugin.message.contains("2/2 points recorded")); + assert!(!plugin.measurement_id.trim().is_empty()); + let _ = std::fs::remove_dir_all(&folder); + } + + /// The a₀ field is a drag control with a 0.01 step. Comparing it to the + /// lock's target on exact equality meant one stray pixel of drag disarmed a + /// lock that had just converged, and the panel then asked for the very thing + /// the operator had done. The operator's own tolerance is the right band. + #[test] + fn nudging_a0_inside_its_tolerance_keeps_the_lock_armed() { + let mut plugin = plugin_with_markers(); + plugin.a0_target = 0.5; + plugin.a0_tolerance = 0.02; + plugin.a0_locks.push(A0LockPoint { + frequency_hz: plugin.frequency_hz().expect("frequency"), + target_a: 0.5, + commanded_a: 0.83, + measured_a: 0.5, + trials: 2, + converged: true, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: None, + high_clip_fraction: None, + depth_source: DepthSource::Photodiode, + }); + assert!(plugin.armed_lock().is_some(), "the fresh lock must arm"); + + plugin.a0_target = 0.51; + assert!( + plugin.armed_lock().is_some(), + "a nudge inside the tolerance must not disarm the lock" + ); + + // Beyond the tolerance it really is a different target, and the refusal + // says so instead of asking for a Find a₀ that was already done. + plugin.a0_target = 0.70; + assert!(plugin.armed_lock().is_none()); + let blocker = plugin.armed_lock_blocker().expect("a reason"); + assert!( + blocker.contains("0.500") && blocker.contains("0.700"), + "the refusal must name both targets: {blocker}" + ); + } + + /// Three different causes used to share one sentence telling the operator to + /// press Find a₀ — which only fixes the first of them. + #[test] + fn a_lock_that_cannot_arm_names_which_of_the_three_causes_it_is() { + let mut plugin = plugin_with_markers(); + plugin.a0_target = 0.5; + plugin.a0_tolerance = 0.02; + let hz = plugin.frequency_hz().expect("frequency"); + + let no_lock = plugin.armed_lock_blocker().expect("a reason"); + assert!( + no_lock.contains("press Find a₀"), + "with no lock at all, pressing Find a₀ is the fix: {no_lock}" + ); + + plugin.a0_locks.push(A0LockPoint { + frequency_hz: hz, + target_a: 0.5, + commanded_a: 6.0, + measured_a: 0.31, + trials: 8, + converged: false, + locked_at_unix_ms: now_unix_ms(), + low_clip_fraction: None, + high_clip_fraction: None, + depth_source: DepthSource::Photodiode, + }); + let not_converged = plugin.armed_lock_blocker().expect("a reason"); + assert!( + not_converged.contains("0.310") && not_converged.contains("tolerance"), + "a lock that stopped short must report where it stopped: {not_converged}" + ); + } + + /// The photodiode's own Data directory is irrelevant to a recording started + /// from A1: A1 names the destination root, so the run proceeds and the PDQ + /// is written into A1's measurement folder. + #[test] + fn the_pdq_start_spec_points_at_the_a1_output_folder() { + let mut photodiode = ready_photodiode(); + photodiode.data_dir = None; + let mut plugin = StageAA1Plugin { + output_folder: "/tmp/a1-destination".into(), + measurement_id: "A1-row".into(), + duration_s: 10, + pending_role: Some(RecRole::Normal), + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!( + plugin.recording.phase, + RecPhase::StartingCamera, + "an unset owner data directory must not block an A1-driven run" + ); + let cam_start_req = sink.hosts[0].request_id; + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: cam_start_req, + outcome: HostCommandOutcome::RecordingStarted { + actual_raw_path: "/camera/A1-row/run.raw".into(), + started_at: "2026-07-25T00:00:00Z".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let connect = sink.services.last().expect("connect").clone(); + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply(connect.request_id, None)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let acquire = sink.services.last().expect("lease").clone(); + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_reply(acquire.request_id, None)], + ..PluginControlInbox::default() + }, + &mut sink, + ); + + let begin: PhotodiodeRequestV1 = + serde_json::from_value(sink.services.last().expect("begin").payload.clone()) + .expect("begin envelope"); + let PhotodiodeCommandV1::BeginRecording { specification } = begin.command else { + panic!("expected BeginRecording"); + }; + assert_eq!( + specification.root_dir.as_deref(), + Some("/tmp/a1-destination"), + "the PDQ must be written below the A1 output folder" + ); + assert_eq!( + specification.pdq_path, + format!("A1-row/{}_pd.pdq", plugin.recording.stem) + ); + } + + /// The regression this whole coordinator exists for: a photodiode failure + /// used to stop the host recorder immediately, leaving a RAW that was a + /// fraction of the requested duration but reported itself as finalized. + #[test] + fn a_photodiode_failure_keeps_the_camera_recording_for_the_full_duration() { + let folder = std::env::temp_dir().join(format!("a1-camera-only-{}", now_unix_ms())); + let host_dir = folder.join("host-output"); + std::fs::create_dir_all(&host_dir).expect("host dir"); + let mut plugin = StageAA1Plugin { + output_folder: folder.display().to_string(), + measurement_id: "A1-row".into(), + duration_s: 10, + pending_role: Some(RecRole::Normal), + photodiode: Some(fresh_photodiode_summary()), + ..StageAA1Plugin::default() + }; + let mut sink = ControlSink::default(); + + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + let cam_start_req = sink.hosts[0].request_id; + let raw_path = host_dir.join(format!("{}.raw", plugin.recording.stem)); + std::fs::write(&raw_path, b"raw-events").expect("raw file"); + std::fs::write(raw_path.with_extension("toml"), b"biases = true").expect("bias sidecar"); + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: cam_start_req, + outcome: HostCommandOutcome::RecordingStarted { + actual_raw_path: raw_path.display().to_string(), + started_at: "2026-07-25T00:00:00Z".into(), + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + let connect = sink.services.last().expect("connect request").clone(); + + // The photodiode refuses to open the stream. + control_tick( + &mut plugin, + PluginControlInbox { + service_replies: vec![pd_rejection( + connect.request_id, + "transport", + "photodiode connection failed", + )], + ..PluginControlInbox::default() + }, + &mut sink, + ); + + assert_eq!( + plugin.recording.phase, + RecPhase::Running, + "the camera must keep recording without the photodiode" + ); + assert_eq!( + sink.hosts.len(), + 1, + "no StopRecording may be sent before the duration elapses" + ); + + // Nothing happens until the fixed duration is actually over. + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(sink.hosts.len(), 1, "the run is still inside its window"); + + plugin.recording.start_unix_ms = now_unix_ms().saturating_sub(10_000); + control_tick(&mut plugin, PluginControlInbox::default(), &mut sink); + assert_eq!(plugin.recording.phase, RecPhase::StoppingCamera); + let cam_stop_req = sink.hosts[1].request_id; + + control_tick( + &mut plugin, + PluginControlInbox { + host_replies: vec![HostCommandReply { + request_id: cam_stop_req, + outcome: HostCommandOutcome::RecordingFinalized { + actual_raw_path: raw_path.display().to_string(), + size: 10, + sha256: "cd".repeat(32), + duration_us: 10_000_000, + }, + }], + ..PluginControlInbox::default() + }, + &mut sink, + ); + + assert_eq!(plugin.recording.phase, RecPhase::Idle); + assert!(!plugin.recording_completed_ok, "the PDQ is missing"); + // The closing message names the cause instead of only "incomplete". + assert!( + plugin.message.contains("photodiode connection failed"), + "message={}", + plugin.message + ); + // Camera RAW, its bias sidecar, and the config all land together. + let measurement_dir = folder.join("A1-row"); + let mut names: Vec = std::fs::read_dir(&measurement_dir) + .expect("measurement folder") + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!(names.len(), 3, "names={names:?}"); + assert!(names.iter().any(|name| name.ends_with(".raw"))); + assert!(names.iter().any(|name| name.ends_with("_config.toml"))); + assert!( + !raw_path.exists(), + "the RAW must be moved out of the host output folder" + ); + + let _ = std::fs::remove_dir_all(&folder); + } + + /// PDQ receipts name the path *relative to the photodiode's data directory*, + /// so gathering has to resolve it against the owner's published root before + /// the file can be found and moved. + #[test] + fn a_relative_pdq_label_is_resolved_against_the_photodiode_data_directory() { + let root = std::env::temp_dir().join(format!("a1-gather-{}", now_unix_ms())); + let pd_root = root.join("pd-data"); + std::fs::create_dir_all(pd_root.join("A1-row")).expect("pd dirs"); + std::fs::write(pd_root.join("A1-row/run_pd.pdq"), b"pdq").expect("pdq"); + std::fs::write(pd_root.join("A1-row/run_pd.json"), b"{}").expect("pd sidecar"); + + let mut photodiode = ready_photodiode(); + photodiode.data_dir = Some(pd_root.display().to_string()); + let mut plugin = StageAA1Plugin { + output_folder: root.display().to_string(), + photodiode: Some(photodiode), + ..StageAA1Plugin::default() + }; + plugin.recording.id = "A1-row".into(); + plugin.recording.stem = "run".into(); + plugin.recording.folder = root.display().to_string(); + // Exactly what the owner reports: a label, not a path. + plugin.recording.pd_pdq_path = Some("A1-row/run_pd.pdq".into()); + plugin.recording.pd_sidecar_path = Some("A1-row/run_pd.json".into()); + + plugin.gather_into_measurement_folder(); + + let measurement_dir = root.join("A1-row"); + assert!(measurement_dir.join("run_pd.pdq").is_file()); + assert!(measurement_dir.join("run_pd.json").is_file()); + assert!(!pd_root.join("A1-row/run_pd.pdq").exists()); + // The sidecar records where the file actually ended up. + assert_eq!( + plugin.recording.pd_pdq_path.as_deref(), + Some(measurement_dir.join("run_pd.pdq").display().to_string()).as_deref() + ); + + let _ = std::fs::remove_dir_all(&root); + } + + /// The host restarts the pipeline when A1 starts its own recording and + /// reports it as SourceChanged. That must not wipe the row's science state. + #[test] + fn a_self_inflicted_source_change_keeps_the_rows_science_state() { + let mut plugin = StageAA1Plugin { + response_points: vec![ResponsePoint { + measured_a: 1.0, + q_on: 0.5, + q_off: 0.4, + cycles: 20, + valid_pixels: 10, + }], + pilot_windows: Some(( + PhaseWindow { + start: 0.1, + end: 0.4, + }, + PhaseWindow { + start: 0.6, + end: 0.9, + }, + )), + camera_markers_us: vec![0, 1_000], + ..StageAA1Plugin::default() + }; + plugin.recording.phase = RecPhase::Running; + + plugin.on_discontinuity(PluginDiscontinuity::SourceChanged); + + assert_eq!(plugin.response_points.len(), 1, "sweep points were wiped"); + assert!(plugin.pilot_windows.is_some(), "pilot windows were wiped"); + assert!( + plugin.camera_markers_us.is_empty(), + "the event timeline really did restart and must reset" + ); + + // Outside a recording the boundary still resets everything. + plugin.recording = Recording::idle(); + plugin.on_discontinuity(PluginDiscontinuity::SourceChanged); + assert!(plugin.response_points.is_empty()); + assert!(plugin.pilot_windows.is_none()); + } +} diff --git a/plugins/stage-a-a1/src/types.rs b/plugins/stage-a-a1/src/types.rs new file mode 100644 index 0000000..91a39cf --- /dev/null +++ b/plugins/stage-a-a1/src/types.rs @@ -0,0 +1,26 @@ +/// Event-camera polarity. A1 always analyses ON and OFF separately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Polarity { + On, + Off, +} + +impl Polarity { + pub const ALL: [Self; 2] = [Self::On, Self::Off]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::On => "on", + Self::Off => "off", + } + } +} + +/// The event fields needed by the pure A1 analysis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CameraEvent { + pub timestamp_us: u64, + pub x: u16, + pub y: u16, + pub polarity: Polarity, +} diff --git a/plugins/stage-a-a2/Cargo.toml b/plugins/stage-a-a2/Cargo.toml new file mode 100644 index 0000000..36d85c4 --- /dev/null +++ b/plugins/stage-a-a2/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "augur-plugin-stage-a-a2" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A A2 optical step-latency protocol runner" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2 = "0.10" +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[lints.rust] +unsafe_code = "forbid" diff --git a/plugins/stage-a-a2/README.md b/plugins/stage-a-a2/README.md new file mode 100644 index 0000000..fbab665 --- /dev/null +++ b/plugins/stage-a-a2/README.md @@ -0,0 +1,28 @@ +# Stage-A A2 latency runner + +Runs a fully validated TOML protocol through the existing modulation owner, +photodiode owner and host camera recorder. One row creates one camera RAW, one +photodiode PDQ and one A2 JSON sidecar. The sidecar links the protocol and its +SHA-256, the optical/gate declarations, controller point and finalized receipts; +it does not copy the host-owned camera configuration sidecar. + +The plugin does not fit latency. It records both EXT_TRIGGER polarities and the +event-load peak needed by the offline first-event analysis. Dark rows are +explicit fixed-duration acquisitions with the modulation forced safe/off. + +The protocol must name a complete host camera profile. It is applied and +confirmed before owner leases and restored on every terminal path. The A2 +sidecar links the host camera and sensor-monitoring companions; it does not copy +their camera settings or bias readbacks. The exact protocol text is archived by +content hash in the measurement folder. + +The current fluorescence-chain template is +`protocols/a2_fluorescence_chain_followup.toml`. It deliberately contains TBD +bring-up gates and therefore refuses to run until H4, H5, the optical-edge/local- +flux calibrations, comparator threshold per row, lobe endpoints and timing floor +are measured and frozen. + +Production firmware mirrors comparator marker frames (`source=2`) onto the +non-blocking photodiode stream, so PDQ contains the independent diagnostic edge +record. Camera EXT_TRIGGER remains the latency clock of record. This does not +replace or weaken the mandatory H4 loopback and H5 polarity/offset gates. diff --git a/plugins/stage-a-a2/plugin.toml b/plugins/stage-a-a2/plugin.toml new file mode 100644 index 0000000..6d8ad62 --- /dev/null +++ b/plugins/stage-a-a2/plugin.toml @@ -0,0 +1,9 @@ +id = "stage-a.a2" +name = "Stage-A A2 Latency" +version = "0.1.0" +description = "Runs validated optical step-latency protocols with synchronized camera RAW and photodiode PDQ acquisition." +domain = "stage-a" +library = "augur_plugin_stage_a_a2" +phase = "raw_events" +min_augur_version = "2.0.2" +host_commands = ["start_recording", "stop_recording"] diff --git a/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml b/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml new file mode 100644 index 0000000..05382cc --- /dev/null +++ b/plugins/stage-a-a2/protocols/a2_fluorescence_chain_followup.toml @@ -0,0 +1,217 @@ +# Immediate A2 follow-up for the current ATTO647 fluorescence chain. +# +# This file is intentionally fail-closed. Replace every TBD/zero gate with the +# value or calibration ID measured during comparator/optical bring-up. The A2 +# plugin rejects the whole file before acquiring a lease while any remains. +name = "a2-atto647-fluorescence-chain-followup" + +[camera] +profile = "TBD" # Complete host profile with EXT_TRIGGER and sensor telemetry. + +[optical] +transfer_scope = "fluorescence_chain" +photodiode_placement = "emission_path" +splitter_fraction_to_pd = 0.5 +optical_config_id = "TBD" + +[gates] +firmware_a2_confirmed = false +comparator_self_test_passed = false +camera_external_trigger_confirmed = false +h4_loopback_id = "TBD" +h5_polarity_calibration_id = "TBD" +optical_edge_calibration_id = "TBD" +local_flux_calibration_id = "TBD" +recorder_safety_limit_events_per_us = 0 # Pre-qualified before this protocol. + +[controller] +v_null_dac = 0 # TBD: measured lower lobe endpoint +v_peak_dac = 0 # TBD: measured absolute lobe maximum +comparator_hysteresis = 1 +comparator_invert = false # TBD: value frozen by H5 +min_half_us = 0 # TBD: max(5*tau_refr, A1 settling guard) +sample_rate_hz = 500000 +block_samples = 256 + +# Dark/sham and cadence commissioning. A pause means the operator must confirm +# the stated physical condition before Continue. No failed row is overwritten. +[[point]] +label="floor_pre" +role="floor_pre_shutter_closed" +acquisition_mode="dark" +duration_s=30.0 +settle_s=2 +pause_before=true + +[[point]] +label="blocked_drive_sham" +role="blocked_drive_sham" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=100 +settle_s=2 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="polarity_cadence_2s" +role="commissioning" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=2.0 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="cadence_1s" +role="commissioning" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="cadence_0p5s" +role="commissioning_conditional" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=0.5 +transitions_per_polarity=50 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +[[point]] +label="load_small" +role="h21_load_reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="load_larger" +role="h21_load_test" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +pause_before=true + +# Opening references and the multi-depth identification block at mean_u=0.30. +[[point]] +label="ref_open_1" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="ref_open_2" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="ref_open_3" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="depth_low" +role="identification" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.28 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="depth_high" +role="identification" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.80 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 + +# Flux/pedestal bridge matching A1. The order is non-monotonic to expose drift. +[[point]] +label="pedestal_u015" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.15 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="pedestal_u045" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.45 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 +[[point]] +label="pedestal_u030" +role="pedestal_core" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=500 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="ref_close" +role="reference" +acquisition_mode="stepped" +mean_u=0.30 +depth_a=0.45 +half_period_s=1.0 +transitions_per_polarity=200 +settle_s=3 +comparator_threshold_dac=0 + +[[point]] +label="floor_post" +role="floor_post_shutter_closed" +acquisition_mode="dark" +duration_s=30.0 +settle_s=2 +pause_before=true diff --git a/plugins/stage-a-a2/src/lib.rs b/plugins/stage-a-a2/src/lib.rs new file mode 100644 index 0000000..fb73c36 --- /dev/null +++ b/plugins/stage-a-a2/src/lib.rs @@ -0,0 +1,10 @@ +//! Stage-A A2: repeated optical-step latency acquisition. +//! +//! This plugin owns no hardware. It runs validated protocol points through the +//! persistent modulation and photodiode owners and the host camera recorder. +//! Scientific latency fits remain offline. + +pub mod protocol; +mod runtime; + +pub use runtime::StageAA2Plugin; diff --git a/plugins/stage-a-a2/src/protocol.rs b/plugins/stage-a-a2/src/protocol.rs new file mode 100644 index 0000000..890b0cc --- /dev/null +++ b/plugins/stage-a-a2/src/protocol.rs @@ -0,0 +1,340 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +pub const MAX_POINTS: usize = 4_096; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Protocol { + pub name: String, + pub camera: CameraSetup, + pub optical: OpticalSetup, + pub gates: Gates, + pub controller: ControllerSetup, + #[serde(rename = "point")] + pub points: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CameraSetup { + pub profile: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OpticalSetup { + pub transfer_scope: String, + pub photodiode_placement: String, + pub splitter_fraction_to_pd: f64, + pub optical_config_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Gates { + pub firmware_a2_confirmed: bool, + pub comparator_self_test_passed: bool, + pub camera_external_trigger_confirmed: bool, + pub h4_loopback_id: String, + pub h5_polarity_calibration_id: String, + pub optical_edge_calibration_id: String, + pub local_flux_calibration_id: String, + pub recorder_safety_limit_events_per_us: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ControllerSetup { + pub v_null_dac: u16, + pub v_peak_dac: u16, + pub comparator_hysteresis: u8, + pub comparator_invert: bool, + pub min_half_us: u32, + #[serde(default = "default_sample_rate")] + pub sample_rate_hz: u32, + #[serde(default = "default_block_samples")] + pub block_samples: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Point { + pub label: String, + pub role: String, + pub settle_s: f64, + #[serde(default)] + pub pause_before: bool, + #[serde(flatten)] + pub acquisition: Acquisition, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "acquisition_mode", rename_all = "snake_case")] +pub enum Acquisition { + Dark { + duration_s: f64, + }, + Stepped { + mean_u: f64, + depth_a: f64, + half_period_s: f64, + transitions_per_polarity: u32, + comparator_threshold_dac: u16, + }, +} + +impl Point { + pub fn acquisition_seconds(&self) -> f64 { + match self.acquisition { + Acquisition::Dark { duration_s } => duration_s, + Acquisition::Stepped { + half_period_s, + transitions_per_polarity, + .. + } => 2.0 * half_period_s * f64::from(transitions_per_polarity), + } + } +} + +fn default_sample_rate() -> u32 { + 500_000 +} +fn default_block_samples() -> u32 { + 256 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError(pub String); + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for ProtocolError {} + +fn real_id(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("tbd") && !value.contains("REPLACE") +} + +impl Protocol { + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.points.is_empty() || self.points.len() > MAX_POINTS { + return Err(ProtocolError(format!( + "protocol must contain 1..={MAX_POINTS} points" + ))); + } + if !real_id(&self.camera.profile) { + return Err(ProtocolError( + "camera.profile is missing/TBD; freeze a complete host camera profile".into(), + )); + } + if self.optical.transfer_scope != "fluorescence_chain" { + return Err(ProtocolError( + "transfer_scope must be fluorescence_chain for this protocol".into(), + )); + } + if self.optical.photodiode_placement != "emission_path" { + return Err(ProtocolError( + "photodiode_placement must be emission_path".into(), + )); + } + if !(0.0..=1.0).contains(&self.optical.splitter_fraction_to_pd) + || self.optical.splitter_fraction_to_pd == 0.0 + { + return Err(ProtocolError( + "splitter_fraction_to_pd must be in (0,1]".into(), + )); + } + if !real_id(&self.optical.optical_config_id) { + return Err(ProtocolError( + "optical_config_id is missing/TBD; freeze the bench first".into(), + )); + } + if !self.gates.firmware_a2_confirmed + || !self.gates.comparator_self_test_passed + || !self.gates.camera_external_trigger_confirmed + { + return Err(ProtocolError( + "A2 firmware, comparator self-test and camera external trigger must be confirmed" + .into(), + )); + } + for (name, value) in [ + ("h4_loopback_id", self.gates.h4_loopback_id.as_str()), + ( + "h5_polarity_calibration_id", + self.gates.h5_polarity_calibration_id.as_str(), + ), + ( + "optical_edge_calibration_id", + self.gates.optical_edge_calibration_id.as_str(), + ), + ( + "local_flux_calibration_id", + self.gates.local_flux_calibration_id.as_str(), + ), + ] { + if !real_id(value) { + return Err(ProtocolError(format!("{name} is missing/TBD"))); + } + } + if self.gates.recorder_safety_limit_events_per_us == 0 { + return Err(ProtocolError( + "recorder_safety_limit_events_per_us must be pre-qualified before this protocol" + .into(), + )); + } + let c = &self.controller; + if c.v_peak_dac <= c.v_null_dac || c.comparator_hysteresis > 3 || c.min_half_us == 0 { + return Err(ProtocolError( + "controller lobe, hysteresis or min_half_us is not frozen".into(), + )); + } + for (index, p) in self.points.iter().enumerate() { + if p.label.trim().is_empty() || p.role.trim().is_empty() { + return Err(ProtocolError(format!( + "point {} needs label and role", + index + 1 + ))); + } + if p.settle_s < 0.0 { + return Err(ProtocolError(format!( + "point {} has invalid numeric values", + index + 1 + ))); + } + match p.acquisition { + Acquisition::Dark { duration_s } => { + if duration_s <= 0.0 { + return Err(ProtocolError(format!( + "point {} dark duration_s must be positive", + index + 1 + ))); + } + } + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + if !(0.0 < mean_u && mean_u <= 1.0) + || depth_a <= 0.0 + || half_period_s <= 0.0 + || transitions_per_polarity == 0 + { + return Err(ProtocolError(format!( + "point {} has invalid stepped numeric values", + index + 1 + ))); + } + if half_period_s * 1e6 < f64::from(c.min_half_us) { + return Err(ProtocolError(format!( + "point {} half-period violates min_half_us", + index + 1 + ))); + } + if !(1..=4_095).contains(&comparator_threshold_dac) { + return Err(ProtocolError(format!( + "point {} comparator_threshold_dac must be a frozen code in 1..=4095", + index + 1 + ))); + } + } + } + } + Ok(()) + } + + pub fn total_seconds(&self) -> f64 { + self.points + .iter() + .map(|p| p.settle_s + p.acquisition_seconds()) + .sum() + } +} + +pub fn parse(text: &str) -> Result { + let protocol: Protocol = toml::from_str(text).map_err(|e| ProtocolError(e.to_string()))?; + protocol.validate()?; + Ok(protocol) +} + +#[cfg(test)] +mod tests { + use super::*; + const BASE: &str = r#" +name="a2-test" +[camera] +profile="A2 qualified" +[optical] +transfer_scope="fluorescence_chain" +photodiode_placement="emission_path" +splitter_fraction_to_pd=0.5 +optical_config_id="opt-1" +[gates] +firmware_a2_confirmed=true +comparator_self_test_passed=true +camera_external_trigger_confirmed=true +h4_loopback_id="h4-1" +h5_polarity_calibration_id="h5-1" +optical_edge_calibration_id="edge-1" +local_flux_calibration_id="flux-1" +recorder_safety_limit_events_per_us=1000 +[controller] +v_null_dac=100 +v_peak_dac=1000 +comparator_hysteresis=1 +comparator_invert=false +min_half_us=100 +[[point]] +label="core" +role="core" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.5 +transitions_per_polarity=500 +settle_s=2 +comparator_threshold_dac=500 +"#; + + #[test] + fn parses_complete_fluorescence_protocol() { + assert_eq!(parse(BASE).unwrap().points.len(), 1); + } + + #[test] + fn dark_point_has_duration_but_no_step_parameters() { + let dark = BASE + .replace("acquisition_mode=\"stepped\"", "acquisition_mode=\"dark\"") + .replace("mean_u=0.3\n", "duration_s=30\n") + .replace("depth_a=0.45\n", "") + .replace("half_period_s=0.5\n", "") + .replace("transitions_per_polarity=500\n", "") + .replace("comparator_threshold_dac=500\n", ""); + let protocol = parse(&dark).unwrap(); + assert_eq!(protocol.points[0].acquisition_seconds(), 30.0); + assert!(matches!( + protocol.points[0].acquisition, + Acquisition::Dark { .. } + )); + } + + #[test] + fn tbd_gate_fails_before_hardware_moves() { + let text = BASE.replace("h4-1", "TBD"); + assert!(parse(&text).unwrap_err().0.contains("h4_loopback_id")); + } + + #[test] + fn shipped_followup_is_deliberately_not_runnable_before_bringup() { + let text = include_str!("../protocols/a2_fluorescence_chain_followup.toml"); + let error = parse(text).unwrap_err().0; + assert!( + error.contains("camera.profile") + || error.contains("optical_config_id") + || error.contains("firmware") + || error.contains("comparator_threshold_dac"), + "unexpected refusal: {error}" + ); + } +} diff --git a/plugins/stage-a-a2/src/runtime.rs b/plugins/stage-a-a2/src/runtime.rs new file mode 100644 index 0000000..3ef9d7b --- /dev/null +++ b/plugins/stage-a-a2/src/runtime.rs @@ -0,0 +1,2047 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use augur_plugin_api::{ + export_plugin, CameraConfigurationProvenanceV1, CameraConfigurationSnapshotV1, + CameraConfigurationSourceV1, EventStoreHandle, GlobalSettings, HostCommand, HostCommandOutcome, + HostCommandRequest, HostContext, HostOutput, PathDialogKind, Plugin, PluginCapabilities, + PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, PluginInput, + PluginRuntimeRole, PluginServiceOutcome, PluginServiceRequest, SensorMonitoringV1, SettingItem, + SettingKind, SettingsSchema, SettingsSection, StatusEntry, CTX_GLOBAL_SETTINGS, + CTX_SENSOR_MONITORING, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use stage_a_plugin_contract::{ + A2AcquisitionConfigV1, ClientId, ConnectionStateV1, LeaseId, ModulationCommandV1, + ModulationRequestV1, ModulationResponseV1, ModulationStateV1, PdqReceiptV1, PdqStartSpecV1, + PdqTerminationV1, PhotodiodeCommandV1, PhotodiodePlacementV1, PhotodiodeRequestV1, + PhotodiodeResponseV1, PhotodiodeSummaryV1, RequestId, RequestOutcomeV1, RunId, + SemanticRevision, CTX_STAGE_A_MODULATION_STATE_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + SERVICE_STAGE_A_MODULATION_CONTROL_V1, SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, +}; + +use crate::protocol::{self, Acquisition, Point, Protocol}; + +const ID: &str = "stage-a.a2"; +const MOD_ID: &str = "stage-a.modulation"; +const PD_ID: &str = "stage-a.photodiode"; +const TIMEOUT_MS: u64 = 20_000; +const LEASE_TTL_MS: u64 = 60_000; + +#[derive(Default, Clone, Copy)] +struct Press { + value: u64, + seen: Option, +} +impl Press { + fn accept(&mut self, v: &Value) -> bool { + if v.as_bool() == Some(true) { + self.value += 1; + self.seen = Some(self.value); + return true; + } + let Some(v) = v.as_u64() else { + return false; + }; + match self.seen { + None => { + self.seen = Some(v); + self.value = self.value.max(v); + false + } + Some(old) if v > old => { + self.seen = Some(v); + self.value = self.value.max(v); + true + } + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + ApplyCamera, + AcquireMod, + AcquirePd, + Prepare, + Settle, + StartCamera, + StartPd, + StartMod, + Recording, + StopMod, + FinalizePd, + StopCamera, + ReleasePd, + ReleaseMod, + RestoreCamera, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + Mod, + Pd, + Host, +} + +#[derive(Debug, Default, Serialize)] +struct PointEvidence { + rising_triggers: u64, + falling_triggers: u64, + expected_triggers_per_polarity: u64, + peak_events_per_us: u64, + raw_path: Option, + raw_sha256: Option, + camera_configuration_sidecar_path: Option, + sensor_monitoring_path: Option, + pdq_path: Option, + pdq_sha256: Option, + valid: bool, + failure: Option, +} + +struct Run { + protocol: Protocol, + protocol_path: String, + protocol_sha256: String, + protocol_archive_path: String, + measurement_id: String, + index: usize, + phase: Phase, + pending: Option<(PendingKind, u64, u64)>, + lease: LeaseId, + run_id: String, + deadline_ms: u64, + next_renew_ms: u64, + stop: bool, + abort_reason: Option, + mod_leased: bool, + pd_leased: bool, + camera_recording: bool, + camera_stop_attempts: u8, + pd_recording: bool, + modulation_active: bool, + camera_session_active: bool, + restore_attempts: u8, + camera_snapshot: Option, + camera_provenance: Option, + camera_readback_age_s: Option, + pause_acknowledged: bool, + last_event_bin_us: Option, + last_event_bin_count: u64, + evidence: PointEvidence, +} + +pub struct StageAA2Plugin { + enabled: bool, + role: PluginRuntimeRole, + output_folder: String, + measurement_id: String, + protocol_path: String, + start: Press, + stop: Press, + continue_press: Press, + start_pending: bool, + stop_pending: bool, + continue_pending: bool, + run: Option, + request: u64, + revision: u64, + message: String, + modulation: Option, + photodiode: Option, + settings: Option, + sensor: Option, +} + +impl Default for StageAA2Plugin { + fn default() -> Self { + Self { + enabled: true, + role: PluginRuntimeRole::LiveWorker, + output_folder: String::new(), + measurement_id: String::new(), + protocol_path: String::new(), + start: Press::default(), + stop: Press::default(), + continue_press: Press::default(), + start_pending: false, + stop_pending: false, + continue_pending: false, + run: None, + request: 0, + revision: 0, + message: "Choose an output folder and a fully qualified A2 protocol".into(), + modulation: None, + photodiode: None, + settings: None, + sensor: None, + } + } +} + +trait Control { + fn service(&mut self, request: &PluginServiceRequest); + fn host(&mut self, request: &HostCommandRequest); +} +impl Control for PluginControlContext<'_> { + fn service(&mut self, request: &PluginServiceRequest) { + let _ = self.request_service(request); + } + fn host(&mut self, request: &HostCommandRequest) { + let _ = self.request_host(request); + } +} + +impl StageAA2Plugin { + fn next_id(&mut self) -> u64 { + self.request += 1; + self.request + } + fn next_revision(&mut self) -> SemanticRevision { + self.revision += 1; + SemanticRevision(self.revision) + } + + fn blocker(&self) -> Option { + if self.role != PluginRuntimeRole::LiveWorker { + return Some("A2 hardware effects are allowed only on the live worker".into()); + } + if self.run.is_some() { + return Some("an A2 protocol is already running".into()); + } + if self.output_folder.trim().is_empty() { + return Some("choose an output folder".into()); + } + if self.protocol_path.trim().is_empty() { + return Some("choose an A2 protocol".into()); + } + let Some(settings) = self.settings.as_ref() else { + return Some("start live camera preview so host camera settings are available".into()); + }; + if settings.event_filters.stc_enabled + || settings.event_filters.trail_enabled + || settings.event_filters.erc_enabled + { + return Some("disable STC, Trail and ERC before A2".into()); + } + if !matches!( + self.modulation.as_ref().map(|s| &s.connection), + Some(ConnectionStateV1::Connected { .. }) + ) { + return Some("connect the Stage-A modulation owner".into()); + } + if !matches!( + self.photodiode.as_ref().map(|s| &s.connection), + Some(ConnectionStateV1::Connected { .. }) + ) { + return Some("connect the Stage-A photodiode owner".into()); + } + if self + .photodiode + .as_ref() + .is_none_or(|summary| summary.placement != PhotodiodePlacementV1::EmissionPath) + { + return Some("set the photodiode owner to emission_path".into()); + } + if self + .photodiode + .as_ref() + .and_then(|summary| summary.splitter_fraction) + .is_none_or(|fraction| (fraction - 0.5).abs() > 1e-6) + { + return Some("set and confirm the photodiode splitter fraction to 0.5".into()); + } + if self.sensor.and_then(|s| s.pixel_dead_time_us).is_none() { + return Some("sensor pixel-dead-time readout is missing".into()); + } + None + } + + fn begin(&mut self, control: &mut impl Control) { + if let Some(blocker) = self.blocker() { + self.message = format!("A2 refused: {blocker}"); + return; + } + let text = match std::fs::read_to_string(self.protocol_path.trim()) { + Ok(v) => v, + Err(e) => { + self.message = format!("Cannot read protocol: {e}"); + return; + } + }; + let plan = match protocol::parse(&text) { + Ok(v) => v, + Err(e) => { + self.message = format!("A2 protocol refused before hardware moved: {e}"); + return; + } + }; + if let Some(dead) = self.sensor.and_then(|s| s.pixel_dead_time_us) { + if f64::from(plan.controller.min_half_us) < 5.0 * f64::from(dead) { + self.message = format!( + "A2 refused: min_half_us={} is below 5 x sensor dead time ({dead:.2} us)", + plan.controller.min_half_us + ); + return; + } + } + let measurement_id = if self.measurement_id.trim().is_empty() { + format!("A2-{}", compact_time()) + } else { + safe(self.measurement_id.trim()) + }; + self.measurement_id = measurement_id.clone(); + let hash = hex_hash(text.as_bytes()); + let protocol_archive_path = + match archive_protocol(&self.output_folder, &measurement_id, &hash, text.as_bytes()) { + Ok(path) => path, + Err(error) => { + self.message = format!("A2 refused: cannot archive exact protocol: {error}"); + return; + } + }; + self.run = Some(Run { + protocol: plan, + protocol_path: self.protocol_path.clone(), + protocol_sha256: hash, + protocol_archive_path, + measurement_id, + index: 0, + phase: Phase::ApplyCamera, + pending: None, + lease: LeaseId::new(format!("a2-{}", now_ms())), + run_id: String::new(), + deadline_ms: 0, + next_renew_ms: now_ms() + 30_000, + stop: false, + abort_reason: None, + mod_leased: false, + pd_leased: false, + camera_recording: false, + camera_stop_attempts: 0, + pd_recording: false, + modulation_active: false, + camera_session_active: true, + restore_attempts: 0, + camera_snapshot: None, + camera_provenance: None, + camera_readback_age_s: None, + pause_acknowledged: false, + last_event_bin_us: None, + last_event_bin_count: 0, + evidence: PointEvidence::default(), + }); + self.send_host( + control, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::NamedProfile { + name: self.run.as_ref().unwrap().protocol.camera.profile.clone(), + }, + }, + ); + } + + fn point(&self) -> Option<&Point> { + self.run + .as_ref()? + .protocol + .points + .get(self.run.as_ref()?.index) + } + + fn send_mod( + &mut self, + control: &mut impl Control, + command: ModulationCommandV1, + revision: bool, + ) { + let request_id = self.next_id(); + let (lease, run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.run_id.clone(), + self.modulation.as_ref().map(|s| s.owner_instance.clone()), + ) + }; + let mut e = ModulationRequestV1::new(RequestId(request_id), ClientId::new(ID), command); + e.lease_id = Some(lease); + e.target_owner_instance = owner; + e.issued_at_unix_ms = now_ms(); + if !run_id.is_empty() { + e.run_id = Some(RunId::new(run_id)); + } + if revision { + e.requested_revision = Some(self.next_revision()); + } + control.service(&PluginServiceRequest { + request_id, + source_plugin_id: ID.into(), + target_plugin_id: MOD_ID.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + payload: serde_json::to_value(e).unwrap(), + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Mod, request_id, now_ms())); + } + + fn send_pd( + &mut self, + control: &mut impl Control, + command: PhotodiodeCommandV1, + revision: bool, + ) { + let request_id = self.next_id(); + let (lease, run_id, owner) = { + let run = self.run.as_ref().unwrap(); + ( + run.lease.clone(), + run.run_id.clone(), + self.photodiode.as_ref().map(|s| s.owner_instance.clone()), + ) + }; + let mut e = PhotodiodeRequestV1::new(RequestId(request_id), ClientId::new(ID), command); + e.lease_id = Some(lease); + e.target_owner_instance = owner; + e.issued_at_unix_ms = now_ms(); + if !run_id.is_empty() { + e.run_id = Some(RunId::new(run_id)); + } + if revision { + e.requested_revision = Some(self.next_revision()); + } + control.service(&PluginServiceRequest { + request_id, + source_plugin_id: ID.into(), + target_plugin_id: PD_ID.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + payload: serde_json::to_value(e).unwrap(), + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Pd, request_id, now_ms())); + } + + fn send_host(&mut self, control: &mut impl Control, command: HostCommand) { + let id = self.next_id(); + control.host(&HostCommandRequest { + request_id: id, + command, + }); + self.run.as_mut().unwrap().pending = Some((PendingKind::Host, id, now_ms())); + } + + fn prepare(&mut self, control: &mut impl Control) { + let (p, c) = { + let r = self.run.as_ref().unwrap(); + ( + r.protocol.points[r.index].clone(), + r.protocol.controller.clone(), + ) + }; + if should_pause(&p, self.run.as_ref().unwrap().pause_acknowledged) { + self.run.as_mut().unwrap().phase = Phase::Paused; + self.message = format!("Paused before {}", p.label); + return; + } + let run = self.run.as_mut().unwrap(); + run.phase = Phase::Prepare; + run.run_id = format!( + "{}_r{:03}_{}", + run.measurement_id, + run.index + 1, + safe(&p.label) + ); + run.evidence = PointEvidence::default(); + run.last_event_bin_us = None; + run.last_event_bin_count = 0; + run.camera_stop_attempts = 0; + match p.acquisition { + Acquisition::Dark { .. } => self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 dark acquisition: force modulation safe/off".into(), + }, + true, + ), + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + let hz = 1.0 / (2.0 * half_period_s); + let cfg = A2AcquisitionConfigV1 { + mean_u_milli: (mean_u * 1000.0).round() as u32, + depth_a_milli: (depth_a * 1000.0).round() as u32, + frequency_millihz: (hz * 1000.0).round() as u64, + min_half_us: c.min_half_us, + v_null_dac: c.v_null_dac, + v_peak_dac: c.v_peak_dac, + comparator_threshold_dac, + comparator_hysteresis: c.comparator_hysteresis, + comparator_invert: c.comparator_invert, + sample_rate_hz: c.sample_rate_hz, + block_samples: c.block_samples, + emit_raw_samples: true, + emit_summary: true, + }; + self.run + .as_mut() + .unwrap() + .evidence + .expected_triggers_per_polarity = u64::from(transitions_per_polarity); + self.send_mod( + control, + ModulationCommandV1::PrepareA2 { configuration: cfg }, + true, + ); + } + } + } + + fn metadata(&self) -> BTreeMap { + let r = self.run.as_ref().unwrap(); + let p = &r.protocol.points[r.index]; + let mut m = BTreeMap::new(); + for (k, v) in [ + ("experiment", "A2".into()), + ("measurement_id", r.measurement_id.clone()), + ("protocol_path", r.protocol_path.clone()), + ("protocol_sha256", r.protocol_sha256.clone()), + ("protocol_name", r.protocol.name.clone()), + ("protocol_row", (r.index + 1).to_string()), + ("label", p.label.clone()), + ("role", p.role.clone()), + ("acquisition_mode", acquisition_mode(&p.acquisition).into()), + ("duration_s", p.acquisition_seconds().to_string()), + ("transfer_scope", r.protocol.optical.transfer_scope.clone()), + ( + "photodiode_placement", + r.protocol.optical.photodiode_placement.clone(), + ), + ( + "splitter_fraction_to_pd", + r.protocol.optical.splitter_fraction_to_pd.to_string(), + ), + ( + "optical_config_id", + r.protocol.optical.optical_config_id.clone(), + ), + ] { + m.insert(k.into(), v); + } + match p.acquisition { + Acquisition::Dark { duration_s } => { + m.insert("dark_duration_s".into(), duration_s.to_string()); + } + Acquisition::Stepped { + mean_u, + depth_a, + half_period_s, + transitions_per_polarity, + comparator_threshold_dac, + } => { + for (key, value) in [ + ("mean_u", mean_u.to_string()), + ("depth_a_commanded", depth_a.to_string()), + ("half_period_s", half_period_s.to_string()), + ( + "transitions_per_polarity", + transitions_per_polarity.to_string(), + ), + ( + "comparator_threshold_dac", + comparator_threshold_dac.to_string(), + ), + ] { + m.insert(key.into(), value); + } + } + } + m + } + + fn advance(&mut self, control: &mut impl Control) { + let done = { + let r = self.run.as_mut().unwrap(); + r.index += 1; + r.pause_acknowledged = false; + r.index >= r.protocol.points.len() || r.stop + }; + if done { + self.release_next(control); + } else { + self.prepare(control); + } + } + + fn release_next(&mut self, control: &mut impl Control) { + let Some(run) = self.run.as_ref() else { return }; + if run.pd_leased { + self.run.as_mut().unwrap().phase = Phase::ReleasePd; + self.send_pd( + control, + PhotodiodeCommandV1::ReleaseLease { + finalize_recording: true, + reason: "A2 cleanup".into(), + }, + false, + ); + } else if run.mod_leased { + self.run.as_mut().unwrap().phase = Phase::ReleaseMod; + self.send_mod( + control, + ModulationCommandV1::ReleaseLease { + safe_off: true, + reason: "A2 cleanup".into(), + }, + true, + ); + } else if run.camera_session_active { + if run.restore_attempts >= 3 { + self.message = format!( + "{}; camera restore was not confirmed after 3 attempts", + self.message + ); + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + return; + } + self.run.as_mut().unwrap().phase = Phase::RestoreCamera; + self.run.as_mut().unwrap().restore_attempts += 1; + self.send_host(control, HostCommand::RestoreCameraConfiguration); + } else { + self.finish_run(); + } + } + + fn stop_camera(&mut self, control: &mut impl Control) { + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StopCamera; + run.camera_stop_attempts = run.camera_stop_attempts.saturating_add(1); + self.send_host(control, HostCommand::StopRecording); + } + + fn finish_run(&mut self) { + let failed = self + .run + .as_ref() + .and_then(|run| run.abort_reason.as_ref()) + .is_some(); + if !failed { + self.message = "A2 protocol finished; inspect point sidecars and offline first-event distributions".into(); + } + self.run = None; + } + + fn fail(&mut self, control: &mut impl Control, reason: String) { + self.message = format!("A2 failed closed: {reason}"); + let Some(run) = self.run.as_mut() else { return }; + run.stop = true; + run.evidence.failure = Some(reason.clone()); + run.abort_reason = Some(reason); + run.pending = None; + if run.modulation_active { + run.phase = Phase::StopMod; + self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 abort".into(), + }, + true, + ); + } else if run.pd_recording { + run.phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else if run.camera_recording { + self.stop_camera(control); + } else { + self.release_next(control); + } + } + + fn drive(&mut self, control: &mut impl Control) { + if self.start_pending { + self.start_pending = false; + self.begin(control); + } + if self.stop_pending { + self.stop_pending = false; + if let Some(r) = self.run.as_mut() { + r.stop = true; + r.abort_reason = Some("operator stopped A2".into()); + } + } + if self.continue_pending { + self.continue_pending = false; + if self.run.as_ref().is_some_and(|r| r.phase == Phase::Paused) { + self.run.as_mut().unwrap().pause_acknowledged = true; + self.prepare(control); + } + } + let Some(run) = self.run.as_ref() else { return }; + if let Some((_, _, sent)) = run.pending { + if now_ms().saturating_sub(sent) > TIMEOUT_MS { + match run.phase { + Phase::ReleasePd => { + self.run.as_mut().unwrap().pd_leased = false; + self.run.as_mut().unwrap().pending = None; + self.release_next(control); + } + Phase::ReleaseMod => { + self.run.as_mut().unwrap().mod_leased = false; + self.run.as_mut().unwrap().pending = None; + self.release_next(control); + } + Phase::FinalizePd => { + let run = self.run.as_mut().unwrap(); + run.pending = None; + run.pd_recording = false; + run.evidence.failure = Some("photodiode finalize timed out".into()); + self.stop_camera(control); + } + Phase::StopCamera => { + self.run.as_mut().unwrap().pending = None; + if self.run.as_ref().unwrap().camera_stop_attempts < 3 { + self.stop_camera(control); + } else { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = + Some("camera stop timed out after 3 attempts".into()); + let _ = self.write_sidecar(); + self.release_next(control); + } + } + _ => self.fail(control, "owner/host reply timed out".into()), + } + } + return; + } + if run.stop + && !matches!( + run.phase, + Phase::StopMod + | Phase::FinalizePd + | Phase::StopCamera + | Phase::ReleasePd + | Phase::ReleaseMod + | Phase::RestoreCamera + ) + { + let reason = run + .abort_reason + .clone() + .unwrap_or_else(|| "A2 stopped".into()); + self.fail(control, reason); + return; + } + if !matches!( + run.phase, + Phase::AcquireMod | Phase::AcquirePd | Phase::ReleasePd | Phase::ReleaseMod + ) && now_ms() >= run.next_renew_ms + { + self.send_mod( + control, + ModulationCommandV1::RenewLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + return; + } + match run.phase { + Phase::Settle if now_ms() >= run.deadline_ms => { + let meta = self.metadata(); + let (run_id, base) = { + let r = self.run.as_ref().unwrap(); + ( + r.run_id.clone(), + format!("{}/{}.raw", r.measurement_id, r.run_id), + ) + }; + self.run.as_mut().unwrap().phase = Phase::StartCamera; + self.run.as_mut().unwrap().camera_recording = true; + self.send_host( + control, + HostCommand::StartRecording { + run_id, + base_path: base, + metadata: meta, + }, + ); + } + Phase::Recording if now_ms() >= run.deadline_ms || run.stop => { + if !starts_modulation(&self.point().unwrap().acquisition) { + self.run.as_mut().unwrap().phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Completed, + }, + true, + ); + } else { + self.run.as_mut().unwrap().phase = Phase::StopMod; + self.send_mod( + control, + ModulationCommandV1::StopAcquisition { + reason: "A2 point complete".into(), + }, + true, + ); + } + } + _ => {} + } + } + + fn accepted(&mut self, control: &mut impl Control, kind: PendingKind, payload: &Value) { + let phase = self.run.as_ref().unwrap().phase; + if kind == PendingKind::Mod { + if let Ok(response) = serde_json::from_value::(payload.clone()) { + if response.common.outcome == RequestOutcomeV1::InProgress { + self.run.as_mut().unwrap().pending = + Some((PendingKind::Mod, response.common.request_id.0, now_ms())); + return; + } + } + } + self.run.as_mut().unwrap().pending = None; + match (kind, phase) { + (PendingKind::Mod, Phase::AcquireMod) => { + let run = self.run.as_mut().unwrap(); + run.mod_leased = true; + run.phase = Phase::AcquirePd; + self.send_pd( + control, + PhotodiodeCommandV1::AcquireLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + } + (PendingKind::Pd, Phase::AcquirePd) => { + self.run.as_mut().unwrap().pd_leased = true; + self.prepare(control) + } + (PendingKind::Mod, Phase::Prepare) => { + let settle = (self.point().unwrap().settle_s * 1000.0) as u64; + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Settle; + r.deadline_ms = now_ms() + settle; + } + (PendingKind::Host, Phase::StartCamera) => { + let r = self.run.as_ref().unwrap(); + let spec = PdqStartSpecV1 { + pdq_path: format!("{}/{}.pdq", r.measurement_id, r.run_id), + sidecar_path: format!("{}/{}.pd.json", r.measurement_id, r.run_id), + expected_sample_rate_hz: Some(r.protocol.controller.sample_rate_hz), + expected_stream_epoch: self.photodiode.as_ref().map(|p| p.stream.stream_epoch), + metadata: self.metadata(), + root_dir: Some(self.output_folder.clone()), + }; + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StartPd; + run.pd_recording = true; + self.send_pd( + control, + PhotodiodeCommandV1::BeginRecording { + specification: spec, + }, + true, + ); + } + (PendingKind::Pd, Phase::StartPd) => { + if !starts_modulation(&self.point().unwrap().acquisition) { + let seconds = self.point().unwrap().acquisition_seconds(); + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Recording; + r.deadline_ms = now_ms() + (seconds * 1000.0) as u64; + } else { + let run = self.run.as_mut().unwrap(); + run.phase = Phase::StartMod; + run.modulation_active = true; + self.send_mod(control, ModulationCommandV1::StartAcquisition, true); + } + } + (PendingKind::Mod, Phase::StartMod) => { + let seconds = self.point().unwrap().acquisition_seconds(); + let r = self.run.as_mut().unwrap(); + r.phase = Phase::Recording; + r.deadline_ms = now_ms() + (seconds * 1000.0) as u64; + } + (PendingKind::Mod, Phase::StopMod) => { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.phase = Phase::FinalizePd; + let termination = if run.abort_reason.is_some() { + PdqTerminationV1::Aborted + } else { + PdqTerminationV1::Completed + }; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { termination }, + true, + ); + } + (PendingKind::Pd, Phase::FinalizePd) => { + let finalized = serde_json::from_value::(payload.clone()) + .ok() + .and_then(|response| match response.receipt { + Some(PdqReceiptV1::Finalized(receipt)) => Some(receipt), + _ => None, + }); + let e = &mut self.run.as_mut().unwrap().evidence; + if let Some(receipt) = finalized { + e.pdq_path = Some(receipt.pdq_path); + e.pdq_sha256 = Some(receipt.sha256.to_string()); + if !receipt.valid { + e.failure = Some("PDQ receipt invalid".into()); + } + } else { + e.failure = Some("photodiode owner returned no finalized PDQ receipt".into()); + } + self.run.as_mut().unwrap().pd_recording = false; + self.stop_camera(control); + } + (PendingKind::Pd, Phase::ReleasePd) => { + self.run.as_mut().unwrap().pd_leased = false; + self.release_next(control); + } + (PendingKind::Mod, Phase::ReleaseMod) => { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(control); + } + (PendingKind::Mod, _) => self.send_pd( + control, + PhotodiodeCommandV1::RenewLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ), + (PendingKind::Pd, _) => self.run.as_mut().unwrap().next_renew_ms = now_ms() + 30_000, + _ => {} + } + } + + fn snapshots(&mut self, inbox: &PluginControlInbox) { + for s in &inbox.snapshots { + match (s.plugin_id.as_str(), s.topic.as_str()) { + (MOD_ID, CTX_STAGE_A_MODULATION_STATE_V1) => { + if let Ok(v) = serde_json::from_value(s.payload.clone()) { + self.modulation = Some(v) + } + } + (PD_ID, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1) => { + if let Ok(v) = serde_json::from_value(s.payload.clone()) { + self.photodiode = Some(v) + } + } + _ => {} + } + } + } + + fn finish_async_mod(&mut self, control: &mut impl Control) { + let Some((PendingKind::Mod, request_id, _)) = self.run.as_ref().and_then(|r| r.pending) + else { + return; + }; + let Some(response) = self + .modulation + .as_ref() + .and_then(|s| s.last_response.as_ref()) + else { + return; + }; + if response.common.request_id.0 != request_id + || response.common.outcome == RequestOutcomeV1::InProgress + { + return; + } + if response.common.outcome == RequestOutcomeV1::Rejected { + let phase = self.run.as_ref().unwrap().phase; + if phase == Phase::ReleaseMod { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(control); + } else if phase == Phase::StopMod { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.phase = Phase::FinalizePd; + self.send_pd( + control, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else { + self.fail( + control, + response + .common + .error + .as_ref() + .map(|e| e.message.clone()) + .unwrap_or_else(|| "modulation owner rejected A2".into()), + ); + } + return; + } + let payload = serde_json::to_value(response).unwrap_or(Value::Null); + self.accepted(control, PendingKind::Mod, &payload); + } + + fn finish_point(&mut self, control: &mut impl Control, outcome: &HostCommandOutcome) { + if let HostCommandOutcome::RecordingFinalized { + actual_raw_path, + size: _, + sha256, + duration_us: _, + } = outcome + { + let acquisition = { + let run = self.run.as_ref().unwrap(); + run.protocol.points[run.index].acquisition.clone() + }; + let e = &mut self.run.as_mut().unwrap().evidence; + e.raw_path = Some(actual_raw_path.clone()); + e.raw_sha256 = Some(sha256.clone()); + let trigger_problem = + validate_trigger_counts(&acquisition, e.rising_triggers, e.falling_triggers).err(); + if e.failure.is_none() { + e.failure = trigger_problem; + } + e.valid = e.failure.is_none(); + self.run.as_mut().unwrap().camera_recording = false; + } else if let HostCommandOutcome::RecordingPartial { reason, .. } = outcome { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = Some(format!("RAW finalized partially: {reason}")); + } else if self.run.as_ref().unwrap().camera_stop_attempts < 3 { + self.stop_camera(control); + return; + } else { + let run = self.run.as_mut().unwrap(); + run.camera_recording = false; + run.evidence.failure = Some(format!( + "camera stop was not confirmed after 3 attempts: {outcome:?}" + )); + } + let _ = self.write_sidecar(); + if self + .run + .as_ref() + .is_some_and(|run| run.abort_reason.is_some()) + { + self.release_next(control); + } else { + self.advance(control); + } + } + + fn host_reply( + &mut self, + control: &mut impl Control, + request_id: u64, + outcome: HostCommandOutcome, + ) { + let expected = self.run.as_ref().and_then(|run| run.pending); + if expected.is_none_or(|(kind, id, _)| kind != PendingKind::Host || id != request_id) { + return; + } + let phase = self.run.as_ref().unwrap().phase; + self.run.as_mut().unwrap().pending = None; + if phase == Phase::ApplyCamera { + match outcome { + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, + readback: _, + readback_age_s, + } => { + let requested = self.run.as_ref().unwrap().protocol.camera.profile.clone(); + let refusal = camera_configuration_refusal( + &snapshot, + &provenance, + &requested, + readback_age_s, + ); + let run = self.run.as_mut().unwrap(); + run.camera_snapshot = Some(snapshot); + run.camera_provenance = Some(provenance); + run.camera_readback_age_s = Some(readback_age_s); + if let Some(reason) = refusal { + self.fail(control, reason); + } else { + run.phase = Phase::AcquireMod; + self.send_mod( + control, + ModulationCommandV1::AcquireLease { + ttl_ms: LEASE_TTL_MS, + }, + false, + ); + } + } + outcome => self.fail( + control, + format!("camera profile was not applied and confirmed: {outcome:?}"), + ), + } + } else if phase == Phase::RestoreCamera { + if matches!( + outcome, + HostCommandOutcome::CameraConfigurationRestored { .. } + ) { + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + } else if self.run.as_ref().unwrap().restore_attempts < 3 { + self.run.as_mut().unwrap().restore_attempts += 1; + self.send_host(control, HostCommand::RestoreCameraConfiguration); + } else { + self.message = format!( + "{}; camera restore was not confirmed after 3 attempts: {:?}", + self.message, outcome + ); + self.run.as_mut().unwrap().camera_session_active = false; + self.finish_run(); + } + } else if phase == Phase::StartCamera { + match outcome { + HostCommandOutcome::RecordingStarted { + actual_raw_path, .. + } => { + let evidence = &mut self.run.as_mut().unwrap().evidence; + evidence.camera_configuration_sidecar_path = + Some(camera_sidecar_path(&actual_raw_path)); + evidence.sensor_monitoring_path = + Some(sensor_monitoring_path(&actual_raw_path)); + evidence.raw_path = Some(actual_raw_path); + self.accepted(control, PendingKind::Host, &Value::Null) + } + outcome => self.fail(control, format!("camera start failed: {outcome:?}")), + } + } else if phase == Phase::StopCamera { + self.finish_point(control, &outcome) + } + } + + fn write_sidecar(&self) -> Result<(), String> { + let r = self.run.as_ref().unwrap(); + let dir = Path::new(&self.output_folder).join(&r.measurement_id); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + #[derive(Serialize)] + struct Side<'a> { + schema_version: u32, + experiment: &'static str, + protocol_path: &'a str, + protocol_sha256: &'a str, + protocol_archive_path: &'a str, + protocol_name: &'a str, + protocol_row: usize, + camera_profile: &'a str, + camera_provenance: Option<&'a CameraConfigurationProvenanceV1>, + camera_readback_age_s: Option, + optical: &'a protocol::OpticalSetup, + gates: &'a protocol::Gates, + controller: &'a protocol::ControllerSetup, + point: &'a Point, + evidence: &'a PointEvidence, + sensor_snapshot: Option, + } + #[derive(Serialize)] + struct DynamicSensorMonitoring { + pixel_dead_time_us: Option, + illumination_lux: Option, + temperature_c: Option, + age_s: f64, + } + let s = Side { + schema_version: 1, + experiment: "A2", + protocol_path: &r.protocol_path, + protocol_sha256: &r.protocol_sha256, + protocol_archive_path: &r.protocol_archive_path, + protocol_name: &r.protocol.name, + protocol_row: r.index + 1, + camera_profile: &r.protocol.camera.profile, + camera_provenance: r.camera_provenance.as_ref(), + camera_readback_age_s: r.camera_readback_age_s, + optical: &r.protocol.optical, + gates: &r.protocol.gates, + controller: &r.protocol.controller, + point: &r.protocol.points[r.index], + evidence: &r.evidence, + sensor_snapshot: self.sensor.map(|sensor| DynamicSensorMonitoring { + pixel_dead_time_us: sensor.pixel_dead_time_us, + illumination_lux: sensor.illumination_lux, + temperature_c: sensor.temperature_c, + age_s: sensor.age_s, + }), + }; + let bytes = serde_json::to_vec_pretty(&s).map_err(|e| e.to_string())?; + std::fs::write(dir.join(format!("{}.a2.json", r.run_id)), bytes).map_err(|e| e.to_string()) + } +} + +impl Plugin for StageAA2Plugin { + fn name(&self) -> &'static str { + "Stage-A A2 Latency" + } + fn description(&self) -> &'static str { + "Runs qualified optical-step latency protocols; fitting stays offline." + } + fn enabled(&self) -> bool { + self.enabled + } + fn set_enabled(&mut self, v: bool) { + self.enabled = v + } + fn set_runtime_role(&mut self, r: PluginRuntimeRole) { + self.role = r + } + fn reset(&mut self) { + self.run = None; + } + fn on_discontinuity(&mut self, _: PluginDiscontinuity) {} + fn input_kind(&self) -> PluginInput { + PluginInput::RawEvents + } + fn capabilities(&self) -> PluginCapabilities { + PluginCapabilities::default() + } + fn process_frame( + &mut self, + frame: &PluginFrame<'_>, + _: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _: &EventStoreHandle<'_>, + ) { + if let Ok(Some(v)) = context.get::(CTX_GLOBAL_SETTINGS) { + self.settings = Some(v); + } + if let Ok(Some(v)) = context.get::(CTX_SENSOR_MONITORING) { + self.sensor = Some(v); + } + if self + .run + .as_ref() + .is_some_and(|r| r.phase == Phase::Recording) + { + let r = self.run.as_mut().unwrap(); + for e in frame.events() { + let bin = e.t_us.max(0) as u64; + if r.last_event_bin_us == Some(bin) { + r.last_event_bin_count += 1; + } else { + r.last_event_bin_us = Some(bin); + r.last_event_bin_count = 1; + } + r.evidence.peak_events_per_us = + r.evidence.peak_events_per_us.max(r.last_event_bin_count); + } + for t in frame.external_triggers() { + if t.is_rising() { + r.evidence.rising_triggers += 1 + } else { + r.evidence.falling_triggers += 1 + } + } + if r.evidence.peak_events_per_us > r.protocol.gates.recorder_safety_limit_events_per_us + { + r.stop = true; + r.evidence.failure = Some(format!( + "pre-qualified recorder safety limit exceeded: {} events/us", + r.evidence.peak_events_per_us + )); + } + } + } + fn process_control(&mut self, c: &mut PluginControlContext<'_>) { + let inbox = c.inbox().clone(); + self.snapshots(&inbox); + self.finish_async_mod(c); + for reply in inbox.service_replies { + let expected = self.run.as_ref().and_then(|r| r.pending); + if expected.is_none_or(|(_, id, _)| id != reply.request_id) { + continue; + } + match reply.outcome { + PluginServiceOutcome::Accepted { payload } => { + self.accepted(c, expected.unwrap().0, &payload) + } + PluginServiceOutcome::Rejected { code, message } => { + let phase = self.run.as_ref().unwrap().phase; + if phase == Phase::ReleasePd { + self.run.as_mut().unwrap().pd_leased = false; + self.release_next(c); + } else if phase == Phase::ReleaseMod { + self.run.as_mut().unwrap().mod_leased = false; + self.release_next(c); + } else if phase == Phase::FinalizePd { + let run = self.run.as_mut().unwrap(); + run.pd_recording = false; + run.evidence.failure = Some(format!("{code}: {message}")); + self.stop_camera(c); + } else if phase == Phase::StopMod { + let run = self.run.as_mut().unwrap(); + run.modulation_active = false; + run.evidence.failure = Some(format!("{code}: {message}")); + run.phase = Phase::FinalizePd; + self.send_pd( + c, + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Aborted, + }, + true, + ); + } else { + self.fail(c, format!("{code}: {message}")) + } + } + } + } + for reply in inbox.host_replies { + self.host_reply(c, reply.request_id, reply.outcome); + } + self.drive(c); + } + fn settings_schema(&self) -> SettingsSchema { + SettingsSchema{sections:vec![SettingsSection{label:"A2 protocol".into(),description:Some("The file is validated completely before any owner lease or camera recording starts.".into()),default_open:true,items:vec![SettingItem{key:"output_folder".into(),label:"Output folder".into(),tooltip:None,kind:SettingKind::Path{dialog:PathDialogKind::Directory,default:self.output_folder.clone()}},SettingItem{key:"measurement_id".into(),label:"Measurement id".into(),tooltip:None,kind:SettingKind::Text{default:self.measurement_id.clone()}},SettingItem{key:"protocol_path".into(),label:"Protocol".into(),tooltip:None,kind:SettingKind::Path{dialog:PathDialogKind::OpenFile,default:self.protocol_path.clone()}},SettingItem{key:"run_protocol".into(),label:"Run protocol".into(),tooltip:None,kind:SettingKind::Button{enabled:true}},SettingItem{key:"continue_run".into(),label:"Continue".into(),tooltip:None,kind:SettingKind::Button{enabled:true}},SettingItem{key:"stop_protocol".into(),label:"Stop".into(),tooltip:None,kind:SettingKind::Button{enabled:true}}]}]} + } + fn get_setting(&self, k: &str) -> Option { + match k { + "output_folder" => Some(json!(self.output_folder)), + "measurement_id" => Some(json!(self.measurement_id)), + "protocol_path" => Some(json!(self.protocol_path)), + "run_protocol" => Some(json!(self.start.value)), + "continue_run" => Some(json!(self.continue_press.value)), + "stop_protocol" => Some(json!(self.stop.value)), + _ => None, + } + } + fn set_setting(&mut self, k: &str, v: Value) -> Result<(), String> { + match k { + "output_folder" => self.output_folder = v.as_str().ok_or("string required")?.into(), + "measurement_id" => self.measurement_id = v.as_str().ok_or("string required")?.into(), + "protocol_path" => self.protocol_path = v.as_str().ok_or("string required")?.into(), + "run_protocol" => { + if self.start.accept(&v) { + self.start_pending = true + } + } + "continue_run" => { + if self.continue_press.accept(&v) { + self.continue_pending = true + } + } + "stop_protocol" => { + if self.stop.accept(&v) { + self.stop_pending = true + } + } + _ => return Err(format!("unknown setting {k}")), + } + Ok(()) + } + fn status_entries(&self) -> Vec { + let mut v = vec![StatusEntry::Text(self.message.clone())]; + if let Some(r) = &self.run { + v.push(StatusEntry::Text(format!( + "{}: point {}/{} ({:?})", + r.protocol.name, + r.index + 1, + r.protocol.points.len(), + r.phase + ))); + } else if let Some(b) = self.blocker() { + v.push(StatusEntry::Text(format!("Not ready: {b}"))); + } + v + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} +fn compact_time() -> String { + now_ms().to_string() +} +fn safe(v: &str) -> String { + v.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} +fn hex_hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn archive_protocol( + output_folder: &str, + measurement_id: &str, + sha256: &str, + bytes: &[u8], +) -> Result { + let directory = Path::new(output_folder).join(measurement_id); + std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?; + let path = directory.join(format!("protocol-{sha256}.toml")); + if path.exists() { + let existing = std::fs::read(&path).map_err(|error| error.to_string())?; + if existing != bytes { + return Err("content-addressed protocol archive has different bytes".into()); + } + } else { + std::fs::write(&path, bytes).map_err(|error| error.to_string())?; + } + Ok(path.to_string_lossy().into_owned()) +} + +fn camera_sidecar_path(raw_path: &str) -> String { + Path::new(raw_path) + .with_extension("toml") + .to_string_lossy() + .into_owned() +} + +fn sensor_monitoring_path(raw_path: &str) -> String { + let path = Path::new(raw_path); + let stem = path.file_stem().unwrap_or_default().to_string_lossy(); + path.parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{stem}.sensor-monitoring.csv")) + .to_string_lossy() + .into_owned() +} + +fn acquisition_mode(acquisition: &Acquisition) -> &'static str { + match acquisition { + Acquisition::Dark { .. } => "dark", + Acquisition::Stepped { .. } => "stepped", + } +} + +fn starts_modulation(acquisition: &Acquisition) -> bool { + matches!(acquisition, Acquisition::Stepped { .. }) +} + +fn should_pause(point: &Point, acknowledged: bool) -> bool { + point.pause_before && !acknowledged +} + +fn validate_trigger_counts( + acquisition: &Acquisition, + rising: u64, + falling: u64, +) -> Result<(), String> { + let Acquisition::Stepped { + transitions_per_polarity, + .. + } = acquisition + else { + return Ok(()); + }; + let expected = u64::from(*transitions_per_polarity); + let minimum = expected.saturating_sub(1); + let maximum = expected.saturating_add(1); + if !(minimum..=maximum).contains(&rising) || !(minimum..=maximum).contains(&falling) { + return Err(format!( + "external-trigger count implausible: expected {expected} +/- 1 per polarity, observed rising={rising}, falling={falling}" + )); + } + Ok(()) +} + +fn camera_configuration_refusal( + snapshot: &CameraConfigurationSnapshotV1, + provenance: &CameraConfigurationProvenanceV1, + requested_profile: &str, + readback_age_s: f64, +) -> Option { + if provenance.profile_name.as_deref() != Some(requested_profile) { + return Some(format!( + "host confirmed camera profile {:?}, expected {requested_profile:?}", + provenance.profile_name + )); + } + if snapshot.digital_filter.stc_enabled + || snapshot.digital_filter.trail_enabled + || snapshot.digital_filter.erc_enabled != Some(false) + { + return Some( + "applied camera profile must explicitly confirm STC, Trail and ERC off".into(), + ); + } + if !snapshot.external_trigger.enabled { + return Some("applied camera profile has EXT_TRIGGER disabled".into()); + } + if !snapshot.global.record_sensor_telemetry { + return Some("applied camera profile does not record sensor telemetry".into()); + } + if !readback_age_s.is_finite() || readback_age_s < 0.0 { + return Some("host returned an invalid sensor readback age".into()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct MockControl { + services: Vec, + hosts: Vec, + } + + impl Control for MockControl { + fn service(&mut self, request: &PluginServiceRequest) { + self.services.push(request.clone()); + } + fn host(&mut self, request: &HostCommandRequest) { + self.hosts.push(request.clone()); + } + } + + fn test_protocol() -> &'static str { + r#" +name="a2-e2e" +[camera] +profile="A2 qualified" +[optical] +transfer_scope="fluorescence_chain" +photodiode_placement="emission_path" +splitter_fraction_to_pd=0.5 +optical_config_id="opt-1" +[gates] +firmware_a2_confirmed=true +comparator_self_test_passed=true +camera_external_trigger_confirmed=true +h4_loopback_id="h4-1" +h5_polarity_calibration_id="h5-1" +optical_edge_calibration_id="edge-1" +local_flux_calibration_id="flux-1" +recorder_safety_limit_events_per_us=1000 +[controller] +v_null_dac=100 +v_peak_dac=1000 +comparator_hysteresis=1 +comparator_invert=false +min_half_us=1000 +sample_rate_hz=500000 +block_samples=256 +[[point]] +label="dark" +role="floor" +acquisition_mode="dark" +duration_s=0.001 +settle_s=0 +pause_before=true +[[point]] +label="step" +role="identification" +acquisition_mode="stepped" +mean_u=0.3 +depth_a=0.45 +half_period_s=0.001 +transitions_per_polarity=2 +comparator_threshold_dac=500 +settle_s=0 +"# + } + + fn test_folder(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("stage-a-a2-{label}-{}", now_ms())) + } + + fn ready_plugin(label: &str) -> StageAA2Plugin { + use stage_a_plugin_contract::{ + ControllerStateV1, FreshnessV1, OwnerInstanceId, PhotodiodeStreamV1, StreamIntegrityV1, + SynchronizationV1, UnsyncedReasonV1, CONTRACT_VERSION_V1, + }; + let folder = test_folder(label); + std::fs::create_dir_all(&folder).unwrap(); + let protocol_path = folder.join("protocol.toml"); + std::fs::write(&protocol_path, test_protocol()).unwrap(); + let settings = GlobalSettings { + nm_per_pixel: 1.0, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_bytes: 1 << 20, + record_sensor_telemetry: true, + roi: augur_plugin_api::RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + event_filters: augur_plugin_api::EventFiltersV1::default(), + }; + let sensor = SensorMonitoringV1 { + pixel_dead_time_us: Some(10.0), + illumination_lux: Some(0.1), + temperature_c: Some(25.0), + bias_codes: None, + age_s: 0.1, + }; + let modulation = ModulationStateV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("mod-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("test".into()), + }, + capabilities: vec![], + lease: None, + controller_state: ControllerStateV1::Configured, + active_run_id: None, + requested: None, + acknowledged: None, + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_ms(), + valid_for_ms: 60_000, + }, + calibration_id: Some("lobe-1".into()), + optical_drive: None, + }; + let photodiode = PhotodiodeSummaryV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::new("pd-test"), + service_revision: 1, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("test".into()), + }, + lease: None, + active_run_id: None, + requested_revision: None, + acknowledged_revision: None, + stream: PhotodiodeStreamV1 { + stream_epoch: 1, + sample_range: None, + sample_rate_hz: Some(500_000), + latest_adc_code: Some(1000), + integrity: StreamIntegrityV1::default(), + level: None, + }, + data_dir: Some(folder.to_string_lossy().into_owned()), + active_recording: None, + last_finalized_recording: None, + optical_summary: None, + optical_unavailable: None, + placement: PhotodiodePlacementV1::EmissionPath, + splitter_fraction: Some(0.5), + dark_reference: None, + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: now_ms(), + valid_for_ms: 60_000, + }, + }; + StageAA2Plugin { + output_folder: folder.to_string_lossy().into_owned(), + protocol_path: protocol_path.to_string_lossy().into_owned(), + measurement_id: format!("A2-{label}"), + settings: Some(settings), + sensor: Some(sensor), + modulation: Some(modulation), + photodiode: Some(photodiode), + ..StageAA2Plugin::default() + } + } + + fn qualified_camera() -> ( + CameraConfigurationSnapshotV1, + CameraConfigurationProvenanceV1, + ) { + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraDigitalFilterV1, CameraExternalTriggerV1, + CameraGlobalSettingsV1, RoiV1, + }; + ( + CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1::default(), + roi: RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 0, + trail_enabled: false, + erc_enabled: Some(false), + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 0, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 1.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_mib: 512, + preview_interval_ms: 16, + point_cloud_interval_ms: 50, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + }, + CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A2 qualified".into()), + schema_version: 1, + profile_revision: Some(1), + sha256: "ab".repeat(32), + }, + ) + } + + fn applied_camera_outcome() -> HostCommandOutcome { + let (snapshot, provenance) = qualified_camera(); + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance, + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + } + } + + fn finalized_pd_payload(request_id: u64, run_id: &str) -> Value { + use stage_a_plugin_contract::{ + OwnerInstanceId, PdqFinalizedReceiptV1, ResponseCommonV1, Sha256V1, StreamIntegrityV1, + CONTRACT_VERSION_V1, + }; + serde_json::to_value(PhotodiodeResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: RequestId(request_id), + owner_instance: OwnerInstanceId::new("pd-test"), + run_id: Some(RunId::new(run_id)), + requested_revision: None, + acknowledged_revision: None, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_ms()), + error: None, + }, + receipt: Some(PdqReceiptV1::Finalized(PdqFinalizedReceiptV1 { + run_id: RunId::new(run_id), + pdq_path: format!("{run_id}.pdq"), + sidecar_path: format!("{run_id}.pd.json"), + opened_at_unix_ms: now_ms(), + finalized_at_unix_ms: now_ms(), + file_size_bytes: 1, + sha256: Sha256V1::parse("cd".repeat(32)).unwrap(), + frames_written: 1, + sample_frames_written: 1, + sample_range: None, + sample_rate_hz: Some(500_000), + segment_count: 1, + integrity: StreamIntegrityV1::default(), + termination: PdqTerminationV1::Completed, + valid: true, + })), + }) + .unwrap() + } + + fn paused_point() -> Point { + Point { + label: "shutter".into(), + role: "dark".into(), + settle_s: 0.0, + pause_before: true, + acquisition: Acquisition::Dark { duration_s: 30.0 }, + } + } + + #[test] + fn continue_acknowledges_a_pause_once_for_the_current_point() { + let point = paused_point(); + assert!(should_pause(&point, false)); + assert!(!should_pause(&point, true)); + } + + #[test] + fn dark_points_do_not_require_external_triggers() { + assert!(validate_trigger_counts(&paused_point().acquisition, 0, 0).is_ok()); + assert!(!starts_modulation(&paused_point().acquisition)); + } + + #[test] + fn stepped_trigger_counts_must_match_the_commanded_count() { + let acquisition = Acquisition::Stepped { + mean_u: 0.3, + depth_a: 0.45, + half_period_s: 1.0, + transitions_per_polarity: 100, + comparator_threshold_dac: 500, + }; + assert!(validate_trigger_counts(&acquisition, 100, 99).is_ok()); + assert!(validate_trigger_counts(&acquisition, 2, 2).is_err()); + } + + #[test] + fn camera_profile_requires_explicit_erc_off() { + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraDigitalFilterV1, CameraExternalTriggerV1, + CameraGlobalSettingsV1, RoiV1, + }; + let mut snapshot = CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1::default(), + roi: RoiV1 { + x: 0, + y: 0, + width: 1280, + height: 720, + }, + masked_pixels: vec![], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 0, + trail_enabled: false, + erc_enabled: None, + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 0, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 1.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 1, + event_store_budget_mib: 512, + preview_interval_ms: 16, + point_cloud_interval_ms: 50, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + }; + let provenance = CameraConfigurationProvenanceV1 { + source: "named_profile".into(), + profile_name: Some("A2 qualified".into()), + schema_version: 1, + profile_revision: Some(1), + sha256: "ab".repeat(32), + }; + assert!( + camera_configuration_refusal(&snapshot, &provenance, "A2 qualified", 0.1) + .unwrap() + .contains("ERC") + ); + snapshot.digital_filter.erc_enabled = Some(false); + assert!( + camera_configuration_refusal(&snapshot, &provenance, "A2 qualified", 0.1).is_none() + ); + } + + #[test] + fn end_to_end_runs_paused_dark_then_stepped_and_restores_camera() { + let mut plugin = ready_plugin("e2e-success"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::ApplyCameraConfiguration { .. } + )); + let apply_id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply_id, applied_camera_outcome()); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::AcquireMod); + + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Paused); + plugin.continue_pending = true; + plugin.drive(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Prepare); + let dark_prepare: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + dark_prepare.command, + ModulationCommandV1::StopAcquisition { .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + let camera_start = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + camera_start, + HostCommandOutcome::RecordingStarted { + actual_raw_path: "/tmp/a2-dark.raw".into(), + started_at: "now".into(), + }, + ); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Recording); + assert!(!plugin.run.as_ref().unwrap().modulation_active); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::FinalizePd); + let pd_id = plugin.run.as_ref().unwrap().pending.unwrap().1; + let run_id = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.accepted( + &mut control, + PendingKind::Pd, + &finalized_pd_payload(pd_id, &run_id), + ); + let stop_camera_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + stop_camera_id, + HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/tmp/a2-dark.raw".into(), + size: 1, + sha256: "ef".repeat(32), + duration_us: 1_000, + }, + ); + + assert_eq!(plugin.run.as_ref().unwrap().index, 1); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::Prepare); + let stepped_prepare: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + stepped_prepare.command, + ModulationCommandV1::PrepareA2 { .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + plugin.run.as_mut().unwrap().deadline_ms = 0; + plugin.drive(&mut control); + let camera_start = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + camera_start, + HostCommandOutcome::RecordingStarted { + actual_raw_path: "/tmp/a2-step.raw".into(), + started_at: "now".into(), + }, + ); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::StartMod); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + { + let run = plugin.run.as_mut().unwrap(); + run.evidence.rising_triggers = 2; + run.evidence.falling_triggers = 2; + run.deadline_ms = 0; + } + plugin.drive(&mut control); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + let pd_id = plugin.run.as_ref().unwrap().pending.unwrap().1; + let run_id = plugin.run.as_ref().unwrap().run_id.clone(); + plugin.accepted( + &mut control, + PendingKind::Pd, + &finalized_pd_payload(pd_id, &run_id), + ); + let stop_camera_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + stop_camera_id, + HostCommandOutcome::RecordingFinalized { + actual_raw_path: "/tmp/a2-step.raw".into(), + size: 1, + sha256: "12".repeat(32), + duration_us: 4_000, + }, + ); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleasePd); + plugin.accepted(&mut control, PendingKind::Pd, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleaseMod); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::RestoreCamera); + let restore_id = control.hosts.last().unwrap().request_id; + assert!(matches!( + control.hosts.last().unwrap().command, + HostCommand::RestoreCameraConfiguration + )); + plugin.host_reply( + &mut control, + restore_id, + HostCommandOutcome::CameraConfigurationRestored { + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + }, + ); + assert!(plugin.run.is_none()); + } + + #[test] + fn failure_before_pd_lease_releases_owned_resources_then_restores_camera() { + let mut plugin = ready_plugin("e2e-failure"); + let mut control = MockControl::default(); + plugin.begin(&mut control); + let apply_id = control.hosts.last().unwrap().request_id; + plugin.host_reply(&mut control, apply_id, applied_camera_outcome()); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert!(plugin.run.as_ref().unwrap().mod_leased); + assert!(!plugin.run.as_ref().unwrap().pd_leased); + + plugin.fail(&mut control, "photodiode lease rejected".into()); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::ReleaseMod); + let release: ModulationRequestV1 = + serde_json::from_value(control.services.last().unwrap().payload.clone()).unwrap(); + assert!(matches!( + release.command, + ModulationCommandV1::ReleaseLease { safe_off: true, .. } + )); + plugin.accepted(&mut control, PendingKind::Mod, &Value::Null); + assert_eq!(plugin.run.as_ref().unwrap().phase, Phase::RestoreCamera); + let restore_id = control.hosts.last().unwrap().request_id; + plugin.host_reply( + &mut control, + restore_id, + HostCommandOutcome::CameraConfigurationRestored { + readback: augur_plugin_api::SensorBiasReadbackV1::default(), + readback_age_s: 0.1, + }, + ); + assert!(plugin.run.is_none()); + assert!(plugin.message.contains("failed closed")); + } +} + +export_plugin!(StageAA2Plugin); diff --git a/plugins/stage-a-a4/Cargo.toml b/plugins/stage-a-a4/Cargo.toml new file mode 100644 index 0000000..bcefe04 --- /dev/null +++ b/plugins/stage-a-a4/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "augur-plugin-stage-a-a4" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A A4 contrast-threshold survey: bias points recorded unattended against a sensor readback" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde.workspace = true +serde_json.workspace = true +# The same digest the host reports for a RAW file, so the protocol copy carries +# a hash that means the same thing as every other hash in the folder. +sha2 = "0.10" +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[lints.rust] +unsafe_code = "forbid" diff --git a/plugins/stage-a-a4/README.md b/plugins/stage-a-a4/README.md new file mode 100644 index 0000000..ff165e1 --- /dev/null +++ b/plugins/stage-a-a4/README.md @@ -0,0 +1,140 @@ +# Stage-A A4 — contrast-threshold survey + +Reproducible `diff_on`/`diff_off` threshold measurements on the IMX636. At one +fixed optical condition, A4 walks a protocol of bias pairs and records a RAW +file at each, with enough provenance to read an event rate against a threshold +setting months later. + +- **Crate:** `augur-plugin-stage-a-a4` · **id:** `stage-a.a4` · **phase:** `raw_events` +- **Host commands:** `start_recording`, `stop_recording`, + `apply_camera_configuration`, `restore_camera_configuration` +- **Requires:** augur-rs with the generic camera-configuration session + (augur-rs ADR 037) + +## What it changes, and what it does not + +A4 changes **two registers**: `diff_on` and `diff_off`. The host offers one +generic verb that carries a whole configuration — there is no A4-specific +command — so the freeze on `fo`, `hpf`, `refr`, the ROI and the pixel mask is +kept by A4 itself: it opens the run by asking the host to confirm the +configuration the bench is on, and every point is that confirmed snapshot with +exactly two fields changed. A test asserts the equality field by field. + +They are recorded with every point exactly as A4 found them. + +The optical condition is yours. A4 never drives the Teensy and never touches a +filter; a row that needs one says `pause_before` and waits for a button. + +## Per point + +1. Clone the configuration the session confirmed, set its `diff_on`/`diff_off`, + and send it back as `ApplyCameraConfiguration`. +2. **Confirm against the sensor's own readback** that the absolute codes on the + die are `factory_default + offset`. A point whose codes disagree, or whose + confirming reading is missing or older than the change, is skipped — it is + not measuring what the protocol says it measures. +3. Settle for `settle_s`, *and* wait for a monitoring sample newer than the + settle. A settle that produced no fresh telemetry is not a settle. +4. Record for `duration_s`, counting ON/OFF events. +5. Check the receipt — size, hash, duration, clean finalization — and write the + sidecar. A partial or truncated file is never counted as recorded. + +On completion, on Stop, and on any abort, the configuration the bench was on +before the survey is put back — the host preserved it when the session opened, +so `RestoreCameraConfiguration` returns the whole state, not only the two +biases. The run does not close until that restore is answered. + +## Refusals vs flags + +The split is deliberate. + +**Hard refusals** (nothing runs, or the point is skipped) are the things that +make a threshold number mean anything at all: + +- STC, Trail or ERC enabled — they discard events before streaming, which is + the quantity being counted +- no bias readback available — the method's central claim would be uncheckable +- bias codes that disagree with the row, or a stale confirming reading +- no output folder, an unreadable or invalid protocol +- a partial, empty, unhashed or truncated recording + +**Flags** (the point is recorded and kept, and marked) are the bench-stability +limits: `max_temperature_drift_c`, `max_illumination_drift_percent`, +`max_event_rate`. Whether a 2 °C drift invalidated a point is a judgement to +make later with the file in hand — a runner that discarded it would have thrown +away the evidence for making it. + +A limit whose quantity could never be measured is flagged too, not passed: a +camera with no temperature readback must not silently report every point as +within a drift limit nobody checked. + +## Protocols + +`protocols/` ships three worked examples, all parsed as test fixtures. + +**CSV — one row per recording.** Only `diff_on` and `diff_off` are required; +columns are found by header name, so their order does not matter. + +```csv +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +threshold-01,LP647+BP700,-20,-10,60,5,2 +threshold-02,LP647+BP700,0,0,60,5,2 +``` + +Optional: `pause_before`, `max_temperature_drift_c`, +`max_illumination_drift_percent`, `max_event_rate`, `filter_id`, `flux_id`. + +**TOML — blocks and ranges.** A block expands to the **product** of its two +axes, which is the 2D threshold map. A symmetric sweep is a set of specific +pairs, not a product, so it belongs in the CSV form. + +```toml +[defaults] +duration_s = 60 +settle_s = 5 + +[[block]] +diff_on = { min = -20, max = 20, step = 10 } +diff_off = 0 +``` + +Bias values are **offsets** around the per-unit factory trim — the same numbers +the host settings panel shows. The absolute codes come from the sensor. + +Everything checkable is checked on the button press: a bad file is refused +before the first bias moves, not at 3 a.m. on row 37. + +## What lands on disk + +Under `//`: + +| File | What it is | +|---|---| +| `.raw` | the recording, gathered out of the host's capture folder | +| `.toml` | the host's own camera/bias sidecar, travelling with its RAW | +| `.a4.toml` | the A4 sidecar — protocol row, bias codes, bench conditions, QC | +| `.sensor.json` | the host's telemetry, compacted column-wise | +| `.csv` \| `.toml` | a copy of the protocol that ran | +| `.protocol-status.toml` | its hash, and the per-row execution status | + +A sidecar is written for failed points too — the record of a failed point is +the reason the survey has a hole in it. + +Fields the sensor could not report are **absent**, never `0`: a die temperature +of 0 °C and "this camera has no temperature readback" are opposite facts. + +Sensor lux is a stability indicator, not a calibrated optical power. The +sidecar says so in the file. + +## Notes + +- QC rates are counted from the preview frames the plugin observed, over + `counted_seconds`; compare that against `recorded_duration_s` for the + coverage. The authoritative counts come from the RAW offline. +- `Restore biases` is the recovery path for a run that could not restore them + itself. During a run it is refused. It asks the host to put its own preserved + configuration back, so a host that was reloaded mid-survey has no session + left and refuses — that gap is not yet closed. + +See [`docs/features/stage-a-a4.md`](../../docs/features/stage-a-a4.md) and +[ADR 035](../../docs/adr/035-stage-a-a4-threshold-survey.md). diff --git a/plugins/stage-a-a4/plugin.toml b/plugins/stage-a-a4/plugin.toml new file mode 100644 index 0000000..4b8d394 --- /dev/null +++ b/plugins/stage-a-a4/plugin.toml @@ -0,0 +1,14 @@ +id = "stage-a.a4" +name = "Stage-A A4 Threshold" +version = "0.1.0" +description = "Stage-A A4 contrast-threshold survey: steps diff_on/diff_off through a protocol at one fixed optical condition, confirming every point against the sensor's own bias readback before it records." +domain = "stage-a" +library = "augur_plugin_stage_a_a4" +phase = "raw_events" +min_augur_version = "1.0.0" +host_commands = [ + "start_recording", + "stop_recording", + "apply_camera_configuration", + "restore_camera_configuration", +] diff --git a/plugins/stage-a-a4/protocols/example.csv b/plugins/stage-a-a4/protocols/example.csv new file mode 100644 index 0000000..96b27fe --- /dev/null +++ b/plugins/stage-a-a4/protocols/example.csv @@ -0,0 +1,17 @@ +# Stage-A A4 — symmetric threshold sweep at one optical condition. +# +# One row per recording. Only diff_on and diff_off are required; everything +# else falls back (60 s, 5 s settle, recorded once, no QC limits). +# +# diff_on/diff_off are OFFSETS around the sensor's per-unit factory trim — the +# same numbers the host settings panel shows. The absolute codes on the die are +# read back from the sensor and written into every sidecar. +# +# Symmetric pairs belong in a CSV: each row is one exact (on, off) pair. Use +# the TOML form when you want the product of two axes instead. +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats,max_temperature_drift_c,max_illumination_drift_percent +threshold-01,LP647+BP700,-20,-20,60,5,2,2.0,5.0 +threshold-02,LP647+BP700,-10,-10,60,5,2,2.0,5.0 +threshold-03,LP647+BP700,0,0,60,5,2,2.0,5.0 +threshold-04,LP647+BP700,10,10,60,5,2,2.0,5.0 +threshold-05,LP647+BP700,20,20,60,5,2,2.0,5.0 diff --git a/plugins/stage-a-a4/protocols/example.toml b/plugins/stage-a-a4/protocols/example.toml new file mode 100644 index 0000000..746b984 --- /dev/null +++ b/plugins/stage-a-a4/protocols/example.toml @@ -0,0 +1,47 @@ +# Stage-A A4 — the 2D threshold map, written as blocks and ranges. +# +# A block expands to the PRODUCT of its two bias axes: every diff_on against +# every diff_off, walked diff_on outermost. That is the right shape for mapping +# the ON/OFF threshold plane. +# +# For a symmetric sweep — diff_on and diff_off moving together — use the CSV +# form instead: those are specific pairs, not a product. + +name = "a4-threshold-map" + +# Defaults every block inherits unless it says otherwise. +[defaults] +duration_s = 60 +settle_s = 5 +repeats = 1 +optical_state = "LP647+BP700" +filter_id = "F-700" +max_temperature_drift_c = 2.0 +max_illumination_drift_percent = 5.0 + +# A coarse map: 5 × 5 = 25 recordings. +[[block]] +name = "coarse-map" +diff_on = { min = -20, max = 20, step = 10 } +diff_off = { min = -20, max = 20, step = 10 } + +# A finer look around the symmetric centre, recorded twice each. +[[block]] +name = "centre-detail" +diff_on = [-4, -2, 0, 2, 4] +diff_off = [0] +repeats = 2 +duration_s = 90 + +# A dark reference at the end. `pause_before` stops once, before the block, so +# the cap goes on and every point under it runs unattended. +[[block]] +name = "dark-reference" +diff_on = [0, 20] +diff_off = [0, 20] +optical_state = "dark cap" +filter_id = "none" +pause_before = true +duration_s = 120 +settle_s = 10 +max_event_rate = 50000 diff --git a/plugins/stage-a-a4/protocols/example_asymmetric.csv b/plugins/stage-a-a4/protocols/example_asymmetric.csv new file mode 100644 index 0000000..c708202 --- /dev/null +++ b/plugins/stage-a-a4/protocols/example_asymmetric.csv @@ -0,0 +1,17 @@ +# Stage-A A4 — ON and OFF thresholds moved independently, with a filter change +# partway through. +# +# `pause_before` stops the run and waits for Continue, once per row: the filter +# is already changed by the time a second repeat starts. Use it for anything +# the operator has to do by hand — a filter swap, a dark cap, a flux change. +# +# `filter_id` and `flux_id` are free text carried into every sidecar, so two +# points can be shown to have been taken under the same optical condition +# rather than merely assumed to be. +label,optical_state,filter_id,flux_id,diff_on,diff_off,duration_s,settle_s,repeats,pause_before,max_event_rate +on-low,LP647+BP700,F-700,flux-A,-20,0,60,5,1,no,2000000 +on-high,LP647+BP700,F-700,flux-A,20,0,60,5,1,no,2000000 +off-low,LP647+BP700,F-700,flux-A,0,-20,60,5,1,no,2000000 +off-high,LP647+BP700,F-700,flux-A,0,20,60,5,1,no,2000000 +dark-01,dark cap,none,dark,0,0,120,10,1,yes,50000 +dark-02,dark cap,none,dark,20,20,120,10,1,no,50000 diff --git a/plugins/stage-a-a4/src/lib.rs b/plugins/stage-a-a4/src/lib.rs new file mode 100644 index 0000000..b5392c7 --- /dev/null +++ b/plugins/stage-a-a4/src/lib.rs @@ -0,0 +1,19 @@ +//! Stage-A A4: reproducible contrast-threshold measurements on the IMX636. +//! +//! At one fixed optical condition, A4 walks a protocol of `diff_on`/`diff_off` +//! bias pairs, confirms each against the sensor's own readback before it +//! records, and writes a RAW file per point with the provenance needed to read +//! an event rate against a threshold setting months later. +//! +//! This crate owns no hardware. Biases are changed through the host's generic +//! camera-configuration session (augur-rs ADR 037), the only way a plugin can +//! touch the sensor. That session carries a whole configuration, so keeping the +//! survey to two registers is A4's own job: it clones the configuration the +//! host confirmed when the run opened, and changes exactly two fields. + +pub mod protocol; +pub mod qc; +mod runtime; +mod sidecar; + +pub use runtime::StageAA4Plugin; diff --git a/plugins/stage-a-a4/src/protocol.rs b/plugins/stage-a-a4/src/protocol.rs new file mode 100644 index 0000000..2e6c989 --- /dev/null +++ b/plugins/stage-a-a4/src/protocol.rs @@ -0,0 +1,1006 @@ +//! Declarative threshold protocols: a file naming the bias points to record, +//! expanded into the flat list the runner walks. +//! +//! A4 holds the optical condition still and sweeps the sensor. Every row states +//! one `(diff_on, diff_off)` pair, how long to record it, how long to settle +//! first, and how many times to repeat it — plus the QC limits that row is +//! judged against and the optical state it was taken under, so the file is a +//! complete description of the survey six months later. +//! +//! ## CSV — one row per recording +//! +//! ```csv +//! label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +//! threshold-01,LP647+BP700,-20,-10,60,5,2 +//! threshold-02,LP647+BP700,0,0,60,5,2 +//! threshold-03,LP647+BP700,20,20,60,5,2 +//! ``` +//! +//! Only `diff_on` and `diff_off` are required. Columns are found **by header +//! name**, so their order does not matter and any of the optional ones may be +//! left out entirely. Optional columns: `label`, `optical_state`, `duration_s`, +//! `settle_s`, `repeats`, `pause_before`, `max_temperature_drift_c`, +//! `max_illumination_drift_percent`, `max_event_rate`, `filter_id`, `flux_id`. +//! +//! ## TOML — blocks and ranges +//! +//! ```toml +//! name = "a4-threshold" +//! +//! [defaults] +//! duration_s = 60 +//! settle_s = 5 +//! repeats = 2 +//! optical_state = "LP647+BP700" +//! +//! [[block]] +//! name = "on-sweep" +//! diff_on = { min = -20, max = 20, step = 10 } +//! diff_off = 0 +//! ``` +//! +//! An axis is a single value, an explicit list, or an inclusive +//! `{ min, max, step }` range (`step` defaults to 1). Bias codes are integers, +//! so a range is stated by its step rather than by a point count — asking for +//! "5 points from -20 to 20" would have to invent a spacing, and the one it +//! invented would not be a code the operator chose. +//! +//! A block expands to the **product** of its two axes, which is the 2D +//! threshold map. A symmetric sweep — where `diff_on` and `diff_off` move +//! together — is a set of specific pairs, not a product, so it belongs in the +//! CSV form where each pair is written out. +//! +//! ## Ordering +//! +//! Points come out in file order, `diff_on` outermost within a block, and each +//! row's repeats consecutively. Nothing is reordered: a threshold survey drifts +//! with the bench, so the order the operator wrote is the order that has to be +//! defensible against the temperature log. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +use stage_a_plugin_contract::csv::split_line; + +/// Hard ceiling on the recordings one protocol may expand to. Repeats multiply, +/// so an operator who typed one zero too many should be told on the button +/// press rather than after the bench has spent a night on it. +pub const MAX_POINTS: usize = 4_096; + +/// Bias offset window the host accepts (and the IMX636 driver behind it). +/// Checked here so a bad value names its own line instead of surfacing as a +/// rejected command on point 37. +pub const BIAS_OFFSET_RANGE: (i64, i64) = (-85, 140); +const DURATION_RANGE: (i64, i64) = (1, 3_600); +const SETTLE_RANGE: (f64, f64) = (0.0, 600.0); +const REPEATS_RANGE: (i64, i64) = (1, 100); + +/// Stability limits one point is judged against. +/// +/// Every limit is optional and every one is a **flag, not a gate**: a breach is +/// recorded in the point's sidecar and the run summary, and the recording is +/// still kept. A threshold survey that silently dropped its drifting points +/// would hide exactly the evidence needed to decide whether the drift mattered. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct QcLimits { + /// Maximum |T − T_start| over the recording, in °C. + pub max_temperature_drift_c: Option, + /// Maximum |lux − lux_start| / lux_start over the recording, in percent. + pub max_illumination_drift_percent: Option, + /// Maximum mean event rate over the recording, in events per second. + pub max_event_rate: Option, +} + +impl QcLimits { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// One recording the protocol asks for, with every parameter resolved. +#[derive(Debug, Clone, PartialEq)] +pub struct A4Point { + /// Where this row came from — a CSV `label` or the `[[block]]` name — for + /// the status line, the file stem and the sidecar. + pub label: String, + /// Free text naming the optical condition: filters, dark cap, flux. A4 + /// never changes it; it is recorded so two points can be shown to have been + /// taken under the same one. + pub optical_state: String, + /// Bias offsets around the factory trim, as the host settings panel + /// expresses them. The absolute codes are read back from the sensor. + pub diff_on: i64, + pub diff_off: i64, + pub duration_s: i64, + pub settle_s: f64, + /// Which repeat of its row this is, and how many there are: `(1, 2)` is the + /// first of two. `(1, 1)` for a row recorded once. + pub repeat: (u32, u32), + /// Stop and wait for the operator before this point — a filter change or a + /// dark cap. The run does not continue until Continue is pressed. + pub pause_before: bool, + pub limits: QcLimits, + pub filter_id: String, + pub flux_id: String, +} + +impl A4Point { + /// Filename fragment identifying this point inside the measurement folder. + /// + /// Signed offsets are rendered with an explicit `p`/`m` rather than a + /// leading `-`, so a stem never starts a shell argument with a dash and + /// sorts the way it reads. + pub fn tag(&self) -> String { + let mut tag = format!( + "on{}_off{}", + signed_tag(self.diff_on), + signed_tag(self.diff_off) + ); + if self.repeat.1 > 1 { + tag.push_str(&format!("_r{:02}", self.repeat.0)); + } + tag + } +} + +fn signed_tag(value: i64) -> String { + if value < 0 { + format!("m{}", value.unsigned_abs()) + } else { + format!("p{value}") + } +} + +/// A parsed protocol: what to record, in order. +#[derive(Debug, Clone, PartialEq)] +pub struct Protocol { + pub name: String, + pub points: Vec, +} + +impl Protocol { + /// Distinct values on each bias axis, for the summary shown before starting. + pub fn axis_counts(&self) -> (usize, usize) { + let count = |values: Vec| { + let mut values = values; + values.sort_unstable(); + values.dedup(); + values.len() + }; + ( + count(self.points.iter().map(|point| point.diff_on).collect()), + count(self.points.iter().map(|point| point.diff_off).collect()), + ) + } + + /// Total bench time the protocol asks for, settling included. The bias + /// handshake per point is not in this number, so it reads a little short. + pub fn total_seconds(&self) -> f64 { + self.points + .iter() + .map(|point| point.duration_s as f64 + point.settle_s) + .sum() + } + + /// Whether any row asks the operator to intervene. A survey with a pause in + /// it cannot be left alone, and the panel should say so before it starts. + pub fn has_pauses(&self) -> bool { + self.points.iter().any(|point| point.pause_before) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ProtocolError { + Toml(String), + /// A named row, column, block or default is unusable, with the reason. + Invalid { + what: String, + detail: String, + }, + Empty, + TooManyPoints(usize), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(detail) => write!(f, "the protocol file is not valid TOML: {detail}"), + Self::Invalid { what, detail } => write!(f, "{what}: {detail}"), + Self::Empty => f.write_str( + "the protocol has no points to record — add at least one row with a diff_on \ + and a diff_off", + ), + Self::TooManyPoints(count) => write!( + f, + "the protocol expands to {count} recordings, past the {MAX_POINTS} limit — \ + narrow an axis, lower the repeats, or split it into several files" + ), + } + } +} + +impl std::error::Error for ProtocolError {} + +fn strip_bom(text: &str) -> &str { + text.strip_prefix('\u{feff}').unwrap_or(text) +} + +/// Parses a protocol, choosing the form from the file extension. +pub fn parse_file(path: &str, text: &str) -> Result { + let is_csv = std::path::Path::new(path) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("csv")); + if is_csv { + parse_csv(text) + } else { + parse_toml(text) + } +} + +// ---- CSV form -------------------------------------------------------------- + +const CSV_REQUIRED: [&str; 2] = ["diff_on", "diff_off"]; +const CSV_OPTIONAL: [&str; 10] = [ + "label", + "optical_state", + "duration_s", + "settle_s", + "repeats", + "pause_before", + "max_temperature_drift_c", + "max_illumination_drift_percent", + "max_event_rate", + "filter_id", +]; + +/// Parses the row-per-recording CSV form. +/// +/// Columns are located **by header name**, so their order does not matter and a +/// column can be left out entirely — which is what keeps a file working after +/// someone drags a column in a spreadsheet. Blank lines and `#` comments are +/// skipped so a file can explain itself, and errors carry the **file line +/// number** because that is what an editor and a spreadsheet both show. +pub fn parse_csv(text: &str) -> Result { + let mut header: Option> = None; + let mut points = Vec::new(); + + // `lines()` already absorbs CRLF; the BOM is what it leaves behind. + for (offset, raw) in strip_bom(text).lines().enumerate() { + let line_no = offset + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields = split_line(raw); + + let Some(columns) = header.as_ref() else { + let columns: Vec = fields + .iter() + .map(|field| field.trim().to_ascii_lowercase()) + .collect(); + for required in CSV_REQUIRED { + if !columns.iter().any(|column| column == required) { + return Err(ProtocolError::Invalid { + what: format!("line {line_no}: the header"), + detail: format!( + "has no '{required}' column. Required: {}. Optional: {}, flux_id", + CSV_REQUIRED.join(", "), + CSV_OPTIONAL.join(", ") + ), + }); + } + } + header = Some(columns); + continue; + }; + + let cell = |name: &str| -> Option<&str> { + let index = columns.iter().position(|column| column == name)?; + fields.get(index).map(|field| field.trim()) + }; + let invalid = |name: &str, detail: String| ProtocolError::Invalid { + what: format!("line {line_no}: {name}"), + detail, + }; + let integer = |name: &str, range: (i64, i64)| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + let value: i64 = raw + .parse() + .map_err(|_| invalid(name, format!("'{raw}' is not a whole number")))?; + check_range_i64(name, value, range).map(Some) + }; + let float = |name: &str, range: (f64, f64)| -> Result, ProtocolError> { + let raw = cell(name).unwrap_or(""); + if raw.is_empty() { + return Ok(None); + } + let value: f64 = raw + .parse() + .map_err(|_| invalid(name, format!("'{raw}' is not a number")))?; + check_range_f64(name, value, range).map(Some) + }; + + let diff_on = integer("diff_on", BIAS_OFFSET_RANGE)? + .ok_or_else(|| invalid("diff_on", "is empty".into()))?; + let diff_off = integer("diff_off", BIAS_OFFSET_RANGE)? + .ok_or_else(|| invalid("diff_off", "is empty".into()))?; + let duration_s = integer("duration_s", DURATION_RANGE)?.unwrap_or(60); + let settle_s = float("settle_s", SETTLE_RANGE)?.unwrap_or(5.0); + let repeats = integer("repeats", REPEATS_RANGE)?.unwrap_or(1) as u32; + let pause_before = parse_bool(cell("pause_before").unwrap_or("")) + .ok_or_else(|| invalid("pause_before", "is not yes/no".into()))?; + + let limits = QcLimits { + max_temperature_drift_c: float("max_temperature_drift_c", (0.0, 1_000.0))?, + max_illumination_drift_percent: float( + "max_illumination_drift_percent", + (0.0, 100_000.0), + )?, + max_event_rate: float("max_event_rate", (0.0, 1e12))?, + }; + + let label = cell("label").unwrap_or("").trim().to_owned(); + let label = if label.is_empty() { + format!("row{}", points.len() + 1) + } else { + label + }; + push_repeats( + &mut points, + A4Point { + label, + optical_state: cell("optical_state").unwrap_or("").to_owned(), + diff_on, + diff_off, + duration_s, + settle_s, + repeat: (1, repeats), + pause_before, + limits, + filter_id: cell("filter_id").unwrap_or("").to_owned(), + flux_id: cell("flux_id").unwrap_or("").to_owned(), + }, + repeats, + )?; + } + + if header.is_none() { + return Err(ProtocolError::Invalid { + what: "the protocol file".into(), + detail: format!( + "has no header line. The first line that is not blank or a # comment must name \ + the columns, at least: {}", + CSV_REQUIRED.join(", ") + ), + }); + } + if points.is_empty() { + return Err(ProtocolError::Empty); + } + Ok(Protocol { + name: "protocol".to_owned(), + points, + }) +} + +/// A repeated row is N recordings, not one recorded N times: each gets its own +/// file, its own sidecar and its own QC verdict, because the drift between two +/// repeats is one of the things the survey is measuring. +fn push_repeats( + points: &mut Vec, + point: A4Point, + repeats: u32, +) -> Result<(), ProtocolError> { + for index in 1..=repeats.max(1) { + let mut repeat = point.clone(); + repeat.repeat = (index, repeats.max(1)); + // Only the first repeat stops for the operator: the filter is already + // changed by the time the second one starts. + repeat.pause_before = point.pause_before && index == 1; + points.push(repeat); + if points.len() > MAX_POINTS { + return Err(ProtocolError::TooManyPoints(points.len())); + } + } + Ok(()) +} + +fn parse_bool(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "" | "0" | "no" | "false" | "n" => Some(false), + "1" | "yes" | "true" | "y" => Some(true), + _ => None, + } +} + +fn check_range_i64(name: &str, value: i64, range: (i64, i64)) -> Result { + if value < range.0 || value > range.1 { + return Err(ProtocolError::Invalid { + what: name.to_owned(), + detail: format!("{value} is outside the supported {}..={}", range.0, range.1), + }); + } + Ok(value) +} + +fn check_range_f64(name: &str, value: f64, range: (f64, f64)) -> Result { + if !value.is_finite() || value < range.0 || value > range.1 { + return Err(ProtocolError::Invalid { + what: name.to_owned(), + detail: format!("{value} is outside the supported {}..={}", range.0, range.1), + }); + } + Ok(value) +} + +// ---- TOML form ------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct ProtocolDoc { + #[serde(default)] + name: Option, + #[serde(default)] + defaults: Defaults, + #[serde(default, rename = "block")] + blocks: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct Defaults { + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + repeats: Option, + #[serde(default)] + optical_state: Option, + #[serde(default)] + filter_id: Option, + #[serde(default)] + flux_id: Option, + #[serde(default)] + max_temperature_drift_c: Option, + #[serde(default)] + max_illumination_drift_percent: Option, + #[serde(default)] + max_event_rate: Option, +} + +#[derive(Debug, Deserialize)] +struct BlockDoc { + #[serde(default)] + name: Option, + diff_on: Axis, + diff_off: Axis, + #[serde(default)] + duration_s: Option, + #[serde(default)] + settle_s: Option, + #[serde(default)] + repeats: Option, + #[serde(default)] + optical_state: Option, + #[serde(default)] + filter_id: Option, + #[serde(default)] + flux_id: Option, + #[serde(default)] + pause_before: Option, + #[serde(default)] + max_temperature_drift_c: Option, + #[serde(default)] + max_illumination_drift_percent: Option, + #[serde(default)] + max_event_rate: Option, +} + +/// One bias axis: a single value, an explicit list, or an inclusive range. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Axis { + One(i64), + List(Vec), + Range { + min: i64, + max: i64, + step: Option, + }, +} + +impl Axis { + /// Expand to the values to visit, in the order they are recorded. + fn values(&self, what: &str) -> Result, ProtocolError> { + let invalid = |detail: String| ProtocolError::Invalid { + what: what.to_owned(), + detail, + }; + let values = match self { + Self::One(value) => vec![*value], + Self::List(values) => { + if values.is_empty() { + return Err(invalid("is an empty list".into())); + } + values.clone() + } + Self::Range { min, max, step } => { + let step = step.unwrap_or(1); + if step <= 0 { + return Err(invalid(format!("step {step} must be positive"))); + } + if max < min { + return Err(invalid(format!("max {max} is below min {min}"))); + } + // Inclusive of `min`, and of `max` when the step lands on it. + // A range whose step overshoots simply stops early rather than + // silently recording a point the file never named. + let mut values = Vec::new(); + let mut value = *min; + while value <= *max { + values.push(value); + value += step; + } + values + } + }; + for value in &values { + check_range_i64(what, *value, BIAS_OFFSET_RANGE)?; + } + Ok(values) + } +} + +/// Parses the block/range TOML form and expands it into points. +pub fn parse_toml(text: &str) -> Result { + let doc: ProtocolDoc = + toml::from_str(strip_bom(text)).map_err(|error| ProtocolError::Toml(error.to_string()))?; + + let mut points = Vec::new(); + // Blocks may be named or not; unnamed ones get a stable positional name so + // every recording can still say which part of the protocol it belongs to. + let mut seen_names: BTreeMap = BTreeMap::new(); + for (index, block) in doc.blocks.iter().enumerate() { + let base = block + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| format!("block{}", index + 1)); + // Two blocks sharing a name would put two different sets of points in + // one namespace; keep them distinguishable rather than refusing. + let occurrence = seen_names.entry(base.clone()).or_insert(0); + *occurrence += 1; + let name = if *occurrence == 1 { + base + } else { + format!("{base}#{occurrence}") + }; + + let duration_s = check_range_i64( + &format!("block '{name}': duration_s"), + block.duration_s.or(doc.defaults.duration_s).unwrap_or(60), + DURATION_RANGE, + )?; + let settle_s = check_range_f64( + &format!("block '{name}': settle_s"), + block.settle_s.or(doc.defaults.settle_s).unwrap_or(5.0), + SETTLE_RANGE, + )?; + let repeats = check_range_i64( + &format!("block '{name}': repeats"), + block.repeats.or(doc.defaults.repeats).unwrap_or(1), + REPEATS_RANGE, + )? as u32; + + let limits = QcLimits { + max_temperature_drift_c: block + .max_temperature_drift_c + .or(doc.defaults.max_temperature_drift_c), + max_illumination_drift_percent: block + .max_illumination_drift_percent + .or(doc.defaults.max_illumination_drift_percent), + max_event_rate: block.max_event_rate.or(doc.defaults.max_event_rate), + }; + let optical_state = block + .optical_state + .clone() + .or_else(|| doc.defaults.optical_state.clone()) + .unwrap_or_default(); + let filter_id = block + .filter_id + .clone() + .or_else(|| doc.defaults.filter_id.clone()) + .unwrap_or_default(); + let flux_id = block + .flux_id + .clone() + .or_else(|| doc.defaults.flux_id.clone()) + .unwrap_or_default(); + + let on_values = block.diff_on.values(&format!("block '{name}': diff_on"))?; + let off_values = block + .diff_off + .values(&format!("block '{name}': diff_off"))?; + // `diff_on` outermost: a block is the 2D threshold map, walked one ON + // row at a time. + let mut first_of_block = true; + for diff_on in &on_values { + for diff_off in &off_values { + push_repeats( + &mut points, + A4Point { + label: name.clone(), + optical_state: optical_state.clone(), + diff_on: *diff_on, + diff_off: *diff_off, + duration_s, + settle_s, + repeat: (1, repeats), + // A block-level pause is about the optical condition + // the whole block shares, so it stops once, before the + // block, not before each of its points. + pause_before: block.pause_before.unwrap_or(false) && first_of_block, + limits, + filter_id: filter_id.clone(), + flux_id: flux_id.clone(), + }, + repeats, + )?; + first_of_block = false; + } + } + } + + if points.is_empty() { + return Err(ProtocolError::Empty); + } + Ok(Protocol { + name: doc + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "protocol".to_owned()), + points, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CSV: &str = "\ +label,optical_state,diff_on,diff_off,duration_s,settle_s,repeats +threshold-01,LP647+BP700,-20,-10,60,5,2 +threshold-02,LP647+BP700,0,0,60,5,2 +threshold-03,LP647+BP700,20,20,60,5,2 +"; + + #[test] + fn the_requirements_example_parses_into_six_recordings() { + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + // Three rows, two repeats each. + assert_eq!(protocol.points.len(), 6); + assert_eq!(protocol.points[0].label, "threshold-01"); + assert_eq!(protocol.points[0].diff_on, -20); + assert_eq!(protocol.points[0].diff_off, -10); + assert_eq!(protocol.points[0].duration_s, 60); + assert_eq!(protocol.points[0].optical_state, "LP647+BP700"); + assert!((protocol.points[0].settle_s - 5.0).abs() < f64::EPSILON); + assert_eq!(protocol.axis_counts(), (3, 3)); + } + + #[test] + fn repeats_are_separate_recordings_numbered_in_order() { + // Each repeat is its own file and its own QC verdict — the drift + // between two repeats is part of what the survey measures. + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + let first_row: Vec<(u32, u32)> = protocol.points[..2] + .iter() + .map(|point| point.repeat) + .collect(); + assert_eq!(first_row, vec![(1, 2), (2, 2)]); + assert_eq!(protocol.points[0].tag(), "onm20_offm10_r01"); + assert_eq!(protocol.points[1].tag(), "onm20_offm10_r02"); + } + + #[test] + fn a_row_recorded_once_carries_no_repeat_suffix() { + let csv = "diff_on,diff_off\n0,0\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points[0].repeat, (1, 1)); + assert_eq!(protocol.points[0].tag(), "onp0_offp0"); + } + + #[test] + fn a_negative_offset_never_starts_a_stem_with_a_dash() { + let csv = "diff_on,diff_off\n-85,140\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + let tag = protocol.points[0].tag(); + assert_eq!(tag, "onm85_offp140"); + assert!(!tag.starts_with('-'), "{tag}"); + } + + #[test] + fn only_the_two_bias_columns_are_required() { + let csv = "diff_off,diff_on\n5,-5\n"; + let protocol = parse_file("a4.csv", csv).expect("column order must not matter"); + assert_eq!(protocol.points[0].diff_on, -5); + assert_eq!(protocol.points[0].diff_off, 5); + // The rest fall back rather than refusing. + assert_eq!(protocol.points[0].duration_s, 60); + assert_eq!(protocol.points[0].repeat, (1, 1)); + assert!(protocol.points[0].limits.is_empty()); + } + + #[test] + fn a_missing_bias_column_names_itself_and_the_line() { + let csv = "label,diff_on\nx,0\n"; + let error = parse_file("a4.csv", csv).expect_err("diff_off is required"); + let text = error.to_string(); + assert!(text.contains("diff_off"), "{text}"); + assert!(text.contains("line 1"), "{text}"); + } + + #[test] + fn an_offset_the_host_would_reject_is_refused_at_parse_time() { + // The point of checking here: the operator finds out on the button + // press, not when point 37 is rejected at 3 a.m. + let csv = "diff_on,diff_off\n0,200\n"; + let error = parse_file("a4.csv", csv).expect_err("200 is out of range"); + let text = error.to_string(); + assert!(text.contains("diff_off"), "{text}"); + assert!(text.contains("-85..=140"), "{text}"); + } + + #[test] + fn optional_qc_limits_are_read_per_row() { + let csv = "diff_on,diff_off,max_temperature_drift_c,max_event_rate\n\ + 0,0,1.5,250000\n\ + 10,10,,\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points[0].limits.max_temperature_drift_c, Some(1.5)); + assert_eq!(protocol.points[0].limits.max_event_rate, Some(250_000.0)); + // An empty cell is "no limit", not zero — a zero limit would flag + // every point. + assert!(protocol.points[1].limits.is_empty()); + } + + #[test] + fn a_pause_stops_once_per_row_not_once_per_repeat() { + // The filter is already changed by the time the second repeat starts. + let csv = "diff_on,diff_off,repeats,pause_before\n0,0,3,yes\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + let pauses: Vec = protocol.points.iter().map(|p| p.pause_before).collect(); + assert_eq!(pauses, vec![true, false, false]); + assert!(protocol.has_pauses()); + } + + #[test] + fn comments_and_blank_lines_let_a_file_explain_itself() { + let csv = "# threshold survey, 2026-08-07\n\ + \n\ + diff_on,diff_off\n\ + # the symmetric points\n\ + 0,0\n\ + \n\ + 10,10\n"; + let protocol = parse_file("a4.csv", csv).expect("valid protocol"); + assert_eq!(protocol.points.len(), 2); + } + + #[test] + fn a_spreadsheet_bom_and_crlf_do_not_hide_the_first_column() { + let csv = "\u{feff}diff_on,diff_off\r\n-20,-10\r\n"; + let protocol = parse_file("a4.csv", csv).expect("BOM + CRLF CSV"); + assert_eq!(protocol.points.len(), 1); + assert_eq!(protocol.points[0].diff_on, -20); + } + + const TOML: &str = r#" +name = "a4-map" + +[defaults] +duration_s = 30 +settle_s = 2.0 +optical_state = "LP647+BP700" +max_temperature_drift_c = 2.0 + +[[block]] +name = "on-sweep" +diff_on = { min = -20, max = 20, step = 10 } +diff_off = 0 + +[[block]] +name = "corner" +diff_on = [30, 40] +diff_off = [30, 40] +repeats = 2 +"#; + + #[test] + fn a_block_expands_to_the_product_of_its_two_bias_axes() { + let protocol = parse_toml(TOML).expect("valid protocol"); + assert_eq!(protocol.name, "a4-map"); + // 5 × 1 + (2 × 2) × 2 repeats + assert_eq!(protocol.points.len(), 5 + 8); + } + + #[test] + fn a_range_is_inclusive_and_walks_diff_on_outermost() { + let protocol = parse_toml(TOML).expect("valid protocol"); + let sweep: Vec = protocol + .points + .iter() + .filter(|point| point.label == "on-sweep") + .map(|point| point.diff_on) + .collect(); + assert_eq!(sweep, vec![-20, -10, 0, 10, 20]); + + let corner: Vec<(i64, i64)> = protocol + .points + .iter() + .filter(|point| point.label == "corner" && point.repeat.0 == 1) + .map(|point| (point.diff_on, point.diff_off)) + .collect(); + assert_eq!(corner, vec![(30, 30), (30, 40), (40, 30), (40, 40)]); + } + + #[test] + fn block_values_override_the_defaults_they_do_not_replace_them() { + let protocol = parse_toml(TOML).expect("valid protocol"); + let corner = protocol + .points + .iter() + .find(|point| point.label == "corner") + .expect("corner block"); + // `repeats` was overridden; everything else still comes from defaults. + assert_eq!(corner.repeat, (1, 2)); + assert_eq!(corner.duration_s, 30); + assert_eq!(corner.optical_state, "LP647+BP700"); + assert_eq!(corner.limits.max_temperature_drift_c, Some(2.0)); + } + + #[test] + fn two_blocks_with_one_name_stay_distinguishable() { + let text = r#" +[[block]] +name = "sweep" +diff_on = 0 +diff_off = 0 + +[[block]] +name = "sweep" +diff_on = 10 +diff_off = 10 +"#; + let protocol = parse_toml(text).expect("valid protocol"); + let labels: Vec<&str> = protocol + .points + .iter() + .map(|point| point.label.as_str()) + .collect(); + assert_eq!(labels, vec!["sweep", "sweep#2"]); + } + + #[test] + fn an_empty_protocol_says_what_to_add() { + assert_eq!(parse_toml("name = \"x\"\n"), Err(ProtocolError::Empty)); + let error = parse_file("a4.csv", "diff_on,diff_off\n").expect_err("no rows"); + assert_eq!(error, ProtocolError::Empty); + } + + #[test] + fn a_protocol_too_large_to_run_is_refused_before_the_bench_starts() { + let text = "[[block]]\ndiff_on = { min = -85, max = 140, step = 1 }\n\ + diff_off = { min = -85, max = 140, step = 1 }\n"; + let error = parse_toml(text).expect_err("226 × 226 is far past the limit"); + assert!(matches!(error, ProtocolError::TooManyPoints(_))); + } + + #[test] + fn a_reversed_or_zero_step_range_names_the_block_it_is_in() { + let text = "[[block]]\nname = \"bad\"\ndiff_on = { min = 20, max = 0 }\ndiff_off = 0\n"; + let error = parse_toml(text).expect_err("max below min"); + let message = error.to_string(); + assert!(message.contains("bad"), "{message}"); + assert!(message.contains("diff_on"), "{message}"); + + let text = "[[block]]\ndiff_on = { min = 0, max = 20, step = 0 }\ndiff_off = 0\n"; + let error = parse_toml(text).expect_err("zero step"); + assert!(error.to_string().contains("step"), "{error}"); + } + + #[test] + fn total_bench_time_counts_every_repeat() { + let protocol = parse_file("a4.csv", CSV).expect("valid protocol"); + // 6 recordings × (60 s + 5 s) + assert!((protocol.total_seconds() - 390.0).abs() < f64::EPSILON); + } +} + +#[cfg(test)] +mod shipped_protocol_tests { + use super::*; + + /// The files under `protocols/` are what an operator copies to start from. + /// A broken example is worse than none, so they are parsed as fixtures. + fn shipped(name: &str) -> Protocol { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/protocols/"); + let full = format!("{path}{name}"); + let text = std::fs::read_to_string(&full) + .unwrap_or_else(|error| panic!("{name} must be readable: {error}")); + parse_file(&full, &text).unwrap_or_else(|error| panic!("{name} must parse: {error}")) + } + + #[test] + fn the_symmetric_example_is_five_pairs_recorded_twice() { + let protocol = shipped("example.csv"); + assert_eq!(protocol.points.len(), 10); + // Symmetric by construction — that is what the file is demonstrating. + for point in &protocol.points { + assert_eq!(point.diff_on, point.diff_off, "{}", point.label); + } + assert!(!protocol.has_pauses()); + } + + #[test] + fn the_asymmetric_example_pauses_once_for_the_dark_cap() { + let protocol = shipped("example_asymmetric.csv"); + assert_eq!(protocol.points.len(), 6); + let paused: Vec<&str> = protocol + .points + .iter() + .filter(|point| point.pause_before) + .map(|point| point.label.as_str()) + .collect(); + assert_eq!(paused, vec!["dark-01"]); + assert_eq!( + protocol.points[4].limits.max_event_rate, + Some(50_000.0), + "the dark rows carry a much tighter rate limit" + ); + } + + #[test] + fn the_toml_example_expands_to_the_map_it_documents() { + let protocol = shipped("example.toml"); + assert_eq!(protocol.name, "a4-threshold-map"); + // 5×5 coarse + 5×1 centre × 2 repeats + 2×2 dark + assert_eq!(protocol.points.len(), 25 + 10 + 4); + // diff_on: the coarse five plus the four centre-detail values; + // diff_off: the coarse five, which the other blocks stay inside. + assert_eq!(protocol.axis_counts(), (9, 5)); + // Every point inherits the defaults it does not override. + let coarse = protocol + .points + .iter() + .find(|point| point.label == "coarse-map") + .expect("coarse block"); + assert_eq!(coarse.optical_state, "LP647+BP700"); + assert_eq!(coarse.duration_s, 60); + assert_eq!(coarse.limits.max_temperature_drift_c, Some(2.0)); + } + + #[test] + fn every_shipped_protocol_stays_inside_the_hosts_bias_range() { + // The parser enforces this, so a passing parse is the assertion; this + // states the intent so the reason is not lost. + for name in ["example.csv", "example_asymmetric.csv", "example.toml"] { + for point in shipped(name).points { + assert!( + (BIAS_OFFSET_RANGE.0..=BIAS_OFFSET_RANGE.1).contains(&point.diff_on), + "{name}: {}", + point.label + ); + assert!( + (BIAS_OFFSET_RANGE.0..=BIAS_OFFSET_RANGE.1).contains(&point.diff_off), + "{name}: {}", + point.label + ); + } + } + } +} diff --git a/plugins/stage-a-a4/src/qc.rs b/plugins/stage-a-a4/src/qc.rs new file mode 100644 index 0000000..6da4511 --- /dev/null +++ b/plugins/stage-a-a4/src/qc.rs @@ -0,0 +1,355 @@ +//! Quality control for one threshold point: what the sensor did while it was +//! recorded, and whether the bench held still enough to believe it. +//! +//! Everything here is pure. The runner feeds it counts and readings; it decides +//! nothing about the recording itself. +//! +//! The limits are **flags, not gates**. A point that drifts is recorded, kept, +//! and marked — because whether a 2 °C drift invalidated a threshold point is a +//! judgement to make later, with the file in hand, and a runner that discarded +//! the point would have thrown away the evidence for making it. + +use crate::protocol::QcLimits; + +/// Event counts and rates over one recording. +/// +/// Rates are over the **recorded wall-clock duration**, not over the analysis +/// window, so they are comparable between points of different lengths. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct RateSummary { + pub on_events: u64, + pub off_events: u64, + /// Seconds the counts were accumulated over. + pub seconds: f64, +} + +impl RateSummary { + pub fn total_events(&self) -> u64 { + self.on_events.saturating_add(self.off_events) + } + + /// Events per second, or `None` when nothing was counted over a real + /// interval. `None` is not zero: a point whose events were never seen must + /// not report a rate of 0 Hz, which is a measurement. + pub fn on_rate_hz(&self) -> Option { + self.rate(self.on_events) + } + + pub fn off_rate_hz(&self) -> Option { + self.rate(self.off_events) + } + + pub fn total_rate_hz(&self) -> Option { + self.rate(self.total_events()) + } + + /// Share of events that were ON, in `0.0..=1.0`. The quantity a threshold + /// survey is usually read through — an asymmetric `diff_on`/`diff_off` pair + /// should move it. + pub fn on_fraction(&self) -> Option { + let total = self.total_events(); + (total > 0).then(|| self.on_events as f64 / total as f64) + } + + fn rate(&self, count: u64) -> Option { + (self.seconds > 0.0).then(|| count as f64 / self.seconds) + } +} + +/// How far a monitoring channel moved between the start and the end of a +/// recording. `None` for a channel the sensor could not report — absent, never +/// zero, because "no reading" and "no drift" are opposite facts. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Drift { + /// |T_end − T_start| in °C. + pub temperature_c: Option, + /// |lux_end − lux_start| / lux_start × 100. + pub illumination_percent: Option, +} + +/// One channel's readings at the two ends of a recording. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct Endpoints { + pub start: Option, + pub end: Option, +} + +impl Endpoints { + fn absolute_change(&self) -> Option { + match (self.start, self.end) { + (Some(start), Some(end)) => Some((end as f64 - start as f64).abs()), + _ => None, + } + } + + fn relative_change_percent(&self) -> Option { + match (self.start, self.end) { + // A relative drift against a zero baseline is not a percentage of + // anything. Report nothing rather than an infinity. + (Some(start), Some(end)) if start.abs() > f32::EPSILON => { + Some(((end as f64 - start as f64) / start as f64).abs() * 100.0) + } + _ => None, + } + } +} + +pub fn drift(temperature: Endpoints, illumination: Endpoints) -> Drift { + Drift { + temperature_c: temperature.absolute_change(), + illumination_percent: illumination.relative_change_percent(), + } +} + +/// The verdict on one recorded point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QcStatus { + /// Every limit the row set was met. + Pass, + /// The row set no limits, so there was nothing to check. Distinct from + /// `Pass`: an unchecked point must not read as a verified one. + NotEvaluated, + /// At least one limit was exceeded. The recording is kept; the reasons are + /// carried into the sidecar and the run summary verbatim. + Flagged(Vec), +} + +impl QcStatus { + /// Short tag for the sidecar and the status table. + pub fn as_str(&self) -> &'static str { + match self { + Self::Pass => "pass", + Self::NotEvaluated => "not_evaluated", + Self::Flagged(_) => "flagged", + } + } + + pub fn flags(&self) -> &[String] { + match self { + Self::Flagged(flags) => flags, + _ => &[], + } + } + + pub fn is_flagged(&self) -> bool { + matches!(self, Self::Flagged(_)) + } +} + +/// Judge a recorded point against the limits its protocol row set. +/// +/// A limit whose quantity could not be measured is **not** a pass and **not** a +/// breach — it is recorded as a flag saying the check could not be made, so a +/// survey run on a camera with no temperature readback does not silently report +/// every point as within a drift limit nobody ever checked. +pub fn evaluate(limits: &QcLimits, rates: &RateSummary, drift: &Drift) -> QcStatus { + if limits.is_empty() { + return QcStatus::NotEvaluated; + } + let mut flags = Vec::new(); + + if let Some(limit) = limits.max_temperature_drift_c { + match drift.temperature_c { + Some(measured) if measured > limit => flags.push(format!( + "temperature drifted {measured:.2} °C, over the {limit:.2} °C limit" + )), + Some(_) => {} + None => flags.push( + "temperature drift could not be checked — the sensor reported no die temperature" + .into(), + ), + } + } + if let Some(limit) = limits.max_illumination_drift_percent { + match drift.illumination_percent { + Some(measured) if measured > limit => flags.push(format!( + "illumination drifted {measured:.1} %, over the {limit:.1} % limit" + )), + Some(_) => {} + None => flags.push( + "illumination drift could not be checked — the sensor reported no usable lux" + .into(), + ), + } + } + if let Some(limit) = limits.max_event_rate { + match rates.total_rate_hz() { + Some(measured) if measured > limit => flags.push(format!( + "event rate {measured:.0} ev/s, over the {limit:.0} ev/s limit" + )), + Some(_) => {} + None => flags.push( + "event rate could not be checked — no events were counted for this point".into(), + ), + } + } + + if flags.is_empty() { + QcStatus::Pass + } else { + QcStatus::Flagged(flags) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rates(on: u64, off: u64, seconds: f64) -> RateSummary { + RateSummary { + on_events: on, + off_events: off, + seconds, + } + } + + #[test] + fn rates_are_per_recorded_second_so_points_of_different_length_compare() { + let summary = rates(6_000, 4_000, 60.0); + assert_eq!(summary.total_events(), 10_000); + assert_eq!(summary.on_rate_hz(), Some(100.0)); + assert_eq!(summary.off_rate_hz(), Some(200.0 / 3.0)); + assert!((summary.total_rate_hz().expect("counted") - 166.666_666).abs() < 1e-4); + assert!((summary.on_fraction().expect("counted") - 0.6).abs() < 1e-12); + } + + #[test] + fn a_point_with_no_counted_interval_reports_no_rate_rather_than_zero() { + // Zero events per second is a measurement. "We never counted" is not, + // and the two must not be written into a sidecar as the same number. + let summary = rates(0, 0, 0.0); + assert_eq!(summary.total_rate_hz(), None); + assert_eq!(summary.on_fraction(), None); + // A real interval with genuinely no events *is* a rate of zero. + assert_eq!(rates(0, 0, 60.0).total_rate_hz(), Some(0.0)); + } + + #[test] + fn temperature_drift_is_absolute_and_illumination_drift_is_relative() { + let measured = drift( + Endpoints { + start: Some(41.0), + end: Some(43.5), + }, + Endpoints { + start: Some(200.0), + end: Some(190.0), + }, + ); + assert!((measured.temperature_c.expect("both ends") - 2.5).abs() < 1e-9); + assert!((measured.illumination_percent.expect("both ends") - 5.0).abs() < 1e-9); + } + + #[test] + fn a_channel_missing_either_end_reports_no_drift_rather_than_zero() { + let measured = drift( + Endpoints { + start: Some(41.0), + end: None, + }, + Endpoints::default(), + ); + assert_eq!(measured.temperature_c, None); + assert_eq!(measured.illumination_percent, None); + } + + #[test] + fn illumination_drift_against_a_dark_baseline_is_not_a_percentage() { + let measured = drift( + Endpoints::default(), + Endpoints { + start: Some(0.0), + end: Some(5.0), + }, + ); + assert_eq!(measured.illumination_percent, None); + } + + #[test] + fn a_row_with_no_limits_is_not_evaluated_rather_than_passing() { + let status = evaluate( + &QcLimits::default(), + &rates(100, 100, 60.0), + &Drift::default(), + ); + assert_eq!(status, QcStatus::NotEvaluated); + assert_eq!(status.as_str(), "not_evaluated"); + assert!(!status.is_flagged()); + } + + #[test] + fn a_point_inside_every_limit_passes() { + let limits = QcLimits { + max_temperature_drift_c: Some(3.0), + max_illumination_drift_percent: Some(10.0), + max_event_rate: Some(1_000.0), + }; + let status = evaluate( + &limits, + &rates(300, 300, 60.0), + &Drift { + temperature_c: Some(1.0), + illumination_percent: Some(2.0), + }, + ); + assert_eq!(status, QcStatus::Pass); + } + + #[test] + fn a_breach_names_the_measured_value_and_the_limit_it_passed() { + let limits = QcLimits { + max_temperature_drift_c: Some(1.0), + max_illumination_drift_percent: None, + max_event_rate: Some(100.0), + }; + let status = evaluate( + &limits, + &rates(6_000, 6_000, 60.0), + &Drift { + temperature_c: Some(2.5), + illumination_percent: None, + }, + ); + let flags = status.flags(); + assert_eq!(flags.len(), 2, "{flags:?}"); + assert!(flags[0].contains("2.50 °C"), "{flags:?}"); + assert!(flags[0].contains("1.00 °C"), "{flags:?}"); + assert!(flags[1].contains("200 ev/s"), "{flags:?}"); + assert!(status.is_flagged()); + } + + #[test] + fn a_limit_whose_quantity_was_never_measured_is_flagged_not_passed() { + // The failure this prevents: a camera with no temperature readback + // silently reporting every point as within a drift limit that was + // never actually checked. + let limits = QcLimits { + max_temperature_drift_c: Some(1.0), + ..QcLimits::default() + }; + let status = evaluate(&limits, &rates(10, 10, 60.0), &Drift::default()); + assert!(status.is_flagged()); + assert!( + status.flags()[0].contains("could not be checked"), + "{:?}", + status.flags() + ); + } + + #[test] + fn a_limit_exactly_met_is_not_a_breach() { + let limits = QcLimits { + max_temperature_drift_c: Some(2.0), + ..QcLimits::default() + }; + let status = evaluate( + &limits, + &rates(10, 10, 60.0), + &Drift { + temperature_c: Some(2.0), + illumination_percent: None, + }, + ); + assert_eq!(status, QcStatus::Pass); + } +} diff --git a/plugins/stage-a-a4/src/runtime.rs b/plugins/stage-a-a4/src/runtime.rs new file mode 100644 index 0000000..8a12563 --- /dev/null +++ b/plugins/stage-a-a4/src/runtime.rs @@ -0,0 +1,3304 @@ +//! Live A4 threshold-survey runner. +//! +//! A4 has one job. At a **fixed optical condition** it walks a protocol of +//! `(diff_on, diff_off)` bias pairs, and for each one it: +//! +//! 1. sets the two biases by cloning the camera configuration the host +//! confirmed when the run opened its session, and changing only `diff_on` +//! and `diff_off` — the host owns no A4-specific verb, so `fo`, `hpf`, +//! `refr`, the ROI and the pixel mask stay frozen because A4 copies them +//! forward unchanged (augur-rs ADR 037); +//! 2. **confirms against the sensor's own readback** that the absolute codes on +//! the die are `factory_default + offset`, and skips the point if they are +//! not, or if the reading is missing or older than the change; +//! 3. settles, and refuses to record until a monitoring sample newer than the +//! settle has arrived — a settle that produced no fresh telemetry is not a +//! settle; +//! 4. records a RAW file for the row's duration, counting ON/OFF events as it +//! goes; +//! 5. checks the receipt (size, hash, duration, clean finalization) and writes +//! an A4 sidecar carrying the protocol row, the bias codes, the bench +//! conditions and the QC verdict. +//! +//! Afterwards — on completion, on Stop, and on any abort — the biases the bench +//! was on before the survey are put back. +//! +//! A4 owns no hardware and never drives the Teensy. The optical condition is +//! the operator's: filters are changed by hand, and a protocol row that needs +//! one says `pause_before` and waits for a button. +//! +//! **What is a hard refusal and what is only a flag** is a deliberate split. +//! The sensor state that makes a threshold number mean something at all — the +//! event filters being off, the bias codes being confirmed, the file being +//! whole — is a gate. The bench *stability* limits (temperature drift, +//! illumination drift, event rate) are flags: the point is recorded, kept, and +//! marked, because whether a 2 °C drift invalidated it is a judgement to make +//! later with the file in hand, and a runner that discarded the point would +//! have thrown away the evidence for making it. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use augur_plugin_api::{ + export_plugin, CameraConfigurationSnapshotV1, CameraConfigurationSourceV1, EventFiltersV1, + EventStoreHandle, GlobalSettings, HostCommand, HostCommandOutcome, HostCommandReply, + HostCommandRequest, HostContext, HostDatasetDescriptor, HostDatasetKind, HostOutput, + HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, PathDialogKind, Plugin, + PluginCapabilities, PluginControlContext, PluginControlInbox, PluginDiscontinuity, PluginFrame, + PluginInput, PluginRuntimeRole, RoiV1, SensorBiasReadbackV1, SensorMonitoringV1, SettingItem, + SettingKind, SettingsSchema, SettingsSection, StatusEntry, TableColumn, TableColumnData, + TableColumnValues, TableDatasetV1, TableSchema, TableValueType, CTX_GLOBAL_SETTINGS, + CTX_SENSOR_MONITORING, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use stage_a_plugin_contract::telemetry; + +use crate::protocol::{self, A4Point, Protocol}; +use crate::qc::{self, Drift, Endpoints, QcStatus, RateSummary}; +use crate::sidecar; + +const STATUS_DATASET_ID: &str = "stage-a-a4.status"; +const STATUS_VIEW_ID: &str = "stage-a-a4.status.view"; +const POINTS_DATASET_ID: &str = "stage-a-a4.points"; +const POINTS_VIEW_ID: &str = "stage-a-a4.points.view"; + +const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// How long to wait for any single host command to answer. The bias command +/// waits on a sensor read the host caps at five seconds, so this has to be +/// comfortably longer or a slow-but-working readback would look like a hang. +const REPLY_TIMEOUT_MS: u64 = 20_000; + +/// A confirming readback older than this is not evidence about the point being +/// recorded. The host already refuses to confirm with a reading taken before +/// the change; this is the plugin's own independent bound on how stale the +/// reading it *records as provenance* may be. +const MAX_READBACK_AGE_S: f64 = 2.0; + +/// A recording shorter than this fraction of what was asked for is a truncated +/// file, not a short one. Below it the point is not counted as recorded. +const MIN_DURATION_FRACTION: f64 = 0.9; + +/// Buttons cross the UI-mirror/live-worker boundary as a monotonic counter, not +/// as a bool. +/// +/// The host runs two instances of every plugin: a UI mirror that renders the +/// panel, and the live worker that actually runs the survey. A click arrives as +/// `true` on the clicked instance, while the other only ever sees the snapshot +/// value from `get_setting` — so a bool would either be missed or replayed +/// forever. A counter advance is one press edge, and the first counter a fresh +/// instance sees is adopted silently so a reloaded worker does not replay old +/// presses. +#[derive(Debug, Default, Clone, Copy)] +struct PressLatch { + counter: u64, + seen: Option, +} + +impl PressLatch { + fn accept(&mut self, value: &Value) -> bool { + if value.as_bool() == Some(true) { + self.counter += 1; + self.seen = Some(self.counter); + return true; + } + let Some(incoming) = value.as_u64() else { + return false; + }; + match self.seen { + None => { + self.seen = Some(incoming); + self.counter = self.counter.max(incoming); + false + } + Some(seen) if incoming > seen => { + self.seen = Some(incoming); + self.counter = self.counter.max(incoming); + true + } + Some(_) => false, + } + } + + fn value(&self) -> Value { + json!(self.counter) + } +} + +/// The two bias offsets A4 varies, around the sensor's per-unit factory trim. +/// +/// Plugin-local bookkeeping: the host contract carries all five biases, and A4 +/// deliberately reads and writes only these two. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct BiasOffsets { + diff_on: i32, + diff_off: i32, +} + +/// Where the run is in the current point's lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunPhase { + /// `ApplyCameraConfiguration { Current }` sent; waiting for the host to + /// confirm the configuration the survey will clone for every point. + OpeningSession, + /// Stopped before a point that needs a filter change or a dark cap. + PausedForOperator, + /// A point's configuration sent; waiting for the host's readback + /// confirmation. + ApplyingBiases, + /// Biases confirmed; waiting out `settle_s` and for fresh telemetry. + Settling, + /// `StartRecording` sent; waiting for the host to acknowledge. + StartingRecording, + /// RAW is being written and events are being counted. + Recording, + /// `StopRecording` sent; waiting for the finalize receipt. + StoppingRecording, + /// Every point is done; putting the operator's biases back. + RestoringBiases, +} + +/// How one point ended. +#[derive(Debug, Clone, PartialEq)] +enum PointOutcome { + Recorded, + /// Skipped or failed, with the reason in the operator's own terms. + Failed(String), +} + +/// One executed point, kept for the status table and the run receipt. +#[derive(Debug, Clone)] +struct PointRecord { + row: usize, + label: String, + diff_on: i64, + diff_off: i64, + repeat: (u32, u32), + outcome: PointOutcome, + codes: Option<(u8, u8)>, + raw: Option, + rates: RateSummary, + qc: QcStatus, +} + +impl PointRecord { + fn status_text(&self) -> String { + match &self.outcome { + PointOutcome::Recorded => "recorded".into(), + PointOutcome::Failed(reason) => format!("failed: {reason}"), + } + } +} + +/// State for the point currently in flight. +#[derive(Debug, Default)] +struct PointState { + stem: String, + /// Set once the host acknowledges the start. + raw_path: Option, + finalized_path: Option, + size: Option, + sha256: Option, + recorded_duration_s: Option, + complete: bool, + incomplete_reason: Option, + /// The confirmed readback for this point, and how stale it was. + readback: Option, + readback_age_s: f64, + applied: BiasOffsets, + /// Bench conditions at the two ends of the recording. + temperature: Endpoints, + illumination: Endpoints, + pixel_dead_time_us: Option, + sensor_age_s: Option, + rates: RateSummary, + /// Where event counting has consumed the stream up to. + counted_to_us: Option, + started_unix_ms: u64, + settle_until_ms: u64, + /// Latest sensor reading the plugin saw when the settle began; the settle + /// is not over until a *newer* one has arrived. + settle_started_ms: u64, + saw_fresh_sensor: bool, +} + +/// An in-flight survey. +#[derive(Debug)] +struct Run { + plan: Protocol, + protocol_path: String, + protocol_sha256: String, + measurement_id: String, + index: usize, + phase: RunPhase, + /// Request id currently awaited, and when it was sent. + pending_request: Option, + last_activity_ms: u64, + stop_requested: bool, + started_at_unix_ms: u64, + /// Set by a reply handler that has decided this point cannot be recorded. + /// Consumed by `drive`, which owns advancing the run — a reply arriving + /// mid-tick must not start the next point before this one is filed. + pending_skip: Option, + point: PointState, + records: Vec, + /// The offsets the bench was on before the survey started. + original: Option, + /// The configuration the host confirmed when this run opened its session. + /// Every point is this snapshot with two fields changed, which is what + /// keeps `fo`, `hpf`, `refr`, the ROI and the mask frozen across the sweep. + camera: Option, + biases_restored: bool, +} + +impl Run { + fn point(&self) -> Option<&A4Point> { + self.plan.points.get(self.index) + } +} + +pub struct StageAA4Plugin { + enabled: bool, + runtime_role: PluginRuntimeRole, + generation: u64, + + output_folder: String, + measurement_id: String, + protocol_path: String, + + press_start: PressLatch, + press_stop: PressLatch, + press_continue: PressLatch, + press_restore: PressLatch, + start_pending: bool, + stop_pending: bool, + continue_pending: bool, + restore_pending: bool, + + host_roi: Option, + sensor_size: (u16, u16), + masked_pixels: usize, + event_filters: Option, + sensor: Option, + /// Bumped every time a fresh monitoring sample lands, so the settle gate + /// can tell "a new reading arrived" from "the same one is still there". + sensor_seq: u64, + + run: Option, + request_seq: u64, + message: String, + /// The offsets the most recent survey found the bench on, kept after the + /// run ends so Restore still has something to put back. A run that died + /// with the host — a crash, a reload — leaves the sensor on whatever + /// threshold it was last set to, and this is the only record of where it + /// started. + last_original: Option, + /// A standalone restore, outside a run, and the request it is waiting on. + restore_request: Option, +} + +impl Default for StageAA4Plugin { + fn default() -> Self { + Self { + enabled: true, + runtime_role: PluginRuntimeRole::LiveWorker, + generation: 1, + output_folder: String::new(), + measurement_id: String::new(), + protocol_path: String::new(), + press_start: PressLatch::default(), + press_stop: PressLatch::default(), + press_continue: PressLatch::default(), + press_restore: PressLatch::default(), + start_pending: false, + stop_pending: false, + continue_pending: false, + restore_pending: false, + host_roi: None, + sensor_size: (1280, 720), + masked_pixels: 0, + event_filters: None, + sensor: None, + sensor_seq: 0, + run: None, + request_seq: 0, + message: "Pick an output folder and a protocol, then press Run protocol".into(), + last_original: None, + restore_request: None, + } + } +} + +/// The control surface the runner drives. Abstracted so the state machine can +/// be tested without a host. +trait HostControl { + fn request_host(&mut self, request: &HostCommandRequest); +} + +impl HostControl for PluginControlContext<'_> { + fn request_host(&mut self, request: &HostCommandRequest) { + // Fully qualified: the trait method and the inherent one share a name, + // so `self.request_host(..)` would resolve back to this one. + let _ = PluginControlContext::request_host(self, request); + } +} + +impl StageAA4Plugin { + fn bump(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + + fn note(&mut self, message: impl Into) { + self.message = message.into(); + self.bump(); + } + + fn next_request_id(&mut self) -> u64 { + self.request_seq += 1; + self.request_seq + } + + /// The offsets the bench is on right now, derived from the sensor's own + /// readback: the configured offset is `current - factory_default`. + /// + /// This is the only way to learn them. The panel value a plugin could read + /// belongs to the host settings UI, not to this plugin, and asking the + /// sensor is the same source the survey confirms every point against. + fn live_offsets(&self) -> Option { + let codes = self.sensor?.bias_codes?; + Some(BiasOffsets { + diff_on: codes.current.diff_on as i32 - codes.factory_default.diff_on as i32, + diff_off: codes.current.diff_off as i32 - codes.factory_default.diff_off as i32, + }) + } + + /// Why a survey must not start right now, phrased as the action that fixes + /// it. `None` means every gate is satisfied. + /// + /// Each of these is checked *before* the first bias moves, because the + /// whole point of a protocol is that it runs unattended: a file that cannot + /// work should say so on the button press. + fn start_blocker(&self) -> Option { + if self.run.is_some() { + return Some("A protocol is already running — press Stop to end it".into()); + } + if self.output_folder.trim().is_empty() { + return Some("Pick an output folder first — that is where the files go".into()); + } + if self.protocol_path.trim().is_empty() { + return Some("Choose a protocol file first".into()); + } + if let Some(filters) = self.event_filters { + let mut on = Vec::new(); + if filters.stc_enabled { + on.push("STC"); + } + if filters.trail_enabled { + on.push("Trail"); + } + if filters.erc_enabled { + on.push("ERC"); + } + if !on.is_empty() { + // These discard events before they are streamed, which is + // exactly the quantity a threshold survey counts. + return Some(format!( + "Turn {} off in the camera settings — a threshold survey counts events, and \ + {} drops some before they are streamed", + on.join(" and "), + if on.len() == 1 { "it" } else { "they" } + )); + } + } + // Without a readback the method is unverifiable: every point would + // record biases nobody can show were live. Refuse rather than run a + // survey whose central claim cannot be checked. + if self.sensor.and_then(|sensor| sensor.bias_codes).is_none() { + return Some( + "The sensor is not reporting its bias codes — A4 confirms every point against \ + that readback, so it will not run without one. Start Preview on a camera with \ + a monitoring block." + .into(), + ); + } + None + } + + /// Load, validate and start the protocol named in the settings. + fn begin_run(&mut self, context: &mut impl HostControl) { + if let Some(blocker) = self.start_blocker() { + self.note(blocker); + return; + } + let path = self.protocol_path.trim().to_owned(); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) => { + self.note(format!("Cannot read {path}: {error}")); + return; + } + }; + let plan = match protocol::parse_file(&path, &text) { + Ok(plan) => plan, + Err(error) => { + self.note(format!("Protocol rejected — {error}")); + return; + } + }; + + let measurement_id = self.ensure_measurement_id(); + let now_ms = now_unix_ms(); + let (on_axis, off_axis) = plan.axis_counts(); + let total = plan.points.len(); + let minutes = plan.total_seconds() / 60.0; + let pauses = if plan.has_pauses() { + " — it has operator pauses, so it cannot be left alone" + } else { + "" + }; + self.message = format!( + "Protocol '{}': {total} recordings ({on_axis} × diff_on, {off_axis} × diff_off), \ + about {minutes:.0} min of bench time{pauses}", + plan.name + ); + + // Captured from the sensor before anything moves, and kept after the + // run ends so Restore can still put the bench back. + let original = self.live_offsets(); + self.last_original = original.or(self.last_original); + + self.run = Some(Run { + plan, + protocol_sha256: sha256_hex(text.as_bytes()), + protocol_path: path, + measurement_id, + index: 0, + phase: RunPhase::OpeningSession, + pending_request: None, + last_activity_ms: now_ms, + stop_requested: false, + started_at_unix_ms: now_ms, + pending_skip: None, + point: PointState::default(), + records: Vec::new(), + original, + camera: None, + biases_restored: false, + }); + self.open_camera_session(context); + self.bump(); + } + + /// Ask the host to preserve and confirm the configuration the bench is on. + /// + /// This is the survey's baseline: the host keeps the pre-run state for the + /// closing restore, and the confirmed snapshot it answers with is what + /// every point clones. Nothing is recorded until it arrives, so a survey + /// can never sweep biases on top of a configuration nobody confirmed. + fn open_camera_session(&mut self, context: &mut impl HostControl) { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::OpeningSession; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } + + /// Begin the point at `index`: pause for the operator if the row asks, else + /// send its biases. + fn enter_point(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + let Some(point) = run.point().cloned() else { + self.finish_run(context); + return; + }; + let (index, total) = (run.index, run.plan.points.len()); + if let Some(run) = self.run.as_mut() { + run.point = PointState::default(); + run.last_activity_ms = now_unix_ms(); + } + if point.pause_before { + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::PausedForOperator; + } + self.note(format!( + "Paused before {}/{total} [{}]: set up '{}', then press Continue", + index + 1, + point.label, + if point.optical_state.is_empty() { + "the next optical condition" + } else { + point.optical_state.as_str() + } + )); + return; + } + self.send_biases(context); + } + + /// Ask the host to program this point's two biases. + /// + /// The request carries a complete configuration because that is the only + /// contract the host offers, but A4 builds it by cloning the snapshot the + /// session confirmed and changing exactly two fields. Everything the + /// threshold measurement depends on staying still is therefore carried + /// forward byte for byte from the baseline. + fn send_biases(&mut self, context: &mut impl HostControl) { + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let (index, total) = self + .run + .as_ref() + .map(|run| (run.index, run.plan.points.len())) + .unwrap_or((0, 0)); + let Some(mut snapshot) = self.run.as_ref().and_then(|run| run.camera.clone()) else { + self.skip_current( + "the host never confirmed a camera configuration for this survey, so there is \ + no baseline to change two biases against", + ); + return; + }; + snapshot.biases.diff_on = point.diff_on as i32; + snapshot.biases.diff_off = point.diff_off as i32; + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::ApplyingBiases; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + self.note(format!( + "Point {}/{total} [{}]: setting diff_on={}, diff_off={}…", + index + 1, + point.label, + point.diff_on, + point.diff_off + )); + } + + /// Start the RAW recording for the settled point. + fn start_recording(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + let Some(point) = run.point().cloned() else { + return; + }; + let (index, total) = (run.index, run.plan.points.len()); + let id = run.measurement_id.clone(); + let stem = format!( + "{id}_{}_{}", + format_compact_utc(now_unix_ms() / 1_000), + point.tag() + ); + let metadata = self.recording_metadata(&point, index, total); + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::StartRecording { + run_id: stem.clone(), + base_path: format!("{id}/{stem}.raw"), + metadata, + }, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::StartingRecording; + run.pending_request = Some(request_id); + run.point.stem = stem; + run.last_activity_ms = now_unix_ms(); + } + self.note(format!( + "Point {}/{total} [{}]: recording for {} s…", + index + 1, + point.label, + point.duration_s + )); + } + + fn stop_recording(&mut self, context: &mut impl HostControl) { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::StopRecording, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::StoppingRecording; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } + + /// Metadata the host writes into the recording's own description, so a RAW + /// found on its own still says which point it is. + fn recording_metadata( + &self, + point: &A4Point, + index: usize, + total: usize, + ) -> BTreeMap { + let mut meta = BTreeMap::new(); + let run = self.run.as_ref(); + meta.insert( + "a4_measurement_id".into(), + run.map(|run| run.measurement_id.clone()) + .unwrap_or_default(), + ); + meta.insert("a4_label".into(), point.label.clone()); + meta.insert("a4_diff_on".into(), point.diff_on.to_string()); + meta.insert("a4_diff_off".into(), point.diff_off.to_string()); + meta.insert("a4_duration_s".into(), point.duration_s.to_string()); + meta.insert( + "a4_repeat".into(), + format!("{}/{}", point.repeat.0, point.repeat.1), + ); + if !point.optical_state.is_empty() { + meta.insert("a4_optical_state".into(), point.optical_state.clone()); + } + if !point.filter_id.is_empty() { + meta.insert("a4_filter_id".into(), point.filter_id.clone()); + } + if !point.flux_id.is_empty() { + meta.insert("a4_flux_id".into(), point.flux_id.clone()); + } + // Read by the host's crash breadcrumb, so a death during an unattended + // survey is pinned to the point it was on. + meta.insert("protocol_point_index".into(), (index + 1).to_string()); + meta.insert("protocol_point_total".into(), total.to_string()); + // The codes actually confirmed on the die for this point. + if let Some(readback) = run.and_then(|run| run.point.readback) { + meta.insert( + "a4_code_diff_on".into(), + readback.current.diff_on.to_string(), + ); + meta.insert( + "a4_code_diff_off".into(), + readback.current.diff_off.to_string(), + ); + } + // Bench conditions, each only when the sensor actually reported it — an + // absent reading must not arrive downstream as 0 °C or 0 lux. + if let Some(sensor) = self.sensor { + if let Some(celsius) = sensor.temperature_c { + meta.insert("sensor_temperature_c".into(), format!("{celsius:.2}")); + } + if let Some(lux) = sensor.illumination_lux { + meta.insert("sensor_illumination_lux".into(), format!("{lux:.3}")); + } + if let Some(dead_time) = sensor.pixel_dead_time_us { + meta.insert( + "sensor_pixel_dead_time_us".into(), + format!("{dead_time:.3}"), + ); + } + } + meta + } + + fn ensure_measurement_id(&mut self) -> String { + if self.measurement_id.trim().is_empty() { + self.measurement_id = generate_measurement_id(); + } + sanitize_stem(self.measurement_id.trim()) + } + + /// Give up on the current point and move on. The run continues: one bad + /// point out of forty is not a reason to lose the other thirty-nine. + fn fail_point(&mut self, context: &mut impl HostControl, reason: impl Into) { + let reason = reason.into(); + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let index = self.run.as_ref().map(|run| run.index).unwrap_or(0); + self.record_point(&point, index, PointOutcome::Failed(reason.clone())); + self.note(format!( + "Point {} [{}] skipped: {reason}", + index + 1, + point.label + )); + self.advance(context); + } + + /// File the point's outcome into the run's own record, and write its + /// sidecar. Both happen for failures too — the record of a failed point is + /// the reason the survey has a hole in it. + fn record_point(&mut self, point: &A4Point, index: usize, outcome: PointOutcome) { + let (rates, drift, status) = self.evaluate_point(point); + let codes = self + .run + .as_ref() + .and_then(|run| run.point.readback) + .map(|readback| (readback.current.diff_on, readback.current.diff_off)); + let raw = self.run.as_ref().and_then(|run| { + run.point + .finalized_path + .clone() + .or(run.point.raw_path.clone()) + }); + + // Gather before writing, so the sidecar records the final paths. + self.gather_point(); + if let Err(error) = self.write_sidecar(point, index, &outcome, &rates, &drift, &status) { + self.message = format!("{}; sidecar not saved: {error}", self.message); + } + + if let Some(run) = self.run.as_mut() { + run.records.push(PointRecord { + row: index + 1, + label: point.label.clone(), + diff_on: point.diff_on, + diff_off: point.diff_off, + repeat: point.repeat, + outcome, + codes, + raw, + rates, + qc: status, + }); + } + } + + fn evaluate_point(&self, point: &A4Point) -> (RateSummary, Drift, QcStatus) { + let Some(run) = self.run.as_ref() else { + return ( + RateSummary::default(), + Drift::default(), + QcStatus::NotEvaluated, + ); + }; + let rates = run.point.rates; + let drift = qc::drift(run.point.temperature, run.point.illumination); + let status = qc::evaluate(&point.limits, &rates, &drift); + (rates, drift, status) + } + + /// Step to the next point, or finish the run. + fn advance(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_mut() else { + return; + }; + run.index += 1; + run.last_activity_ms = now_unix_ms(); + let done = run.index >= run.plan.points.len() || run.stop_requested; + if done { + self.finish_run(context); + } else { + self.enter_point(context); + } + } + + /// Put the operator's biases back and end the run. + /// + /// The restore is a command like any other, so the run does not disappear + /// until it is answered — a survey that vanished while the sensor was still + /// on its last threshold would leave the bench silently misconfigured. + fn finish_run(&mut self, context: &mut impl HostControl) { + let Some(run) = self.run.as_ref() else { + return; + }; + // The host preserved the pre-run configuration when the session opened, + // so the restore is its own verb rather than a bias change back — which + // also puts back anything a point's snapshot carried along with the two + // biases. Nothing to restore if the session never opened. + if run.camera.is_some() && !run.biases_restored { + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::RestoreCameraConfiguration, + }); + if let Some(run) = self.run.as_mut() { + run.phase = RunPhase::RestoringBiases; + run.pending_request = Some(request_id); + run.last_activity_ms = now_unix_ms(); + } + } else { + self.close_run(); + } + } + + /// Write the run receipt, report, and drop the run. + fn close_run(&mut self) { + let Some(run) = self.run.take() else { + return; + }; + let recorded = run + .records + .iter() + .filter(|record| record.outcome == PointOutcome::Recorded) + .count(); + let failed = run.records.len() - recorded; + let flagged = run + .records + .iter() + .filter(|record| record.qc.is_flagged()) + .count(); + let total = run.plan.points.len(); + let name = run.plan.name.clone(); + let stopped = run.stop_requested; + + let receipt = self.write_receipt(&run, recorded, failed, flagged); + + let mut message = format!( + "Protocol '{name}' {}: {recorded}/{total} recorded", + if stopped { "stopped" } else { "finished" } + ); + if failed > 0 { + // Name the reasons, not just the count: an unattended run's whole + // report is this one line. + let mut reasons: Vec = run + .records + .iter() + .filter_map(|record| match &record.outcome { + PointOutcome::Failed(reason) => Some(reason.clone()), + PointOutcome::Recorded => None, + }) + .collect::>() + .into_iter() + .collect(); + reasons.truncate(3); + message.push_str(&format!(" — {failed} skipped ({})", reasons.join("; "))); + } + if flagged > 0 { + message.push_str(&format!(", {flagged} QC-flagged")); + } + message.push_str(if run.biases_restored { + ". Biases restored." + } else { + ". Biases NOT restored — check the camera settings." + }); + if let Err(error) = receipt { + message.push_str(&format!(" Protocol receipt not saved: {error}")); + } + self.note(message); + } + + // ---- artefacts --------------------------------------------------------- + + fn measurement_dir(&self) -> Option { + let run = self.run.as_ref()?; + let folder = self.output_folder.trim(); + if folder.is_empty() { + return None; + } + Some(Path::new(folder).join(&run.measurement_id)) + } + + /// Collect the finalized artefacts into `//`. + /// + /// The host resolves plugin recording paths below *its* output directory + /// and rejects absolute ones, so without this a measurement is split across + /// two unrelated folders. The RAW is closed and hashed by the time its + /// receipt arrives, so moving it here is safe. + fn gather_point(&mut self) { + let Some(dir) = self.measurement_dir() else { + return; + }; + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let raw = self.run.as_ref().and_then(|run| { + run.point + .finalized_path + .clone() + .or(run.point.raw_path.clone()) + }); + let Some(raw) = raw else { + return; + }; + if let Some(moved) = move_into(&dir, &raw) { + if let Some(run) = self.run.as_mut() { + if run.point.finalized_path.is_some() { + run.point.finalized_path = Some(moved.clone()); + } + run.point.raw_path = Some(moved); + } + } + // The host writes the camera's own bias/config sidecar as a sibling of + // the RAW; it travels with it so the recording stays self-describing. + if let Some(bias) = sibling_toml(&raw) { + move_into(&dir, &bias); + } + self.gather_sensor_readout(&dir, &raw); + } + + /// Compact the host's sensor-telemetry CSV into the measurement folder + /// under this recording's own stem, and remove the wide original. + /// + /// Best-effort throughout: a missing telemetry file is normal (a camera + /// with no monitoring block, a host that did not poll) and must not cost + /// the operator the point that just finished. + fn gather_sensor_readout(&mut self, dir: &Path, raw: &str) { + let Some(run) = self.run.as_ref() else { + return; + }; + let (id, stem) = (run.measurement_id.clone(), run.point.stem.clone()); + let source = Path::new(raw) + .file_stem() + .map(|file_stem| { + Path::new(raw) + .parent() + .unwrap_or(Path::new(".")) + .join(format!( + "{}.sensor-monitoring.csv", + file_stem.to_string_lossy() + )) + }) + .filter(|path| path.exists()); + let Some(source) = source else { + return; + }; + let Ok(text) = std::fs::read_to_string(&source) else { + return; + }; + let readout = telemetry::parse_csv(&text); + if readout.is_empty() { + // Nothing worth keeping, but the wide original is still clutter in + // the host's capture folder. + let _ = std::fs::remove_file(&source); + return; + } + let destination = dir.join(format!("{stem}.sensor.json")); + let json = readout.to_json(telemetry::SCHEMA_A4, &id, &stem); + if std::fs::write(&destination, json).is_ok() { + let _ = std::fs::remove_file(&source); + } + } + + fn write_sidecar( + &self, + point: &A4Point, + index: usize, + outcome: &PointOutcome, + rates: &RateSummary, + drift: &Drift, + status: &QcStatus, + ) -> Result { + let run = self.run.as_ref().ok_or("no run")?; + let dir = self.measurement_dir().ok_or("no output folder")?; + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let state = &run.point; + let readback = state.readback.unwrap_or_default(); + let roi = self.host_roi.unwrap_or_default(); + let filters = self.event_filters.unwrap_or_default(); + // A stem is only assigned once a recording starts; a point that failed + // before that still gets a sidecar, named after its protocol row. + let stem = if state.stem.is_empty() { + format!("{}_{}_unrecorded", run.measurement_id, point.tag()) + } else { + state.stem.clone() + }; + + let doc = sidecar::SidecarDoc { + schema: sidecar::SIDECAR_SCHEMA, + measurement_id: run.measurement_id.clone(), + recording: stem.clone(), + recorded_at_utc: format_iso_utc(now_unix_ms() / 1_000), + plugin_version: PLUGIN_VERSION, + protocol: sidecar::ProtocolSection { + name: run.plan.name.clone(), + file: run.protocol_path.clone(), + sha256: run.protocol_sha256.clone(), + row: index + 1, + rows_total: run.plan.points.len(), + label: point.label.clone(), + repeat: point.repeat.0, + repeats: point.repeat.1, + requested_duration_s: point.duration_s, + requested_settle_s: point.settle_s, + }, + bias: sidecar::BiasSection { + requested_diff_on: point.diff_on, + requested_diff_off: point.diff_off, + applied_diff_on: state.applied.diff_on, + applied_diff_off: state.applied.diff_off, + code_diff_on: readback.current.diff_on, + code_diff_off: readback.current.diff_off, + factory_diff_on: readback.factory_default.diff_on, + factory_diff_off: readback.factory_default.diff_off, + code_fo: readback.current.fo, + code_hpf: readback.current.hpf, + code_refr: readback.current.refr, + readback_age_s: state.readback_age_s, + confirmed: state.readback.is_some(), + }, + optics: sidecar::OpticsSection { + optical_state: point.optical_state.clone(), + filter_id: point.filter_id.clone(), + flux_id: point.flux_id.clone(), + paused_for_operator: point.pause_before, + }, + sensor: sidecar::SensorSection { + temperature_c_start: state.temperature.start, + temperature_c_end: state.temperature.end, + illumination_lux_start: state.illumination.start, + illumination_lux_end: state.illumination.end, + pixel_dead_time_us: state.pixel_dead_time_us, + reading_age_s: state.sensor_age_s, + illumination_note: + "Sensor lux is the die's own integrated reading, used here as a stability \ + indicator only. It is not a calibrated optical power.", + }, + filters: sidecar::FiltersSection { + stc_enabled: filters.stc_enabled, + trail_enabled: filters.trail_enabled, + erc_enabled: filters.erc_enabled, + erc_note: "This host has no event-rate controller, so ERC is off by construction \ + rather than by configuration.", + }, + camera: sidecar::CameraSection { + roi_x: roi.x, + roi_y: roi.y, + roi_width: roi.width, + roi_height: roi.height, + masked_pixels: self.masked_pixels, + sensor_width: self.sensor_size.0, + sensor_height: self.sensor_size.1, + }, + files: sidecar::FilesSection { + raw: state.finalized_path.clone().or(state.raw_path.clone()), + raw_size_bytes: state.size, + raw_sha256: state.sha256.clone(), + recorded_duration_s: state.recorded_duration_s, + sensor_readout: (!state.stem.is_empty()) + .then(|| dir.join(format!("{stem}.sensor.json"))) + .filter(|path| path.exists()) + .map(|path| path.display().to_string()), + complete: *outcome == PointOutcome::Recorded, + incomplete_reason: match outcome { + PointOutcome::Recorded => None, + PointOutcome::Failed(reason) => Some(reason.clone()), + }, + }, + qc: sidecar::QcSection { + status: status.as_str().to_owned(), + flags: status.flags().to_vec(), + on_events: rates.on_events, + off_events: rates.off_events, + total_events: rates.total_events(), + counted_seconds: rates.seconds, + on_rate_hz: rates.on_rate_hz(), + off_rate_hz: rates.off_rate_hz(), + total_rate_hz: rates.total_rate_hz(), + on_fraction: rates.on_fraction(), + temperature_drift_c: drift.temperature_c, + illumination_drift_percent: drift.illumination_percent, + limit_temperature_drift_c: point.limits.max_temperature_drift_c, + limit_illumination_drift_percent: point.limits.max_illumination_drift_percent, + limit_event_rate: point.limits.max_event_rate, + rate_note: "Rates are counted from the frames this plugin observed, over \ + counted_seconds. Compare that against recorded_duration_s for the coverage.", + }, + }; + + let path = dir.join(format!("{stem}.a4.toml")); + let text = toml::to_string_pretty(&doc).map_err(|error| error.to_string())?; + std::fs::write(&path, text).map_err(|error| error.to_string())?; + Ok(path.display().to_string()) + } + + /// Copy the protocol into the measurement folder and write the receipt + /// beside it, so the folder says which rows ran without anyone having to + /// diff filenames against the source file. + fn write_receipt( + &self, + run: &Run, + recorded: usize, + failed: usize, + flagged: usize, + ) -> Result<(), String> { + let folder = self.output_folder.trim(); + if folder.is_empty() { + return Err("no output folder".into()); + } + let dir = Path::new(folder).join(&run.measurement_id); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + + // The copy travels with the data; the original stays where the + // operator keeps it. + let source = Path::new(&run.protocol_path); + if let Some(name) = source.file_name() { + let _ = std::fs::copy(source, dir.join(name)); + } + + let receipt = sidecar::ProtocolReceipt { + schema: sidecar::RECEIPT_SCHEMA, + measurement_id: run.measurement_id.clone(), + protocol_name: run.plan.name.clone(), + protocol_file: run.protocol_path.clone(), + protocol_sha256: run.protocol_sha256.clone(), + started_at_utc: format_iso_utc(run.started_at_unix_ms / 1_000), + finished_at_utc: format_iso_utc(now_unix_ms() / 1_000), + outcome: if run.stop_requested { + "stopped".into() + } else { + "finished".into() + }, + rows_total: run.plan.points.len(), + rows_recorded: recorded, + rows_failed: failed, + rows_flagged: flagged, + restored_diff_on: run.original.map(|original| original.diff_on), + restored_diff_off: run.original.map(|original| original.diff_off), + biases_restored: run.biases_restored, + row: run + .records + .iter() + .map(|record| sidecar::ReceiptRow { + row: record.row, + label: record.label.clone(), + diff_on: record.diff_on, + diff_off: record.diff_off, + repeat: record.repeat.0, + status: match &record.outcome { + PointOutcome::Recorded => "recorded".into(), + PointOutcome::Failed(_) => "failed".into(), + }, + reason: match &record.outcome { + PointOutcome::Recorded => None, + PointOutcome::Failed(reason) => Some(reason.clone()), + }, + raw: record.raw.clone(), + qc: record.qc.as_str().to_owned(), + }) + .collect(), + }; + let name = format!("{}.protocol-status.toml", run.measurement_id); + let text = toml::to_string_pretty(&receipt).map_err(|error| error.to_string())?; + std::fs::write(dir.join(name), text).map_err(|error| error.to_string()) + } + + // ---- replies ----------------------------------------------------------- + + fn on_host_reply(&mut self, reply: &HostCommandReply) { + // A standalone restore, pressed outside a run. + if self.restore_request == Some(reply.request_id) { + self.restore_request = None; + self.message = match &reply.outcome { + HostCommandOutcome::CameraConfigurationRestored { readback, .. } => format!( + "Biases restored — the sensor reports diff_on={}, diff_off={}", + readback.current.diff_on, readback.current.diff_off + ), + HostCommandOutcome::Rejected { code, message } => { + format!("Restore refused ({code}): {message}") + } + _ => "Restore answered with an unexpected receipt".into(), + }; + self.bump(); + return; + } + + let Some(run) = self.run.as_ref() else { + return; + }; + if run.pending_request != Some(reply.request_id) { + return; + } + let phase = run.phase; + if let Some(run) = self.run.as_mut() { + run.pending_request = None; + run.last_activity_ms = now_unix_ms(); + } + match phase { + RunPhase::OpeningSession => self.on_session_reply(&reply.outcome), + RunPhase::ApplyingBiases => self.on_biases_reply(&reply.outcome), + RunPhase::StartingRecording => self.on_start_reply(&reply.outcome), + RunPhase::StoppingRecording => self.on_stop_reply(&reply.outcome), + RunPhase::RestoringBiases => { + if let Some(run) = self.run.as_mut() { + run.biases_restored = matches!( + reply.outcome, + HostCommandOutcome::CameraConfigurationRestored { .. } + ); + } + self.close_run(); + } + _ => {} + } + } + + /// The host confirmed the baseline configuration. Keep it as the snapshot + /// every point clones, then begin the first point. + /// + /// A survey that cannot get this cannot run at all — unlike a single point, + /// there is nothing to skip forward to — so a refusal ends the run instead + /// of failing a point. + fn on_session_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + // Kept here and consumed by `drive`, which owns advancing the run. + HostCommandOutcome::CameraConfigurationApplied { snapshot, .. } => { + if let Some(run) = self.run.as_mut() { + run.camera = Some(snapshot.clone()); + } + } + HostCommandOutcome::Rejected { code, message } => { + self.note(format!( + "The host refused to confirm the camera configuration ({code}): {message}" + )); + self.close_run(); + } + _ => { + self.note("The host answered the configuration request with an unexpected receipt"); + self.close_run(); + } + } + } + + fn on_biases_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + HostCommandOutcome::CameraConfigurationApplied { + snapshot, + readback, + readback_age_s, + .. + } => { + let applied = BiasOffsets { + diff_on: snapshot.biases.diff_on, + diff_off: snapshot.biases.diff_off, + }; + let point = self.run.as_ref().and_then(|run| run.point().cloned()); + let Some(point) = point else { return }; + // The host already confirmed the codes; A4 checks them again + // against what *it* asked for. The two are the same check from + // two sides, and a threshold point is worth the second look. + let expected_on = expected_code(readback.factory_default.diff_on, point.diff_on); + let expected_off = expected_code(readback.factory_default.diff_off, point.diff_off); + if readback.current.diff_on != expected_on + || readback.current.diff_off != expected_off + { + let reason = format!( + "the sensor reports diff_on={}/diff_off={} but the row asks for \ + {expected_on}/{expected_off}", + readback.current.diff_on, readback.current.diff_off + ); + self.skip_current(reason); + return; + } + if *readback_age_s > MAX_READBACK_AGE_S { + self.skip_current(format!( + "the confirming bias reading was {readback_age_s:.1} s old, past the \ + {MAX_READBACK_AGE_S:.0} s this point will accept" + )); + return; + } + let now_ms = now_unix_ms(); + let settle_ms = (point.settle_s * 1_000.0).round().max(0.0) as u64; + let seq = self.sensor_seq; + if let Some(run) = self.run.as_mut() { + run.point.readback = Some(*readback); + run.point.readback_age_s = *readback_age_s; + run.point.applied = applied; + run.phase = RunPhase::Settling; + run.point.settle_until_ms = now_ms.saturating_add(settle_ms); + run.point.settle_started_ms = seq; + run.point.saw_fresh_sensor = false; + run.last_activity_ms = now_ms; + } + self.note(format!( + "Point [{}]: codes {}/{} confirmed, settling {:.1} s…", + point.label, + readback.current.diff_on, + readback.current.diff_off, + point.settle_s + )); + } + HostCommandOutcome::Rejected { code, message } => { + // Carry the host's own wording through: "turn the STC filter + // off" tells the operator what to do, "bias change failed" + // does not. + self.skip_current(format!( + "the host refused the bias change ({code}): {message}" + )); + } + _ => self.skip_current("the host answered the bias change with a recording receipt"), + } + } + + fn on_start_reply(&mut self, outcome: &HostCommandOutcome) { + match outcome { + HostCommandOutcome::RecordingStarted { + actual_raw_path, .. + } => { + let now_ms = now_unix_ms(); + let sensor = self.sensor; + if let Some(run) = self.run.as_mut() { + run.point.raw_path = Some(actual_raw_path.clone()); + run.phase = RunPhase::Recording; + run.point.started_unix_ms = now_ms; + // Freeze the bench conditions this point begins under, + // before the recording has had time to move them. + if let Some(sensor) = sensor { + run.point.temperature.start = sensor.temperature_c; + run.point.illumination.start = sensor.illumination_lux; + run.point.pixel_dead_time_us = sensor.pixel_dead_time_us; + run.point.sensor_age_s = Some(sensor.age_s); + } + run.last_activity_ms = now_ms; + } + } + HostCommandOutcome::Rejected { code, message } => { + self.skip_current(format!( + "the host refused the recording ({code}): {message}" + )); + } + _ => self.skip_current("the host answered the start with an unexpected receipt"), + } + } + + fn on_stop_reply(&mut self, outcome: &HostCommandOutcome) { + let requested_s = self + .run + .as_ref() + .and_then(|run| run.point().map(|point| point.duration_s)) + .unwrap_or(0) as f64; + let sensor = self.sensor; + let Some(run) = self.run.as_mut() else { + return; + }; + if let Some(sensor) = sensor { + run.point.temperature.end = sensor.temperature_c; + run.point.illumination.end = sensor.illumination_lux; + } + let verdict = match outcome { + HostCommandOutcome::RecordingFinalized { + actual_raw_path, + size, + sha256, + duration_us, + } => { + let seconds = *duration_us as f64 / 1_000_000.0; + run.point.finalized_path = Some(actual_raw_path.clone()); + run.point.size = Some(*size); + run.point.sha256 = Some(sha256.clone()); + run.point.recorded_duration_s = Some(seconds); + // Every part of the receipt is checked, not just the word the + // host used: an empty file, a missing hash, or a recording cut + // short is not a threshold point. + if *size == 0 { + Err("the recording is empty (0 bytes)".to_owned()) + } else if sha256.trim().is_empty() { + Err("the recording finished without a hash".to_owned()) + } else if requested_s > 0.0 && seconds < requested_s * MIN_DURATION_FRACTION { + Err(format!( + "the recording is {seconds:.1} s of the {requested_s:.0} s asked for" + )) + } else { + Ok(()) + } + } + HostCommandOutcome::RecordingPartial { + actual_raw_path, + size, + sha256, + duration_us, + reason, + } => { + // Kept on disk and fully described, but never counted as a + // success: a partial file is not a threshold point. + run.point.finalized_path = Some(actual_raw_path.clone()); + run.point.size = *size; + run.point.sha256 = sha256.clone(); + run.point.recorded_duration_s = Some(*duration_us as f64 / 1_000_000.0); + Err(format!("the recording did not finalize cleanly: {reason}")) + } + HostCommandOutcome::Rejected { code, message } => { + Err(format!("the stop was refused ({code}): {message}")) + } + HostCommandOutcome::RecordingStarted { .. } + | HostCommandOutcome::CameraConfigurationApplied { .. } + | HostCommandOutcome::CameraConfigurationRestored { .. } => { + Err("the stop answered with an unexpected receipt".to_owned()) + } + }; + // Filed on the next tick by `drive`, which owns advancing the run. + run.point.complete = verdict.is_ok(); + run.point.incomplete_reason = verdict.err(); + } + + /// Mark the current point as unrecordable. `drive` files it on the next + /// tick — reply handlers must not advance the run themselves, or a reply + /// arriving mid-tick would start the next point before this one is filed. + fn skip_current(&mut self, reason: impl Into) { + let reason = reason.into(); + if let Some(run) = self.run.as_mut() { + run.pending_skip = Some(reason.clone()); + run.point.complete = false; + } + self.message = reason; + self.bump(); + } + + // ---- the tick ---------------------------------------------------------- + + /// Advance the run one control tick. + fn drive(&mut self, context: &mut impl HostControl) { + if self.restore_pending { + self.restore_pending = false; + self.restore_biases_now(context); + } + if self.run.is_none() { + if self.start_pending { + self.start_pending = false; + self.begin_run(context); + } + self.stop_pending = false; + self.continue_pending = false; + return; + } + if self.start_pending { + // Say so rather than swallowing the press: Stop is a different + // button, and a silently ignored one reads as a dead control. + self.start_pending = false; + self.note("A protocol is already running — press Stop to end it"); + } + if self.stop_pending { + self.stop_pending = false; + if let Some(run) = self.run.as_mut() { + run.stop_requested = true; + } + self.note("Stopping after the point in flight…"); + } + + let now_ms = now_unix_ms(); + // A reply handler decided this point cannot be recorded. File it here, + // before anything else looks at the phase. + if let Some(reason) = self.run.as_mut().and_then(|run| run.pending_skip.take()) { + self.fail_point(context, reason); + return; + } + + let (phase, stop_requested, pending, last_activity) = { + let run = self.run.as_ref().expect("checked above"); + ( + run.phase, + run.stop_requested, + run.pending_request, + run.last_activity_ms, + ) + }; + + // A command that never came back must not strand an unattended survey. + if pending.is_some() && now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + if let Some(run) = self.run.as_mut() { + run.pending_request = None; + } + match phase { + RunPhase::RestoringBiases => { + self.note( + "The host did not answer the bias restore — check the camera settings", + ); + self.close_run(); + } + // Nothing has been changed or recorded yet, and there is no + // baseline to record against, so end the run rather than fail + // every point in it one timeout at a time. + RunPhase::OpeningSession => { + self.note( + "The host did not confirm the camera configuration in time — the survey \ + did not start", + ); + self.close_run(); + } + RunPhase::StoppingRecording => { + self.fail_point(context, "the host did not answer the stop in time"); + } + _ => self.fail_point(context, "the host did not answer in time"), + } + return; + } + // Stop ends the run as soon as it can do so safely, which is not the + // same as immediately. A recording in flight has to wind down — an + // abandoned one leaves a truncated RAW behind — and a start already + // sent has to be answered before it can be stopped at all, or the host + // is left recording with nobody to end it. Everywhere else there is no + // file at risk, so waiting out a bias reply would only make Stop feel + // dead for twenty seconds. + if stop_requested + && matches!( + phase, + RunPhase::OpeningSession + | RunPhase::PausedForOperator + | RunPhase::ApplyingBiases + | RunPhase::Settling + ) + { + self.finish_run(context); + return; + } + if pending.is_some() { + return; + } + + match phase { + // The baseline reply has landed (pending is clear); begin the + // first point against it. + RunPhase::OpeningSession => { + if self.run.as_ref().is_some_and(|run| run.camera.is_some()) { + self.enter_point(context); + } + } + RunPhase::PausedForOperator => { + if self.continue_pending { + self.continue_pending = false; + self.send_biases(context); + } + } + // Waiting on a reply that has not arrived and has not timed out. + RunPhase::ApplyingBiases | RunPhase::StartingRecording | RunPhase::RestoringBiases => {} + RunPhase::Settling => { + if now_ms < self.run.as_ref().map_or(0, |run| run.point.settle_until_ms) { + return; + } + // A settle that produced no fresh telemetry is not a settle: + // without a new reading there is no evidence the bench has + // stopped moving, and the point's start conditions would be + // copied from before the bias change. + if !self + .run + .as_ref() + .is_some_and(|run| run.point.saw_fresh_sensor) + { + if now_ms.saturating_sub(last_activity) > REPLY_TIMEOUT_MS { + self.fail_point( + context, + "no fresh sensor reading arrived during the settle, so the bench \ + could not be confirmed stable", + ); + } + return; + } + self.start_recording(context); + } + RunPhase::Recording => { + let (started, duration_s) = { + let run = self.run.as_ref().expect("checked above"); + ( + run.point.started_unix_ms, + run.point().map(|point| point.duration_s).unwrap_or(0), + ) + }; + let elapsed_ms = now_ms.saturating_sub(started); + let over = elapsed_ms >= (duration_s.max(0) as u64).saturating_mul(1_000); + if over || stop_requested { + self.stop_recording(context); + } + } + RunPhase::StoppingRecording => { + // The reply has landed (pending is clear); file the point. + let Some(point) = self.run.as_ref().and_then(|run| run.point().cloned()) else { + return; + }; + let index = self.run.as_ref().map(|run| run.index).unwrap_or(0); + let complete = self.run.as_ref().is_some_and(|run| run.point.complete); + let reason = self + .run + .as_ref() + .and_then(|run| run.point.incomplete_reason.clone()); + if complete { + self.record_point(&point, index, PointOutcome::Recorded); + self.note(format!("Point {} [{}] recorded", index + 1, point.label)); + self.advance(context); + } else { + self.fail_point( + context, + reason.unwrap_or_else(|| "the recording did not finalize".into()), + ); + } + } + } + } + + /// Put the biases back where the last survey found them, outside a run. + /// + /// This is the recovery path for a run that did not get to restore them + /// itself — a point left the sensor on a threshold nobody wants it on. + /// During a run it is refused: the run restores them when it ends, and a + /// restore in the middle would silently retarget the point being recorded. + /// + /// The pre-run state belongs to the host's session, not to this plugin, so + /// this asks the host to put it back rather than re-sending remembered + /// offsets. A host that was reloaded mid-survey has no session left and + /// refuses; its wording is carried through to the operator. + fn restore_biases_now(&mut self, context: &mut impl HostControl) { + if self.run.is_some() { + self.note("A protocol is running — it puts the biases back when it ends"); + return; + } + if self.restore_request.is_some() { + return; + } + if self.last_original.is_none() { + self.note( + "Nothing to restore — no survey has changed the biases since this plugin loaded", + ); + return; + } + let request_id = self.next_request_id(); + context.request_host(&HostCommandRequest { + request_id, + command: HostCommand::RestoreCameraConfiguration, + }); + self.restore_request = Some(request_id); + self.note("Restoring the configuration the survey started from…"); + } + + // ---- datasets ---------------------------------------------------------- + + fn status_dataset(&self) -> TableDatasetV1 { + let column = |id: &str, value: String| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(vec![value]), + }; + let run = self.run.as_ref(); + let state = match run.map(|run| run.phase) { + None => "idle".to_owned(), + Some(RunPhase::OpeningSession) => "confirming the camera configuration".to_owned(), + Some(RunPhase::PausedForOperator) => "paused — press Continue".to_owned(), + Some(RunPhase::ApplyingBiases) => "setting biases".to_owned(), + Some(RunPhase::Settling) => "settling".to_owned(), + Some(RunPhase::StartingRecording) => "starting".to_owned(), + Some(RunPhase::Recording) => "recording".to_owned(), + Some(RunPhase::StoppingRecording) => "saving".to_owned(), + Some(RunPhase::RestoringBiases) => "restoring biases".to_owned(), + }; + let progress = run + .map(|run| { + format!( + "{}/{}", + (run.index + 1).min(run.plan.points.len()), + run.plan.points.len() + ) + }) + .unwrap_or_else(|| "—".into()); + let biases = run + .and_then(|run| run.point()) + .map(|point| format!("{} / {}", point.diff_on, point.diff_off)) + .unwrap_or_else(|| "—".into()); + let codes = self + .sensor + .and_then(|sensor| sensor.bias_codes) + .map(|codes| format!("{} / {}", codes.current.diff_on, codes.current.diff_off)) + .unwrap_or_else(|| "—".into()); + let rate = run + .and_then(|run| run.point.rates.total_rate_hz()) + .map(|hz| format!("{hz:.0} ev/s")) + .unwrap_or_else(|| "—".into()); + let temperature = self + .sensor + .and_then(|sensor| sensor.temperature_c) + .map(|celsius| format!("{celsius:.1} °C")) + .unwrap_or_else(|| "—".into()); + + TableDatasetV1 { + columns: vec![ + column("state", state), + column("progress", progress), + column("biases", biases), + column("codes", codes), + column("rate", rate), + column("temperature", temperature), + column("message", self.message.clone()), + ], + } + } + + fn points_dataset(&self) -> TableDatasetV1 { + let records: &[PointRecord] = self + .run + .as_ref() + .map(|run| run.records.as_slice()) + .unwrap_or(&[]); + let column = |id: &str, values: Vec| TableColumnData { + column_id: id.into(), + values: TableColumnValues::String(values), + }; + let map = + |select: fn(&PointRecord) -> String| records.iter().map(select).collect::>(); + TableDatasetV1 { + columns: vec![ + column("row", map(|record| record.row.to_string())), + column("label", map(|record| record.label.clone())), + column( + "offsets", + map(|record| format!("{} / {}", record.diff_on, record.diff_off)), + ), + column( + "codes", + map(|record| { + record + .codes + .map(|(on, off)| format!("{on} / {off}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "repeat", + map(|record| format!("{}/{}", record.repeat.0, record.repeat.1)), + ), + column( + "on_rate", + map(|record| { + record + .rates + .on_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "off_rate", + map(|record| { + record + .rates + .off_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column( + "total_rate", + map(|record| { + record + .rates + .total_rate_hz() + .map(|hz| format!("{hz:.0}")) + .unwrap_or_else(|| "—".into()) + }), + ), + column("qc", map(|record| record.qc.as_str().to_owned())), + column("status", map(|record| record.status_text())), + ], + } + } +} + +/// The absolute code a sensor programs for an offset: the factory trim plus the +/// offset, saturated into the 8-bit register. Mirrors the host's own rule so +/// A4 can state what it expects before the readback arrives. +fn expected_code(factory_default: u8, offset: i64) -> u8 { + (factory_default as i64 + offset).clamp(0, 255) as u8 +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Moves `source` into `dir`, returning the new path when it now lives there. +/// +/// A rename covers the common case (one volume) at zero cost; a cross-volume +/// move falls back to copy-then-delete, and the copy is size-checked before the +/// original goes away so a failed move never loses measurement data. `None` +/// means the file stayed where it was — callers keep the original path. +fn move_into(dir: &Path, source: &str) -> Option { + let source = Path::new(source); + let name = source.file_name()?; + if source.parent() == Some(dir) { + return None; + } + if !source.is_file() { + return None; + } + let destination = dir.join(name); + if destination.exists() { + return None; + } + if std::fs::rename(source, &destination).is_ok() { + return Some(destination.display().to_string()); + } + let copied = std::fs::copy(source, &destination).ok()?; + let expected = source.metadata().ok()?.len(); + if copied != expected { + let _ = std::fs::remove_file(&destination); + return None; + } + // Keeping the original after a verified copy is harmless; losing it is not. + let _ = std::fs::remove_file(source); + Some(destination.display().to_string()) +} + +fn sibling_toml(raw_path: &str) -> Option { + let path = Path::new(raw_path); + let stem = path.file_stem()?.to_string_lossy(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + Some(parent.join(format!("{stem}.toml")).display().to_string()) +} + +/// Replace anything that is not `[A-Za-z0-9._-]` with `_` so ids are file-safe. +fn sanitize_stem(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for character in input.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + out.push(character); + } else if !out.ends_with('_') { + out.push('_'); + } + } + let trimmed = out.trim_matches('_').to_string(); + if trimmed.is_empty() { + "A4".into() + } else { + trimmed + } +} + +fn generate_measurement_id() -> String { + let ms = now_unix_ms(); + format!("A4-{}-{:04x}", format_compact_date(ms / 1_000), ms & 0xffff) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// Gregorian date for a count of days since the Unix epoch (Howard Hinnant's +/// civil-from-days algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (year + i64::from(month <= 2), month, day) +} + +fn ymd_hms(unix_secs: u64) -> (i64, u32, u32, u64, u64, u64) { + let days = (unix_secs / 86_400) as i64; + let sod = unix_secs % 86_400; + let (year, month, day) = civil_from_days(days); + (year, month, day, sod / 3_600, (sod % 3_600) / 60, sod % 60) +} + +fn format_compact_date(unix_secs: u64) -> String { + let (y, m, d, ..) = ymd_hms(unix_secs); + format!("{y:04}{m:02}{d:02}") +} + +fn format_compact_utc(unix_secs: u64) -> String { + let (y, m, d, hh, mm, ss) = ymd_hms(unix_secs); + format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}") +} + +fn format_iso_utc(unix_secs: u64) -> String { + let (y, m, d, hh, mm, ss) = ymd_hms(unix_secs); + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +impl Plugin for StageAA4Plugin { + fn name(&self) -> &'static str { + "Stage-A A4 Threshold" + } + + fn description(&self) -> &'static str { + "Stage-A A4 contrast-threshold survey: steps diff_on/diff_off through a protocol at one \ + fixed optical condition, confirming every point against the sensor's own bias readback \ + before it records." + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn set_runtime_role(&mut self, role: PluginRuntimeRole) { + self.runtime_role = role; + } + + fn reset(&mut self) { + self.bump(); + } + + fn on_discontinuity(&mut self, reason: PluginDiscontinuity) { + // Starting and stopping the host recorder restarts the capture + // pipeline, and the host reports that as SourceChanged — twice per + // point. Those boundaries are self-inflicted, so none of them may + // disturb a survey in flight. Nothing here caches across a point + // anyway: the event counters are reset when each point starts. + let _ = reason; + } + + fn input_kind(&self) -> PluginInput { + PluginInput::RawEvents + } + + fn capabilities(&self) -> PluginCapabilities { + // The QC rates are counted from preview frames, which is enough for a + // stability indicator; the authoritative counts come from the RAW file + // offline. Retaining event history would cost memory a 60 s point does + // not need. + PluginCapabilities::default() + } + + fn process_frame( + &mut self, + frame: &PluginFrame<'_>, + _output: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _event_store: &EventStoreHandle<'_>, + ) { + if let Some(settings) = context + .get::(CTX_GLOBAL_SETTINGS) + .ok() + .flatten() + { + self.host_roi = Some(settings.roi); + self.masked_pixels = settings.masked_pixels.len(); + self.sensor_size = (settings.sensor_width, settings.sensor_height); + self.event_filters = Some(settings.event_filters); + } + if let Some(monitoring) = context + .get::(CTX_SENSOR_MONITORING) + .ok() + .flatten() + { + // Only a genuinely new reading counts as one: the host republishes + // the same snapshot on every frame between polls, and the settle + // gate is asking whether the sensor has been read *again*. + if self.sensor.map(|previous| previous.age_s) != Some(monitoring.age_s) { + self.sensor_seq = self.sensor_seq.wrapping_add(1); + if let Some(run) = self.run.as_mut() { + if run.phase == RunPhase::Settling + && self.sensor_seq != run.point.settle_started_ms + { + run.point.saw_fresh_sensor = true; + } + } + } + self.sensor = Some(monitoring); + } + + // Count events only while a point's RAW is being written, and only over + // the slice of the stream not yet counted — preview windows overlap, so + // taking every frame whole would double-count the overlap. + let recording = self + .run + .as_ref() + .is_some_and(|run| run.phase == RunPhase::Recording); + if recording { + let window_end = frame.window_end_us(); + let (mut on, mut off, mut seconds) = (0_u64, 0_u64, 0.0_f64); + if let Some(run) = self.run.as_ref() { + let from = run.point.counted_to_us.unwrap_or(window_end); + if window_end > from { + for event in frame.events() { + let timestamp = event.t_us.max(0) as u64; + if timestamp >= from && timestamp < window_end { + if event.polarity != 0 { + on += 1; + } else { + off += 1; + } + } + } + seconds = (window_end - from) as f64 / 1_000_000.0; + } + } + if let Some(run) = self.run.as_mut() { + run.point.rates.on_events += on; + run.point.rates.off_events += off; + run.point.rates.seconds += seconds; + run.point.counted_to_us = Some(window_end); + } + } + self.bump(); + } + + fn process_control(&mut self, context: &mut PluginControlContext<'_>) { + let inbox: PluginControlInbox = context.inbox().clone(); + for reply in &inbox.host_replies { + self.on_host_reply(reply); + } + self.drive(context); + self.bump(); + } + + fn settings_schema(&self) -> SettingsSchema { + // Deliberately *not* gated on "is something running": `settings_schema` + // is rendered by the UI mirror, and the run lives on the live worker, + // which is the only instance the host calls `process_control` on. A + // mirror reading its own always-idle state would disable nothing and + // mislead the next reader into thinking it did. The authoritative + // interlocks stay worker-side, where `start_blocker` refuses with a + // message that names what is wrong. + SettingsSchema { + sections: vec![ + SettingsSection { + label: "Measurement".into(), + description: Some( + "Where the survey's files go. The output folder is the only thing A4 \ + needs from you before it can run — the measurement id is filled in if \ + you leave it blank." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "output_folder".into(), + label: "Output folder".into(), + tooltip: Some( + "Every recording, sidecar and the protocol copy land in \ + //." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::Directory, + default: self.output_folder.clone(), + }, + }, + SettingItem { + key: "measurement_id".into(), + label: "Measurement id".into(), + tooltip: Some( + "Names the folder and every file stem under it. Left blank, a \ + dated one is generated and written back here." + .into(), + ), + kind: SettingKind::Text { + default: self.measurement_id.clone(), + }, + }, + ], + }, + SettingsSection { + label: "Protocol".into(), + description: Some( + "The survey itself: a CSV with one row per recording, or a TOML of \ + blocks and ranges. Every row names its bias pair, how long to record \ + and how long to settle first.\n\n\ + A4 changes nothing but diff_on and diff_off. The optical condition, the \ + ROI, the pixel mask and the other three biases are yours, and are \ + recorded with every point exactly as it found them." + .into(), + ), + default_open: true, + items: vec![ + SettingItem { + key: "protocol_path".into(), + label: "Protocol file".into(), + tooltip: Some( + "A .csv or .toml protocol. It is validated in full on Run, so a \ + bad file is refused before the first bias moves." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::OpenFile, + default: self.protocol_path.clone(), + }, + }, + SettingItem { + key: "run_protocol".into(), + label: "Run protocol".into(), + tooltip: Some( + "Validate the file, capture the biases the bench is on now, and \ + record every point. The originals are put back at the end, on \ + Stop, and on any abort." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "continue_run".into(), + label: "Continue".into(), + tooltip: Some( + "Resume a protocol paused for a filter change or a dark cap." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "stop_protocol".into(), + label: "Stop".into(), + tooltip: Some( + "End the run after the recording in flight winds down — \ + abandoning it mid-write would leave a truncated RAW behind." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "restore_biases".into(), + label: "Restore biases".into(), + tooltip: Some( + "Put diff_on and diff_off back where the last survey found them. \ + A run does this itself when it ends; this is the recovery path \ + for one that could not — a reload mid-survey, say." + .into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + ], + }, + ], + } + } + + fn get_setting(&self, key: &str) -> Option { + match key { + "output_folder" => Some(json!(self.output_folder)), + "measurement_id" => Some(json!(self.measurement_id)), + "protocol_path" => Some(json!(self.protocol_path)), + "run_protocol" => Some(self.press_start.value()), + "continue_run" => Some(self.press_continue.value()), + "stop_protocol" => Some(self.press_stop.value()), + "restore_biases" => Some(self.press_restore.value()), + _ => None, + } + } + + fn set_setting(&mut self, key: &str, value: Value) -> Result<(), String> { + match key { + "output_folder" => { + self.output_folder = value + .as_str() + .ok_or("output_folder must be a string")? + .to_string(); + } + "measurement_id" => { + self.measurement_id = value + .as_str() + .ok_or("measurement_id must be a string")? + .to_string(); + } + "protocol_path" => { + self.protocol_path = value + .as_str() + .ok_or("protocol_path must be a string")? + .to_string(); + } + // Every button arm is effectful, so each one is edge-guarded: the + // host syncs settings to both plugin instances, and an unguarded + // arm would fire twice per click. + "run_protocol" => { + if self.press_start.accept(&value) { + self.start_pending = true; + } + } + "continue_run" => { + if self.press_continue.accept(&value) { + self.continue_pending = true; + } + } + "stop_protocol" => { + if self.press_stop.accept(&value) { + self.stop_pending = true; + } + } + "restore_biases" => { + if self.press_restore.accept(&value) { + self.restore_pending = true; + } + } + _ => return Err(format!("unknown setting '{key}'")), + } + self.bump(); + Ok(()) + } + + fn status_entries(&self) -> Vec { + let mut entries = Vec::new(); + match self.run.as_ref() { + Some(run) => { + entries.push(StatusEntry::LabeledValue { + label: "Protocol".into(), + value: format!( + "{} — point {}/{}", + run.plan.name, + (run.index + 1).min(run.plan.points.len()), + run.plan.points.len() + ), + color: None, + }); + let recorded = run + .records + .iter() + .filter(|record| record.outcome == PointOutcome::Recorded) + .count(); + let flagged = run + .records + .iter() + .filter(|record| record.qc.is_flagged()) + .count(); + entries.push(StatusEntry::Text(format!( + "{recorded} recorded, {} skipped, {flagged} QC-flagged", + run.records.len() - recorded + ))); + } + None => { + entries.push(StatusEntry::LabeledValue { + label: "Protocol".into(), + value: "idle".into(), + color: None, + }); + if let Some(blocker) = self.start_blocker() { + entries.push(StatusEntry::Text(format!("Not ready — {blocker}"))); + } + } + } + // The bias codes the sensor is actually running, always, so the panel + // never has to be trusted about them. + entries.push(StatusEntry::Text( + match self.sensor.and_then(|sensor| sensor.bias_codes) { + Some(codes) => format!( + "Sensor reports diff_on={} (offset {}), diff_off={} (offset {})", + codes.current.diff_on, + codes.current.diff_on as i32 - codes.factory_default.diff_on as i32, + codes.current.diff_off, + codes.current.diff_off as i32 - codes.factory_default.diff_off as i32, + ), + None => { + "The sensor is not reporting bias codes — A4 will not run without them".into() + } + }, + )); + entries.push(StatusEntry::Text(self.message.clone())); + entries + } + + fn host_views(&self) -> HostViewRegistry { + fn column(id: &str, title: &str) -> TableColumn { + TableColumn { + id: id.into(), + title: title.into(), + value_type: TableValueType::String, + } + } + HostViewRegistry { + datasets: vec![ + HostDatasetDescriptor { + id: STATUS_DATASET_ID.into(), + title: "A4 status".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("state", "State"), + column("progress", "Point"), + column("biases", "Asked (on/off)"), + column("codes", "On die (on/off)"), + column("rate", "Rate"), + column("temperature", "Die temp"), + column("message", "Message"), + ], + ..TableSchema::default() + }), + empty_message: "A4 idle".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: POINTS_DATASET_ID.into(), + title: "A4 threshold points".into(), + kind: HostDatasetKind::TableV1(TableSchema { + columns: vec![ + column("row", "Row"), + column("label", "Label"), + column("offsets", "Offsets"), + column("codes", "Codes"), + column("repeat", "Repeat"), + column("on_rate", "ON (ev/s)"), + column("off_rate", "OFF (ev/s)"), + column("total_rate", "Total (ev/s)"), + column("qc", "QC"), + column("status", "Status"), + ], + ..TableSchema::default() + }), + empty_message: "No points recorded yet — press Run protocol".into(), + display: None, + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: STATUS_VIEW_ID.into(), + title: "A4 status".into(), + dataset_id: STATUS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: POINTS_VIEW_ID.into(), + title: "A4 threshold points".into(), + dataset_id: POINTS_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::TableWindow, + }, + ], + actions: Vec::new(), + } + } + + fn host_view_dataset(&self, dataset_id: &str) -> Option> { + match dataset_id { + STATUS_DATASET_ID => serde_json::to_vec(&self.status_dataset()).ok(), + POINTS_DATASET_ID => serde_json::to_vec(&self.points_dataset()).ok(), + _ => None, + } + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + match dataset_id { + STATUS_DATASET_ID | POINTS_DATASET_ID => self.generation, + _ => 0, + } + } +} + +export_plugin!(StageAA4Plugin); + +#[cfg(test)] +mod tests { + use super::*; + use augur_plugin_api::{ + CameraBiasOffsetsV1, CameraConfigurationProvenanceV1, CameraDigitalFilterV1, + CameraExternalTriggerV1, CameraGlobalSettingsV1, SensorBiasCodesV1, SensorBiasReadbackV1, + }; + + /// Factory trim of the unit these tests pretend to run on. + const FACTORY_ON: u8 = 102; + const FACTORY_OFF: u8 = 40; + + #[derive(Default)] + struct ControlSink { + hosts: Vec, + } + + impl HostControl for ControlSink { + fn request_host(&mut self, request: &HostCommandRequest) { + self.hosts.push(request.clone()); + } + } + + impl ControlSink { + fn last_id(&self) -> u64 { + self.hosts + .last() + .map(|request| request.request_id) + .unwrap_or(0) + } + + /// The two biases of every configuration a point applied. The session's + /// opening `Current` carries no snapshot and does not appear here. + fn applied_biases(&self) -> Vec<(i32, i32)> { + self.applied_snapshots() + .iter() + .map(|snapshot| (snapshot.biases.diff_on, snapshot.biases.diff_off)) + .collect() + } + + fn applied_snapshots(&self) -> Vec { + self.hosts + .iter() + .filter_map(|request| match &request.command { + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Snapshot { snapshot }, + } => Some(snapshot.clone()), + _ => None, + }) + .collect() + } + + fn restores(&self) -> usize { + self.hosts + .iter() + .filter(|request| { + matches!(request.command, HostCommand::RestoreCameraConfiguration) + }) + .count() + } + } + + const BASELINE_ON: i32 = 7; + const BASELINE_OFF: i32 = -3; + + /// The configuration the host confirms when a survey opens its session. + /// Everything except the two biases must survive the sweep untouched. + fn baseline_snapshot() -> CameraConfigurationSnapshotV1 { + CameraConfigurationSnapshotV1 { + schema_version: 1, + biases: CameraBiasOffsetsV1 { + diff_on: BASELINE_ON, + diff_off: BASELINE_OFF, + fo: 4, + hpf: 1, + refr: -2, + }, + roi: RoiV1 { + x: 16, + y: 32, + width: 640, + height: 480, + }, + masked_pixels: vec![(3, 4), (5, 6)], + digital_filter: CameraDigitalFilterV1 { + stc_enabled: false, + stc_threshold_us: 10_000, + trail_enabled: false, + erc_enabled: Some(false), + }, + external_trigger: CameraExternalTriggerV1 { + enabled: true, + channel: 2, + }, + global: CameraGlobalSettingsV1 { + nm_per_pixel: 100.0, + pixel_scale_calibrated: true, + sensor_width: 1280, + sensor_height: 720, + acq_time_ms: 20, + event_store_budget_mib: 512, + preview_interval_ms: 33, + point_cloud_interval_ms: 100, + disk_writer_buffer_mib: 64, + record_sensor_telemetry: true, + }, + } + } + + fn readback(on_offset: i64, off_offset: i64) -> SensorBiasReadbackV1 { + SensorBiasReadbackV1 { + current: SensorBiasCodesV1 { + diff_on: expected_code(FACTORY_ON, on_offset), + diff_off: expected_code(FACTORY_OFF, off_offset), + fo: 55, + hpf: 0, + refr: 138, + }, + factory_default: SensorBiasCodesV1 { + diff_on: FACTORY_ON, + diff_off: FACTORY_OFF, + fo: 55, + hpf: 0, + refr: 138, + }, + } + } + + fn monitoring(on_offset: i64, off_offset: i64, age_s: f64) -> SensorMonitoringV1 { + SensorMonitoringV1 { + pixel_dead_time_us: Some(12.5), + illumination_lux: Some(200.0), + temperature_c: Some(41.0), + bias_codes: Some(readback(on_offset, off_offset)), + age_s, + } + } + + fn temp_folder(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("a4-{tag}-{}", now_unix_ms())); + std::fs::create_dir_all(&dir).expect("test folder"); + dir + } + + /// A plugin that has seen a camera reporting its biases, so `start_blocker` + /// is satisfied and `live_offsets` has something to capture. + fn ready_plugin(folder: &Path, protocol: &Path) -> StageAA4Plugin { + StageAA4Plugin { + output_folder: folder.display().to_string(), + protocol_path: protocol.display().to_string(), + measurement_id: "A4-TEST".into(), + event_filters: Some(EventFiltersV1::default()), + sensor: Some(monitoring(7, -3, 0.1)), + ..StageAA4Plugin::default() + } + } + + fn write_protocol(folder: &Path, body: &str) -> PathBuf { + let path = folder.join("survey.csv"); + std::fs::write(&path, body).expect("protocol written"); + path + } + + /// Mark the settle as satisfied, the way a fresh monitoring frame would. + fn deliver_fresh_sensor(plugin: &mut StageAA4Plugin, sensor: SensorMonitoringV1) { + plugin.sensor_seq = plugin.sensor_seq.wrapping_add(1); + if let Some(run) = plugin.run.as_mut() { + if run.phase == RunPhase::Settling { + run.point.saw_fresh_sensor = true; + } + } + plugin.sensor = Some(sensor); + } + + fn applied_reply( + request_id: u64, + on_offset: i64, + off_offset: i64, + age_s: f64, + ) -> HostCommandReply { + let mut snapshot = baseline_snapshot(); + snapshot.biases.diff_on = on_offset as i32; + snapshot.biases.diff_off = off_offset as i32; + HostCommandReply { + request_id, + outcome: HostCommandOutcome::CameraConfigurationApplied { + snapshot, + provenance: CameraConfigurationProvenanceV1 { + source: "snapshot".into(), + profile_name: None, + schema_version: 1, + profile_revision: None, + sha256: "0".repeat(64), + }, + readback: readback(on_offset, off_offset), + readback_age_s: age_s, + }, + } + } + + fn restored_reply(request_id: u64) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::CameraConfigurationRestored { + readback: readback(BASELINE_ON as i64, BASELINE_OFF as i64), + readback_age_s: 0.1, + }, + } + } + + fn started_reply(request_id: u64, path: &str) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::RecordingStarted { + actual_raw_path: path.to_owned(), + started_at: "2026-08-08T10:00:00Z".into(), + }, + } + } + + fn finalized_reply( + request_id: u64, + path: &str, + size: u64, + duration_us: u64, + ) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::RecordingFinalized { + actual_raw_path: path.to_owned(), + size, + sha256: "a".repeat(64), + duration_us, + }, + } + } + + fn rejected_reply(request_id: u64, code: &str, message: &str) -> HostCommandReply { + HostCommandReply { + request_id, + outcome: HostCommandOutcome::Rejected { + code: code.into(), + message: message.into(), + }, + } + } + + /// Mirrors the ordering of `process_control`. + fn tick(plugin: &mut StageAA4Plugin, replies: Vec, sink: &mut ControlSink) { + for reply in &replies { + plugin.on_host_reply(reply); + } + plugin.drive(sink); + } + + /// Answer the run's closing restore, so it writes its receipt and ends. + /// The receipt records whether the biases went back, so it is deliberately + /// not written until that is known. + fn settle_restore(plugin: &mut StageAA4Plugin, sink: &mut ControlSink) { + let restore_id = sink.last_id(); + tick(plugin, vec![restored_reply(restore_id)], sink); + } + + /// Press Run and answer the baseline confirmation every survey opens with. + /// Leaves the run on its first point's configuration command — or paused, + /// if the first row asks the operator for something. + fn start_survey(plugin: &mut StageAA4Plugin, sink: &mut ControlSink) { + plugin.start_pending = true; + tick(plugin, vec![], sink); + let session_id = sink.last_id(); + tick( + plugin, + vec![applied_reply( + session_id, + BASELINE_ON as i64, + BASELINE_OFF as i64, + 0.1, + )], + sink, + ); + } + + /// Walk one point from its bias command through a clean finalize. Returns + /// the RAW path it was told to write. + fn run_one_point( + plugin: &mut StageAA4Plugin, + sink: &mut ControlSink, + folder: &Path, + on_offset: i64, + off_offset: i64, + ) -> PathBuf { + let bias_id = sink.last_id(); + tick( + plugin, + vec![applied_reply(bias_id, on_offset, off_offset, 0.2)], + sink, + ); + deliver_fresh_sensor(plugin, monitoring(on_offset, off_offset, 0.1)); + // Settle is 0 s in the test protocols, so the next tick starts it. + tick(plugin, vec![], sink); + + let start_id = sink.last_id(); + let raw = folder.join(format!("point-{on_offset}-{off_offset}.raw")); + std::fs::write(&raw, b"raw-bytes").expect("raw written"); + tick( + plugin, + vec![started_reply(start_id, &raw.display().to_string())], + sink, + ); + // Duration is 1 s in the test protocols; force the clock past it. + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(5_000); + } + tick(plugin, vec![], sink); + + let stop_id = sink.last_id(); + tick( + plugin, + vec![finalized_reply( + stop_id, + &raw.display().to_string(), + 9, + 1_000_000, + )], + sink, + ); + tick(plugin, vec![], sink); + raw + } + + #[test] + fn a_survey_sets_confirms_records_and_then_puts_the_biases_back() { + let folder = temp_folder("happy"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n-20,-10,1,0\n20,20,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + + tick(&mut plugin, vec![], &mut sink); + start_survey(&mut plugin, &mut sink); + + run_one_point(&mut plugin, &mut sink, &folder, -20, -10); + run_one_point(&mut plugin, &mut sink, &folder, 20, 20); + + assert_eq!( + sink.applied_biases(), + vec![(-20, -10), (20, 20)], + "each point applies its own two biases" + ); + // The host preserved the pre-run configuration when the session opened, + // so putting the bench back is its own verb, sent last. + assert_eq!(sink.restores(), 1); + assert!(matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration), + )); + + // The run only closes once the restore is answered. + assert!( + plugin.run.is_some(), + "the run waits for its restore receipt" + ); + let restore_id = sink.last_id(); + tick(&mut plugin, vec![restored_reply(restore_id)], &mut sink); + assert!(plugin.run.is_none(), "the run ends after the restore"); + assert!( + plugin.message.contains("2/2 recorded"), + "{}", + plugin.message + ); + assert!( + plugin.message.contains("Biases restored"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_recorded_point_lands_in_the_measurement_folder_with_its_sidecar() { + let folder = temp_folder("gather"); + let protocol = write_protocol( + &folder, + "label,diff_on,diff_off,duration_s,settle_s\nthr-01,12,-8,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 12, -8); + settle_restore(&mut plugin, &mut sink); + + let dir = folder.join("A4-TEST"); + let sidecars: Vec = std::fs::read_dir(&dir) + .expect("measurement folder") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.to_string_lossy().ends_with(".a4.toml")) + .collect(); + assert_eq!(sidecars.len(), 1, "one sidecar per point: {sidecars:?}"); + let text = std::fs::read_to_string(&sidecars[0]).expect("sidecar readable"); + + // The absolute codes, not just the offsets the row asked for — this is + // the whole reason the sidecar exists. + assert!( + text.contains(&format!("code_diff_on = {}", FACTORY_ON as i64 + 12)), + "{text}" + ); + assert!( + text.contains(&format!("code_diff_off = {}", FACTORY_OFF as i64 - 8)), + "{text}" + ); + assert!(text.contains("requested_diff_on = 12"), "{text}"); + assert!(text.contains("confirmed = true"), "{text}"); + assert!(text.contains("complete = true"), "{text}"); + assert!(text.contains("label = \"thr-01\""), "{text}"); + // The protocol travels with the data, with its hash. + assert!(dir.join("survey.csv").exists(), "the protocol is copied in"); + assert!(text.contains("sha256"), "{text}"); + // And the RAW was moved out of the host's folder into this one. + assert!( + dir.join("point-12--8.raw").exists(), + "the RAW is gathered in" + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn codes_that_disagree_with_the_row_skip_the_point_and_keep_going() { + // The central guarantee: a point whose biases cannot be shown to be + // the requested ones is not recorded at all. + let folder = temp_folder("mismatch"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n10,10,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + // The sensor answers with codes for a different offset entirely. + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 99, 99, 0.1)], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + // No recording was ever started for that point, and the run moved on. + assert!( + !sink + .hosts + .iter() + .any(|request| matches!(request.command, HostCommand::StartRecording { .. })), + "a mismatched point must not be recorded" + ); + let records = &plugin.run.as_ref().expect("still running").records; + assert_eq!(records.len(), 1); + assert!(matches!(records[0].outcome, PointOutcome::Failed(_))); + assert!( + records[0].status_text().contains("the row asks for"), + "{}", + records[0].status_text() + ); + // And the second point is under way rather than the run being over. + assert_eq!(plugin.run.as_ref().expect("still running").index, 1); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_stale_confirming_reading_is_not_evidence_about_this_point() { + let folder = temp_folder("stale"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + // Correct codes, but read far too long after the change. + tick( + &mut plugin, + vec![applied_reply(bias_id, 5, 5, 9.0)], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + let records = &plugin + .run + .as_ref() + .map(|run| run.records.clone()) + .unwrap_or_default(); + assert_eq!(records.len(), 1); + assert!( + records[0].status_text().contains("old"), + "{}", + records[0].status_text() + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_refused_bias_change_quotes_the_hosts_own_reason() { + // "Turn the STC filter off" tells the operator what to do; "bias + // change failed" does not. + let folder = temp_folder("refused"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![rejected_reply( + bias_id, + "event_filters_enabled", + "turn the STC and Trail filters off before changing threshold biases", + )], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + + let message = plugin + .run + .as_ref() + .and_then(|run| run.records.first().map(|record| record.status_text())) + .unwrap_or_default(); + assert!(message.contains("STC"), "{message}"); + assert!(message.contains("event_filters_enabled"), "{message}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_partial_receipt_is_never_counted_as_a_recorded_point() { + let folder = temp_folder("partial"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + deliver_fresh_sensor(&mut plugin, monitoring(0, 0, 0.1)); + tick(&mut plugin, vec![], &mut sink); + let start_id = sink.last_id(); + let raw = folder.join("partial.raw"); + std::fs::write(&raw, b"x").expect("raw written"); + tick( + &mut plugin, + vec![started_reply(start_id, &raw.display().to_string())], + &mut sink, + ); + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(5_000); + } + tick(&mut plugin, vec![], &mut sink); + + let stop_id = sink.last_id(); + tick( + &mut plugin, + vec![HostCommandReply { + request_id: stop_id, + outcome: HostCommandOutcome::RecordingPartial { + actual_raw_path: raw.display().to_string(), + size: Some(1), + sha256: None, + duration_us: 1_000_000, + reason: "the writer did not flush".into(), + }, + }], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + settle_restore(&mut plugin, &mut sink); + + let receipt = std::fs::read_to_string(folder.join("A4-TEST/A4-TEST.protocol-status.toml")) + .expect("receipt written"); + assert!(receipt.contains("rows_recorded = 0"), "{receipt}"); + assert!(receipt.contains("rows_failed = 1"), "{receipt}"); + assert!(receipt.contains("did not finalize cleanly"), "{receipt}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_recording_cut_short_is_a_truncated_file_not_a_short_point() { + let folder = temp_folder("short"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,60,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + deliver_fresh_sensor(&mut plugin, monitoring(0, 0, 0.1)); + tick(&mut plugin, vec![], &mut sink); + let start_id = sink.last_id(); + let raw = folder.join("short.raw"); + std::fs::write(&raw, b"x").expect("raw written"); + tick( + &mut plugin, + vec![started_reply(start_id, &raw.display().to_string())], + &mut sink, + ); + if let Some(run) = plugin.run.as_mut() { + run.point.started_unix_ms = now_unix_ms().saturating_sub(70_000); + } + tick(&mut plugin, vec![], &mut sink); + + // A clean receipt, but only 10 s of the 60 s asked for. + let stop_id = sink.last_id(); + tick( + &mut plugin, + vec![finalized_reply( + stop_id, + &raw.display().to_string(), + 4096, + 10_000_000, + )], + &mut sink, + ); + tick(&mut plugin, vec![], &mut sink); + settle_restore(&mut plugin, &mut sink); + + let receipt = std::fs::read_to_string(folder.join("A4-TEST/A4-TEST.protocol-status.toml")) + .expect("receipt written"); + assert!(receipt.contains("rows_recorded = 0"), "{receipt}"); + assert!(receipt.contains("10.0 s of the 60 s"), "{receipt}"); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_settle_with_no_fresh_reading_never_starts_a_recording() { + // Without a new reading there is no evidence the bench stopped moving, + // and the point's start conditions would be copied from before the + // bias change. + let folder = temp_folder("nosettle"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n0,0,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + let bias_id = sink.last_id(); + tick( + &mut plugin, + vec![applied_reply(bias_id, 0, 0, 0.2)], + &mut sink, + ); + + // Several ticks with no new monitoring sample. + for _ in 0..3 { + tick(&mut plugin, vec![], &mut sink); + } + assert!( + !sink + .hosts + .iter() + .any(|request| matches!(request.command, HostCommand::StartRecording { .. })), + "no recording may start without a fresh reading" + ); + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::Settling) + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_row_that_needs_a_filter_change_waits_for_the_operator() { + let folder = temp_folder("pause"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s,pause_before,optical_state\n\ + 0,0,1,0,yes,LP647+BP700\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::PausedForOperator) + ); + assert!( + sink.applied_biases().is_empty(), + "nothing moves while paused" + ); + assert!(plugin.message.contains("LP647+BP700"), "{}", plugin.message); + + plugin.continue_pending = true; + tick(&mut plugin, vec![], &mut sink); + assert_eq!(sink.applied_biases(), vec![(0, 0)]); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_survey_refuses_to_start_while_a_filter_is_dropping_events() { + let folder = temp_folder("filters"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + plugin.event_filters = Some(EventFiltersV1 { + stc_enabled: true, + trail_enabled: false, + erc_enabled: false, + }); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none(), "the survey must not start"); + assert!(sink.hosts.is_empty(), "nothing is sent to the host"); + assert!(plugin.message.contains("STC"), "{}", plugin.message); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_survey_refuses_to_start_without_a_bias_readback_to_confirm_against() { + // Without one, every point would record biases nobody can show were + // live — the method's central claim would be uncheckable. + let folder = temp_folder("noreadback"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + plugin.sensor = Some(SensorMonitoringV1 { + bias_codes: None, + ..monitoring(0, 0, 0.1) + }); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none()); + assert!(plugin.message.contains("bias codes"), "{}", plugin.message); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn an_invalid_protocol_is_refused_before_a_single_bias_moves() { + let folder = temp_folder("badfile"); + let protocol = write_protocol(&folder, "diff_on,diff_off\n0,900\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + plugin.start_pending = true; + tick(&mut plugin, vec![], &mut sink); + + assert!(plugin.run.is_none()); + assert!(sink.hosts.is_empty(), "nothing reached the host"); + assert!( + plugin.message.contains("Protocol rejected"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn stop_ends_the_run_and_still_restores_the_biases() { + let folder = temp_folder("stop"); + let protocol = write_protocol( + &folder, + "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n15,15,1,0\n25,25,1,0\n", + ); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 5, 5); + + plugin.stop_pending = true; + tick(&mut plugin, vec![], &mut sink); + tick(&mut plugin, vec![], &mut sink); + + // Point 2 had already been targeted when Stop arrived — it is dropped + // before it records — and point 3 was never reached at all. + assert_eq!( + sink.applied_biases(), + vec![(5, 5), (15, 15)], + "Stop must not target another point" + ); + assert!( + matches!( + sink.hosts.last().map(|request| &request.command), + Some(HostCommand::RestoreCameraConfiguration), + ), + "a stopped run still puts the bench back" + ); + settle_restore(&mut plugin, &mut sink); + assert!(plugin.message.contains("stopped"), "{}", plugin.message); + assert!( + plugin.message.contains("1/3 recorded"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_host_that_never_answers_does_not_strand_an_unattended_survey() { + let folder = temp_folder("timeout"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + // Push the send far enough into the past to trip the reply timeout. + if let Some(run) = plugin.run.as_mut() { + run.last_activity_ms = now_unix_ms().saturating_sub(REPLY_TIMEOUT_MS + 1_000); + } + tick(&mut plugin, vec![], &mut sink); + + assert!( + plugin.message.contains("did not answer") || plugin.message.contains("skipped"), + "{}", + plugin.message + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn a_reply_to_a_request_the_run_is_not_waiting_on_is_ignored() { + // The runtime caches and can re-emit replies; a stale one must not + // advance a point that is waiting on a different request. + let folder = temp_folder("stalereply"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + + let waiting_on = plugin.run.as_ref().and_then(|run| run.pending_request); + tick( + &mut plugin, + vec![applied_reply(9_999, 5, 5, 0.1)], + &mut sink, + ); + assert_eq!( + plugin.run.as_ref().and_then(|run| run.pending_request), + waiting_on, + "an unrelated reply must not settle the point" + ); + assert_eq!( + plugin.run.as_ref().map(|run| run.phase), + Some(RunPhase::ApplyingBiases) + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn only_diff_on_and_diff_off_are_ever_changed_from_the_baseline() { + // The host contract carries a whole configuration, so the freeze on + // fo/hpf/refr/ROI/mask/trigger is no longer structural — A4 keeps it by + // cloning the confirmed baseline. That is exactly what this asserts: a + // point's configuration must differ from the baseline in two fields and + // nowhere else, or a threshold sweep could silently move the ROI. + let folder = temp_folder("narrow"); + let protocol = write_protocol(&folder, "diff_on,diff_off,duration_s,settle_s\n5,5,1,0\n"); + let mut plugin = ready_plugin(&folder, &protocol); + let mut sink = ControlSink::default(); + start_survey(&mut plugin, &mut sink); + run_one_point(&mut plugin, &mut sink, &folder, 5, 5); + + let snapshots = sink.applied_snapshots(); + assert_eq!(snapshots.len(), 1, "one point, one configuration"); + let mut expected = baseline_snapshot(); + expected.biases.diff_on = 5; + expected.biases.diff_off = 5; + assert_eq!( + snapshots[0], expected, + "a point must change the two biases and copy everything else forward" + ); + + // The session is opened by confirming what the bench is on, never by + // naming a profile — the survey measures the bench as it stands. + assert!( + sink.hosts.iter().any(|request| matches!( + &request.command, + HostCommand::ApplyCameraConfiguration { + configuration: CameraConfigurationSourceV1::Current + } + )), + "the survey must open its session against the live configuration" + ); + + let _ = std::fs::remove_dir_all(folder); + } + + #[test] + fn measurement_ids_are_file_safe() { + assert_eq!(sanitize_stem("a/b:c"), "a_b_c"); + assert_eq!(sanitize_stem(" "), "A4"); + // A generated id must already be file-safe, or every unnamed run would + // silently be filed under a sanitized variant of its own name. + let generated = generate_measurement_id(); + assert!(generated.starts_with("A4-"), "{generated}"); + assert_eq!(sanitize_stem(&generated), generated); + } + + #[test] + fn expected_codes_saturate_the_way_the_sensor_does() { + assert_eq!(expected_code(102, 12), 114); + assert_eq!(expected_code(10, -85), 0); + assert_eq!(expected_code(250, 140), 255); + } + + #[test] + fn compact_utc_formats_a_known_epoch() { + assert_eq!(format_compact_utc(1_767_225_600), "20260101-000000"); + assert_eq!(format_iso_utc(1_767_225_600), "2026-01-01T00:00:00Z"); + } +} diff --git a/plugins/stage-a-a4/src/sidecar.rs b/plugins/stage-a-a4/src/sidecar.rs new file mode 100644 index 0000000..c191ec7 --- /dev/null +++ b/plugins/stage-a-a4/src/sidecar.rs @@ -0,0 +1,226 @@ +//! The per-recording A4 sidecar, and the run-level protocol receipt. +//! +//! A threshold point is only worth keeping if it can answer, months later, +//! *which absolute bias codes were on the die while this file was written* — +//! and under what optical and thermal conditions. That is what this file is +//! for. It is written for every point, including the ones that failed, because +//! the record of a failed point is the reason the survey has a hole in it. +//! +//! Fields the sensor could not report are **absent**, never `0`. A die +//! temperature of 0 °C and "this camera has no temperature readback" are +//! opposite facts, and a reader six months from now cannot tell them apart from +//! a zero. + +use serde::Serialize; + +pub const SIDECAR_SCHEMA: &str = "stage-a.a4.sidecar.v1"; +pub const RECEIPT_SCHEMA: &str = "stage-a.a4.protocol-status.v1"; + +#[derive(Debug, Serialize)] +pub struct SidecarDoc { + pub schema: &'static str, + pub measurement_id: String, + pub recording: String, + pub recorded_at_utc: String, + pub plugin_version: &'static str, + pub protocol: ProtocolSection, + pub bias: BiasSection, + pub optics: OpticsSection, + pub sensor: SensorSection, + pub filters: FiltersSection, + pub camera: CameraSection, + pub files: FilesSection, + pub qc: QcSection, +} + +/// The protocol row this recording came from, copied verbatim, plus where in +/// the file it sat and which file that was. +#[derive(Debug, Serialize)] +pub struct ProtocolSection { + pub name: String, + pub file: String, + pub sha256: String, + /// 1-based, so it matches what the operator counts in the file. + pub row: usize, + pub rows_total: usize, + pub label: String, + pub repeat: u32, + pub repeats: u32, + pub requested_duration_s: i64, + pub requested_settle_s: f64, +} + +/// What was asked for, what was programmed, and what the sensor said it was +/// running. The three are kept separate on purpose: they are the same number +/// only when nothing went wrong, and this file exists to prove that. +#[derive(Debug, Serialize)] +pub struct BiasSection { + /// Offsets the protocol row asked for. + pub requested_diff_on: i64, + pub requested_diff_off: i64, + /// Offsets the host programmed, after its own range clamp. + pub applied_diff_on: i32, + pub applied_diff_off: i32, + /// Absolute 8-bit codes read back off the die. + pub code_diff_on: u8, + pub code_diff_off: u8, + /// The per-unit factory trim the offsets are relative to. + pub factory_diff_on: u8, + pub factory_diff_off: u8, + /// Codes for the biases A4 never touches, recorded so a reader can confirm + /// they were the same across the survey. + pub code_fo: u8, + pub code_hpf: u8, + pub code_refr: u8, + /// Seconds between the reconfigure and the reading that confirmed it. + pub readback_age_s: f64, + pub confirmed: bool, +} + +/// The optical condition, which A4 never changes and only records. +#[derive(Debug, Serialize)] +pub struct OpticsSection { + pub optical_state: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub filter_id: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub flux_id: String, + /// Whether the operator was asked to intervene before this point. + pub paused_for_operator: bool, +} + +/// Bench conditions at the two ends of the recording. Every field is optional; +/// a sensor that cannot report a quantity leaves it out. +#[derive(Debug, Serialize)] +pub struct SensorSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_c_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_c_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_lux_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_lux_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pixel_dead_time_us: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reading_age_s: Option, + /// Sensor lux is a stability indicator, not a calibrated optical power. + /// Stated in the file so nobody later reads it as one. + pub illumination_note: &'static str, +} + +/// The on-sensor filters, which must all be off for the counts to mean +/// anything. Recorded rather than assumed. +#[derive(Debug, Serialize)] +pub struct FiltersSection { + pub stc_enabled: bool, + pub trail_enabled: bool, + pub erc_enabled: bool, + pub erc_note: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct CameraSection { + pub roi_x: u16, + pub roi_y: u16, + pub roi_width: u16, + pub roi_height: u16, + pub masked_pixels: usize, + pub sensor_width: u16, + pub sensor_height: u16, +} + +#[derive(Debug, Serialize)] +pub struct FilesSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_size_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recorded_duration_s: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sensor_readout: Option, + /// True only for a clean `RecordingFinalized` with a plausible size, hash + /// and duration. A partial receipt is never complete, whatever survived. + pub complete: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub incomplete_reason: Option, +} + +#[derive(Debug, Serialize)] +pub struct QcSection { + pub status: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub flags: Vec, + pub on_events: u64, + pub off_events: u64, + pub total_events: u64, + /// Seconds of the recording the plugin actually observed events over. Less + /// than the recording duration when frames were dropped, so a reader can + /// see the coverage the rates were computed from rather than assuming it. + pub counted_seconds: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub off_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_rate_hz: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub on_fraction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_drift_c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illumination_drift_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_temperature_drift_c: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_illumination_drift_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_event_rate: Option, + pub rate_note: &'static str, +} + +// ---- run-level receipt ----------------------------------------------------- + +/// Written next to the copy of the protocol, so the folder says which rows ran +/// and which did not without anyone having to diff filenames against the file. +#[derive(Debug, Serialize)] +pub struct ProtocolReceipt { + pub schema: &'static str, + pub measurement_id: String, + pub protocol_name: String, + pub protocol_file: String, + pub protocol_sha256: String, + pub started_at_utc: String, + pub finished_at_utc: String, + pub outcome: String, + pub rows_total: usize, + pub rows_recorded: usize, + pub rows_failed: usize, + pub rows_flagged: usize, + /// The bias offsets the bench was on before the survey, restored afterwards. + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_diff_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_diff_off: Option, + pub biases_restored: bool, + pub row: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ReceiptRow { + pub row: usize, + pub label: String, + pub diff_on: i64, + pub diff_off: i64, + pub repeat: u32, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + pub qc: String, +} diff --git a/plugins/stage-a-modulation/Cargo.toml b/plugins/stage-a-modulation/Cargo.toml new file mode 100644 index 0000000..f843774 --- /dev/null +++ b/plugins/stage-a-modulation/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "augur-plugin-stage-a-modulation" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A laser modulation control: one capped power slider plus constant/sine/square drive of the Teensy DAC (J23), applied immediately." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde_json.workspace = true +stage-a-io = { path = "../../stage-a-io" } +stage-a-plugin-contract = { path = "../../stage-a-plugin-contract" } +toml = "0.8" + +[dev-dependencies] +augur-plugin-stage-a-a1 = { path = "../stage-a-a1" } diff --git a/plugins/stage-a-modulation/README.md b/plugins/stage-a-modulation/README.md new file mode 100644 index 0000000..6bac4a9 --- /dev/null +++ b/plugins/stage-a-modulation/README.md @@ -0,0 +1,129 @@ +# Stage-A Modulation + +Controls the laser modulation input (Hermit J23, `DAC1.4`) through the Teensy **command port** +(the first of the two USB serial ports enumerated by `stage-a-controller` firmware 0.3.0+). +The optical load is an Excelitas **LM 0202** (`84502049000`), four-crystal +KD*P, 3×3 mm, 400–850 nm, 5 W. + +## What it does + +- **Drive method** selects how the DAC operating band is defined: + - `MANUAL`: **Power** is the peak/operating code and **Min threshold** is the lower endpoint. + - `CALIBRATED`: `V_null`, `V_peak`, normalized cycle mean `ū`, and optical depth `a` determine the endpoints. + Both lobe fields are **absolute DAC codes** — where the light is dimmest and where it is + brightest — and the half-wave span `|V_peak − V_null|` is derived, never typed + ([ADR 016](../../docs/adr/016-stage-a-lobe-endpoints-not-a-distance.md)). + Measure them with the built-in transfer sweep — see [Calibration](#calibration--measuring-v_null-and-v_peak). +- **Mode** independently selects the waveform that fills that band. All five modes are available + under both methods. +- **Max limit** is always visible and is the hard DAC ceiling for every drive. +- Every accepted change is sent to the Teensy **immediately** (one `MOD` command); there is no + Apply button. +- The panel shows the modulation and live DAC code the **board reports** (from the `MOD` reply and + a 2 Hz `STATUS` poll), plus the selected method and resolved DAC band. + +| Mode | Manual band `[min, power]` | Calibrated band from `ū`, `a`, `V_null`, `V_peak` | +|---|---|---| +| `CONST` | hold `power` | hold the DAC code for `ū` | +| `DAC_SINE` | DAC sine across the band | DAC sine across the band | +| `SQUARE` | DAC square across the band | DAC square across the band | +| `OPTICAL_LOG_SINE` | optical log-sine across the band | mean `ū`, converted to `u_g=ū/I_0(a/2)` | +| `OPTICAL_LINEAR_SINE` | optical linear-sine across the band | centre/mean `u_c=ū` | + +Manual optical modes reuse the stored `V_null`/`V_peak` lobe and derive their effective `(u, a)` +from the slider band through the forward optical transfer. + +In calibrated `CONST`, `a` is irrelevant: the hold is +`V_null + (2·span/π)·asin(sqrt(u))`. With `V_null=1630` and a span of `860`, this is +2490 at `ū=1` and 1685 at `ū=0.01`. This dimensionless `ū` is not the physical +A1 flux point `I_k`. Periodic modes still need optical +headroom and reject impossible `ū`/`a` combinations without changing the +displayed setting or leaving it out of sync with the board. + +## Calibration — measuring `V_null` and `V_peak` + +Do not type these in from a datasheet. Static birefringence, alignment, PBS extinction, driver +gain, temperature, and the actual electrical load all enter the realised map, so measure them: + +1. Connect the command port **and** the photodiode plugin (the sweep reads its published level; + it needs no lease and takes no recording). +2. Set **Detector port**. Stage-A watches the PBS *reject* port, where the detector is + **brightest** at `V_null` — the default. This cannot be inferred from the sweep: a bright and + a dark extremum fit the measured curve equally well, and only the optics say which one is zero + excitation. Getting it wrong puts `V_null` one half-wave-voltage span out. +3. Press **Measure transfer curve**. It steps settled `CONST` codes across `0..max limit`, up and + back down (~20 s), and fits the lobe. Your armed drive is restored afterwards, on every exit + path. +4. Read the result in the **Pockels transfer curve** view and the status line, then press + **Apply to V_null / V_peak**, which writes both endpoint codes. Anything questionable — a high residual, dropped points, + hysteresis, clipping — appears as a `Check:` line but does not block the apply: the plot is + the arbiter, and a single stray sample can inflate the residual fivefold while leaving the fit + accurate to a few codes. Wild points are dropped from the fit automatically. + +Each point is a real measurement, not a sample: the sweep waits 0.1 s for the cell to settle and +then takes the photodiode's 20 ms averaged level. Both are durations the sweep and the photodiode +plugin own, deliberately not sample counts and not the chart's averaging setting — those made the +precision of the calibration follow the acquisition rate and a display knob +([ADR 019](../../docs/adr/019-stage-a-calibration-measures-its-own-window.md)). + +The `Check:` lines are measured against the fit's own noise, never against zero. Hysteresis is +compared with what independent point scatter alone would produce, so a noisy bench is not reported +as a drifting cell. Points that reach an end of the detector's range truncate the reported detector +extrema but not `V_null`/`V_peak`, and the fix for them is detector **gain** — on the reject port it is +the dark end that hits the bottom rail, so attenuation is backwards. + +The view also works *before* any measurement: it draws the lobe your current `V_null`/`V_peak` +claim, on a normalised axis, with markers at `V_null` and `V_peak` — the two settings themselves, +so the plot reads straight back into the two fields. The status pane states the same thing in +codes: `Lobe: half-wave span 860 codes — u 0 → 1630 (min light), 0.5 → 2060, 1 → 2490 (max light)`. + +Two properties worth knowing: + +- `V_null`/`V_peak` need **no** dark measurement and **no** total-power anchor — the fitted offset and + amplitude absorb any DC offset and the front-end gain. +- The detector level at the null is reported as a **lower bound** on the total-power anchor + `I_tot`, *not* as the anchor. On the reject port the residual transmitted floor is not separable + from it; freezing a real anchor needs a transmitted-port power measurement. + +Set a **Calibration folder** to archive each applied calibration (points, fit, residual, +hysteresis) and stamp `calibration_id` into the state snapshot, so recordings can cite the +inversion they used. Full detail: [feature brief](../../docs/features/stage-a-pockels-calibration.md), +[ADR 011](../../docs/adr/011-stage-a-pockels-transfer-calibration.md). + +## Connecting + +- **Connect** is a checkbox in the plugin settings — it opens/closes the command port and works + **without a running camera** (device I/O lives in a plugin-owned thread, independent of the + host's frame-driven plugin passes). Connecting never changes the output; only changes made + while connected are transferred. +- The firmware output is **set-and-hold**: disconnecting, closing the GUI, or a crash leaves the + last modulation running (`stage-a-controller` ADR 002). In Manual mode, Power `0` drives `0 V`; + automated workflows use their explicit `SafeOff` command. + +## Ports + +**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. + +## Workflow-owner service + +This plugin is the sole command-port owner for manual operation and automated Stage-A workflows. +The live-worker instance exposes `stage_a.modulation.control.v1` under the stable plugin ID +`stage-a.modulation`; UI-mirror and offline instances never open the port or apply hardware +effects. Automated clients acquire a renewable lease and submit semantic, idempotent commands +(`SetWaveform`, `PrepareA1`, `StartAcquisition`, `StopAcquisition`, `SafeOff`) rather than changing +UI settings or sending raw firmware strings. While leased, manual control settings are locked. + +The bounded `stage_a.modulation_state.v1` snapshot keeps requested and board-acknowledged semantic +revisions separate. Lease expiry, replay/effects revocation, or owner shutdown during an automated +run performs a best-effort controller `STOP` followed by `MOD wave=OFF` before releasing the port. +Automation specifies exact waveforms and therefore does not use the UI Drive method. diff --git a/plugins/stage-a-modulation/plugin.toml b/plugins/stage-a-modulation/plugin.toml new file mode 100644 index 0000000..059aa60 --- /dev/null +++ b/plugins/stage-a-modulation/plugin.toml @@ -0,0 +1,8 @@ +id = "stage-a.modulation" +name = "Stage-A Modulation" +version = "0.4.0" +description = "Laser modulation control: capped power slider plus constant/sine/square/optical drive of the Teensy DAC (J23), applied immediately, with a measured Pockels transfer calibration for V_null/Vπ." +domain = "stage-a" +library = "augur_plugin_stage_a_modulation" +phase = "frame_only" +min_augur_version = "1.0.0" diff --git a/plugins/stage-a-modulation/src/calibration.rs b/plugins/stage-a-modulation/src/calibration.rs new file mode 100644 index 0000000..be4d658 --- /dev/null +++ b/plugins/stage-a-modulation/src/calibration.rs @@ -0,0 +1,1094 @@ +//! Measured Pockels/PBS transfer calibration: fits `V_null` and `Vπ` from a +//! sweep of settled `CONST` DAC codes against the photodiode level. +//! +//! The operator must not have to trust a nominal `Vπ` (knowledge base: +//! `methodology/pockels-waveform-linearisation.md` §4). This module turns a +//! table of `(DAC code, detector volts)` points into the lobe parameters the +//! optical inversion in [`crate::waveform`] needs. +//! +//! # Model +//! +//! ```text +//! P(c) = p0 + p1 · sin²(π (c − V_null) / (2 Vπ)) +//! ``` +//! +//! `p1` is **signed**, because the Stage-A photodiode sits behind the PBS +//! *reject* port and measures the complement `I_pd = I_tot − I_exc`, moving +//! *against* the excitation (knowledge base: `setup/optical-path.md`). +//! +//! The sign cannot be inferred from the sweep. `sin²` is symmetric about its +//! peak, so `(v, p0, p1)` and `(v + Vπ, p0 + p1, −p1)` describe the *same* +//! measured curve exactly; the data alone cannot say which extremum is the +//! excitation null. That is a physical fact about the port, not a fit +//! parameter, so [`fit_transfer`] takes the geometry as an **input** and picks +//! the matching representation. Getting it wrong would place `V_null` a +//! one half-wave-voltage span off and run the drive on the inverted branch, so it is asked +//! rather than guessed. +//! +//! Two consequences worth stating, because they remove procedure rather than +//! add it: +//! +//! - **The shape is dark- and gain-immune.** `p0` absorbs the dark level and +//! any DC offset, `p1` absorbs the front-end gain. `V_null`/`Vπ` therefore +//! need neither a dark measurement nor the total-power anchor. +//! - **The absolute scale is not recoverable here.** On the reject port the +//! residual transmitted floor cannot be separated from the anchor `I_tot` +//! (knowledge base §4.4), so this module reports the detector extrema and +//! explicitly does *not* derive a maximum achievable `a` from them. +//! +//! # Fit +//! +//! Because `sin²(x) = (1 − cos 2x)/2`, the model is exactly a constant plus +//! **one sinusoid of period `2Vπ`** — and a sinusoid of known period is linear +//! in its quadrature components. So for each candidate `Vπ` the phase (hence +//! `V_null`) and both amplitudes fall out of a 3×3 linear solve, and the +//! search is one-dimensional: scan `Vπ` over every period the sweep can +//! resolve, then refine. See [`solve_harmonic`]. +//! +//! This matters beyond elegance. Seeding the period from the measured extrema +//! — the obvious approach — breaks on exactly the sweeps that matter: with a +//! real `Vπ` near 860 the DAC range holds ~2.4 lobes, so the global minimum +//! and maximum can sit whole periods apart and the seed is meaningless. +//! +//! # Noise is measured, not assumed +//! +//! Every judgement about whether a sweep is good — was a lobe resolved at all, +//! is the up/down difference real drift — is made against the **fit's own RMS +//! residual**, which is the scatter of the averaged points about the curve. +//! Nothing here reads `peak_to_peak_volts`, which measures the detector *before* +//! averaging and therefore says more about the photodiode owner's window length +//! than about the precision of a point (ADR 019). + +use std::f64::consts::PI; + +use crate::waveform::{LobeInversion, DAC_FULL_SCALE}; + +/// Sweep direction, kept per point so ascending/descending repeatability can +/// be reported (knowledge base §5 acceptance test 1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Ascending, + Descending, +} + +impl Direction { + pub fn label(self) -> &'static str { + match self { + Self::Ascending => "up", + Self::Descending => "down", + } + } +} + +/// One settled `(DAC code, detector level)` measurement. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SweepPoint { + pub code: u16, + pub direction: Direction, + /// Raw detector level in volts, as published by the photodiode owner. + pub volts: f64, + /// Spread over the averaged window. Archived as a settle-quality witness the + /// operator can read next to the plot; the fit deliberately does not use it + /// (see the module docs). + pub peak_to_peak_volts: f64, + pub clipped: bool, +} + +/// Which port the detector watches. An input to the fit, not an output: the +/// swept curve is identical either way (see the module docs), so this states +/// the bench geometry that resolves the ambiguity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectorGeometry { + /// Detector darkens as excitation rises — the Stage-A PBS reject port, and + /// the default: on this bench the geometry is settled by construction. + RejectedComplement, + /// Detector brightens with excitation (a transmitted-port tap). + Direct, +} + +impl DetectorGeometry { + pub const VARIANTS: [Self; 2] = [Self::RejectedComplement, Self::Direct]; + + /// Named by what the operator can *observe*, not by optics jargon: the + /// question the setting actually asks is which way the photodiode reading + /// moves when the light reaching the sample gets brighter. + pub fn name(self) -> &'static str { + match self { + Self::RejectedComplement => "REJECT PORT (PD falls as light rises)", + Self::Direct => "DIRECT (PD rises with light)", + } + } + + pub fn from_name(name: &str) -> Option { + Self::VARIANTS.into_iter().find(|kind| kind.name() == name) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TransferFit { + /// DAC code at the excitation minimum. + pub v_null_dac: f64, + /// DAC codes from `v_null` to the excitation maximum (one half-wave-voltage span). + pub v_pi_dac: f64, + /// Detector volts at the excitation null (`p0`). + pub offset_volts: f64, + /// Signed detector span across one lobe (`p1`); negative on the reject port. + pub span_volts: f64, + pub rms_residual_volts: f64, + /// Residual as a fraction of the detector span — the headline fit quality. + pub quality: f64, + pub geometry: DetectorGeometry, + /// Mean |ascending − descending| at matched codes, as a fraction of the + /// span. `None` when the sweep ran in one direction only. Judge it against + /// [`TransferFit::hysteresis_noise_floor`], never against zero. + pub hysteresis: Option, + /// Fraction of one full lobe (`Vπ` codes) the sweep actually covered. + /// Below ~1 the half-wave-voltage span is extrapolated, not measured. + pub lobe_coverage: f64, + /// Points discarded as wild before the final fit. A couple is ordinary; a + /// large share means the sweep, not the model, is the problem. + pub rejected_points: usize, + /// Every measured point, rejected ones included, so the plot shows what was + /// actually seen. + pub points: Vec, +} + +impl TransferFit { + pub fn inversion(&self) -> LobeInversion { + LobeInversion { + v_null_dac: self.v_null_dac, + v_pi_dac: self.v_pi_dac, + } + } + + /// DAC code at the excitation maximum — the second of the two codes the + /// drive is configured with. + pub fn v_peak_dac(&self) -> f64 { + self.v_null_dac + self.v_pi_dac + } + + /// Detector extremum at the excitation null. On the reject port this is the + /// detector *maximum* and a **lower bound** on the total-power anchor + /// `I_tot` — not the anchor itself, because the residual transmitted floor + /// is not separable here (knowledge base §4.4). + pub fn detector_volts_at_null(&self) -> f64 { + self.offset_volts + } + + /// Detector extremum at the excitation maximum. + pub fn detector_volts_at_peak(&self) -> f64 { + self.offset_volts + self.span_volts + } + + /// The value [`Self::hysteresis`] takes when the two passes differ by + /// nothing but independent point noise. + /// + /// Both passes measure the same curve, so their difference at a matched code + /// is the difference of two independent errors of scale `σ` — and for those, + /// `E|Δ| = σ√2 · √(2/π) = 1.128 σ`. The fit already measures `σ` as its RMS + /// residual, so the floor comes out of numbers that are on the table. + /// + /// Without it the metric reports noise as drift: on a real bench sweep whose + /// points carried 11.3 mV of scatter against a 50.8 mV lobe, the "hysteresis" + /// read 25.7 % against a floor of 25.1 % — a clean, drift-free cell flagged + /// as drifting (ADR 019). + pub fn hysteresis_noise_floor(&self) -> f64 { + let span = self.span_volts.abs(); + if span <= f64::EPSILON { + return f64::INFINITY; + } + 1.128 * self.rms_residual_volts / span + } + + /// How far the up/down disagreement stands above what the point noise alone + /// explains: [`Self::hysteresis`] over [`Self::hysteresis_noise_floor`]. + /// + /// The ratio lives between two derivable endpoints, which is what makes it + /// usable as a test. Write `Δ` for a systematic offset between the passes and + /// `σ` for the per-point noise. The metric itself behaves as + /// `√(Δ² + (1.128σ)²)`, while the fit — which splits the difference between + /// the two passes — carries a residual of `√(Δ²/4 + σ²)`. So: + /// + /// - **pure noise** (`Δ = 0`) → **1.0**, by construction; + /// - **pure drift** (`Δ ≫ σ`) → `Δ / (1.128 · Δ/2)` = **1.77**. + /// + /// A systematic offset therefore inflates the residual too, and the ratio + /// saturates rather than growing without bound — which is exactly why a + /// generous multiple of the floor (2×, say) never fires at all. The + /// discriminating range is narrow and known, so the threshold belongs inside + /// it: [`Self::hysteresis_is_systematic`]. + /// + /// `None` when the sweep ran in one direction only. + pub fn hysteresis_above_noise(&self) -> Option { + self.hysteresis + .map(|value| value / self.hysteresis_noise_floor()) + } + + /// Whether the up/down disagreement is drift rather than scatter. + /// + /// The cut sits between the two endpoints derived in + /// [`Self::hysteresis_above_noise`], at the point where the systematic part + /// is about 1.5× the point noise — sensitive enough to catch a real lag, + /// blind to a bench that is merely noisy. + pub fn hysteresis_is_systematic(&self) -> bool { + const SYSTEMATIC_ABOVE: f64 = 1.33; + self.hysteresis_above_noise() + .is_some_and(|ratio| ratio > SYSTEMATIC_ABOVE) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FitError { + /// Fewer points than parameters can be resolved from. + TooFewPoints { count: usize, minimum: usize }, + /// The fitted lobe does not stand above the scatter of the points about it, + /// so the sweep does not resolve a lobe. + NoModulation { + span_volts: f64, + residual_volts: f64, + }, + /// A fitted lobe exists but no `[V_null, V_null+Vπ]` fits inside the + /// commandable range, so no monotonic branch is usable. + NoLobeInRange { v_pi_dac: f64 }, +} + +impl std::fmt::Display for FitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooFewPoints { count, minimum } => { + write!(f, "only {count} sweep points (minimum {minimum})") + } + Self::NoModulation { + span_volts, + residual_volts, + } => write!( + f, + "the fitted lobe spans {span_volts:.6} V but the points scatter {residual_volts:.6} \ + V about it, so no lobe is resolved; check the light path and HV amplifier, or \ + reduce detector noise" + ), + Self::NoLobeInRange { v_pi_dac } => write!( + f, + "fitted Vπ = {v_pi_dac:.0} DAC codes leaves no full lobe inside the max limit; \ + raise the max limit or re-check the drive gain" + ), + } + } +} + +impl std::error::Error for FitError {} + +/// Smallest usable sweep: four points per fitted parameter. +pub const MIN_POINTS: usize = 16; + +/// Largest `rms_residual / |span|` that still counts as a resolved lobe. See the +/// gate in [`fit_transfer`] for where the number comes from; above it the sweep +/// is refused outright, below it the residual only warns. +const MAX_RESOLVED_QUALITY: f64 = 0.5; + +/// Least-squares solution for one candidate half-wave-voltage span `w`. +struct Harmonic { + /// Mean level `A`, and the quadrature amplitudes of `cos`/`sin(πc/w)`. + mean: f64, + amplitude: f64, + phase: f64, + sse: f64, +} + +/// Fits `P = A + B·cos(πc/w) + C·sin(πc/w)` for a fixed `w`. +/// +/// This is the whole trick that makes the search one-dimensional. Because +/// `sin²(x) = (1 − cos 2x)/2`, the lobe model +/// `p0 + p1·sin²(π(c − v)/(2w))` is *exactly* a constant plus one sinusoid of +/// period `2w` — and a sinusoid of known period is **linear** in its +/// quadrature components. So `V_null` (a phase) and both amplitudes drop out +/// of a 3×3 normal-equation solve, and only `Vπ` is ever searched. No seeding +/// from measured extrema, which is what fails once a sweep spans several +/// lobes and the global extrema sit periods apart. +fn solve_harmonic(points: &[SweepPoint], w: f64) -> Harmonic { + let n = points.len() as f64; + let (mut s_c, mut s_s, mut s_cc, mut s_ss, mut s_cs) = (0.0, 0.0, 0.0, 0.0, 0.0); + let (mut s_y, mut s_yc, mut s_ys) = (0.0, 0.0, 0.0); + for point in points { + let theta = PI * f64::from(point.code) / w; + let (sin, cos) = theta.sin_cos(); + s_c += cos; + s_s += sin; + s_cc += cos * cos; + s_ss += sin * sin; + s_cs += cos * sin; + s_y += point.volts; + s_yc += point.volts * cos; + s_ys += point.volts * sin; + } + // Symmetric 3×3 normal equations for (A, B, C), solved by cofactors. + let m = [[n, s_c, s_s], [s_c, s_cc, s_cs], [s_s, s_cs, s_ss]]; + let rhs = [s_y, s_yc, s_ys]; + let cofactor = [ + m[1][1] * m[2][2] - m[1][2] * m[2][1], + m[1][2] * m[2][0] - m[1][0] * m[2][2], + m[1][0] * m[2][1] - m[1][1] * m[2][0], + ]; + let determinant = m[0][0] * cofactor[0] + m[0][1] * cofactor[1] + m[0][2] * cofactor[2]; + if determinant.abs() < 1e-12 { + return Harmonic { + mean: s_y / n, + amplitude: 0.0, + phase: 0.0, + sse: f64::MAX, + }; + } + let solve = |column: usize| { + let mut augmented = m; + for row in 0..3 { + augmented[row][column] = rhs[row]; + } + (augmented[0][0] * (augmented[1][1] * augmented[2][2] - augmented[1][2] * augmented[2][1]) + - augmented[0][1] + * (augmented[1][0] * augmented[2][2] - augmented[1][2] * augmented[2][0]) + + augmented[0][2] + * (augmented[1][0] * augmented[2][1] - augmented[1][1] * augmented[2][0])) + / determinant + }; + let (a, b, c) = (solve(0), solve(1), solve(2)); + let sse = points + .iter() + .map(|point| { + let theta = PI * f64::from(point.code) / w; + let residual = point.volts - (a + b * theta.cos() + c * theta.sin()); + residual * residual + }) + .sum(); + Harmonic { + mean: a, + amplitude: b.hypot(c), + phase: c.atan2(b), + sse, + } +} + +/// Golden-section minimisation of `f` on `[lo, hi]`, used one axis at a time. +fn golden_min(lo: f64, hi: f64, tolerance: f64, f: impl Fn(f64) -> f64) -> f64 { + const INV_PHI: f64 = 0.618_033_988_749_895; + let (mut lo, mut hi) = (lo, hi); + let mut c = hi - (hi - lo) * INV_PHI; + let mut d = lo + (hi - lo) * INV_PHI; + let (mut fc, mut fd) = (f(c), f(d)); + while (hi - lo) > tolerance { + if fc < fd { + hi = d; + d = c; + fd = fc; + c = hi - (hi - lo) * INV_PHI; + fc = f(c); + } else { + lo = c; + c = d; + fc = fd; + d = lo + (hi - lo) * INV_PHI; + fd = f(d); + } + } + 0.5 * (lo + hi) +} + +/// Mean of the points at each distinct code, smoothed over three neighbours, so +/// the seed extrema are not chosen by a single noisy sample. +fn smoothed_profile(points: &[SweepPoint]) -> Vec<(f64, f64)> { + let mut codes: Vec = points.iter().map(|point| point.code).collect(); + codes.sort_unstable(); + codes.dedup(); + let means: Vec<(f64, f64)> = codes + .iter() + .map(|&code| { + let matching: Vec = points + .iter() + .filter(|point| point.code == code) + .map(|point| point.volts) + .collect(); + ( + f64::from(code), + matching.iter().sum::() / matching.len() as f64, + ) + }) + .collect(); + (0..means.len()) + .map(|index| { + let lo = index.saturating_sub(1); + let hi = (index + 2).min(means.len()); + let window = &means[lo..hi]; + ( + means[index].0, + window.iter().map(|(_, v)| v).sum::() / window.len() as f64, + ) + }) + .collect() +} + +/// Shifts `v` by whole lobe periods to the **lowest** null whose lobe +/// `[v, v + w]` fits inside `0..=max_code`. +/// +/// A sweep across several periods finds several equally valid nulls, so the +/// choice needs a rule the operator can predict rather than a nearest-match. +/// The lowest one drives the Pockels cell at the smallest codes — least +/// voltage across the crystal, most headroom under the max limit. +fn select_lobe(v: f64, w: f64, max_code: f64) -> Option { + // Sub-code precision is meaningless on a 12-bit DAC, so a null fitted a + // hair below 0 (or a peak a hair past the ceiling) is snapped into range + // rather than refused — otherwise a lobe nulling exactly at code 0 fails + // on fit noise alone. + const TOLERANCE: f64 = 1.0; + // The model repeats every `2w` in code, and `v + kw` for odd `k` is the + // same branch mirrored, so stepping by `2w` enumerates every null. + let period = 2.0 * w; + let mut candidate = v - period * ((v / period).floor() + 1.0); + while candidate <= max_code + TOLERANCE { + if candidate >= -TOLERANCE && candidate + w <= max_code + TOLERANCE { + return Some(candidate.clamp(0.0, (max_code - w).max(0.0))); + } + candidate += period; + } + None +} + +/// Mean |ascending − descending| at codes visited in both directions, as a +/// fraction of the detector span. +fn hysteresis_fraction(points: &[SweepPoint], span: f64) -> Option { + let mut differences = Vec::new(); + for up in points + .iter() + .filter(|point| point.direction == Direction::Ascending) + { + if let Some(down) = points + .iter() + .find(|point| point.direction == Direction::Descending && point.code == up.code) + { + differences.push((up.volts - down.volts).abs()); + } + } + if differences.is_empty() || span.abs() < f64::EPSILON { + return None; + } + Some(differences.iter().sum::() / differences.len() as f64 / span.abs()) +} + +/// Scans the half-wave-voltage span over every period the sweep could resolve, then +/// refines. Returns the best `(Vπ, harmonic)`. +/// +/// `code_count` is the number of **distinct** codes visited, not the number of +/// points: a sweep that runs up and back visits each code twice, and counting +/// the repeats halves the apparent code step and pushes the scan floor below +/// what the sweep can resolve — straight into aliasing. +fn fit_period( + points: &[SweepPoint], + swept_span: f64, + code_count: usize, +) -> Option<(f64, Harmonic)> { + // From four samples per lobe (below that the lobe is aliased) out to a + // lobe twice the swept span (a barely-curved arc). Log-spaced, because a + // fixed step wastes resolution at long periods and misses short ones. + let point_spacing = swept_span / code_count.max(2) as f64; + let w_min = (2.0 * point_spacing).max(1.0); + let w_max = (2.0 * swept_span).max(w_min * 1.5); + const SCAN_STEPS: usize = 600; + let log_step = (w_max / w_min).ln() / SCAN_STEPS as f64; + let mut best: Option<(f64, f64)> = None; // (sse, w) + for step in 0..=SCAN_STEPS { + let w = w_min * (log_step * step as f64).exp(); + let sse = solve_harmonic(points, w).sse; + if best.is_none_or(|(previous, _)| sse < previous) { + best = Some((sse, w)); + } + } + let (_, coarse_w) = best?; + // Refine inside one scan cell, where the SSE is unimodal. + let cell = coarse_w * log_step; + let w = golden_min( + (coarse_w - cell).max(w_min * 0.5), + coarse_w + cell, + 1e-3, + |candidate| solve_harmonic(points, candidate).sse, + ); + let harmonic = solve_harmonic(points, w); + Some((w, harmonic)) +} + +/// Points whose residual against `harmonic` is not wildly out of family. +/// +/// The cut is on the **median** absolute residual, not the mean or the +/// standard deviation: those are themselves dragged out by the very points +/// being looked for. `6 × median` is roughly 4σ for Gaussian noise, so ordinary +/// scatter survives untouched and only genuine strays are dropped. +fn without_outliers(points: &[SweepPoint], w: f64, harmonic: &Harmonic) -> Vec { + let residual = |point: &SweepPoint| { + let theta = PI * f64::from(point.code) / w; + point.volts - (harmonic.mean + harmonic.amplitude * (theta - harmonic.phase).cos()) + }; + let mut magnitudes: Vec = points.iter().map(|point| residual(point).abs()).collect(); + magnitudes.sort_by(f64::total_cmp); + let median = magnitudes[magnitudes.len() / 2]; + if median <= 0.0 { + return points.to_vec(); + } + let limit = 6.0 * median; + points + .iter() + .filter(|point| residual(point).abs() <= limit) + .copied() + .collect() +} + +/// Fits the lobe. `max_code` is the highest commandable DAC code (the drive's +/// max limit), which constrains which branch can be used; `geometry` resolves +/// the null/peak ambiguity the data cannot (see the module docs). +pub fn fit_transfer( + points: &[SweepPoint], + max_code: f64, + geometry: DetectorGeometry, +) -> Result { + if points.len() < MIN_POINTS { + return Err(FitError::TooFewPoints { + count: points.len(), + minimum: MIN_POINTS, + }); + } + let profile = smoothed_profile(points); + let min_volts = profile.iter().map(|(_, v)| *v).fold(f64::MAX, f64::min); + let max_volts = profile.iter().map(|(_, v)| *v).fold(f64::MIN, f64::max); + let observed_span = max_volts - min_volts; + // Only the degenerate case is refused before fitting — a flat or non-finite + // sweep has no curve to measure anything against. Whether a real lobe was + // resolved is decided *after* the fit, from the fit's own residual. + if !observed_span.is_finite() || observed_span <= f64::EPSILON { + return Err(FitError::NoModulation { + span_volts: observed_span.max(0.0), + residual_volts: 0.0, + }); + } + + let swept_lo = profile.first().map(|(code, _)| *code).unwrap_or(0.0); + let swept_hi = profile.last().map(|(code, _)| *code).unwrap_or(max_code); + let swept_span = (swept_hi - swept_lo).max(1.0); + let code_count = profile.len(); + + // A single stray point — one window caught mid-settle, one stream hiccup — + // barely moves the fitted period but inflates the RMS residual several + // fold. Fit once, drop the points the fit says are wild, and fit again on + // what is left, so the reported residual describes the curve rather than + // the worst sample. + let (w, harmonic, rejected_points) = { + let first = fit_period(points, swept_span, code_count).ok_or(FitError::NoModulation { + span_volts: observed_span, + residual_volts: 0.0, + })?; + let kept = without_outliers(points, first.0, &first.1); + if kept.len() < points.len() && kept.len() >= MIN_POINTS { + match fit_period(&kept, swept_span, code_count) { + Some((w, harmonic)) => (w, harmonic, points.len() - kept.len()), + None => (first.0, first.1, 0), + } + } else { + (first.0, first.1, 0) + } + }; + // `A + R·cos(θ − φ)` with `θ = πc/w` is the same curve as + // `p0 + p1·sin²(π(c − v)/(2w))` with `|p1| = 2R`. Which of the two signs + // of `p1` applies — and therefore whether the null sits at the phase or a + // one half-wave-voltage span past it — is the geometry question the data cannot answer. + let radius = harmonic.amplitude; + let (v, p0, p1) = match geometry { + DetectorGeometry::RejectedComplement => ( + harmonic.phase * w / PI, + harmonic.mean + radius, + -2.0 * radius, + ), + DetectorGeometry::Direct => ( + harmonic.phase * w / PI + w, + harmonic.mean - radius, + 2.0 * radius, + ), + }; + + // Over the points the fit actually used: dividing the kept residual by the + // full count would flatter the number. + let rms = (harmonic.sse / (points.len() - rejected_points).max(1) as f64).sqrt(); + // A lobe is resolved when its amplitude stands above the scatter of the + // points about it. `p1` and the residual are spans of the *same* averaged + // points, so they are directly comparable — which the previous test, against + // the median raw within-window excursion, was not: that measures the detector + // *before* averaging, so it tracks whatever window the photodiode owner + // happens to publish rather than the precision of a point. It came within a + // factor of two of refusing a real, clean bench sweep, and would have got + // stricter as the owner's window grew (ADR 019). + // + // The threshold has to leave room on both sides, because a free period + // search over pure noise does *not* return an amplitude of zero: with `n` + // points the quadrature pair has scale `σ√(2/n)`, and taking the best of a + // 600-step scan inflates it by about `√(2 ln 600)`. For the sweeps this + // module actually sees (n = 49 and n = 98) that lands the noise-only quality + // at 0.7–1.0 — measured at 0.97 in `refuses_a_lobe_that_does_not_stand_above + // _the_point_scatter`. A resolved lobe sits far below: the noisiest real + // bench record on file reads 0.22. Half-way between, at 0.5, is a plain + // statement — the lobe must be at least twice its own scatter — with better + // than 2× margin either way. + if !p1.is_finite() || p1.abs() <= f64::EPSILON || rms >= MAX_RESOLVED_QUALITY * p1.abs() { + return Err(FitError::NoModulation { + span_volts: p1.abs(), + residual_volts: rms, + }); + } + + let v_null = select_lobe(v, w, max_code).ok_or(FitError::NoLobeInRange { v_pi_dac: w })?; + + Ok(TransferFit { + v_null_dac: v_null, + v_pi_dac: w, + offset_volts: p0, + span_volts: p1, + rms_residual_volts: rms, + quality: rms / p1.abs(), + geometry, + hysteresis: hysteresis_fraction(points, p1), + lobe_coverage: swept_span / w, + rejected_points, + // Every measured point is kept for the plot, rejected ones included: + // seeing the strays next to the fit is how the operator judges it. + points: points.to_vec(), + }) +} + +/// Ascending then descending sweep codes over `0..=max_code`. +pub fn sweep_codes( + max_code: u16, + points_per_pass: usize, + both_directions: bool, +) -> Vec<(u16, Direction)> { + let points_per_pass = points_per_pass.max(2); + let max_code = max_code.min(DAC_FULL_SCALE); + let ascending: Vec = (0..points_per_pass) + .map(|index| { + (f64::from(max_code) * index as f64 / (points_per_pass - 1) as f64).round() as u16 + }) + .collect(); + let mut codes: Vec<(u16, Direction)> = ascending + .iter() + .map(|&code| (code, Direction::Ascending)) + .collect(); + if both_directions { + codes.extend( + ascending + .iter() + .rev() + .map(|&code| (code, Direction::Descending)), + ); + } + codes +} + +/// Deterministic per-point scatter in `[-1, 1]`, shared by the fit tests and the +/// plugin's warning tests. +/// +/// Not an RNG — failures reproduce — but genuinely *uncorrelated between the two +/// passes*, which a wobble alternating with the point index is not: with an odd +/// number of points per pass, matched codes always land on opposite signs, so +/// what looks like noise is a systematic offset between the passes. That is the +/// exact thing the hysteresis test has to tell apart, so the fixture must not +/// quietly be the wrong one. +#[cfg(test)] +pub(crate) fn scatter(code: u16, direction: Direction) -> f64 { + let mut x = u64::from(code).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ match direction { + Direction::Ascending => 0, + Direction::Descending => 0xD1B5_4A32_D192_ED03, + }; + x ^= x >> 33; + x = x.wrapping_mul(0xFF51_AFD7_ED55_8CCD); + x ^= x >> 33; + ((x >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sweep the operator recorded on 2026-07-30, verbatim. + /// + /// A clean 625-code lobe that the plugin then reported as bad: 22 % residual, + /// 26 % hysteresis, 34 "clipped" points. Every one of those was an artifact + /// of publishing four ADC samples per settled code (ADR 019). Kept as a + /// fixture because synthetic sweeps cannot reproduce what a real detector's + /// signal-proportional noise does to metrics that are compared against zero. + const REAL_SWEEP: &str = include_str!("../testdata/pockels-20260730-083123.json"); + + fn real_sweep_points() -> Vec { + let record: serde_json::Value = + serde_json::from_str(REAL_SWEEP).expect("the archived record parses"); + record["points"] + .as_array() + .expect("points array") + .iter() + .map(|point| SweepPoint { + code: point["code"].as_u64().expect("code") as u16, + direction: match point["direction"].as_str().expect("direction") { + "up" => Direction::Ascending, + "down" => Direction::Descending, + other => panic!("unknown direction {other}"), + }, + volts: point["volts"].as_f64().expect("volts"), + peak_to_peak_volts: point["peak_to_peak_volts"].as_f64().expect("p2p"), + clipped: point["clipped"].as_bool().expect("clipped"), + }) + .collect() + } + + #[test] + fn the_recorded_bench_sweep_resolves_its_lobe() { + let points = real_sweep_points(); + assert_eq!(points.len(), 98); + let fit = + fit_transfer(&points, 3_000.0, DetectorGeometry::RejectedComplement).expect("fits"); + + assert!((fit.v_pi_dac - 625.4).abs() < 1.0, "Vπ = {}", fit.v_pi_dac); + assert!( + (fit.v_null_dac - 711.9).abs() < 1.0, + "V_null = {}", + fit.v_null_dac + ); + assert!(fit.span_volts < 0.0, "reject port darkens with excitation"); + assert!(fit.lobe_coverage > 4.0, "coverage = {}", fit.lobe_coverage); + } + + #[test] + fn the_recorded_sweeps_hysteresis_is_exactly_its_point_noise() { + // The load-bearing claim behind the hysteresis noise floor, and the + // reason the operator's clean cell was reported as drifting. + // + // Both passes measure one curve, so at a matched code they differ by two + // independent errors of scale σ, for which E|Δ| = 1.128 σ. The fit + // measures σ as its RMS residual. If the observed 25.7 % lands on that + // prediction, the passes disagree by nothing but noise — there is no + // drift to warn about, at any threshold that ignores the noise. + let fit = fit_transfer( + &real_sweep_points(), + 3_000.0, + DetectorGeometry::RejectedComplement, + ) + .expect("fits"); + + let hysteresis = fit.hysteresis.expect("both directions were swept"); + let floor = fit.hysteresis_noise_floor(); + assert!( + (hysteresis / floor - 1.0).abs() < 0.05, + "hysteresis {hysteresis:.4} vs. noise floor {floor:.4}: not explained by noise alone" + ); + assert!( + !fit.hysteresis_is_systematic(), + "ratio = {:?}", + fit.hysteresis_above_noise() + ); + } + + #[test] + fn the_hysteresis_ratio_sits_between_its_two_derived_endpoints() { + // The threshold in `hysteresis_is_systematic` is only meaningful if the + // ratio really does run from 1.0 (pure noise) to 1.77 (pure drift). Both + // ends are asserted here, because the cut sits between them and nowhere + // else would work. + let scattered = fit_transfer( + &synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.050, true), + 4_095.0, + DetectorGeometry::RejectedComplement, + ) + .expect("fits"); + let noise_end = scattered.hysteresis_above_noise().expect("both directions"); + assert!((noise_end - 1.0).abs() < 0.15, "noise end = {noise_end}"); + + // Same curve, no scatter, one pass offset wholesale: pure drift. + let mut points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.0, true); + for point in &mut points { + if point.direction == Direction::Descending { + point.volts -= 0.2; + } + } + let drifting = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + let drift_end = drifting.hysteresis_above_noise().expect("both directions"); + assert!((drift_end - 1.772).abs() < 0.15, "drift end = {drift_end}"); + assert!(drifting.hysteresis_is_systematic()); + } + + /// Synthesizes a sweep of a known lobe as seen through a given port. + /// `noise` is the amplitude of the deterministic per-point [`scatter`]. + fn synthetic_sweep( + v_null: f64, + v_pi: f64, + offset: f64, + span: f64, + max_code: u16, + noise: f64, + both_directions: bool, + ) -> Vec { + let lobe = LobeInversion { + v_null_dac: v_null, + v_pi_dac: v_pi, + }; + sweep_codes(max_code, 49, both_directions) + .into_iter() + .map(|(code, direction)| { + let u = lobe.u_for_dac(f64::from(code)); + SweepPoint { + code, + direction, + volts: offset + span * u + noise * scatter(code, direction), + peak_to_peak_volts: 0.002, + clipped: false, + } + }) + .collect() + } + + #[test] + fn recovers_a_known_lobe_from_the_reject_port() { + // Reject port: detector is brightest (2.4 V) at the excitation null. + let points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.004, true); + let fit = fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement) + .expect("fits the lobe"); + + assert!( + (fit.v_null_dac - 300.0).abs() < 5.0, + "V_null = {}", + fit.v_null_dac + ); + assert!( + (fit.v_pi_dac - 1_600.0).abs() < 10.0, + "Vπ = {}", + fit.v_pi_dac + ); + assert!(fit.span_volts < 0.0, "reject port darkens with excitation"); + assert!((fit.detector_volts_at_null() - 2.4).abs() < 0.02); + assert!(fit.quality < 0.01, "quality = {}", fit.quality); + // Both directions carry the same synthetic curve, so the only + // difference at matched codes is the alternating wobble. + assert!(fit.hysteresis.expect("both directions") < 0.01); + } + + #[test] + fn recovers_the_same_lobe_from_a_direct_detector() { + // Same physical lobe, opposite port: dim at the null, bright at peak. + let points = synthetic_sweep(300.0, 1_600.0, 0.2, 2.2, 4_095, 0.004, true); + let fit = fit_transfer(&points, 4_095.0, DetectorGeometry::Direct).expect("fits the lobe"); + + assert!( + (fit.v_null_dac - 300.0).abs() < 5.0, + "V_null = {}", + fit.v_null_dac + ); + assert!((fit.v_pi_dac - 1_600.0).abs() < 10.0); + assert!(fit.span_volts > 0.0, "direct detector brightens"); + } + + #[test] + fn geometry_selects_between_the_two_equivalent_representations() { + // One curve, two readings. Declaring the wrong port must move V_null by + // exactly one half-wave-voltage span — the failure this input exists to prevent. + let points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.0, false); + let reject = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + let direct = fit_transfer(&points, 4_095.0, DetectorGeometry::Direct).expect("fits"); + + assert!((reject.v_null_dac - 300.0).abs() < 5.0); + assert!( + ((direct.v_null_dac - reject.v_null_dac).abs() - reject.v_pi_dac).abs() < 10.0, + "direct = {}, reject = {}, Vπ = {}", + direct.v_null_dac, + reject.v_null_dac, + reject.v_pi_dac + ); + // Both describe the measured curve equally well; only the physics + // distinguishes them. + assert!((reject.rms_residual_volts - direct.rms_residual_volts).abs() < 1e-6); + } + + #[test] + fn resolves_a_sweep_spanning_several_lobes() { + // A real Vπ near 860 puts ~2.4 lobes inside the DAC range. Seeding the + // period from the global extrema fails here — they can sit whole + // periods apart — which is why the period is scanned, not seeded. + let points = synthetic_sweep(1_630.0, 860.0, 2.4, -2.2, 4_095, 0.003, true); + let fit = fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement) + .expect("fits a multi-lobe sweep"); + assert!((fit.v_pi_dac - 860.0).abs() < 10.0, "Vπ = {}", fit.v_pi_dac); + // Any null is a valid answer as long as it names a real one and the + // lobe it opens fits inside the range. + let offset = (fit.v_null_dac - 1_630.0).rem_euclid(2.0 * 860.0); + assert!( + offset.min(2.0 * 860.0 - offset) < 10.0, + "V_null = {} is not a null of the swept lobe", + fit.v_null_dac + ); + assert!(fit.v_null_dac >= 0.0 && fit.v_null_dac + fit.v_pi_dac <= 4_095.0); + assert!(fit.quality < 0.01, "quality = {}", fit.quality); + assert!(fit.lobe_coverage > 4.0, "coverage = {}", fit.lobe_coverage); + } + + #[test] + fn a_null_at_code_zero_is_not_lost_to_fit_noise() { + // V_null = 0 fits a hair either side of the rail; snapping sub-code + // slack into range is the difference between a usable calibration and + // a refusal. + let points = synthetic_sweep(0.0, 1_200.0, 2.4, -2.2, 4_095, 0.003, false); + let fit = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + assert!(fit.v_null_dac.abs() < 2.0, "V_null = {}", fit.v_null_dac); + } + + #[test] + fn picks_a_lobe_that_fits_inside_the_max_limit() { + // Null at 2600 with Vπ = 1600 would put peak light at 4200, past the + // rail; the previous null one period down (2600 − 3200 < 0) does not + // fit either, so only a lower branch inside the range is acceptable. + let points = synthetic_sweep(1_000.0, 900.0, 2.4, -2.2, 4_095, 0.002, false); + let fit = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + assert!(fit.v_null_dac >= 0.0); + assert!( + fit.v_null_dac + fit.v_pi_dac <= 4_095.0, + "peak light at {} leaves the rail", + fit.v_null_dac + fit.v_pi_dac + ); + } + + #[test] + fn refuses_a_flat_sweep() { + let points: Vec = sweep_codes(4_095, 49, false) + .into_iter() + .map(|(code, direction)| SweepPoint { + code, + direction, + volts: 1.5, + peak_to_peak_volts: 0.001, + clipped: false, + }) + .collect(); + assert!(matches!( + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement), + Err(FitError::NoModulation { .. }) + )); + } + + #[test] + fn accepts_a_repeatable_sub_10mv_transfer() { + // The real detector commonly operates between roughly 0.5 and 15 mV. + // A repeatable 4 mV lobe was rejected by an absolute 10 mV threshold + // even though the points sit tightly on it. + let points = synthetic_sweep(300.0, 1_600.0, 0.010, -0.004, 4_095, 0.000_05, true); + let fit = fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement) + .expect("a resolved millivolt-scale lobe must fit"); + + assert!( + (fit.v_pi_dac - 1_600.0).abs() < 10.0, + "Vπ = {}", + fit.v_pi_dac + ); + assert!(fit.span_volts.abs() < 0.010); + assert!(fit.span_volts.abs() > 0.003); + } + + #[test] + fn refuses_a_lobe_that_does_not_stand_above_the_point_scatter() { + // No lobe at all (span 0), only scatter. A free period search over noise + // does not return zero amplitude — it returns the best of 600 tries — + // which is exactly why the gate cannot sit at `residual >= span`. + let points = synthetic_sweep(300.0, 1_600.0, 0.008, 0.0, 4_095, 0.000_4, false); + let error = fit_transfer(&points, 4_095.0, DetectorGeometry::Direct) + .expect_err("noise alone must not pass as a lobe"); + let FitError::NoModulation { + span_volts, + residual_volts, + } = error + else { + panic!("{error:?}"); + }; + // Pins the noise-only quality the threshold was chosen against: this + // fixture reads ~0.97, and the cut at 0.5 keeps a factor of two clear. + let noise_quality = residual_volts / span_volts; + assert!( + (0.6..1.2).contains(&noise_quality), + "noise-only quality = {noise_quality}" + ); + } + + #[test] + fn a_noisy_but_real_lobe_still_resolves() { + // The gate is about resolution, not tidiness: a lobe carrying a fifth of + // its own span in scatter — the state the bench was actually in — must + // still fit. Only the warnings are allowed to comment on it. + let points = synthetic_sweep(700.0, 625.0, 0.058, -0.051, 3_000, 0.011, true); + let fit = fit_transfer(&points, 3_000.0, DetectorGeometry::RejectedComplement) + .expect("a noisy but resolved lobe must fit"); + assert!((fit.v_pi_dac - 625.0).abs() < 20.0, "Vπ = {}", fit.v_pi_dac); + assert!(fit.quality > 0.1, "quality = {}", fit.quality); + } + + #[test] + fn refuses_too_few_points() { + let points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.0, false); + assert!(matches!( + fit_transfer(&points[..4], 4_095.0, DetectorGeometry::RejectedComplement), + Err(FitError::TooFewPoints { .. }) + )); + } + + #[test] + fn reports_hysteresis_between_the_two_passes() { + // Descending runs 20 mV below ascending: a real hysteresis signature. + let mut points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.0, true); + for point in &mut points { + if point.direction == Direction::Descending { + point.volts -= 0.02; + } + } + let fit = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + let hysteresis = fit.hysteresis.expect("both directions"); + assert!( + (hysteresis - 0.02 / 2.2).abs() < 1e-3, + "hysteresis = {hysteresis}" + ); + } + + #[test] + fn single_direction_sweep_reports_no_hysteresis() { + let points = synthetic_sweep(300.0, 1_600.0, 2.4, -2.2, 4_095, 0.002, false); + let fit = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + assert_eq!(fit.hysteresis, None); + } + + #[test] + fn lobe_coverage_flags_an_extrapolated_half_wave_span() { + // Sweeping only to code 800 with Vπ = 1600 sees half a lobe. + let points = synthetic_sweep(0.0, 1_600.0, 2.4, -2.2, 800, 0.001, false); + let fit = + fit_transfer(&points, 4_095.0, DetectorGeometry::RejectedComplement).expect("fits"); + assert!(fit.lobe_coverage < 0.75, "coverage = {}", fit.lobe_coverage); + } + + #[test] + fn sweep_codes_span_the_range_in_both_directions() { + let codes = sweep_codes(4_000, 5, true); + let ascending: Vec = codes + .iter() + .filter(|(_, direction)| *direction == Direction::Ascending) + .map(|(code, _)| *code) + .collect(); + assert_eq!(ascending, [0, 1_000, 2_000, 3_000, 4_000]); + let descending: Vec = codes + .iter() + .filter(|(_, direction)| *direction == Direction::Descending) + .map(|(code, _)| *code) + .collect(); + assert_eq!(descending, [4_000, 3_000, 2_000, 1_000, 0]); + assert_eq!(sweep_codes(4_000, 5, false).len(), 5); + } +} diff --git a/plugins/stage-a-modulation/src/lib.rs b/plugins/stage-a-modulation/src/lib.rs new file mode 100644 index 0000000..cde22a9 --- /dev/null +++ b/plugins/stage-a-modulation/src/lib.rs @@ -0,0 +1,5597 @@ +//! Stage-A laser modulation control. +//! +//! Drives the laser modulation input (Hermit J23, `DAC1.4`/address 3) through +//! the firmware 0.3.0 `MOD` command. Two orthogonal settings define a drive: +//! the method selects a manually entered or optically calibrated DAC band, +//! while the mode selects the waveform that fills that band. A separate max +//! limit is the hard DAC ceiling for every drive. Every accepted change is +//! transferred to the Teensy immediately, with no Apply button. +//! +//! **Frame-independent by design.** The host only calls `process_frame()` +//! while camera frames flow, so nothing here depends on it: connecting is a +//! checkbox *setting* (settings arrive from the UI thread at any time), a +//! dedicated device thread owns the serial client, and slider changes are +//! coalesced into a pending-command slot that thread drains. The bench works +//! with no camera attached. `process_frame()` only tears the connection down +//! defensively in replay mode. +//! +//! The plugin owns the Teensy **command port**; the photodiode stream port is +//! owned by `stage-a-photodiode`. The firmware output is set-and-hold +//! (`stage-a-controller` ADR 002): disconnecting does NOT switch the +//! 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}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use augur_plugin_api::{ + export_plugin, EventStoreHandle, ExecutionMode, HostContext, HostDatasetDescriptor, + HostDatasetKind, HostOutput, HostViewDescriptor, HostViewKind, HostViewPlacement, + HostViewRegistry, PathDialogKind, Plugin, PluginControlContext, PluginControlSnapshot, + PluginFrame, PluginRuntimeRole, PluginServiceOutcome, PluginServiceReply, PluginServiceRequest, + Series1dLine, Series1dPoint, Series1dV1, SettingItem, SettingKind, SettingsSchema, + SettingsSection, StatusEntry, TableColumn, TableColumnData, TableColumnValues, TableDatasetV1, + TableSchema, TableValueType, +}; +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, A2AcquisitionConfigV1, ClientId, ConnectionStateV1, ControllerStateV1, + FreshnessV1, LeaseId, LeaseSnapshotV1, ModulationCommandV1, ModulationRequestV1, + ModulationResponseV1, ModulationStateV1, ModulationTargetV1, OpticalDriveStateV1, + OpticalTargetV1, OwnerInstanceId, PhotodiodeLevelV1, PhotodiodeSummaryV1, RequestOutcomeV1, + ResponseCommonV1, RunId, SemanticRevision, ServiceErrorCodeV1, ServiceErrorV1, + SynchronizationV1, UnsyncedReasonV1, WaveformV1, CONTRACT_VERSION_V1, + CTX_STAGE_A_MODULATION_STATE_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + DRIVE_FREQUENCY_MAX_MILLIHZ, DRIVE_FREQUENCY_MIN_MILLIHZ, PLUGIN_ID_STAGE_A_MODULATION, + PLUGIN_ID_STAGE_A_PHOTODIODE, SERVICE_STAGE_A_MODULATION_CONTROL_V1, +}; + +const STATUS_DATASET_ID: &str = "stage-a-modulation.status"; +const STATUS_VIEW_ID: &str = "stage-a-modulation.status.view"; +const CURVE_DATASET_ID: &str = "stage-a-modulation.transfer-curve"; +const CURVE_VIEW_ID: &str = "stage-a-modulation.transfer-curve.view"; + +/// Codes measured per sweep pass. 49 points over the full range put a sample +/// every ~85 codes, ~19 per lobe at a typical Vπ of 860. +const SWEEP_POINTS_PER_PASS: usize = 49; +/// How long the detector must have run *after* a code was commanded before its +/// window counts as settled — enough for the HV amplifier and the cell to +/// arrive, proven from the sample clock rather than assumed from a timer. +/// +/// A duration, not a sample count: settling is a property of the amplifier and +/// the crystal, not of the acquisition rate. The former fixed 2 000 samples was +/// written for 20 kSa/s (100 ms) and silently became 4 ms when the bench moved to +/// 500 kSa/s. +const SETTLE_SECONDS: f64 = 0.1; +/// Settle window when the photodiode has not published a sample rate. Matches +/// [`SETTLE_SECONDS`] at the firmware's original 20 kSa/s. +const SETTLE_SAMPLES: u64 = 2_000; +/// Give up on a point if no settled level arrives within this long. A stalled +/// photodiode stream must abort the sweep, not hang it. +const POINT_TIMEOUT: Duration = Duration::from_secs(5); +/// Warn (never block) above this residual, as a fraction of the detector span. +/// A clean bench sits near 1 %; a stray point or two reaches ~10 % while `Vπ` +/// stays good, which is why this warns rather than refuses. +const WARN_QUALITY: f64 = 0.05; +/// Warn above this ascending/descending disagreement, as a fraction of the span. +const WARN_HYSTERESIS: f64 = 0.05; + +const MAX_DAC_CODE: i64 = 4_095; +const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(500); +const DEVICE_LOOP_TICK: Duration = Duration::from_millis(10); +/// After this many serial requests failing in a row the device thread declares +/// the link dead and exits, so the owner can reap it and reconnect. A wedged +/// link that stays "up" otherwise swallows every queued command while the +/// settings UI keeps responding. +const DEVICE_MAX_CONSECUTIVE_ERRORS: u32 = 5; +/// Minimum spacing between automatic reconnect attempts after the device +/// thread died. +const RECONNECT_BACKOFF_MS: u64 = 2_000; +const REQUEST_CACHE_LIMIT: usize = 256; +const MIN_LEASE_TTL_MS: u64 = 250; +const MAX_LEASE_TTL_MS: u64 = 60_000; + +/// The lobe a calibration was last applied to, published process-wide. +/// +/// The host runs two instances of this plugin — a UI mirror that renders the +/// settings and a live worker that owns the device link — and settings only +/// ever travel *mirror → worker*: every live-analysis pass collects +/// `get_setting` from the mirror and writes it onto the worker. +/// +/// The measured fit lives on the worker (it is the instance with the +/// photodiode and the DAC), so "Apply to V_null / V_peak" wrote the new lobe +/// into the worker's fields and the very next sync overwrote it with the +/// mirror's stale codes. The button appeared to do nothing, twice over: the +/// mirror had no fit to apply, and the worker's result did not survive a tick. +/// +/// Both instances live in the same process, so the applied lobe is published +/// here with a monotonic generation and adopted by whichever instance is +/// behind. The generation is what makes adoption one-way: a mirror that has +/// already seen generation *n* keeps accepting ordinary edits. +static APPLIED_LOBE: Mutex> = Mutex::new(None); +static APPLIED_LOBE_GENERATION: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone)] +struct AppliedLobe { + generation: u64, + v_null_dac: i64, + v_peak_dac: i64, + calibration_id: String, +} + +/// Publishes a freshly applied lobe to every instance in this process. +fn publish_applied_lobe(v_null_dac: i64, v_peak_dac: i64, calibration_id: String) -> u64 { + let generation = APPLIED_LOBE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + if let Ok(mut slot) = APPLIED_LOBE.lock() { + *slot = Some(AppliedLobe { + generation, + v_null_dac, + v_peak_dac, + calibration_id, + }); + } + generation +} + +/// The applied lobe an instance at `seen` generation has not adopted yet. +fn applied_lobe_after(seen: u64) -> Option { + if APPLIED_LOBE_GENERATION.load(Ordering::SeqCst) <= seen { + return None; + } + APPLIED_LOBE + .lock() + .ok()? + .clone() + .filter(|applied| applied.generation > seen) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Const, + /// Pure DAC sine (DAC_SINE): the firmware synthesises a sinusoid directly in + /// DAC codes. The optical output is the non-linear `sin²` of this drive. + Sine, + Square, + /// OPTICAL_LOG_SINE: the DAC is warped so the *optical* output is a + /// log-intensity sine (the clean A1 target). Requires the lobe inversion. + OpticalLogSine, + /// OPTICAL_LINEAR_SINE: the DAC is warped so the optical output is a + /// linear-intensity sine. + OpticalLinearSine, +} + +impl Mode { + const VARIANTS: [Mode; 5] = [ + Mode::Const, + Mode::Sine, + Mode::Square, + Mode::OpticalLogSine, + Mode::OpticalLinearSine, + ]; + + fn name(self) -> &'static str { + match self { + Self::Const => "CONST", + Self::Sine => "DAC_SINE", + Self::Square => "SQUARE", + Self::OpticalLogSine => "OPTICAL_LOG_SINE", + Self::OpticalLinearSine => "OPTICAL_LINEAR_SINE", + } + } + + fn from_name(name: &str) -> Option { + // Accept the historical "SINE" alias for the pure DAC sine. + if name == "SINE" { + return Some(Self::Sine); + } + Self::VARIANTS.into_iter().find(|m| m.name() == name) + } + + fn is_periodic(self) -> bool { + !matches!(self, Self::Const) + } + + /// Firmware `wave` token. Optical modes upload a warp table and share the + /// `WARP` playback path. + fn wire_wave(self) -> &'static str { + match self { + Self::Const => "CONST", + Self::Sine => "SINE", + Self::Square => "SQUARE", + Self::OpticalLogSine | Self::OpticalLinearSine => "WARP", + } + } + + fn optical_target(self) -> Option { + match self { + Self::OpticalLogSine => Some(waveform::OpticalTarget::LogSine), + Self::OpticalLinearSine => Some(waveform::OpticalTarget::LinearSine), + _ => None, + } + } + + /// How this mode's peak intensity follows from `ū` and `a` on a calibrated + /// band. This is what bounds both controls — see [`waveform::PeakLaw`]. + fn peak_law(self) -> waveform::PeakLaw { + match self.optical_target() { + Some(target) => waveform::PeakLaw::of(target), + None => match self { + Self::Const => waveform::PeakLaw::Constant, + _ => waveform::PeakLaw::LogSwing, + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DriveMethod { + Manual, + Calibrated, +} + +impl DriveMethod { + const VARIANTS: [Self; 2] = [Self::Manual, Self::Calibrated]; + + fn name(self) -> &'static str { + match self { + Self::Manual => "MANUAL", + Self::Calibrated => "CALIBRATED", + } + } + + fn from_name(name: &str) -> Option { + Self::VARIANTS + .into_iter() + .find(|method| method.name() == name) + } +} + +/// State the device thread reports back for the UI (status entries, table). +struct DeviceState { + connected: bool, + firmware: String, + capabilities: Vec, + board_code: Option, + board_mod: String, + /// Structured board-echoed modulation (`mod_wave`/`mod_level`/`mod_min`/ + /// `mod_freq_mhz` reply fields). Lets the published snapshot expose the + /// *operator-armed* drive to consumers (A1 derives its fallback + /// modulation period from it) — UI-driven MOD commands never populate the + /// service-path `acknowledged` target. + board_wave: Option, + board_level: Option, + board_min: Option, + board_freq_millihz: Option, + last_error: Option, + controller_state: ControllerStateV1, + requested: Option, + acknowledged: Option, + last_response: Option, + last_device_update_unix_ms: u64, +} + +impl Default for DeviceState { + fn default() -> Self { + Self { + connected: false, + firmware: String::new(), + capabilities: Vec::new(), + board_code: None, + board_mod: String::new(), + board_wave: None, + board_level: None, + board_min: None, + board_freq_millihz: None, + last_error: None, + controller_state: ControllerStateV1::Unknown, + requested: None, + acknowledged: None, + last_response: None, + last_device_update_unix_ms: 0, + } + } +} + +impl DeviceState { + /// Board-echo view of the armed drive as a contract target (revision 0), + /// for the published snapshot when no service-path acknowledgement + /// exists. WARP (optical) drives report as `Periodic` — the consumers of + /// this fallback only need the modulation frequency. + fn board_echo_target(&self) -> Option { + let wave = self.board_wave.as_deref()?; + let level = || u16::try_from(self.board_level.unwrap_or(0)).unwrap_or(0); + let min = || u16::try_from(self.board_min.unwrap_or(0)).unwrap_or(0); + let waveform = match wave { + "OFF" => WaveformV1::Off, + "CONST" => WaveformV1::Constant { level_dac: level() }, + "SINE" | "WARP" => WaveformV1::Periodic { + waveform: stage_a_plugin_contract::PeriodicWaveformV1::Sine, + min_dac: min(), + max_dac: level(), + frequency_millihz: self.board_freq_millihz.unwrap_or(0), + }, + "SQUARE" => WaveformV1::Periodic { + waveform: stage_a_plugin_contract::PeriodicWaveformV1::Square, + min_dac: min(), + max_dac: level(), + frequency_millihz: self.board_freq_millihz.unwrap_or(0), + }, + _ => return None, + }; + 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, + }) + } +} + +#[derive(Clone)] +struct OperationMeta { + request_id: stage_a_plugin_contract::RequestId, + run_id: Option, + requested_revision: SemanticRevision, + target: ModulationTargetV1, + owner_instance: OwnerInstanceId, +} + +struct PendingOperation { + commands: Vec, + purpose: &'static str, + meta: Option, +} + +/// Everything shared between the plugin (UI thread) and the device thread. +struct SharedLink { + state: Mutex, + /// Latest not-yet-sent command; newer settings overwrite older ones so + /// slider drags coalesce instead of queueing. + pending: Mutex>, + priority: Mutex>, + stop: AtomicBool, + fail_closed_on_stop: AtomicBool, + generation: AtomicU64, +} + +impl SharedLink { + fn new() -> Self { + Self { + state: Mutex::new(DeviceState::default()), + pending: Mutex::new(None), + priority: Mutex::new(None), + stop: AtomicBool::new(false), + fail_closed_on_stop: AtomicBool::new(false), + generation: AtomicU64::new(1), + } + } + + fn bump(&self) { + self.generation.fetch_add(1, Ordering::Relaxed); + } +} + +/// In-process mock controller thread behind the `mock` port. +struct MockService { + stop: Arc, + join: Option>, +} + +impl MockService { + fn spawn() -> (Self, StageAClient) { + let link = stage_a_io::MockLink::new(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let mut controller = MockController::new(link.device_end()).with_waveform_extension(); + let join = std::thread::Builder::new() + .name("stage-a-modulation-mock".into()) + .spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + controller.poll_commands(); + std::thread::sleep(Duration::from_millis(1)); + } + }) + .expect("spawning the mock controller thread must succeed"); + ( + Self { + stop, + join: Some(join), + }, + StageAClient::new(link.host_end()), + ) + } +} + +impl Drop for MockService { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +/// Handle to the running device thread; dropping it stops the thread. +struct DeviceLink { + shared: Arc, + join: Option>, + _mock: Option, +} + +impl Drop for DeviceLink { + fn drop(&mut self) { + self.shared.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +/// Device thread: HELLO once, then drain the pending command slot and poll +/// STATUS. All serial I/O lives here — the UI thread never blocks. +fn run_device(mut client: StageAClient, shared: Arc) { + match client.request(&Command::new("HELLO").field("protocol", 1)) { + Ok(fields) => { + let mut state = shared.state.lock().expect("device state lock"); + state.connected = true; + state.controller_state = ControllerStateV1::SafeIdle; + state.firmware = fields + .get("firmware") + .cloned() + .unwrap_or_else(|| "unknown".into()); + state.capabilities = fields + .get("capabilities") + .map(|value| value.split(',').map(str::to_owned).collect()) + .unwrap_or_default(); + let has_mod = fields + .get("capabilities") + .is_some_and(|caps| caps.split(',').any(|c| c == "MOD")); + state.last_error = (!has_mod).then(|| { + "firmware has no MOD capability — flash stage-a-controller 0.3.0+".to_owned() + }); + } + Err(err) => { + let mut state = shared.state.lock().expect("device state lock"); + state.connected = false; + state.controller_state = ControllerStateV1::Faulted; + state.last_error = Some(format!("HELLO failed: {err}")); + shared.bump(); + return; + } + } + shared.bump(); + + let mut last_status = Instant::now() - STATUS_POLL_INTERVAL; + 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()); + if let Some(operation) = pending { + if execute_operation(&mut client, &shared, operation) { + consecutive_errors = 0; + } else { + consecutive_errors += 1; + } + } else if last_status.elapsed() >= STATUS_POLL_INTERVAL { + last_status = Instant::now(); + let result = client.request(&Command::new("STATUS")); + if result.is_ok() { + consecutive_errors = 0; + } else { + consecutive_errors += 1; + } + apply_status_reply(&shared, "STATUS", result); + } else { + if let Ok(events) = client.poll_events() { + apply_device_events(&shared, events); + } + std::thread::sleep(DEVICE_LOOP_TICK); + } + if consecutive_errors >= DEVICE_MAX_CONSECUTIVE_ERRORS { + // The link is wedged (unplugged cable, stale fd): declare it dead + // so the owner reaps this thread and reconnects, instead of + // silently swallowing every queued command from here on. + let mut state = shared.state.lock().expect("device state lock"); + state.connected = false; + state.controller_state = ControllerStateV1::Faulted; + state.last_error = Some("serial link failed repeatedly — reconnecting".to_owned()); + drop(state); + shared.bump(); + return; + } + } + + if shared.fail_closed_on_stop.load(Ordering::Relaxed) { + let _ = client.request(&Command::new("STOP").field("reason", "owner_shutdown")); + let result = client.request(&Command::new("MOD").field("wave", "OFF")); + apply_status_reply(&shared, "SAFE_OFF", result); + } + + let mut state = shared.state.lock().expect("device state lock"); + state.connected = false; + shared.bump(); +} + +/// Runs one queued operation; returns whether every command succeeded. +fn execute_operation( + client: &mut StageAClient, + shared: &SharedLink, + operation: PendingOperation, +) -> bool { + let mut merged = BTreeMap::new(); + let mut error = None; + for command in &operation.commands { + match client.request(command) { + Ok(fields) => merged.extend(fields), + Err(err) => { + error = Some(err.to_string()); + break; + } + } + if let Ok(events) = client.poll_events() { + apply_device_events(shared, events); + } + } + + if error.is_none() && operation.purpose == "PREPARE_A2" { + let prepared = merged.get("trigger_source").map(String::as_str) == Some("comparator") + && merged.get("cmp_armed").map(String::as_str) == Some("1") + && merged.get("mod_wave").map(String::as_str) == Some("LOG_SQUARE"); + if !prepared { + error = Some( + "firmware did not confirm trigger_source=comparator, cmp_armed=1 and mod_wave=LOG_SQUARE" + .into(), + ); + } + } + + let mut state = shared.state.lock().expect("device state lock"); + let succeeded = error.is_none(); + if let Some(message) = error { + state.last_error = Some(format!("{}: {message}", operation.purpose)); + if let Some(meta) = operation.meta { + state.last_response = Some(ModulationResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: meta.request_id, + owner_instance: meta.owner_instance, + run_id: meta.run_id, + requested_revision: Some(meta.requested_revision), + acknowledged_revision: state.acknowledged.as_ref().map(|value| value.revision), + outcome: RequestOutcomeV1::Rejected, + completed_at_unix_ms: Some(now_unix_ms()), + error: Some(ServiceErrorV1 { + code: ServiceErrorCodeV1::DeviceRejected, + message, + retryable: false, + }), + }, + controller_state: state.controller_state, + acknowledged_target: state.acknowledged.clone(), + }); + } + } else { + apply_reply_fields(&mut state, &merged); + state.last_error = None; + if let Some(meta) = operation.meta { + let mut acknowledged = meta.target; + acknowledged.board_dac_code = + state.board_code.and_then(|code| u16::try_from(code).ok()); + acknowledged.firmware_configuration_revision = merged + .get("rev") + .and_then(|value| value.parse::().ok()); + state.acknowledged = Some(acknowledged.clone()); + state.last_response = Some(ModulationResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: meta.request_id, + owner_instance: meta.owner_instance, + run_id: meta.run_id, + requested_revision: Some(meta.requested_revision), + acknowledged_revision: Some(meta.requested_revision), + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_unix_ms()), + error: None, + }, + controller_state: state.controller_state, + acknowledged_target: Some(acknowledged), + }); + } + } + state.last_device_update_unix_ms = now_unix_ms(); + drop(state); + shared.bump(); + succeeded +} + +fn apply_status_reply( + shared: &SharedLink, + purpose: &str, + result: Result, stage_a_io::ClientError>, +) { + let mut state = shared.state.lock().expect("device state lock"); + match result { + Ok(fields) => { + apply_reply_fields(&mut state, &fields); + if purpose == "MOD" { + state.last_error = None; + } + } + Err(err) => state.last_error = Some(format!("{purpose}: {err}")), + } + state.last_device_update_unix_ms = now_unix_ms(); + drop(state); + shared.bump(); +} + +fn apply_reply_fields(state: &mut DeviceState, fields: &BTreeMap) { + if let Some(code) = fields.get("code").and_then(|v| v.parse::().ok()) { + state.board_code = Some(code); + } + if let Some(controller) = fields.get("state") { + state.controller_state = match controller.as_str() { + "SAFE_IDLE" => ControllerStateV1::SafeIdle, + "CONFIGURED" => ControllerStateV1::Configured, + "RUNNING" => ControllerStateV1::Running, + _ => ControllerStateV1::Unknown, + }; + } + if let Some(wave) = fields.get("mod_wave") { + let level = fields.get("mod_level").map(String::as_str).unwrap_or("?"); + let min = fields.get("mod_min").map(String::as_str).unwrap_or("?"); + // Parse the echoed frequency exactly once, as f64. Parsing it a second + // time as u64 silently yielded None the moment the firmware echoed a + // decimal ("10000.0"): `board_echo_target` then published + // frequency_millihz: 0, A1 rejected it, and A1 lost its only fallback + // modulation period whenever the EXT_TRIGGER markers were absent. + let freq_millihz = fields + .get("mod_freq_mhz") + .and_then(|v| v.parse::().ok()) + .filter(|hz| hz.is_finite() && *hz >= 0.0); + let freq_mhz = freq_millihz.unwrap_or(0.0); + state.board_mod = if wave == "SINE" || wave == "SQUARE" { + format!("{wave} {min}..{level} @ {:.3} Hz", freq_mhz / 1_000.0) + } else { + format!("{wave} level={level}") + }; + state.board_wave = Some(wave.clone()); + state.board_level = fields.get("mod_level").and_then(|v| v.parse().ok()); + state.board_min = fields.get("mod_min").and_then(|v| v.parse().ok()); + state.board_freq_millihz = freq_millihz.map(|hz| hz.round() as u64); + } +} + +fn apply_device_events(shared: &SharedLink, events: Vec) { + let fault = events.into_iter().find_map(|event| match event { + DeviceEvent::Async { name, fields } if name == "FAULT" => Some( + fields + .get("code") + .cloned() + .unwrap_or_else(|| "unknown".into()), + ), + _ => None, + }); + if let Some(code) = fault { + let mut state = shared.state.lock().expect("device state lock"); + state.controller_state = ControllerStateV1::Faulted; + state.last_error = Some(format!("controller fault: {code}")); + state.last_device_update_unix_ms = now_unix_ms(); + drop(state); + shared.bump(); + } +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +/// A transfer-curve sweep in flight. One point at a time: command a settled +/// `CONST` code, wait for a photodiode window that *starts* after the command, +/// record it, move on. +struct CalibrationSweep { + /// Remaining `(code, direction)` steps, and the points collected so far. + steps: Vec<(u16, calibration::Direction)>, + index: usize, + points: Vec, + /// Detector sample index when the current code was commanded. A level only + /// counts once its window begins after this plus [`SETTLE_SAMPLES`], which + /// needs no shared clock and tolerates any tick jitter. + commanded_at_sample: Option, + /// Wall-clock guard for a stream that stops delivering entirely. + point_started: Instant, + /// Drive to restore when the sweep ends, however it ends. + restore: Option, + /// Max limit in force when the sweep started; the fit's branch constraint. + max_code: u16, +} + +/// Forwards momentary button presses across the host's UI-mirror → live-worker +/// settings snapshot. A click arrives as `true` on the clicked instance; the +/// other instance only ever sees the snapshot value from `get_setting`, so the +/// press is transported as a monotonic counter and a counter advance counts as +/// one press edge. The first counter a fresh instance sees is adopted silently +/// so a reloaded worker does not replay old presses (ADR 010). +/// +/// The baseline is tracked separately from the counter: folding the two +/// together makes a fresh worker mistake the operator's *first* real press for +/// its initial sight of the counter and swallow it. +#[derive(Debug, Default, Clone, Copy)] +struct PressLatch { + counter: u64, + seen: Option, +} + +impl PressLatch { + /// Interprets a settings write to this button; returns true on a press edge. + 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) + } +} + +impl CalibrationSweep { + fn total(&self) -> usize { + self.steps.len() + } + + fn current(&self) -> Option<(u16, calibration::Direction)> { + self.steps.get(self.index).copied() + } +} + +pub struct StageAModulationPlugin { + enabled: bool, + runtime_role: PluginRuntimeRole, + effects_allowed: bool, + owner_instance: OwnerInstanceId, + lease: Option, + deferred_release_request: Option, + deferred_release_ack_published: bool, + request_cache: VecDeque<(PluginServiceRequest, PluginServiceReply)>, + link: Option, + shared: Arc, + // -- settings (every accepted change is sent immediately) -- + connect_requested: bool, + port_hint: String, + max_level: i64, + level: i64, + min_level: i64, + method: DriveMethod, + mode: Mode, + frequency_hz: f64, + // -- optical drive inversion (OPTICAL_* modes) -- + /// Requested optical log-modulation depth `a = ln(I_max / I_min)`. + depth_a: f64, + /// The operator's armed `depth_a`, parked while a lease drives the optical + /// depth (A1's amplitude sweep) and restored by [`Self::end_lease`]. + armed_depth_a: Option, + /// The operator's armed `frequency_hz`, parked while a lease drives the + /// frequency (A1's frequency sweep) and restored by [`Self::end_lease`]. + armed_frequency_hz: Option, + /// The operator's armed `operating_point`, parked while a lease drives it + /// (A1's `I_k` sweep) and restored by [`Self::end_lease`]. + armed_operating_point: Option, + /// Dimensionless, floor-subtracted **cycle-mean** lobe coordinate + /// `ū ∈ (0,1]`; not the physical A1 flux point `I_k`. + operating_point: f64, + /// DAC code at the excitation minimum of one monotonic Pockels lobe. + v_null_dac: i64, + /// DAC code at the excitation maximum of that lobe. An absolute code like + /// `v_null_dac`, not a distance: the half-wave span is derived from the + /// pair (see [`waveform::LobeInversion::resolve`]). + v_peak_dac: i64, + // -- measured transfer calibration -- + /// Bench detector geometry. Not inferable from a sweep — see + /// [`calibration::DetectorGeometry`]. + detector_geometry: calibration::DetectorGeometry, + /// Sweep in flight, ticked from `process_control`. + sweep: Option, + /// Last completed fit, awaiting review and an explicit apply. + fit: Option, + /// Set once a fit has been applied to `v_null_dac`/`v_peak_dac`; published on + /// the contract so a consumer's sidecar can cite the inversion in use. + calibration_id: Option, + /// Highest [`APPLIED_LOBE`] generation this instance has taken on. Below + /// the published one, its `v_null_dac`/`v_peak_dac` are stale and must not + /// be exported into the settings snapshot. + applied_lobe_generation: u64, + /// Directory for the archived calibration record; empty means "apply the + /// fit but do not archive it". + calibration_dir: String, + /// Operator-visible outcome of the last sweep or apply. + calibration_status: String, + /// Momentary calibration buttons, forwarded mirror → worker (ADR 010). + press_measure: PressLatch, + press_apply: PressLatch, + last_error: Option, + /// Last automatic reconnect attempt after the device thread died, for the + /// watchdog backoff in `apply_execution_context`. + last_reconnect_ms: u64, +} + +/// What the configured lobe and DAC ceiling can express right now. Shown to +/// the operator and used to clamp edits instead of refusing them. +#[derive(Debug, Clone, Copy)] +struct Achievable { + /// Highest normalised intensity the DAC ceiling leaves reachable. + u_max: f64, + /// Where the current drive actually peaks, against that ceiling. + peak_u: f64, + /// Deepest `a` at the current operating point. + max_depth_a: f64, + /// Brightest operating point at the current `a`. + max_mean_u: f64, +} + +/// Which of the two coupled optical controls the operator just moved. The one +/// they touched is the one [`StageAModulationPlugin::reconcile_drive`] limits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DriveKnob { + Depth, + Mean, + /// The lobe, the ceiling or the mode moved, so neither control has priority. + Lobe, +} + +#[derive(Clone)] +struct ControlLease { + lease_id: LeaseId, + holder: ClientId, + run_id: Option, + expires_at_unix_ms: u64, +} + +impl Default for StageAModulationPlugin { + fn default() -> Self { + Self { + enabled: false, + runtime_role: PluginRuntimeRole::UiMirror, + effects_allowed: false, + owner_instance: OwnerInstanceId::new(format!( + "modulation-{}-{}", + std::process::id(), + now_unix_ms() + )), + lease: None, + deferred_release_request: None, + deferred_release_ack_published: false, + request_cache: VecDeque::new(), + link: None, + shared: Arc::new(SharedLink::new()), + connect_requested: false, + port_hint: "auto".into(), + max_level: MAX_DAC_CODE, + level: 0, + min_level: 0, + method: DriveMethod::Manual, + mode: Mode::Const, + frequency_hz: 10.0, + depth_a: 0.5, + armed_depth_a: None, + armed_frequency_hz: None, + armed_operating_point: None, + operating_point: 0.5, + v_null_dac: 0, + v_peak_dac: 2_048, + detector_geometry: calibration::DetectorGeometry::RejectedComplement, + sweep: None, + fit: None, + calibration_id: None, + // Deliberately zero rather than the current generation: a mirror + // built after a calibration — a plugin reload, say — has to pick + // the applied lobe up, not assume it is already current. + applied_lobe_generation: 0, + calibration_dir: String::new(), + calibration_status: String::new(), + press_measure: PressLatch::default(), + press_apply: PressLatch::default(), + last_error: None, + last_reconnect_ms: 0, + } + } +} + +impl StageAModulationPlugin { + fn connect(&mut self) { + if self.link.is_some() { + return; + } + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + self.last_error = Some("connection deferred: hardware effects are not allowed".into()); + return; + } + self.last_error = None; + *self.shared.state.lock().expect("device state lock") = DeviceState::default(); + *self.shared.pending.lock().expect("pending lock") = None; + *self.shared.priority.lock().expect("priority lock") = None; + self.shared.stop.store(false, Ordering::Relaxed); + self.shared + .fail_closed_on_stop + .store(self.lease.is_some(), Ordering::Relaxed); + self.shared.bump(); + + let shared = Arc::clone(&self.shared); + let spawn = |name: &str, f: Box| { + std::thread::Builder::new() + .name(name.to_owned()) + .spawn(f) + .expect("spawning the device thread must succeed") + }; + if self.port_hint == "mock" { + let (mock, client) = MockService::spawn(); + let join = spawn( + "stage-a-modulation-device", + Box::new(move || run_device(client, shared)), + ); + self.link = Some(DeviceLink { + shared: Arc::clone(&self.shared), + join: Some(join), + _mock: Some(mock), + }); + } else { + match open_serial(&self.port_hint) { + Ok(client) => { + let join = spawn( + "stage-a-modulation-device", + Box::new(move || run_device(client, shared)), + ); + self.link = Some(DeviceLink { + shared: Arc::clone(&self.shared), + join: Some(join), + _mock: None, + }); + } + Err(err) => { + self.last_error = Some(err); + self.connect_requested = false; + } + } + } + // Connecting never drives the output (set-and-hold firmware); only + // changes made while connected are transferred. + } + + fn disconnect(&mut self) { + self.link = None; // Drop stops and joins the device thread. + self.shared.bump(); + } + + /// The lobe the two configured codes name, or why they name none. + /// + /// Resolved against the **DAC's** range, not the operator's `max_level` + /// ceiling: where the crystal nulls and peaks is a fact about the bench, and + /// a ceiling that cuts the lobe short still leaves the codes below it + /// perfectly drivable (a MANUAL band inside the ceiling must keep working). + /// What the ceiling constrains is the codes actually emitted, which + /// [`Self::dac_band`] checks. + fn resolved_lobe(&self) -> Result { + waveform::LobeInversion::resolve( + self.v_null_dac as f64, + self.v_peak_dac as f64, + MAX_DAC_CODE as f64, + ) + .map_err(|error| error.to_string()) + } + + fn lobe_inversion(&self) -> Result { + self.resolved_lobe().map(|lobe| lobe.inversion) + } + + /// Takes on a lobe the live worker applied. Idempotent and cheap: an atomic + /// load unless there is something new to adopt. + /// + /// Only the UI mirror adopts, and only the live worker publishes. That is + /// the direction the problem actually has — the worker is the instance with + /// the photodiode and the fit, the mirror is the one whose stale codes were + /// overwriting it — and it keeps an instance from ever reading back its own + /// publication. + fn adopt_applied_lobe(&mut self) -> bool { + if self.runtime_role != PluginRuntimeRole::UiMirror { + return false; + } + let Some(applied) = applied_lobe_after(self.applied_lobe_generation) else { + return false; + }; + self.v_null_dac = applied.v_null_dac; + self.v_peak_dac = applied.v_peak_dac; + self.calibration_id = Some(applied.calibration_id); + self.applied_lobe_generation = applied.generation; + true + } + + /// The lobe codes to export, without needing `&mut self`. + /// + /// `get_setting` and `settings_schema` are `&self`, and the UI mirror only + /// reaches [`Self::adopt_applied_lobe`] when the operator next edits + /// something. Reading through here means a freshly applied lobe shows up in + /// the panel — and in the snapshot pushed to the worker — on the very next + /// repaint instead of being overwritten by the stale pair. + fn effective_lobe(&self) -> (i64, i64) { + if self.runtime_role != PluginRuntimeRole::UiMirror { + return (self.v_null_dac, self.v_peak_dac); + } + match applied_lobe_after(self.applied_lobe_generation) { + Some(applied) => (applied.v_null_dac, applied.v_peak_dac), + None => (self.v_null_dac, self.v_peak_dac), + } + } + + /// What the current lobe and DAC ceiling can actually produce for the + /// current mode. `None` for the manual method (where the band is entered + /// directly) or when the two endpoint codes name no drivable lobe. + fn achievable(&self) -> Option { + if self.method != DriveMethod::Calibrated { + return None; + } + let law = self.mode.peak_law(); + let inversion = self.lobe_inversion().ok()?; + let u_max = inversion.peak_intensity_ceiling(self.max_level.clamp(0, MAX_DAC_CODE) as f64); + Some(Achievable { + u_max, + peak_u: law.peak(self.operating_point, self.depth_a), + max_depth_a: law.max_depth_for_mean(self.operating_point, u_max), + max_mean_u: law.max_mean_for_depth(self.depth_a, u_max), + }) + } + + /// Brings `a` and `ū` back inside what the bench can actually produce, then + /// re-arms the drive. + /// + /// Nothing here rejects the operator's edit. Refusing a setting and + /// snapping the control back is what made the mode dropdown feel broken: an + /// `a` left over from a different lobe made an optical mode simply + /// unselectable, with no indication of *which* value was in the way. + /// Clamping moves the drive to the nearest thing the lobe can do, and the + /// achievable range is on the status line either way. + /// + /// Only the knob the operator just touched is clamped — dragging `a` up + /// means "more depth", so `a` is what gets limited, and the operating point + /// stays where it was put. A lobe change has no such preference, so it + /// settles the brightness first and then the depth that fits under it. + fn reconcile_drive(&mut self, edited: DriveKnob) { + if self.method == DriveMethod::Calibrated { + if let Ok(inversion) = self.lobe_inversion() { + let law = self.mode.peak_law(); + let u_max = + inversion.peak_intensity_ceiling(self.max_level.clamp(0, MAX_DAC_CODE) as f64); + let clamp_mean = |plugin: &mut Self| { + let max = law + .max_mean_for_depth(plugin.depth_a, u_max) + .max(waveform::MEAN_U_MIN); + plugin.operating_point = + plugin.operating_point.clamp(waveform::MEAN_U_MIN, max); + }; + let clamp_depth = |plugin: &mut Self| { + let max = law + .max_depth_for_mean(plugin.operating_point, u_max) + .max(waveform::DEPTH_A_MIN); + plugin.depth_a = plugin.depth_a.clamp(waveform::DEPTH_A_MIN, max); + }; + match edited { + DriveKnob::Depth => clamp_depth(self), + DriveKnob::Mean => clamp_mean(self), + DriveKnob::Lobe => { + clamp_mean(self); + clamp_depth(self); + } + } + } + } + // An edit made while a lease drives the depth is withheld from the + // board (`send_modulation` is guarded), so it has to land in the parked + // value or it would be lost when the lease ends. + if self.armed_depth_a.is_some() { + self.armed_depth_a = Some(self.depth_a); + } + // Re-judge the drive whether or not it can be sent right now. A stale + // "drive rejected" left over from an earlier combination would + // otherwise outlive the edit that fixed it — and with no link attached + // `send_modulation` never reaches the point where it clears one. + self.last_error = match self.drive_command() { + Ok(_) => None, + Err(error) => Some(format!("drive not sent: {error}")), + }; + self.send_modulation(); + } + + /// Resolves the calibrated UI setting (cycle-mean normalized lobe + /// coordinate) to the target law's internal pedestal/centre. + fn periodic_lobe_point(&self) -> f64 { + let point = if self.mode == Mode::OpticalLogSine { + waveform::log_sine_geometric_pedestal(self.operating_point, self.depth_a) + } else { + self.operating_point + }; + if matches!(self.mode, Mode::OpticalLogSine | Mode::OpticalLinearSine) { + // The firmware contract carries this coordinate in milli-units. + // Validate and preview the value the board will actually rebuild, + // not a higher-precision local table. + (point * 1_000.0).round() / 1_000.0 + } else { + point + } + } + + /// Resolves the selected method into the DAC band used by every waveform. + /// The third value is the constant-mode operating code. + fn dac_band(&self) -> Result<(i64, i64, i64), String> { + if self.method == DriveMethod::Manual { + let hi = self.level.clamp(0, self.max_level); + return Ok((self.min_level.clamp(0, hi), hi, hi)); + } + + let mean_u = self.operating_point; + let a = self.depth_a; + if !mean_u.is_finite() || mean_u <= 0.0 || mean_u > 1.0 { + return Err("operating point must be in (0, 1]".into()); + } + let inversion = self.lobe_inversion()?; + + // Every code the inversion emits lies between the two endpoints, and + // `resolve` has already placed both inside the DAC range — so the floor + // can no longer be breached and only the operator's ceiling is left to + // check. One guard, naming the settings that actually exist. + let under_ceiling = |code: i64, what: &str| -> Result { + if code > self.max_level { + return Err(format!( + "{what} needs DAC code {code}, above the max limit {}; raise the max limit \ + or lower u / a", + self.max_level + )); + } + Ok(code) + }; + + // A constant hold modulates nothing: no ±a/2 headroom applies, so the + // full (0, 1] range of u is expressible (u = 1 holds exactly at + // V_peak). Requiring the modulated band here silently froze the drive + // at the last accepted code whenever u·e^{a/2} exceeded 1. + if self.mode == Mode::Const { + let hold = under_ceiling( + inversion.dac_for_u(mean_u).round() as i64, + "the constant hold", + )?; + return Ok((hold, hold, hold)); + } + + if !a.is_finite() || a <= 0.0 { + return Err("optical depth a must be finite and positive".into()); + } + let target_u = self.periodic_lobe_point(); + let (u_lo, u_hi) = if self.mode == Mode::OpticalLinearSine { + let m = (0.5 * a).tanh(); + (target_u * (1.0 - m), target_u * (1.0 + m)) + } else { + (target_u * (-0.5 * a).exp(), target_u * (0.5 * a).exp()) + }; + if u_hi > 1.0 { + return Err(format!( + "calibrated optical peak u = {u_hi:.3} exceeds the lobe ceiling; lower a or u" + )); + } + + let lo = inversion.dac_for_u(u_lo).round() as i64; + let hi = under_ceiling( + inversion.dac_for_u(u_hi).round() as i64, + "the modulation peak", + )?; + let hold = inversion.dac_for_u(target_u).round() as i64; + Ok((lo, hi, hold)) + } + + fn optical_drive( + &self, + target: waveform::OpticalTarget, + ) -> Result { + let inversion = self.lobe_inversion()?; + Ok(match self.method { + DriveMethod::Manual => { + let hi = self.level.clamp(0, self.max_level); + let lo = self.min_level.clamp(0, hi); + waveform::OpticalDrive::from_dac_band(target, inversion, lo as f64, hi as f64) + } + DriveMethod::Calibrated => waveform::OpticalDrive { + target, + depth_a: self.depth_a, + operating_point: self.periodic_lobe_point(), + inversion, + }, + }) + } + + fn optical_drive_state(&self) -> Option { + if self.method != DriveMethod::Calibrated { + return None; + } + let (target, contract_target) = match self.mode { + Mode::OpticalLogSine => (waveform::OpticalTarget::LogSine, OpticalTargetV1::LogSine), + Mode::OpticalLinearSine => ( + waveform::OpticalTarget::LinearSine, + OpticalTargetV1::LinearSine, + ), + _ => return None, + }; + self.dac_band().ok()?; + let drive = self.optical_drive(target).ok()?; + Some(OpticalDriveStateV1 { + target: contract_target, + requested_mean_u_milli: (self.operating_point * 1_000.0).round() as u32, + resolved_mean_u_milli: (match target { + waveform::OpticalTarget::LogSine => { + waveform::log_sine_cycle_mean(drive.operating_point, self.depth_a) + } + waveform::OpticalTarget::LinearSine => drive.operating_point, + } * 1_000.0) + .round() as u32, + internal_u_milli: (drive.operating_point * 1_000.0).round() as u32, + depth_a_milli: (self.depth_a * 1_000.0).round() as u32, + v_null_dac: u16::try_from(self.v_null_dac).ok()?, + v_peak_dac: u16::try_from(drive.inversion.v_peak_dac().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( + 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") + .field("level", hold), + Mode::Sine | Mode::Square => Command::new("MOD") + .field("wave", self.mode.wire_wave()) + .field("level", hi) + .field("min", lo) + .field("freq_mhz", freq_mhz), + Mode::OpticalLogSine | Mode::OpticalLinearSine => { + let target = self + .mode + .optical_target() + .expect("optical modes have a target"); + // Validate the drive locally; the firmware rebuilds the same + // table from compact parameters because a full table does not + // fit on one command line. + self.optical_warp_table(target) + .map_err(|error| format!("optical drive: {error}"))?; + let drive = self.optical_drive(target)?; + // The firmware rebuilds the table from `v_null` and the *quarter + // wave*, so the derived distance goes on the wire, not the peak + // code the operator configures. + let inversion = drive.inversion; + Command::new("MOD") + .field("wave", "WARP") + .field("freq_mhz", freq_mhz) + .field("target", optical_target_token(target)) + .field("a_milli", (drive.depth_a * 1_000.0).round() as i64) + .field( + "u_k_milli", + (drive.operating_point * 1_000.0).round() as i64, + ) + .field("v_null", inversion.v_null_dac.round() as i64) + .field("v_pi", inversion.v_pi_dac.round() as i64) + } + }) + } + + /// Queues one MOD command carrying the complete current drive settings; + /// newer changes overwrite queued ones (drag coalescing). + /// + /// Silent while another owner holds the DAC: an automation lease, a + /// calibration sweep. The host re-applies the + /// *whole* settings snapshot on every sync, and most handlers here call + /// this unconditionally, so without the guard every sync would re-arm the + /// operator's drive on top of the code the current owner just commanded — + /// the sweep would measure the armed waveform instead of its own + /// staircase. + fn send_modulation(&mut self) { + if self.link.is_none() || self.lease.is_some() || self.sweep.is_some() { + return; + } + let command = match self.drive_command() { + Ok(command) => command, + Err(error) => { + self.last_error = Some(format!("drive rejected: {error}")); + return; + } + }; + self.last_error = None; + *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { + commands: vec![command], + purpose: "MOD", + meta: None, + }); + } + + /// Builds the DAC warp table for the current optical drive settings. The + /// max limit is the hard ceiling; absolute lobe codes cannot be rescaled + /// without distorting the target, so an over-limit drive is refused. + fn optical_warp_table(&self, target: waveform::OpticalTarget) -> Result, String> { + let table = self + .optical_drive(target)? + .warp_table() + .map_err(|error| error.to_string())?; + let peak = table.iter().copied().max().unwrap_or(0); + if i64::from(peak) > self.max_level { + return Err(format!( + "optical peak {peak} exceeds the max limit {}; raise the max limit or lower the \ + operating band, a or ū", + self.max_level + )); + } + Ok(table) + } + + // ---- measured transfer calibration ---- + + /// Whether the calibration buttons can be offered, from **mirrored** + /// settings only. + /// + /// `settings_schema()` is rendered by the UI mirror, which never owns the + /// device link, a lease, a sweep, or a fit — those live on the live worker. + /// Gating `enabled` on any of them disables the button permanently. So the + /// affordance uses the one prerequisite the mirror does know (the operator + /// asked to connect) and the authoritative interlocks stay worker-side in + /// [`Self::calibration_blocker`], reported through the status entries the + /// host takes from the worker. + fn calibration_offered(&self) -> bool { + self.connect_requested + } + + /// Why a sweep cannot start right now, if it cannot. + fn calibration_blocker(&self) -> Option { + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + return Some("hardware effects are not allowed on this instance".into()); + } + if self.link.is_none() { + return Some("connect the command port first".into()); + } + if self.lease.is_some() { + // A1 owns the drive under a lease; two owners stepping the same DAC + // would interleave silently. + return Some("the drive is leased by an automation client".into()); + } + None + } + + /// Starts a sweep, remembering the drive to restore afterwards. + fn start_calibration_sweep(&mut self) { + if let Some(blocker) = self.calibration_blocker() { + self.calibration_status = format!("sweep refused: {blocker}"); + return; + } + let max_code = self.max_level.clamp(0, MAX_DAC_CODE) as u16; + if max_code < 2 { + self.calibration_status = "sweep refused: the max limit leaves no range".into(); + return; + } + self.sweep = Some(CalibrationSweep { + steps: calibration::sweep_codes(max_code, SWEEP_POINTS_PER_PASS, true), + index: 0, + points: Vec::new(), + commanded_at_sample: None, + point_started: Instant::now(), + // Restoring the drive the operator had armed is part of the + // measurement contract: a sweep must leave the bench as it found it. + restore: self.drive_command().ok(), + max_code, + }); + self.fit = None; + self.calibration_status = "sweep starting…".into(); + } + + /// Ends the sweep and hands the DAC back to the armed drive. + fn finish_calibration_sweep(&mut self, status: String) { + // Clear the sweep first: it is what silences `send_modulation`. + let restore = self.sweep.take().and_then(|sweep| sweep.restore); + self.calibration_status = status; + if self.link.is_some() { + // Prefer the *current* settings — drive changes made during the + // sweep were withheld from the board, and this is where they land. + // The command captured at the start is the fallback for settings + // that no longer form a valid drive. + if self.drive_command().is_ok() { + self.send_modulation(); + } else if let Some(command) = restore { + *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { + commands: vec![command], + purpose: "MOD", + meta: None, + }); + } + } + self.shared.bump(); + } + + /// Queues one settled `CONST` code, bypassing the drive builder: a sweep + /// deliberately visits codes the armed drive would refuse. + fn command_sweep_code(&mut self, code: u16) { + *self.shared.pending.lock().expect("pending lock") = Some(PendingOperation { + commands: vec![Command::new("MOD") + .field("wave", "CONST") + .field("level", i64::from(code))], + purpose: "MOD", + meta: None, + }); + } + + /// Settle window in samples for a stream running at `sample_rate_hz`. + /// [`SETTLE_SECONDS`] is the physical quantity; the rate only converts it. + fn settle_samples(sample_rate_hz: Option) -> u64 { + match sample_rate_hz { + Some(rate) if rate > 0 => (f64::from(rate) * SETTLE_SECONDS).round() as u64, + _ => SETTLE_SAMPLES, + } + } + + /// One tick of the sweep. `level` is the newest photodiode reading, if any, + /// and `sample_rate_hz` the stream it was measured on. + fn drive_calibration(&mut self, level: Option, sample_rate_hz: Option) { + if self.sweep.is_none() { + return; + } + if let Some(blocker) = self.calibration_blocker() { + self.finish_calibration_sweep(format!("sweep aborted: {blocker}")); + return; + } + let Some(level) = level else { + if self + .sweep + .as_ref() + .is_some_and(|sweep| sweep.point_started.elapsed() > POINT_TIMEOUT) + { + self.finish_calibration_sweep( + "sweep aborted: no photodiode level (connect the photodiode plugin)".into(), + ); + } + return; + }; + + let Some((code, direction)) = self.sweep.as_ref().and_then(CalibrationSweep::current) + else { + self.complete_calibration_sweep(); + return; + }; + + // Command the point once, then wait for a window that began after it. + let commanded_at = match self.sweep.as_ref().expect("sweep").commanded_at_sample { + Some(sample) => sample, + None => { + self.command_sweep_code(code); + let sweep = self.sweep.as_mut().expect("sweep"); + sweep.commanded_at_sample = Some(level.end_sample_index); + sweep.point_started = Instant::now(); + self.calibration_status = format!( + "sweeping {}/{}…", + self.sweep.as_ref().expect("sweep").index + 1, + self.sweep.as_ref().expect("sweep").total() + ); + return; + } + }; + + let window_start = level.end_sample_index.saturating_sub(level.sample_count); + if window_start < commanded_at + Self::settle_samples(sample_rate_hz) { + if self.sweep.as_ref().expect("sweep").point_started.elapsed() > POINT_TIMEOUT { + self.finish_calibration_sweep( + "sweep aborted: the photodiode stream stalled".into(), + ); + } + return; + } + + let sweep = self.sweep.as_mut().expect("sweep"); + sweep.points.push(calibration::SweepPoint { + code, + direction, + volts: level.mean_volts, + peak_to_peak_volts: level.peak_to_peak_volts, + clipped: level.clipped, + }); + sweep.index += 1; + sweep.commanded_at_sample = None; + if sweep.index >= sweep.steps.len() { + self.complete_calibration_sweep(); + } + } + + /// Fits the collected points and leaves the result awaiting an explicit + /// apply — a bad fit silently retargeting the drive is the dangerous case. + fn complete_calibration_sweep(&mut self) { + let Some(sweep) = self.sweep.as_ref() else { + return; + }; + let points = sweep.points.clone(); + let max_code = f64::from(sweep.max_code); + match calibration::fit_transfer(&points, max_code, self.detector_geometry) { + Ok(fit) => { + let status = format!( + "V_null {:.0} V_peak {:.0} span {:.3} V residual {:.1}%{}{} ({:.1} lobes)", + fit.v_null_dac, + fit.v_pi_dac, + fit.span_volts.abs(), + fit.quality * 100.0, + fit.hysteresis + .map(|value| format!(" hysteresis {:.1}%", value * 100.0)) + .unwrap_or_default(), + if fit.rejected_points > 0 { + format!(" {} dropped", fit.rejected_points) + } else { + String::new() + }, + fit.lobe_coverage, + ); + self.fit = Some(fit); + self.finish_calibration_sweep(status); + } + Err(error) => { + self.fit = None; + self.finish_calibration_sweep(format!("fit failed: {error}")); + } + } + } + + /// Things worth the operator's attention before trusting a fit. Compare + /// them against the transfer-curve plot. + /// + /// Deliberately warnings and not blocks. The only condition that makes a + /// fit meaningless — no full lobe inside the commandable range — is already + /// refused by [`calibration::fit_transfer`] itself, so there is no second + /// fit to reject here. Everything below is a judgement the operator makes + /// against the plot: a single stray sample can push the residual past any + /// threshold while `Vπ` stays accurate to a few codes, so blocking on it + /// would withhold a good calibration for a bad reason. + fn fit_warnings(&self) -> Vec { + let Some(fit) = self.fit.as_ref() else { + return Vec::new(); + }; + let mut warnings = Vec::new(); + if fit.quality > WARN_QUALITY { + warnings.push(format!( + "residual is {:.1}% of the detector span — check the fit against the points \ + in the transfer-curve plot before trusting the fit", + fit.quality * 100.0 + )); + } + if fit.rejected_points > 0 { + warnings.push(format!( + "{} of {} points were wild and left out of the fit", + fit.rejected_points, + fit.points.len() + )); + } + // Two independently noisy passes over the same curve already differ by + // `1.128 σ` on average, so a raw 5 % cut reports point scatter as cell + // drift on any bench whose points are not far quieter than that. Both + // tests have to pass: the disagreement must be large enough to matter + // *and* systematic rather than scatter. + let hysteresis = fit + .hysteresis + .filter(|value| *value > WARN_HYSTERESIS && fit.hysteresis_is_systematic()); + if let Some(hysteresis) = hysteresis { + warnings.push(format!( + "up and down passes differ by {:.1}% of the span, past the {:.1}% the point \ + scatter alone explains — the cell is drifting or the settle time is too short", + hysteresis * 100.0, + fit.hysteresis_noise_floor() * 100.0 + )); + } + let clipped = fit.points.iter().filter(|point| point.clipped).count(); + if clipped > 0 { + // Naming what it actually costs. Clipping truncates the reported + // detector extrema (and with them the I_tot lower bound); V_null and + // Vπ come from the *shape*, which a truncated extremum barely moves. + // The old text advised attenuation, which is backwards for the + // reject-port detector — there it is the dark end that reaches the + // bottom rail, and the fix is more gain, not less light. + warnings.push(format!( + "{clipped} of {} points reached an end of the detector's range — V_null and V_peak \ + are unaffected, but the reported detector extrema (and the I_tot lower bound) \ + are truncated there; change the detector gain if you need them", + fit.points.len() + )); + } + warnings + } + + /// Applies the reviewed fit to the `V_null`/`V_peak` settings and archives + /// the fitted internal `Vπ` span. + fn apply_calibration_fit(&mut self) { + let Some(fit) = self.fit.clone() else { + self.calibration_status = "nothing to apply: measure a transfer curve first".into(); + return; + }; + let previous = (self.v_null_dac, self.v_peak_dac); + self.v_null_dac = fit.v_null_dac.round().clamp(0.0, MAX_DAC_CODE as f64) as i64; + // The fit reports the half-wave span; the settings hold the peak code + // the operator can see on the plot. + self.v_peak_dac = fit.v_peak_dac().round().clamp(0.0, MAX_DAC_CODE as f64) as i64; + // The lobe itself has to be resolvable — two codes that name no + // monotonic branch are not a calibration. Whether the *drive* fits + // under the current a/ū is not this button's business: those clamp + // themselves to the new lobe rather than blocking the measurement the + // operator just took. + if let Err(error) = self.lobe_inversion() { + self.v_null_dac = previous.0; + self.v_peak_dac = previous.1; + self.calibration_status = format!("not applied: {error}"); + return; + } + let calibration_id = format!("pockels-{}", timestamp_slug()); + let archived = match self.archive_calibration(&calibration_id, &fit) { + Ok(Some(path)) => format!(", archived to {path}"), + Ok(None) => ", not archived (no calibration folder set)".into(), + Err(error) => format!(", archive failed: {error}"), + }; + self.calibration_id = Some(calibration_id.clone()); + // Hand the lobe to the UI mirror before anything else can overwrite it + // — see [`APPLIED_LOBE`]. Only the live worker ever gets this far: it + // is the instance the sweep and the fit live on. + if self.runtime_role == PluginRuntimeRole::LiveWorker { + self.applied_lobe_generation = + publish_applied_lobe(self.v_null_dac, self.v_peak_dac, calibration_id); + } + self.calibration_status = format!( + "applied V_null {} / V_peak {} (span {} codes){archived}", + self.v_null_dac, + self.v_peak_dac, + self.v_peak_dac - self.v_null_dac + ); + self.reconcile_drive(DriveKnob::Lobe); + self.shared.bump(); + } + + /// Writes the calibration record. A calibration is named and never + /// silently overwritten (knowledge base §4.6). + fn archive_calibration( + &self, + calibration_id: &str, + fit: &calibration::TransferFit, + ) -> Result, String> { + if self.calibration_dir.trim().is_empty() { + return Ok(None); + } + let directory = std::path::Path::new(self.calibration_dir.trim()); + std::fs::create_dir_all(directory) + .map_err(|error| format!("creating {}: {error}", directory.display()))?; + let path = directory.join(format!("{calibration_id}.json")); + let record = json!({ + "calibration_id": calibration_id, + "port": self.port_hint, + "max_level": self.max_level, + "detector_geometry": fit.geometry.name(), + "v_null_dac": fit.v_null_dac, + "v_peak_dac": fit.v_peak_dac(), + "half_wave_span_dac": fit.v_pi_dac, + "detector_volts_at_null": fit.detector_volts_at_null(), + "detector_volts_at_peak": fit.detector_volts_at_peak(), + "span_volts": fit.span_volts, + "rms_residual_volts": fit.rms_residual_volts, + "quality": fit.quality, + "hysteresis": fit.hysteresis, + "lobe_coverage": fit.lobe_coverage, + "rejected_points": fit.rejected_points, + "anchor_note": "detector_volts_at_null is a lower bound on the total-power \ + anchor I_tot, not the anchor: on the reject port the residual \ + transmitted floor is not separable from it", + "points": fit + .points + .iter() + .map(|point| json!({ + "code": point.code, + "direction": point.direction.label(), + "volts": point.volts, + "peak_to_peak_volts": point.peak_to_peak_volts, + "clipped": point.clipped, + })) + .collect::>(), + }); + let encoded = serde_json::to_vec_pretty(&record) + .map_err(|error| format!("encoding the calibration record: {error}"))?; + std::fs::write(&path, encoded) + .map_err(|error| format!("writing {}: {error}", path.display()))?; + Ok(Some(path.display().to_string())) + } + + fn lease_snapshot(&self) -> Option { + self.lease.as_ref().map(|lease| LeaseSnapshotV1 { + lease_id: lease.lease_id.clone(), + holder: lease.holder.clone(), + expires_at_unix_ms: lease.expires_at_unix_ms, + run_id: lease.run_id.clone(), + }) + } + + fn require_lease(&self, request: &ModulationRequestV1) -> Result<(), ServiceErrorV1> { + let lease = self.lease.as_ref().ok_or_else(|| { + service_error( + ServiceErrorCodeV1::LeaseRequired, + "the modulation owner requires an active automation lease", + false, + ) + })?; + if now_unix_ms() > lease.expires_at_unix_ms { + return Err(service_error( + ServiceErrorCodeV1::LeaseExpired, + "the modulation automation lease expired", + false, + )); + } + if request.lease_id.as_ref() != Some(&lease.lease_id) || request.requester != lease.holder { + return Err(service_error( + ServiceErrorCodeV1::LeaseMismatch, + "request lease/holder does not match the active lease", + false, + )); + } + if request.run_id != lease.run_id { + return Err(service_error( + ServiceErrorCodeV1::LeaseMismatch, + "request run does not match the leased run", + false, + )); + } + Ok(()) + } + + fn requested_revision( + &self, + request: &ModulationRequestV1, + ) -> Result { + let revision = request.requested_revision.ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "state-changing modulation commands require requested_revision", + false, + ) + })?; + let current = self + .shared + .state + .lock() + .ok() + .and_then(|state| state.requested.as_ref().map(|target| target.revision)); + if current.is_some_and(|current| revision <= current) { + return Err(service_error( + ServiceErrorCodeV1::StaleRequest, + "requested_revision must be newer than the current requested state", + false, + )); + } + Ok(revision) + } + + fn base_target(&self, revision: SemanticRevision) -> ModulationTargetV1 { + let state = self.shared.state.lock().expect("device state lock"); + let mut target = state + .requested + .clone() + .or_else(|| state.acknowledged.clone()) + .unwrap_or(ModulationTargetV1 { + revision, + waveform: None, + a1_configuration: None, + a2_configuration: None, + acquisition_running: false, + board_dac_code: None, + firmware_configuration_revision: None, + }); + target.revision = revision; + target.board_dac_code = None; + target.firmware_configuration_revision = None; + target + } + + fn queue_service_operation( + &mut self, + request: &ModulationRequestV1, + target: ModulationTargetV1, + commands: Vec, + purpose: &'static str, + priority: bool, + ) -> Result { + if self.link.is_none() { + return Err(service_error( + ServiceErrorCodeV1::NotConnected, + "the Teensy command port is not connected", + true, + )); + } + let revision = target.revision; + let meta = OperationMeta { + request_id: request.request_id, + run_id: request.run_id.clone(), + requested_revision: revision, + target: target.clone(), + owner_instance: self.owner_instance.clone(), + }; + { + let mut state = self.shared.state.lock().expect("device state lock"); + state.requested = Some(target); + } + let operation = PendingOperation { + commands, + purpose, + meta: Some(meta), + }; + if priority { + *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); + } + self.shared.bump(); + Ok(ModulationResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: request.request_id, + owner_instance: self.owner_instance.clone(), + run_id: request.run_id.clone(), + requested_revision: Some(revision), + acknowledged_revision: self + .shared + .state + .lock() + .ok() + .and_then(|state| state.acknowledged.as_ref().map(|value| value.revision)), + outcome: RequestOutcomeV1::InProgress, + completed_at_unix_ms: None, + error: None, + }, + controller_state: self + .shared + .state + .lock() + .map(|state| state.controller_state) + .unwrap_or(ControllerStateV1::Unknown), + acknowledged_target: None, + }) + } + + fn handle_modulation_command( + &mut self, + request: &ModulationRequestV1, + ) -> Result { + match &request.command { + ModulationCommandV1::Connect => { + if self.lease.is_some() { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "connection cannot be changed while leased", + false, + )); + } + self.connect_requested = true; + self.connect(); + self.immediate_response(request, RequestOutcomeV1::Applied, None) + } + ModulationCommandV1::Disconnect { safe_off, reason } => { + if self.lease.is_some() { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "use ReleaseLease while the owner is leased", + false, + )); + } + if *safe_off && self.link.is_some() { + self.shared + .fail_closed_on_stop + .store(true, Ordering::Relaxed); + } + self.connect_requested = false; + self.disconnect(); + self.last_error = Some(format!("disconnected by service: {reason}")); + self.immediate_response(request, RequestOutcomeV1::Applied, None) + } + ModulationCommandV1::AcquireLease { ttl_ms } => { + let lease_id = request.lease_id.clone().ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "AcquireLease requires lease_id", + false, + ) + })?; + if let Some(active) = &self.lease { + if active.lease_id != lease_id || active.holder != request.requester { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "the modulation owner is already leased", + true, + )); + } + } + self.lease = Some(ControlLease { + lease_id, + holder: request.requester.clone(), + run_id: request.run_id.clone(), + expires_at_unix_ms: lease_deadline(*ttl_ms), + }); + self.shared + .fail_closed_on_stop + .store(true, Ordering::Relaxed); + self.immediate_response(request, RequestOutcomeV1::Applied, None) + } + ModulationCommandV1::RenewLease { ttl_ms } => { + self.require_lease(request)?; + if let Some(lease) = &mut self.lease { + lease.expires_at_unix_ms = lease_deadline(*ttl_ms); + } + self.immediate_response(request, RequestOutcomeV1::Applied, None) + } + ModulationCommandV1::ReleaseLease { safe_off, reason } => { + self.require_lease(request)?; + if *safe_off { + let revision = request.requested_revision.unwrap_or_else(|| { + let current = self + .shared + .state + .lock() + .ok() + .and_then(|state| { + state.requested.as_ref().map(|value| value.revision.0) + }) + .unwrap_or(0); + SemanticRevision(current.saturating_add(1)) + }); + let mut target = self.base_target(revision); + target.waveform = Some(WaveformV1::Off); + target.acquisition_running = false; + let response = self.queue_service_operation( + request, + target, + vec![ + Command::new("STOP").field("reason", reason.replace(' ', "_")), + Command::new("MOD").field("wave", "OFF"), + ], + "SAFE_OFF", + true, + )?; + self.deferred_release_request = Some(request.request_id); + 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.immediate_response(request, RequestOutcomeV1::Applied, None) + } + ModulationCommandV1::SafeOff { reason } => { + let revision = request.requested_revision.unwrap_or_else(|| { + let current = self + .shared + .state + .lock() + .ok() + .and_then(|state| state.requested.as_ref().map(|value| value.revision.0)) + .unwrap_or(0); + SemanticRevision(current.saturating_add(1)) + }); + let mut target = self.base_target(revision); + target.waveform = Some(WaveformV1::Off); + target.acquisition_running = false; + self.shared + .fail_closed_on_stop + .store(true, Ordering::Relaxed); + self.queue_service_operation( + request, + target, + vec![ + Command::new("STOP").field("reason", reason.replace(' ', "_")), + Command::new("MOD").field("wave", "OFF"), + ], + "SAFE_OFF", + true, + ) + } + ModulationCommandV1::SetWaveform { waveform } => { + self.require_lease(request)?; + let revision = self.requested_revision(request)?; + let mut target = self.base_target(revision); + target.waveform = Some(waveform.clone()); + self.queue_service_operation( + request, + target, + vec![waveform_command(waveform)], + "SET_WAVEFORM", + false, + ) + } + ModulationCommandV1::SetOpticalDepth { depth_a_milli } => { + self.require_lease(request)?; + if self.link.is_none() { + return Err(service_error( + ServiceErrorCodeV1::NotConnected, + "the modulation owner is not connected to the device", + false, + )); + } + let depth_a = f64::from(*depth_a_milli) / 1_000.0; + if !(0.01..=6.0).contains(&depth_a) { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + format!("optical depth a={depth_a:.3} outside the supported 0.01..=6.0"), + false, + )); + } + // A1's depth command has one scientific meaning: a calibrated + // log-intensity sine. Reject every other mode instead of + // silently sweeping a DAC or linear-intensity waveform. + if self.method != DriveMethod::Calibrated + || self.mode != Mode::OpticalLogSine + || self.calibration_id.is_none() + { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "apply a calibration and arm OPTICAL_LOG_SINE in the modulation plugin \ + before sweeping optical depth a", + false, + )); + } + let previous = self.depth_a; + self.depth_a = depth_a; + let command = match self.drive_command() { + Ok(command) => command, + Err(error) => { + self.depth_a = previous; + return Err(service_error( + ServiceErrorCodeV1::DeviceRejected, + format!("optical depth a={depth_a:.3} rejected: {error}"), + false, + )); + } + }; + // Remember what the operator had armed before the first + // 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) + } + ModulationCommandV1::SetDriveFrequency { frequency_millihz } => { + self.require_lease(request)?; + if self.link.is_none() { + return Err(service_error( + ServiceErrorCodeV1::NotConnected, + "the modulation owner is not connected to the device", + false, + )); + } + 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 !stage_a_plugin_contract::drive_frequency_supported(*frequency_millihz) { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + format!( + "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, + )); + } + // A constant hold has no frequency, and the manual DAC band is + // not the calibrated drive this path retargets. + if self.method == DriveMethod::Manual || self.mode == Mode::Const { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "arm a calibrated periodic/optical drive in the modulation plugin \ + before sweeping the frequency", + false, + )); + } + let previous = self.frequency_hz; + self.frequency_hz = frequency_hz; + let command = match self.drive_command() { + Ok(command) => command, + Err(error) => { + self.frequency_hz = previous; + return Err(service_error( + ServiceErrorCodeV1::DeviceRejected, + format!("frequency {frequency_hz:.3} Hz rejected: {error}"), + false, + )); + } + }; + // As for the depth: park the operator's own frequency on the + // 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) + } + ModulationCommandV1::SetOperatingPoint { mean_u_milli } => { + self.require_lease(request)?; + if self.link.is_none() { + return Err(service_error( + ServiceErrorCodeV1::NotConnected, + "the modulation owner is not connected to the device", + false, + )); + } + let mean_u = f64::from(*mean_u_milli) / 1_000.0; + if !(waveform::MEAN_U_MIN..=1.0).contains(&mean_u) { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + format!( + "operating point ū={mean_u:.3} outside the supported {:.2}..=1.0", + waveform::MEAN_U_MIN + ), + false, + )); + } + // `ū` only means anything against a measured lobe: on the + // manual method the band is the operator's two DAC codes and + // there is no normalized coordinate to retarget. + if self.method != DriveMethod::Calibrated { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "arm the calibrated drive method in the modulation plugin before \ + sweeping the operating point", + false, + )); + } + let previous = self.operating_point; + self.operating_point = mean_u; + let command = match self.drive_command() { + Ok(command) => command, + Err(error) => { + // Unlike an interactive edit this is *not* clamped: a + // protocol asked for a specific brightness, and quietly + // recording a different one would put the wrong `ū` in + // every sidecar of that block. + self.operating_point = previous; + return Err(service_error( + ServiceErrorCodeV1::DeviceRejected, + format!("operating point ū={mean_u:.3} rejected: {error}"), + false, + )); + } + }; + // As for depth and frequency: park the operator's own value on + // 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) + } + ModulationCommandV1::PrepareA1 { configuration } => { + self.require_lease(request)?; + let revision = self.requested_revision(request)?; + let mut target = self.base_target(revision); + target.a1_configuration = Some(configuration.clone()); + target.acquisition_running = false; + self.queue_service_operation( + request, + target, + vec![ + Command::new("STOP").field("reason", "prepare_a1"), + a1_config_command(configuration), + ], + "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.a1_configuration = None; + target.a2_configuration = Some(configuration.clone()); + target.acquisition_running = false; + self.queue_service_operation( + request, + target, + vec![ + Command::new("STOP").field("reason", "prepare_a2"), + a2_config_command(configuration), + a2_comparator_command(configuration), + a2_log_square_command(configuration), + ], + "PREPARE_A2", + false, + ) + } + ModulationCommandV1::StartAcquisition => { + self.require_lease(request)?; + let revision = self.requested_revision(request)?; + let mut target = self.base_target(revision); + target.acquisition_running = true; + self.queue_service_operation( + request, + target, + vec![Command::new("START")], + "START", + false, + ) + } + ModulationCommandV1::StopAcquisition { reason } => { + self.require_lease(request)?; + let revision = self.requested_revision(request)?; + let mut target = self.base_target(revision); + target.acquisition_running = false; + self.queue_service_operation( + request, + target, + vec![Command::new("STOP").field("reason", reason.replace(' ', "_"))], + "STOP", + false, + ) + } + } + } + + fn immediate_response( + &mut self, + request: &ModulationRequestV1, + outcome: RequestOutcomeV1, + error: Option, + ) -> Result { + let state = self.shared.state.lock().expect("device state lock"); + let response = ModulationResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: request.request_id, + owner_instance: self.owner_instance.clone(), + run_id: request.run_id.clone(), + requested_revision: request.requested_revision, + acknowledged_revision: state.acknowledged.as_ref().map(|value| value.revision), + outcome, + completed_at_unix_ms: Some(now_unix_ms()), + error, + }, + controller_state: state.controller_state, + acknowledged_target: state.acknowledged.clone(), + }; + drop(state); + self.shared + .state + .lock() + .expect("device state lock") + .last_response = Some(response.clone()); + self.shared.bump(); + Ok(response) + } + + fn control_state(&self) -> ModulationStateV1 { + let state = self.shared.state.lock().expect("device state lock"); + let connection = if state.connected { + ConnectionStateV1::Connected { + port_label: self.port_hint.clone(), + firmware_version: Some(state.firmware.clone()), + } + } else if let Some(error) = state.last_error.clone().or_else(|| self.last_error.clone()) { + ConnectionStateV1::Faulted { message: error } + } else if self.connect_requested { + ConnectionStateV1::Connecting + } else { + ConnectionStateV1::Disconnected + }; + let synchronization = match ( + self.lease.as_ref().and_then(|lease| lease.run_id.clone()), + state.requested.as_ref(), + state.acknowledged.as_ref(), + ) { + (Some(run_id), Some(requested), Some(acknowledged)) + if requested.revision == acknowledged.revision => + { + SynchronizationV1::Synced { + run_id, + acknowledged_revision: acknowledged.revision, + stream_epoch: None, + } + } + (None, _, _) => SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + _ => SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::RequestedRevisionNotAcknowledged, + detail: None, + }, + }; + ModulationStateV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: self.owner_instance.clone(), + service_revision: self.shared.generation.load(Ordering::Relaxed), + connection, + capabilities: state.capabilities.clone(), + lease: self.lease_snapshot(), + controller_state: state.controller_state, + 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 + // board-echoed operator-armed drive (revision 0) so consumers + // like A1 can read the modulation frequency without a lease ever + // having existed. + acknowledged: state + .acknowledged + .clone() + .or_else(|| state.board_echo_target()), + synchronization, + last_response: state.last_response.clone(), + freshness: FreshnessV1 { + observed_at_unix_ms: if state.last_device_update_unix_ms == 0 { + now_unix_ms() + } else { + state.last_device_update_unix_ms + }, + valid_for_ms: 1_500, + }, + calibration_id: self.calibration_id.clone(), + optical_drive: self.optical_drive_state(), + } + } + + /// Ends the current lease and gives the operator their armed drive back. + /// + /// A leased `SetOpticalDepth` (A1's amplitude sweep) writes straight into + /// `depth_a`. Without this the modulation UI kept showing — and the board + /// kept holding — the last sweep point's depth after the sweep finished, + /// 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.lease = None; + let depth = self.armed_depth_a.take(); + let frequency = self.armed_frequency_hz.take(); + let operating_point = self.armed_operating_point.take(); + if let Some(depth) = depth { + self.depth_a = depth; + } + if let Some(frequency) = frequency { + self.frequency_hz = frequency; + } + if let Some(operating_point) = operating_point { + self.operating_point = operating_point; + } + if depth.is_some() || frequency.is_some() || operating_point.is_some() { + // Re-arm the board only if nobody else now owns the DAC; + // `send_modulation` is itself guarded. + self.send_modulation(); + } + } + + fn expire_lease_if_needed(&mut self) { + let expired = self + .lease + .as_ref() + .is_some_and(|lease| now_unix_ms() > lease.expires_at_unix_ms); + if !expired { + return; + } + self.shared + .fail_closed_on_stop + .store(true, Ordering::Relaxed); + *self.shared.pending.lock().expect("pending lock") = None; + *self.shared.priority.lock().expect("priority lock") = Some(PendingOperation { + commands: vec![ + Command::new("STOP").field("reason", "lease_expired"), + Command::new("MOD").field("wave", "OFF"), + ], + purpose: "LEASE_EXPIRED_SAFE_OFF", + meta: None, + }); + self.end_lease(); + self.last_error = Some("automation lease expired; queued STOP + output off".into()); + self.shared.bump(); + } + + fn advance_deferred_release(&mut self) { + let Some(request_id) = self.deferred_release_request else { + return; + }; + let terminal_applied = self + .shared + .state + .lock() + .ok() + .and_then(|state| state.last_response.clone()) + .is_some_and(|response| { + response.common.request_id == request_id + && response.common.outcome == RequestOutcomeV1::Applied + }); + if !terminal_applied { + return; + } + if self.deferred_release_ack_published { + self.end_lease(); + self.deferred_release_request = None; + self.deferred_release_ack_published = false; + self.shared + .fail_closed_on_stop + .store(false, Ordering::Relaxed); + self.shared.bump(); + } else { + // Preserve the lease for one complete snapshot publication so + // the orchestrator can consume the terminal ACK before the owner + // advertises the release. + self.deferred_release_ack_published = true; + } + } + + fn apply_execution_context(&mut self, execution: &augur_plugin_api::ExecutionContext) { + let allowed = self.runtime_role == PluginRuntimeRole::LiveWorker + && execution.hardware_effects_allowed(); + self.effects_allowed = allowed; + if !allowed { + if self.link.is_some() { + self.shared + .fail_closed_on_stop + .store(self.lease.is_some(), Ordering::Relaxed); + self.disconnect(); + } + self.end_lease(); + self.deferred_release_request = None; + self.deferred_release_ack_published = false; + return; + } + self.expire_lease_if_needed(); + self.advance_deferred_release(); + // Reap a dead device thread (failed HELLO, wedged serial): a finished + // thread leaves `link` occupied, which both swallows every queued + // command (the settings UI keeps responding while the board holds the + // old waveform) and blocks the auto-reconnect below. + if self + .link + .as_ref() + .and_then(|link| link.join.as_ref()) + .is_some_and(JoinHandle::is_finished) + { + self.link = None; + } + if self.connect_requested && self.link.is_none() { + let now_ms = now_unix_ms(); + if now_ms.saturating_sub(self.last_reconnect_ms) >= RECONNECT_BACKOFF_MS { + self.last_reconnect_ms = now_ms; + self.connect(); + } + } + } + + #[cfg(test)] + fn device_connected(&self) -> bool { + self.shared + .state + .lock() + .map(|state| state.connected) + .unwrap_or(false) + } + + fn commanded_summary(&self) -> String { + match self.dac_band() { + Ok((lo, hi, hold)) if self.mode == Mode::Const => format!( + "{} {} hold={} (band {}..{})", + self.method.name(), + self.mode.name(), + hold, + lo, + hi + ), + Ok((lo, hi, _)) => format!( + "{} {} {}..{} @ {:.3} Hz", + self.method.name(), + self.mode.name(), + lo, + hi, + self.frequency_hz + ), + Err(error) => format!( + "{} {} invalid: {error}", + self.method.name(), + self.mode.name() + ), + } + } + + /// The transfer curve the operator reasons about. Before any sweep it shows + /// the lobe the configured `V_null`/`V_peak` endpoints claim, on a + /// normalised axis, so + /// the two numbers are legible with no hardware attached; after a fit it + /// shows what was actually measured, in detector volts. + fn curve_dataset(&self) -> Series1dV1 { + let max_code = self.max_level.clamp(1, MAX_DAC_CODE) as f64; + let sample_curve = |scale: f64, offset: f64, inversion: waveform::LobeInversion| { + (0..=256) + .map(|step| { + let code = max_code * f64::from(step) / 256.0; + Series1dPoint { + x: code, + y: offset + scale * inversion.u_for_dac(code), + } + }) + .collect::>() + }; + // Two-point verticals mark the lobe endpoints on whatever y-range the + // rest of the plot spans. + let marker = |name: &str, code: f64, lo: f64, hi: f64| Series1dLine { + name: name.to_owned(), + points: vec![ + Series1dPoint { x: code, y: lo }, + Series1dPoint { x: code, y: hi }, + ], + }; + + let Some(fit) = self.fit.as_ref() else { + let mut lines = Vec::new(); + if let Ok(inversion) = self.lobe_inversion() { + lines.push(Series1dLine { + name: "configured lobe".into(), + points: sample_curve(1.0, 0.0, inversion), + }); + lines.push(marker("V_null", inversion.v_null_dac, 0.0, 1.0)); + lines.push(marker("V_peak", inversion.v_peak_dac(), 0.0, 1.0)); + } + return Series1dV1 { + x_label: "DAC code".into(), + y_label: "normalised transmission u (not yet measured)".into(), + lines, + }; + }; + + let point_line = |direction: calibration::Direction| Series1dLine { + name: format!("measured {}", direction.label()), + points: fit + .points + .iter() + .filter(|point| point.direction == direction) + .map(|point| Series1dPoint { + x: f64::from(point.code), + y: point.volts, + }) + .collect(), + }; + let (lo, hi) = fit + .points + .iter() + .fold((f64::MAX, f64::MIN), |(lo, hi), point| { + (lo.min(point.volts), hi.max(point.volts)) + }); + let mut lines = vec![ + point_line(calibration::Direction::Ascending), + point_line(calibration::Direction::Descending), + Series1dLine { + name: "fit".into(), + points: sample_curve(fit.span_volts, fit.offset_volts, fit.inversion()), + }, + ]; + // The configured lobe on the fit's own scale: after applying they + // coincide, and any divergence is the un-applied difference. + if let Ok(configured) = self.lobe_inversion() { + if configured != fit.inversion() { + lines.push(Series1dLine { + name: "configured lobe".into(), + points: sample_curve(fit.span_volts, fit.offset_volts, configured), + }); + } + } + lines.push(marker("V_null", fit.v_null_dac, lo, hi)); + lines.push(marker("V_peak", fit.v_peak_dac(), lo, hi)); + Series1dV1 { + x_label: "DAC code".into(), + y_label: "photodiode [V]".into(), + lines, + } + } + + fn status_dataset(&self) -> TableDatasetV1 { + let state = self.shared.state.lock().expect("device state lock"); + let connection = if state.connected { + format!("connected ({})", state.firmware) + } else if self.connect_requested { + "connecting…".into() + } else { + "disconnected".into() + }; + let board_code = state + .board_code + .map_or_else(|| "—".into(), |code| code.to_string()); + let error = state + .last_error + .clone() + .or_else(|| self.last_error.clone()) + .unwrap_or_default(); + let board_mod = if state.board_mod.is_empty() { + "—".to_owned() + } else { + state.board_mod.clone() + }; + drop(state); + let text_column = |id: &str, value: String| TableColumnData { + column_id: id.to_owned(), + values: TableColumnValues::String(vec![value]), + }; + TableDatasetV1 { + columns: vec![ + text_column("state", connection), + text_column("commanded", self.commanded_summary()), + text_column("board_mod", board_mod), + text_column("board_code", board_code), + text_column("error", error), + ], + } + } + + fn status_schema(&self) -> TableSchema { + let column = |id: &str, title: &str| TableColumn { + id: id.to_owned(), + title: title.to_owned(), + value_type: TableValueType::String, + }; + TableSchema { + columns: vec![ + column("state", "State"), + column("commanded", "Commanded drive"), + column("board_mod", "Board modulation"), + column("board_code", "Board DAC code"), + column("error", "Last error"), + ], + ..TableSchema::default() + } + } +} + +/// `YYYYmmdd-HHMMSS` in UTC, from the wall clock alone (no chrono dependency). +fn timestamp_slug() -> String { + let seconds = now_unix_ms() / 1_000; + let (days, time) = (seconds / 86_400, seconds % 86_400); + // Civil-from-days, Howard Hinnant's algorithm, shifted to a 0000-03-01 era. + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = if month_prime < 10 { + month_prime + 3 + } else { + month_prime - 9 + }; + let year = year_of_era + era * 400 + i64::from(month <= 2); + format!( + "{year:04}{month:02}{day:02}-{:02}{:02}{:02}", + time / 3_600, + (time % 3_600) / 60, + time % 60 + ) +} + +fn open_serial(port_hint: &str) -> Result, String> { + if port_hint == "auto" { + // The dual-serial Teensy enumerates two ports and only the command + // port answers HELLO — probe until one does. + let candidates = serial_ports(); + if candidates.is_empty() { + return Err(stage_a_io::transport::no_candidate_ports_message()); + } + let mut failures = Vec::new(); + for path in &candidates { + match probe_command_port(path) { + // Restore the client's default reply timeout after probing. + Ok(client) => return Ok(client.with_reply_timeout(Duration::from_millis(500))), + Err(err) => failures.push(format!("{path}: {err}")), + } + } + return Err(format!( + "no Teensy command port answered HELLO ({})", + failures.join("; ") + )); + } + open_path(port_hint) +} + +fn service_error( + code: ServiceErrorCodeV1, + message: impl Into, + retryable: bool, +) -> ServiceErrorV1 { + ServiceErrorV1 { + code, + message: message.into(), + retryable, + } +} + +fn lease_deadline(ttl_ms: u64) -> u64 { + now_unix_ms().saturating_add(ttl_ms.clamp(MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS)) +} + +/// Wire token for the optical target on the `MOD wave=WARP` command. +fn optical_target_token(target: waveform::OpticalTarget) -> &'static str { + match target { + waveform::OpticalTarget::LogSine => "LOG_SINE", + waveform::OpticalTarget::LinearSine => "LINEAR_SINE", + } +} + +fn waveform_command(waveform: &WaveformV1) -> Command { + match waveform { + WaveformV1::Off => Command::new("MOD").field("wave", "OFF"), + WaveformV1::Constant { level_dac } => Command::new("MOD") + .field("wave", "CONST") + .field("level", *level_dac), + WaveformV1::Periodic { + waveform, + min_dac, + max_dac, + frequency_millihz, + } => Command::new("MOD") + .field( + "wave", + match waveform { + stage_a_plugin_contract::PeriodicWaveformV1::Sine => "SINE", + stage_a_plugin_contract::PeriodicWaveformV1::Square => "SQUARE", + }, + ) + .field("level", *max_dac) + .field("min", *min_dac) + .field("freq_mhz", *frequency_millihz), + } +} + +fn a1_config_command(configuration: &A1AcquisitionConfigV1) -> Command { + Command::new("CONFIG") + .field("mode", "A1") + .field( + "wave", + match configuration.waveform { + stage_a_plugin_contract::PeriodicWaveformV1::Sine => "SINE", + stage_a_plugin_contract::PeriodicWaveformV1::Square => "SQUARE", + }, + ) + .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("block_samples", configuration.block_samples) + .field("raw", u8::from(configuration.emit_raw_samples)) + .field("summary", u8::from(configuration.emit_summary)) +} + +fn validate_a2_configuration(configuration: &A2AcquisitionConfigV1) -> Result<(), ServiceErrorV1> { + let invalid = |message: &str| service_error(ServiceErrorCodeV1::InvalidCommand, message, false); + if !(1..=1_000).contains(&configuration.mean_u_milli) { + return Err(invalid("A2 mean_u_milli must be in 1..=1000")); + } + if configuration.depth_a_milli == 0 { + return Err(invalid("A2 depth_a_milli must be positive")); + } + if !drive_frequency_supported(configuration.frequency_millihz) { + return Err(invalid("A2 frequency is outside the firmware drive range")); + } + let half_us = 500_000_000_u64 / configuration.frequency_millihz; + if half_us < u64::from(configuration.min_half_us) { + return Err(invalid("A2 half-period is below min_half_us")); + } + if configuration.v_peak_dac <= configuration.v_null_dac { + return Err(invalid("A2 v_peak_dac must be greater than v_null_dac")); + } + if !(1..=4_095).contains(&configuration.comparator_threshold_dac) { + return Err(invalid("A2 comparator threshold must be in 1..=4095")); + } + if configuration.comparator_hysteresis > 3 { + return Err(invalid("A2 comparator hysteresis must be in 0..=3")); + } + if !(100..=500_000).contains(&configuration.sample_rate_hz) { + return Err(invalid("A2 sample_rate_hz must be in 100..=500000")); + } + if configuration.block_samples == 0 { + return Err(invalid("A2 block_samples must be positive")); + } + if !configuration.emit_raw_samples && !configuration.emit_summary { + return Err(invalid("A2 must enable raw samples or summaries")); + } + Ok(()) +} + +fn a2_config_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("CONFIG") + .field("mode", "A2") + .field("rate_hz", configuration.sample_rate_hz) + .field("block_samples", configuration.block_samples) + .field("raw", u8::from(configuration.emit_raw_samples)) + .field("summary", u8::from(configuration.emit_summary)) +} + +fn a2_comparator_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("CMP") + .field("thr", configuration.comparator_threshold_dac) + .field("hyst", configuration.comparator_hysteresis) + .field("invert", u8::from(configuration.comparator_invert)) +} + +fn a2_log_square_command(configuration: &A2AcquisitionConfigV1) -> Command { + Command::new("MOD") + .field("wave", "LOG_SQUARE") + .field("a_milli", configuration.depth_a_milli) + .field("u_k_milli", configuration.mean_u_milli) + .field("v_null", configuration.v_null_dac) + .field("v_pi", configuration.v_peak_dac - configuration.v_null_dac) + .field("freq_mhz", configuration.frequency_millihz) + .field("min_half_us", configuration.min_half_us) +} + +fn accepted_service_reply( + request: &PluginServiceRequest, + response: &ModulationResponseV1, +) -> PluginServiceReply { + PluginServiceReply { + request_id: request.request_id, + source_plugin_id: request.source_plugin_id.clone(), + target_plugin_id: request.target_plugin_id.clone(), + service: request.service.clone(), + outcome: PluginServiceOutcome::Accepted { + payload: serde_json::to_value(response).unwrap_or(Value::Null), + }, + } +} + +fn rejected_service_reply( + request: &PluginServiceRequest, + code: &str, + message: impl Into, +) -> PluginServiceReply { + PluginServiceReply { + request_id: request.request_id, + source_plugin_id: request.source_plugin_id.clone(), + target_plugin_id: request.target_plugin_id.clone(), + service: request.service.clone(), + outcome: PluginServiceOutcome::Rejected { + code: code.into(), + message: message.into(), + }, + } +} + +fn open_path(path: &str) -> Result, String> { + let transport = + stage_a_io::SerialTransport::open(path, 115_200, std::time::Duration::from_millis(20)) + .map_err(|err| err.to_string())?; + Ok(StageAClient::new(transport)) +} + +/// Opens `path` and sends HELLO with a short timeout: only the Teensy +/// command port replies (the photodiode stream port never answers). +fn probe_command_port(path: &str) -> Result, String> { + let mut client = open_path(path)?.with_reply_timeout(Duration::from_millis(300)); + client + .request(&Command::new("HELLO").field("protocol", 1)) + .map_err(|err| err.to_string())?; + Ok(client) +} + +fn serial_ports() -> Vec { + stage_a_io::transport::candidate_ports() + .into_iter() + .map(|port| port.name) + .collect() +} + +/// The exact variant list the settings schema shows for the port enum — the +/// host exchanges enum settings as indices into this list. Real ports carry +/// their USB label (e.g. "(Teensyduino Dual Serial)") for recognisability; +/// only the leading path is the value. +fn port_variants() -> Vec { + let mut variants = vec!["auto".to_owned(), "mock".to_owned()]; + variants.extend( + stage_a_io::transport::candidate_ports() + .iter() + .map(stage_a_io::transport::PortInfo::variant), + ); + variants +} + +/// The path part of a port variant; the parenthesised USB label is display-only. +fn variant_path(variant: &str) -> &str { + variant.split_whitespace().next().unwrap_or(variant) +} + +/// Host enum widgets send the selected index; string names are also accepted +/// (tests, saved configs). +fn enum_choice(value: &Value, variants: &[String]) -> Result { + if let Some(index) = value.as_u64() { + return variants + .get(usize::try_from(index).map_err(|_| "index out of range".to_owned())?) + .cloned() + .ok_or_else(|| format!("enum index {index} out of range")); + } + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| "expected an enum index or name".to_owned()) +} + +impl Plugin for StageAModulationPlugin { + fn name(&self) -> &'static str { + "Stage-A Modulation" + } + + fn description(&self) -> &'static str { + "Laser modulation control on the Teensy command port: capped power slider, constant/sine/square with frequency, applied immediately; shows the DAC code the board reports." + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.connect_requested = false; + self.shared + .fail_closed_on_stop + .store(self.lease.is_some(), Ordering::Relaxed); + self.disconnect(); + self.end_lease(); + self.deferred_release_request = None; + } + } + + fn set_runtime_role(&mut self, role: PluginRuntimeRole) { + self.runtime_role = role; + if role != PluginRuntimeRole::LiveWorker { + self.effects_allowed = false; + if self.link.is_some() { + self.shared + .fail_closed_on_stop + .store(self.lease.is_some(), Ordering::Relaxed); + self.disconnect(); + } + self.end_lease(); + self.deferred_release_request = None; + self.deferred_release_ack_published = false; + } + } + + fn reset(&mut self) {} + + fn process_frame( + &mut self, + _frame: &PluginFrame<'_>, + _output: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _event_store: &EventStoreHandle<'_>, + ) { + // Control is settings-driven and works without camera frames. The + // only frame-pass policy: replaying a recording must never keep a + // hardware connection alive. + if context.execution().mode == ExecutionMode::Replay && self.link.is_some() { + self.connect_requested = false; + self.shared + .fail_closed_on_stop + .store(self.lease.is_some(), Ordering::Relaxed); + self.disconnect(); + self.end_lease(); + self.last_error = Some("disconnected: replay mode".into()); + } + } + + fn process_control(&mut self, context: &mut PluginControlContext<'_>) { + let execution = context.execution(); + self.apply_execution_context(&execution); + // The photodiode owner broadcasts its summary to every plugin's inbox, + // so a calibration sweep reads the light with no lease and no request. + let level = context + .inbox() + .snapshots + .iter() + .find(|snapshot| { + snapshot.plugin_id == PLUGIN_ID_STAGE_A_PHOTODIODE + && snapshot.topic == CTX_STAGE_A_PHOTODIODE_SUMMARY_V1 + }) + .and_then(|snapshot| { + serde_json::from_value::(snapshot.payload.clone()).ok() + }) + .map(|summary| (summary.stream.level, summary.stream.sample_rate_hz)); + let (level, sample_rate_hz) = level.unwrap_or((None, None)); + self.drive_calibration(level, sample_rate_hz); + } + + fn handle_service_request( + &mut self, + request: &PluginServiceRequest, + execution: &augur_plugin_api::ExecutionContext, + ) -> PluginServiceReply { + if let Some(index) = self.request_cache.iter().position(|(previous, _)| { + previous.source_plugin_id == request.source_plugin_id + && previous.request_id == request.request_id + }) { + let (previous, cached_reply) = self.request_cache[index].clone(); + if previous != *request { + return rejected_service_reply( + request, + "request_id_conflict", + "request ID was reused for different modulation payload", + ); + } + let cached_in_progress = match &cached_reply.outcome { + PluginServiceOutcome::Accepted { payload } => serde_json::from_value::< + ModulationResponseV1, + >(payload.clone()) + .is_ok_and(|response| response.common.outcome == RequestOutcomeV1::InProgress), + 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 + }); + if let Some(terminal) = terminal { + let upgraded = accepted_service_reply(request, &terminal); + self.request_cache[index].1 = upgraded.clone(); + return upgraded; + } + } + return cached_reply; + } + + let reply = if request.target_plugin_id != PLUGIN_ID_STAGE_A_MODULATION { + rejected_service_reply(request, "wrong_target", "wrong modulation owner target") + } else if request.service != SERVICE_STAGE_A_MODULATION_CONTROL_V1 { + rejected_service_reply( + request, + "unsupported_service", + format!("unsupported modulation service '{}'", request.service), + ) + } else if self.runtime_role != PluginRuntimeRole::LiveWorker + || !execution.hardware_effects_allowed() + { + rejected_service_reply( + request, + "effects_not_allowed", + "modulation effects are allowed only on the active live worker", + ) + } else { + self.effects_allowed = true; + match serde_json::from_value::(request.payload.clone()) { + Err(err) => rejected_service_reply( + request, + "invalid_payload", + format!("invalid modulation request: {err}"), + ), + Ok(payload) + if payload.contract_version != CONTRACT_VERSION_V1 + || payload.request_id.0 != request.request_id + || payload.requester.as_str() != request.source_plugin_id + || payload + .target_owner_instance + .as_ref() + .is_some_and(|owner| owner != &self.owner_instance) => + { + rejected_service_reply( + request, + "identity_mismatch", + "contract version, request, requester, or owner instance mismatch", + ) + } + Ok(payload) + if payload.issued_at_unix_ms != 0 + && (now_unix_ms().saturating_sub(payload.issued_at_unix_ms) > 120_000 + || payload.issued_at_unix_ms.saturating_sub(now_unix_ms()) + > 30_000) => + { + rejected_service_reply(request, "stale_request", "request timestamp is stale") + } + Ok(payload) => match self.handle_modulation_command(&payload) { + Ok(response) => accepted_service_reply(request, &response), + Err(error) => rejected_service_reply( + request, + &format!("{:?}", error.code).to_ascii_lowercase(), + error.message, + ), + }, + } + }; + self.request_cache + .push_back((request.clone(), reply.clone())); + while self.request_cache.len() > REQUEST_CACHE_LIMIT { + self.request_cache.pop_front(); + } + reply + } + + fn control_snapshots(&self) -> Vec { + vec![PluginControlSnapshot { + plugin_id: PLUGIN_ID_STAGE_A_MODULATION.into(), + topic: CTX_STAGE_A_MODULATION_STATE_V1.into(), + revision: self.shared.generation.load(Ordering::Relaxed).max(1), + payload: serde_json::to_value(self.control_state()).unwrap_or(Value::Null), + }] + } + + fn settings_schema(&self) -> SettingsSchema { + let port_variants = port_variants(); + let port_default = port_variants + .iter() + .position(|p| variant_path(p) == self.port_hint) + .unwrap_or(0); + let method_variants: Vec = DriveMethod::VARIANTS + .iter() + .map(|method| method.name().to_owned()) + .collect(); + let method_default = DriveMethod::VARIANTS + .iter() + .position(|method| *method == self.method) + .unwrap_or(0); + let mode_variants: Vec = + Mode::VARIANTS.iter().map(|m| m.name().to_owned()).collect(); + let mode_default = Mode::VARIANTS + .iter() + .position(|m| *m == self.mode) + .unwrap_or(0); + let mut modulation_items = vec![ + SettingItem { + key: "port".into(), + label: "Port".into(), + tooltip: Some( + "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(), + ), + kind: SettingKind::Enum { + variants: port_variants, + default: port_default, + }, + }, + SettingItem { + key: "connect".into(), + label: "Connect".into(), + tooltip: Some( + "Opens/closes the command port. Connecting never changes the \ + output; disconnecting leaves it held (set-and-hold firmware)." + .into(), + ), + kind: SettingKind::Bool { + default: self.connect_requested, + }, + }, + SettingItem { + key: "max_level".into(), + label: "Max limit (DAC code)".into(), + tooltip: Some( + "Hard ceiling for every drive. No manual or calibrated waveform may \ + produce a DAC code above this value at J23." + .into(), + ), + kind: SettingKind::I64Drag { + min: 0, + max: MAX_DAC_CODE, + default: self.max_level, + }, + }, + SettingItem { + key: "method".into(), + label: "Drive method".into(), + tooltip: Some( + "MANUAL defines the DAC band with Power and Min threshold. CALIBRATED \ + derives it from V_null, V_peak, the mean lobe point ū and the depth a." + .into(), + ), + kind: SettingKind::Enum { + variants: method_variants, + default: method_default, + }, + }, + SettingItem { + key: "mode".into(), + label: "Mode".into(), + tooltip: Some( + "Selects the waveform that fills the method-defined operating band. \ + All five modes are available with both drive methods." + .into(), + ), + kind: SettingKind::Enum { + variants: mode_variants, + default: mode_default, + }, + }, + SettingItem { + key: "frequency_hz".into(), + label: "Frequency".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: 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, + }, + }, + ]; + match self.method { + DriveMethod::Manual => { + modulation_items.push(SettingItem { + key: "level".into(), + label: "Power (DAC code)".into(), + tooltip: Some( + "Manual peak/operating DAC code. CONST holds this value; periodic \ + modes use it as the upper end of the manual band." + .into(), + ), + kind: SettingKind::I64Slider { + min: 0, + max: self.max_level, + default: self.level, + suffix: None, + }, + }); + modulation_items.push(SettingItem { + key: "min_level".into(), + label: "Min threshold (DAC code)".into(), + tooltip: Some( + "Lower end of the manual DAC band. Ignored by CONST, which holds Power." + .into(), + ), + kind: SettingKind::I64Slider { + min: 0, + max: self.max_level, + default: self.min_level, + suffix: None, + }, + }); + } + DriveMethod::Calibrated => { + let (v_null, v_peak) = self.effective_lobe(); + let range = self.achievable(); + modulation_items.push(SettingItem { + key: "v_null_dac".into(), + label: "V_null (DAC code at MIN light)".into(), + tooltip: Some( + "DAC code where the excitation light bottoms out (sin² = 0) on one \ + monotonic Pockels lobe. Read it off a sweep — Measure transfer curve \ + below fills both codes in for you." + .into(), + ), + kind: SettingKind::I64Drag { + min: 0, + max: MAX_DAC_CODE, + default: v_null, + }, + }); + modulation_items.push(SettingItem { + key: "v_peak_dac".into(), + label: "V_peak (DAC code at MAX light)".into(), + tooltip: Some( + "DAC code where the excitation light is brightest, on the same lobe as \ + V_null. Both fields are codes you can point at on the transfer curve; \ + the half-wave span between them is derived, never typed. u = 1 holds \ + exactly here and u = 0 at V_null." + .into(), + ), + kind: SettingKind::I64Drag { + min: 0, + max: MAX_DAC_CODE, + default: v_peak, + }, + }); + modulation_items.push(SettingItem { + key: "operating_point".into(), + label: match range { + Some(range) => { + format!( + "Mean lobe point ū (0..{:.2} at a={:.2})", + range.max_mean_u, self.depth_a + ) + } + None => "Mean lobe point ū (0..1)".into(), + }, + tooltip: Some( + "How bright the light sits on average, as a fraction of the lobe's \ + maximum. Dimensionless — not the physical flux I_k.\n\n\ + The label shows how far up you can go at the depth a currently set; \ + past that the peak of the swing would run off the top of the lobe. \ + Dragging beyond it stops at the limit instead of refusing the edit." + .into(), + ), + kind: SettingKind::F64Drag { + min: waveform::MEAN_U_MIN, + max: 1.0, + speed: 0.01, + default: self.operating_point, + }, + }); + modulation_items.push(SettingItem { + key: "depth_a".into(), + label: match range { + Some(range) => format!( + "Optical depth a (0..{:.2} at ū={:.2})", + range.max_depth_a, self.operating_point + ), + None => "Optical depth a".into(), + }, + tooltip: Some( + "Peak-to-trough log contrast a = ln(I_max/I_min) of the light.\n\n\ + The label shows the deepest a the lobe can reach at the mean point \ + currently set — lower ū to get more depth. Dragging beyond it stops at \ + the limit instead of refusing the edit." + .into(), + ), + kind: SettingKind::F64Drag { + min: waveform::DEPTH_A_MIN, + max: waveform::DEPTH_A_MAX, + speed: 0.01, + default: self.depth_a, + }, + }); + } + } + let geometry_variants: Vec = calibration::DetectorGeometry::VARIANTS + .iter() + .map(|geometry| geometry.name().to_owned()) + .collect(); + let geometry_default = calibration::DetectorGeometry::VARIANTS + .iter() + .position(|geometry| *geometry == self.detector_geometry) + .unwrap_or(0); + SettingsSchema { + sections: vec![ + SettingsSection { + label: "Laser modulation".into(), + description: Some( + "Tick Connect, then every change is sent to the Teensy immediately — no \ + camera required. Method selects the operating band; Mode selects its \ + waveform. Max limit is the hard ceiling. The firmware holds the output \ + when disconnected." + .into(), + ), + default_open: true, + items: modulation_items, + }, + SettingsSection { + label: "Calibration".into(), + description: Some( + "Measures the Pockels/PBS transfer curve: steps settled CONST DAC codes \ + across the range while reading the photodiode, then fits the lobe. \ + Needs the photodiode plugin connected. The sweep restores your armed \ + drive when it finishes, and the fit is never applied without your \ + confirmation. Watch the transfer-curve view." + .into(), + ), + default_open: false, + items: vec![ + SettingItem { + key: "detector_geometry".into(), + label: "Detector port".into(), + tooltip: Some( + "Which way the photodiode moves when the light reaching the \ + sample gets brighter. Stage-A's photodiode sits on the PBS \ + reject port and reads the leftover light, I_pd = I_tot − I_exc, \ + so it goes DOWN as the sample gets brighter — that is REJECT \ + PORT, the default. Pick DIRECT only for a detector that watches \ + the sample beam itself. The sweep cannot work this out: a bright \ + and a dark extremum fit the measured curve equally well, and \ + only the optics say which one is zero light on the sample. \ + Choosing wrong puts V_null one half-wave-voltage span off." + .into(), + ), + kind: SettingKind::Enum { + variants: geometry_variants, + default: geometry_default, + }, + }, + SettingItem { + key: "calibrate".into(), + label: "Measure transfer curve".into(), + tooltip: Some( + "Sweeps the full range up and back down (~20 s), then fits the \ + lobe. Press again to abort; the armed drive is restored either \ + way. Progress and the result appear in the status lines below." + .into(), + ), + kind: SettingKind::Button { + enabled: self.calibration_offered(), + }, + }, + SettingItem { + key: "calibrate_apply".into(), + label: "Apply to V_null / V_peak".into(), + tooltip: Some( + "Writes the measured lobe into V_null and V_peak above. Residual, \ + dropped points, hysteresis and clipping stay visible as warnings \ + — applying only refuses a pair that names no monotonic lobe at \ + all. If the depth or mean point no longer fit the new lobe they \ + move to its nearest reachable value rather than blocking this." + .into(), + ), + kind: SettingKind::Button { + enabled: self.calibration_offered(), + }, + }, + SettingItem { + key: "calibration_dir".into(), + label: "Calibration folder (optional)".into(), + tooltip: Some( + "Where each applied calibration is archived, as one named JSON \ + file per apply. It holds every swept point (code, direction, \ + volts, whether it clipped), the fitted V_null and V_peak, the \ + residual and hysteresis, and the detector reading at the \ + excitation null — which is the total power I_tot the photodiode \ + plugin measures its depth against. That makes an old run's \ + inversion reproducible after the bench has been touched.\n\n\ + Leave it empty to apply the fit without keeping a record." + .into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::Directory, + default: self.calibration_dir.clone(), + }, + }, + ], + }, + ], + } + } + + fn get_setting(&self, key: &str) -> Option { + match key { + // Enum settings are exchanged as indices into the schema's + // variant list (see the host settings UI). + "port" => { + let index = port_variants() + .iter() + .position(|p| variant_path(p) == self.port_hint) + .unwrap_or(0); + Some(json!(index)) + } + "connect" => Some(json!(self.connect_requested)), + "level" => Some(json!(self.level)), + "max_level" => Some(json!(self.max_level)), + "method" => { + let index = DriveMethod::VARIANTS + .iter() + .position(|method| *method == self.method) + .unwrap_or(0); + Some(json!(index)) + } + "mode" => { + let index = Mode::VARIANTS + .iter() + .position(|m| *m == self.mode) + .unwrap_or(0); + Some(json!(index)) + } + "frequency_hz" => Some(json!(self.frequency_hz)), + "min_level" => Some(json!(self.min_level)), + "depth_a" => Some(json!(self.depth_a)), + "operating_point" => Some(json!(self.operating_point)), + // Through `effective_lobe`, so a lobe another instance just + // applied is what goes into the settings snapshot — not the stale + // pair that would overwrite it (see APPLIED_LOBE). + "v_null_dac" => Some(json!(self.effective_lobe().0)), + "v_peak_dac" => Some(json!(self.effective_lobe().1)), + "detector_geometry" => { + let index = calibration::DetectorGeometry::VARIANTS + .iter() + .position(|geometry| *geometry == self.detector_geometry) + .unwrap_or(0); + Some(json!(index)) + } + "calibration_dir" => Some(json!(self.calibration_dir)), + // Momentary buttons export a monotonic press counter so a press on + // the UI mirror reaches the live worker through the settings + // snapshot (ADR 010). + "calibrate" => Some(self.press_measure.value()), + "calibrate_apply" => Some(self.press_apply.value()), + // The live worker reports the actual run state; the UI mirror + // reports the operator's request so the settings snapshot can + // transport the start to the worker (which owns the device link). + _ => None, + } + } + + fn set_setting(&mut self, key: &str, value: Value) -> Result<(), String> { + // Catch up on a lobe applied elsewhere in this process before reading + // any of it, so an incoming echo of the old codes cannot land on top. + if self.adopt_applied_lobe() { + self.reconcile_drive(DriveKnob::Lobe); + } + if self.lease.is_some() { + return Err(format!( + "setting '{key}' is locked while automation holds the modulation lease" + )); + } + match key { + "port" => { + self.port_hint = variant_path(&enum_choice(&value, &port_variants())?).to_owned(); + Ok(()) + } + "connect" => { + let requested = value.as_bool().ok_or("connect must be a boolean")?; + self.connect_requested = requested; + if requested { + self.connect(); + } else { + self.disconnect(); + } + Ok(()) + } + "level" => { + self.level = value + .as_i64() + .ok_or("level must be an integer")? + .clamp(0, self.max_level); + if self.min_level > self.level { + self.min_level = self.level; + } + if self.method == DriveMethod::Manual { + self.send_modulation(); + } + Ok(()) + } + "max_level" => { + self.max_level = value + .as_i64() + .ok_or("max_level must be an integer")? + .clamp(0, MAX_DAC_CODE); + // Lowering the ceiling below the manual peak lowers that peak. + if self.level > self.max_level { + self.level = self.max_level; + } + if self.min_level > self.max_level { + self.min_level = self.max_level; + } + // The ceiling caps the reachable intensity, so it moves the + // optical range too. + self.reconcile_drive(DriveKnob::Lobe); + Ok(()) + } + "method" => { + let method_names: Vec = DriveMethod::VARIANTS + .iter() + .map(|method| method.name().to_owned()) + .collect(); + let name = enum_choice(&value, &method_names)?; + self.method = DriveMethod::from_name(&name) + .ok_or_else(|| format!("unknown drive method: {name}"))?; + self.reconcile_drive(DriveKnob::Lobe); + Ok(()) + } + "mode" => { + let mode_names: Vec = + Mode::VARIANTS.iter().map(|m| m.name().to_owned()).collect(); + let name = enum_choice(&value, &mode_names)?; + // Always accepted. Selecting a mode whose parameters do not + // fit yet used to snap the dropdown back with an error about a + // value the operator was not editing; now the mode takes and + // the parameters follow it. + self.mode = + Mode::from_name(&name).ok_or_else(|| format!("unknown mode: {name}"))?; + self.reconcile_drive(DriveKnob::Lobe); + Ok(()) + } + "frequency_hz" => { + let hz = value.as_f64().ok_or("frequency_hz must be a number")?; + 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(); + } + Ok(()) + } + "min_level" => { + self.min_level = value + .as_i64() + .ok_or("min_level must be an integer")? + .clamp(0, self.level); + if self.method == DriveMethod::Manual { + self.send_modulation(); + } + Ok(()) + } + "depth_a" => { + let depth_a = value + .as_f64() + .ok_or("depth_a must be a number")? + .clamp(waveform::DEPTH_A_MIN, waveform::DEPTH_A_MAX); + self.depth_a = depth_a; + self.reconcile_drive(DriveKnob::Depth); + Ok(()) + } + "operating_point" => { + self.operating_point = value + .as_f64() + .ok_or("operating_point must be a number")? + .clamp(waveform::MEAN_U_MIN, 1.0); + self.reconcile_drive(DriveKnob::Mean); + Ok(()) + } + "v_null_dac" => { + self.v_null_dac = value + .as_i64() + .ok_or("v_null_dac must be an integer")? + .clamp(0, MAX_DAC_CODE); + self.reconcile_drive(DriveKnob::Lobe); + Ok(()) + } + // `v_pi_dac` is the pre-endpoint key: a *distance* from V_null. Kept + // settable so a stored config still loads, converted on the way in. + // It is deliberately absent from `settings_schema`, so nothing new + // can be authored against the form that caused the mix-up. + "v_peak_dac" | "v_pi_dac" => { + let entered = value + .as_i64() + .ok_or("v_peak_dac must be an integer")? + .clamp(0, MAX_DAC_CODE); + let v_peak_dac = if key == "v_pi_dac" { + (self.v_null_dac + entered).clamp(0, MAX_DAC_CODE) + } else { + entered + }; + self.v_peak_dac = v_peak_dac; + self.reconcile_drive(DriveKnob::Lobe); + Ok(()) + } + "detector_geometry" => { + let variants: Vec = calibration::DetectorGeometry::VARIANTS + .iter() + .map(|geometry| geometry.name().to_owned()) + .collect(); + let chosen = enum_choice(&value, &variants)?; + self.detector_geometry = calibration::DetectorGeometry::from_name(&chosen) + .ok_or("unknown detector geometry")?; + // The stored fit was resolved against the old geometry; re-fit + // rather than leave a V_null that is now one half-wave-voltage span out. + if let Some(fit) = self.fit.take() { + match calibration::fit_transfer( + &fit.points, + f64::from(self.max_level.clamp(1, MAX_DAC_CODE) as u16), + self.detector_geometry, + ) { + Ok(refitted) => self.fit = Some(refitted), + Err(error) => self.calibration_status = format!("re-fit failed: {error}"), + } + } + Ok(()) + } + "calibration_dir" => { + self.calibration_dir = value + .as_str() + .ok_or("calibration_dir must be a string")? + .to_owned(); + Ok(()) + } + "calibrate" => { + if !self.press_measure.accept(&value) { + return Ok(()); + } + if self.sweep.is_some() { + self.finish_calibration_sweep("sweep stopped".into()); + } else { + self.start_calibration_sweep(); + } + self.shared.bump(); + Ok(()) + } + "calibrate_apply" => { + if !self.press_apply.accept(&value) { + return Ok(()); + } + self.apply_calibration_fit(); + Ok(()) + } + _ => Err(format!("unknown setting: {key}")), + } + } + + fn status_entries(&self) -> Vec { + let mut entries = Vec::new(); + let state = self.shared.state.lock().expect("device state lock"); + entries.push(StatusEntry::Text(if state.connected { + format!("Modulation: connected ({})", state.firmware) + } else if self.connect_requested { + "Modulation: connecting…".into() + } else { + "Modulation: disconnected".into() + })); + if let Some(code) = state.board_code { + entries.push(StatusEntry::Text(format!( + "Board: code={code} ({})", + state.board_mod + ))); + } + entries.push(StatusEntry::Text(format!( + "Drive: method={}, mode={}", + self.method.name(), + self.mode.name() + ))); + if self.method == DriveMethod::Calibrated || self.mode.optical_target().is_some() { + // Spell the lobe out in the operator's own units. A wrong endpoint + // shows up here immediately — the code u = 1 maps to is the code + // where the light should be brightest, and nothing in between may + // overshoot it. + entries.push(StatusEntry::Text(match self.resolved_lobe() { + Ok(lobe) => { + let inversion = lobe.inversion; + format!( + "Lobe: half-wave span {:.0} codes — u 0 → {:.0} (min light), 0.5 → {:.0}, \ + 1 → {:.0} (max light){}", + inversion.v_pi_dac, + inversion.dac_for_u(0.0), + inversion.dac_for_u(0.5), + inversion.dac_for_u(1.0), + if lobe.folded { + format!( + ", folded onto the ascending branch V_null {:.0} → V_peak {:.0} \ + (the pair was entered running downward in code)", + inversion.v_null_dac, + inversion.v_peak_dac() + ) + } else { + String::new() + } + ) + } + Err(error) => format!("Lobe invalid: {error}"), + })); + } + match self.dac_band() { + Ok((lo, hi, hold)) => entries.push(StatusEntry::Text(format!( + "Resolved DAC band: {lo}..{hi} (hold {hold}, {} codes peak-to-peak)", + hi.saturating_sub(lo) + ))), + Err(error) => entries.push(StatusEntry::Text(format!( + "Resolved DAC band invalid: {error}" + ))), + } + if let Some(target) = self.mode.optical_target() { + match (self.optical_warp_table(target), self.optical_drive(target)) { + (Ok(_), Ok(drive)) => { + entries.push(StatusEntry::Text(format!( + "{}: a={:.2}, ū={:.2}, internal u={:.2}, V_null={:.0}, V_peak={:.0} @ {:.3} Hz", + self.mode.name(), + drive.depth_a, + self.operating_point, + drive.operating_point, + drive.inversion.v_null_dac, + drive.inversion.v_peak_dac(), + self.frequency_hz, + ))); + } + (Err(error), _) | (_, Err(error)) => entries.push(StatusEntry::Text(format!( + "Optical drive not sent: {error}" + ))), + } + // The two optical controls are coupled through one ceiling, so the + // useful readout is not "that value is invalid" but where the + // boundary actually is. Edits clamp against exactly these numbers. + if let Some(range) = self.achievable() { + entries.push(StatusEntry::Text(format!( + "Achievable now: a ≤ {:.2} at ū={:.2} · ū ≤ {:.2} at a={:.2} \ + (peaks at u={:.2} of {:.2}; max limit {})", + range.max_depth_a, + self.operating_point, + range.max_mean_u, + self.depth_a, + range.peak_u, + range.u_max, + self.max_level, + ))); + } + } + if let Some(sweep) = self.sweep.as_ref() { + entries.push(StatusEntry::Text(format!( + "Calibration: sweeping {}/{}", + sweep.index + 1, + sweep.total() + ))); + } else if !self.calibration_status.is_empty() { + entries.push(StatusEntry::Text(format!( + "Calibration: {}", + self.calibration_status + ))); + } + if let Some(fit) = self.fit.as_ref() { + // The reject-port extremum bounds the anchor from below but is not + // the anchor: the residual transmitted floor is not separable here + // (knowledge base `pockels-waveform-linearisation.md` §4.4). + entries.push(StatusEntry::Text(format!( + "Detector at null: {:.3} V — lower bound on the total-power anchor I_tot, \ + not the anchor itself", + fit.detector_volts_at_null() + ))); + for warning in self.fit_warnings() { + entries.push(StatusEntry::Text(format!("Check: {warning}"))); + } + } + if let Some(calibration_id) = self.calibration_id.as_ref() { + entries.push(StatusEntry::Text(format!( + "Calibration in use: {calibration_id}" + ))); + } + if let Some(error) = state.last_error.clone().or_else(|| self.last_error.clone()) { + entries.push(StatusEntry::Text(format!("Error: {error}"))); + } + entries + } + + fn host_views(&self) -> HostViewRegistry { + HostViewRegistry { + datasets: vec![ + HostDatasetDescriptor { + id: STATUS_DATASET_ID.into(), + title: "Laser modulation".into(), + kind: HostDatasetKind::TableV1(self.status_schema()), + empty_message: "Modulation control idle.".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: CURVE_DATASET_ID.into(), + title: "Pockels transfer curve".into(), + kind: HostDatasetKind::Series1dV1, + empty_message: "Set V_null and V_peak, or measure a transfer curve.".into(), + display: None, + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: STATUS_VIEW_ID.into(), + title: "Laser modulation".into(), + dataset_id: STATUS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + HostViewDescriptor { + id: CURVE_VIEW_ID.into(), + title: "Pockels transfer curve".into(), + dataset_id: CURVE_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::LineSeriesWindow, + }, + ], + 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(), + CURVE_DATASET_ID => serde_json::to_vec(&self.curve_dataset()).ok(), + _ => None, + } + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + match dataset_id { + STATUS_DATASET_ID | CURVE_DATASET_ID => { + self.shared.generation.load(Ordering::Relaxed).max(1) + } + _ => 0, + } + } +} + +impl Drop for StageAModulationPlugin { + fn drop(&mut self) { + self.disconnect(); + } +} + +export_plugin!(StageAModulationPlugin); + +#[cfg(test)] +mod tests { + use super::*; + use augur_plugin_api::{ExecutionContext, ExecutionMode}; + + fn live_execution() -> ExecutionContext { + ExecutionContext { + mode: ExecutionMode::LiveCapture, + effects_allowed: true, + session_id: Some("test".into()), + } + } + + fn service_request( + plugin: &StageAModulationPlugin, + id: u64, + requester: &str, + command: ModulationCommandV1, + revision: Option, + ) -> PluginServiceRequest { + let mut payload = ModulationRequestV1::new( + stage_a_plugin_contract::RequestId(id), + ClientId::from(requester), + command, + ); + payload.target_owner_instance = Some(plugin.owner_instance.clone()); + payload.run_id = Some(RunId::from("run-a")); + payload.lease_id = Some(LeaseId::from("lease-a")); + payload.requested_revision = revision.map(SemanticRevision); + payload.issued_at_unix_ms = now_unix_ms(); + PluginServiceRequest { + request_id: id, + source_plugin_id: requester.into(), + target_plugin_id: PLUGIN_ID_STAGE_A_MODULATION.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + payload: serde_json::to_value(payload).unwrap(), + } + } + + fn live_plugin() -> StageAModulationPlugin { + let mut plugin = StageAModulationPlugin::default(); + plugin.set_runtime_role(PluginRuntimeRole::LiveWorker); + plugin.effects_allowed = true; + plugin + } + + fn wait_until bool>( + plugin: &StageAModulationPlugin, + timeout: Duration, + done: F, + ) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if done(plugin) { + return; + } + std::thread::sleep(Duration::from_millis(2)); + } + panic!("condition not reached within {timeout:?}"); + } + + fn board_code(plugin: &StageAModulationPlugin) -> Option { + plugin.shared.state.lock().unwrap().board_code + } + + /// Connect checkbox → slider change → MOD sent by the device thread → + /// board echoes the code. No process_frame involved anywhere. + #[test] + fn level_change_transfers_without_frames() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + assert_eq!( + plugin.shared.state.lock().unwrap().firmware, + "0.3.0-mock".to_owned() + ); + + plugin.set_setting("level", json!(1234)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(1234) + }); + assert!(plugin.shared.state.lock().unwrap().last_error.is_none()); + + plugin.set_setting("connect", json!(false)).unwrap(); + assert!(!plugin.device_connected()); + } + + /// The max cap bounds the slider, and lowering it re-sends a lower level. + #[test] + fn max_level_caps_the_slider() { + let mut plugin = live_plugin(); + plugin.set_setting("max_level", json!(1000)).unwrap(); + plugin.set_setting("level", json!(4095)).unwrap(); + assert_eq!(plugin.level, 1000, "slider clamps to the cap"); + + plugin.set_setting("max_level", json!(500)).unwrap(); + assert_eq!(plugin.level, 500, "lowering the cap lowers the level"); + + let schema = plugin.settings_schema(); + let level_item = schema.sections[0] + .items + .iter() + .find(|item| item.key == "level") + .expect("level setting exists"); + match &level_item.kind { + SettingKind::I64Slider { max, .. } => assert_eq!(*max, 500), + other => panic!("level must stay a slider, got {other:?}"), + } + } + + /// Square drive with min threshold reaches the mock and starts at min; + /// slider to 0 drives the output to 0. + #[test] + fn square_with_min_threshold_round_trips() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + + plugin.set_setting("level", json!(2000)).unwrap(); + plugin.set_setting("frequency_hz", json!(10.0)).unwrap(); + plugin.set_setting("min_level", json!(500)).unwrap(); + plugin.set_setting("mode", json!("SQUARE")).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(500) + }); + assert!(plugin + .shared + .state + .lock() + .unwrap() + .board_mod + .contains("SQUARE 500..2000")); + + plugin.set_setting("mode", json!("CONST")).unwrap(); + plugin.set_setting("level", json!(0)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(0) + }); + plugin.set_setting("connect", json!(false)).unwrap(); + } + + /// The host settings UI exchanges enum values as indices into the + /// schema's variant list (radio buttons send `json!(index)`). + #[test] + fn enum_settings_round_trip_as_indices() { + let mut plugin = live_plugin(); + // Drive method: index 1 = CALIBRATED. + plugin + .set_setting("method", json!(1)) + .expect("method index accepted"); + assert_eq!(plugin.method, DriveMethod::Calibrated); + assert_eq!(plugin.get_setting("method"), Some(json!(1))); + // Mode: index 2 = SQUARE in the schema's variant order. + plugin + .set_setting("mode", json!(2)) + .expect("index accepted"); + assert_eq!(plugin.mode, Mode::Square); + assert_eq!(plugin.get_setting("mode"), Some(json!(2))); + // Port: index 1 = "mock" (variants start with auto, mock). + plugin + .set_setting("port", json!(1)) + .expect("index accepted"); + assert_eq!(plugin.port_hint, "mock"); + assert_eq!(plugin.get_setting("port"), Some(json!(1))); + // Out-of-range indices are visible errors, not silent no-ops. + assert!(plugin.set_setting("mode", json!(99)).is_err()); + assert!(plugin.set_setting("method", json!(99)).is_err()); + // String names keep working (tests, saved configs). + plugin + .set_setting("mode", json!("SINE")) + .expect("name accepted"); + assert_eq!(plugin.mode, Mode::Sine); + plugin + .set_setting("method", json!("MANUAL")) + .expect("method name accepted"); + assert_eq!(plugin.method, DriveMethod::Manual); + } + + #[test] + fn method_switches_only_its_settings_block() { + let mut plugin = live_plugin(); + let keys = |plugin: &StageAModulationPlugin| { + plugin.settings_schema().sections[0] + .items + .iter() + .map(|item| item.key.clone()) + .collect::>() + }; + + let manual = keys(&plugin); + assert_eq!( + &manual[..6], + [ + "port", + "connect", + "max_level", + "method", + "mode", + "frequency_hz" + ] + ); + assert!(manual.iter().any(|key| key == "level")); + assert!(manual.iter().any(|key| key == "min_level")); + assert!(!manual.iter().any(|key| key == "depth_a")); + assert!(!manual.iter().any(|key| key == "operating_point")); + assert!(!manual.iter().any(|key| key == "v_null_dac")); + assert!(!manual.iter().any(|key| key == "v_peak_dac")); + + let schema = plugin.settings_schema(); + let mode = schema.sections[0] + .items + .iter() + .find(|item| item.key == "mode") + .expect("mode setting"); + match &mode.kind { + SettingKind::Enum { variants, .. } => { + assert_eq!(variants.len(), 5, "all modes stay available"); + } + other => panic!("mode must be an enum, got {other:?}"), + } + + plugin.last_error = Some("stale".into()); + plugin.set_setting("method", json!(1)).unwrap(); + assert!( + plugin.last_error.is_none(), + "method change clears stale errors" + ); + let calibrated = keys(&plugin); + assert_eq!( + &calibrated[..6], + [ + "port", + "connect", + "max_level", + "method", + "mode", + "frequency_hz" + ] + ); + assert!(!calibrated.iter().any(|key| key == "level")); + assert!(!calibrated.iter().any(|key| key == "min_level")); + assert!(calibrated.iter().any(|key| key == "depth_a")); + assert!(calibrated.iter().any(|key| key == "operating_point")); + assert!(calibrated.iter().any(|key| key == "v_null_dac")); + assert!(calibrated.iter().any(|key| key == "v_peak_dac")); + } + + #[test] + fn drive_method_resolves_manual_and_calibrated_bands() { + let mut plugin = live_plugin(); + plugin.level = 1_500; + plugin.min_level = 600; + assert_eq!(plugin.dac_band().unwrap(), (600, 1_500, 1_500)); + + plugin.method = DriveMethod::Calibrated; + // The ±a/2 band only exists for modulating modes (Const resolves to a + // pure hold since the full-lobe fix). + plugin.mode = Mode::Sine; + plugin.v_null_dac = 200; + plugin.v_peak_dac = 1_800; + plugin.operating_point = 0.4; + plugin.depth_a = 0.8; + let inversion = plugin.lobe_inversion().expect("a real lobe"); + let expected_lo = inversion + .dac_for_u(plugin.operating_point * (-0.5 * plugin.depth_a).exp()) + .round() as i64; + let expected_hi = inversion + .dac_for_u(plugin.operating_point * (0.5 * plugin.depth_a).exp()) + .round() as i64; + let expected_hold = inversion.dac_for_u(plugin.operating_point).round() as i64; + assert_eq!( + plugin.dac_band().unwrap(), + (expected_lo, expected_hi, expected_hold) + ); + + // The ceiling still bites the emitted codes — but the lobe itself stays + // valid, so a MANUAL band under the ceiling keeps working. + plugin.max_level = expected_hi - 1; + assert!(plugin + .dac_band() + .unwrap_err() + .contains("above the max limit")); + plugin.method = DriveMethod::Manual; + plugin.level = plugin.max_level; + assert!( + plugin.dac_band().is_ok(), + "a manual band inside the ceiling" + ); + } + + #[test] + fn manual_optical_drive_is_derived_from_the_slider_band() { + let mut plugin = live_plugin(); + plugin.v_null_dac = 200; + plugin.v_peak_dac = 1_800; + plugin.min_level = 600; + plugin.level = 1_500; + plugin.depth_a = 5.0; + plugin.operating_point = 0.9; + + for target in [ + waveform::OpticalTarget::LogSine, + waveform::OpticalTarget::LinearSine, + ] { + let drive = plugin.optical_drive(target).expect("a real lobe"); + let table = plugin + .optical_warp_table(target) + .expect("valid manual band"); + let min = table.iter().copied().min().unwrap(); + let max = table.iter().copied().max().unwrap(); + assert!((i64::from(min) - plugin.min_level).abs() <= 1); + assert!((i64::from(max) - plugin.level).abs() <= 1); + assert_ne!(drive.depth_a, plugin.depth_a); + assert_ne!(drive.operating_point, plugin.operating_point); + } + } + + #[test] + fn every_mode_drives_under_both_methods() { + 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.device_connected() + }); + plugin.max_level = MAX_DAC_CODE; + plugin.min_level = 600; + plugin.level = 1_500; + plugin.v_null_dac = 200; + plugin.v_peak_dac = 1_800; + plugin.operating_point = 0.4; + plugin.depth_a = 0.8; + + for method in DriveMethod::VARIANTS { + plugin.method = method; + for mode in Mode::VARIANTS { + plugin.mode = mode; + plugin.shared.state.lock().unwrap().board_mod.clear(); + plugin.send_modulation(); + assert!( + plugin.last_error.is_none(), + "{} {}: {:?}", + method.name(), + mode.name(), + plugin.last_error + ); + let expected_wave = match mode { + Mode::Const => "CONST", + Mode::Sine => "SINE", + Mode::Square => "SQUARE", + Mode::OpticalLogSine | Mode::OpticalLinearSine => "WARP", + }; + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .board_mod + .starts_with(expected_wave) + }); + let board_mod = plugin.shared.state.lock().unwrap().board_mod.clone(); + assert!( + board_mod.starts_with(expected_wave), + "{} {} produced {board_mod}", + method.name(), + mode.name() + ); + } + } + plugin.disconnect(); + } + + /// min_level can never exceed the level. + #[test] + fn min_threshold_is_clamped_to_level() { + let mut plugin = live_plugin(); + plugin.set_setting("level", json!(1000)).unwrap(); + plugin.set_setting("min_level", json!(3000)).unwrap(); + assert_eq!(plugin.min_level, 1000); + plugin.set_setting("level", json!(200)).unwrap(); + assert_eq!(plugin.min_level, 200, "lowering level drags min down"); + } + + #[test] + fn ui_mirror_never_opens_the_command_port() { + let mut plugin = StageAModulationPlugin::default(); + plugin.port_hint = "mock".into(); + plugin.set_setting("connect", json!(true)).unwrap(); + assert!(plugin.link.is_none()); + assert!(!plugin.device_connected()); + assert!(matches!( + plugin + .handle_service_request( + &service_request( + &plugin, + 1, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ), + &live_execution(), + ) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + } + + /// Runs the sweep to completion against the mock board, synthesizing the + /// light the reject-port photodiode *would* report for whatever code the + /// board is actually holding. The fit must then recover the synthetic + /// lobe, which makes this a ground-truth check of the whole loop: + /// commanding, settle gating, point collection, and the fit. + fn run_sweep_against_mock(plugin: &mut StageAModulationPlugin, v_null: f64, v_pi: f64) { + let mut sample_index = 0_u64; + for _ in 0..4_000 { + let Some((code, _)) = plugin.sweep.as_ref().and_then(CalibrationSweep::current) else { + break; + }; + // Only report light once the board actually holds the commanded + // code. On the bench the settle margin covers the serial + // round-trip; here it is asserted, so a level can never be + // attributed to a code the board had not reached. + if plugin + .sweep + .as_ref() + .is_some_and(|sweep| sweep.commanded_at_sample.is_some()) + { + wait_until(plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(i64::from(code)) + }); + } + let held = board_code(plugin).unwrap_or(0) as f64; + let u = (std::f64::consts::PI * (held - v_null) / (2.0 * v_pi)) + .sin() + .powi(2); + sample_index += SETTLE_SAMPLES; + plugin.drive_calibration( + Some(PhotodiodeLevelV1 { + // Reject port: brightest at the excitation null. + mean_volts: 2.4 - 2.2 * u, + peak_to_peak_volts: 0.001, + sample_count: SETTLE_SAMPLES, + end_sample_index: sample_index, + clipped: false, + }), + None, + ); + } + } + + #[test] + fn sweep_recovers_a_synthetic_lobe_and_restores_the_armed_drive() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + // Arm a drive the sweep must put back afterwards. + plugin.set_setting("level", json!(1_234)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(1_234) + }); + + plugin.set_setting("calibrate", json!(true)).unwrap(); + assert!(plugin.sweep.is_some(), "sweep started"); + run_sweep_against_mock(&mut plugin, 300.0, 1_600.0); + + assert!(plugin.sweep.is_none(), "sweep ran to completion"); + let fit = plugin.fit.as_ref().expect("produced a fit"); + assert!( + (fit.v_null_dac - 300.0).abs() < 5.0, + "V_null {}", + fit.v_null_dac + ); + assert!((fit.v_pi_dac - 1_600.0).abs() < 10.0, "Vπ {}", fit.v_pi_dac); + assert_eq!(fit.points.len(), SWEEP_POINTS_PER_PASS * 2); + + // The armed drive is back on the board: a calibration sweep must leave + // the bench as it found it. + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(1_234) + }); + + // Applying writes the lobe through and publishes a calibration id. + assert!( + plugin.fit_warnings().is_empty(), + "{:?}", + plugin.fit_warnings() + ); + plugin.set_setting("calibrate_apply", json!(true)).unwrap(); + assert_eq!(plugin.v_null_dac, 300); + assert!((plugin.v_peak_dac - plugin.v_null_dac - 1_600).abs() <= 10); + assert!(plugin.calibration_id.is_some()); + assert!(plugin.control_state().calibration_id.is_some()); + + // And the UI mirror — the instance the host actually collects the + // settings snapshot from — reports the applied lobe rather than its own + // stale codes. Without this the next sync wrote the old pair straight + // back onto the worker and the button looked like it did nothing. + let mut mirror = StageAModulationPlugin::default(); + mirror.set_runtime_role(PluginRuntimeRole::UiMirror); + assert_eq!( + mirror.get_setting("v_null_dac"), + Some(json!(plugin.v_null_dac)), + "the mirror kept exporting a pre-calibration V_null" + ); + assert_eq!( + mirror.get_setting("v_peak_dac"), + Some(json!(plugin.v_peak_dac)) + ); + // Adoption is one-way and settles: once taken on, the mirror is free to + // be edited again without the applied lobe snapping back. + mirror.set_setting("v_null_dac", json!(111)).unwrap(); + assert_eq!(mirror.get_setting("v_null_dac"), Some(json!(111))); + } + + /// The host re-applies the **whole** settings snapshot on every sync, and + /// most drive handlers push to the board unconditionally. Without a guard + /// each sync re-arms the operator's waveform on top of the code the sweep + /// just commanded, so every point measures the armed drive instead of the + /// staircase and the fit sees a flat curve. + #[test] + fn a_settings_sync_during_a_sweep_does_not_re_arm_the_operator_drive() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + // Arm a periodic drive, as an operator would before calibrating. + let sine = Mode::VARIANTS + .iter() + .position(|m| *m == Mode::Sine) + .unwrap(); + plugin.set_setting("level", json!(3_000)).unwrap(); + plugin.set_setting("min_level", json!(1_000)).unwrap(); + plugin.set_setting("mode", json!(sine)).unwrap(); + // Let the device thread drain the armed drive, so anything still queued + // below is something the sync put there. + wait_until(&plugin, Duration::from_secs(2), |p| { + p.shared.pending.lock().unwrap().is_none() + }); + + plugin.set_setting("calibrate", json!(true)).unwrap(); + assert!(plugin.sweep.is_some()); + + // Exactly what `apply_live_plugin_snapshot` does: write every key back. + let resync = |plugin: &mut StageAModulationPlugin| { + for key in [ + "frequency_hz", + "level", + "max_level", + "method", + "min_level", + "mode", + "v_null_dac", + "v_peak_dac", + ] { + let value = plugin.get_setting(key).expect("exported"); + plugin.set_setting(key, value).expect("re-applies"); + } + }; + resync(&mut plugin); + assert!( + plugin.shared.pending.lock().unwrap().is_none(), + "a settings sync queued a drive while the sweep owned the DAC" + ); + + // With the sync fighting it on every tick, the sweep must still see the + // staircase and produce a usable fit. + let mut sample_index = 0_u64; + for _ in 0..4_000 { + let Some((code, _)) = plugin.sweep.as_ref().and_then(CalibrationSweep::current) else { + break; + }; + resync(&mut plugin); + if plugin + .sweep + .as_ref() + .is_some_and(|sweep| sweep.commanded_at_sample.is_some()) + { + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p) == Some(i64::from(code)) + }); + } + let held = board_code(&plugin).unwrap_or(0) as f64; + let u = (std::f64::consts::PI * (held - 300.0) / 3_200.0) + .sin() + .powi(2); + sample_index += SETTLE_SAMPLES; + plugin.drive_calibration( + Some(PhotodiodeLevelV1 { + mean_volts: 2.4 - 2.2 * u, + peak_to_peak_volts: 0.001, + sample_count: SETTLE_SAMPLES, + end_sample_index: sample_index, + clipped: false, + }), + None, + ); + } + + let fit = plugin + .fit + .as_ref() + .unwrap_or_else(|| panic!("no fit: {}", plugin.calibration_status)); + assert!( + (fit.v_null_dac - 300.0).abs() < 5.0, + "V_null {}", + fit.v_null_dac + ); + + // The armed sine comes back once the sweep releases the DAC. + wait_until(&plugin, Duration::from_secs(2), |p| { + board_code(p).is_some_and(|code| code != 0) + }); + assert_eq!(plugin.mode, Mode::Sine); + } + + #[test] + fn sweep_waits_for_a_window_measured_after_the_code_was_commanded() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + plugin.set_setting("calibrate", json!(true)).unwrap(); + + let stale = |end_sample_index| PhotodiodeLevelV1 { + mean_volts: 1.0, + peak_to_peak_volts: 0.001, + sample_count: 100, + end_sample_index, + clipped: false, + }; + // First tick commands the point and adopts the sample index. + plugin.drive_calibration(Some(stale(10_000)), None); + assert_eq!(plugin.sweep.as_ref().unwrap().points.len(), 0); + // A window that began before the command must not be accepted, however + // many times it arrives — this is what makes settling provable. + for _ in 0..5 { + plugin.drive_calibration(Some(stale(10_050)), None); + } + assert_eq!(plugin.sweep.as_ref().unwrap().points.len(), 0); + // Once the window starts past the settle margin the point is taken. + plugin.drive_calibration(Some(stale(10_000 + SETTLE_SAMPLES + 100)), None); + assert_eq!(plugin.sweep.as_ref().unwrap().points.len(), 1); + } + + #[test] + fn sweep_is_refused_while_automation_holds_the_lease() { + let mut plugin = live_plugin(); + plugin.set_setting("port", json!("mock")).unwrap(); + plugin.set_setting("connect", json!(true)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |p| p.device_connected()); + plugin.handle_service_request( + &service_request( + &plugin, + 1, + "stage-a-a1", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ), + &live_execution(), + ); + assert!(plugin.lease.is_some()); + // Two owners stepping the same DAC would interleave silently. + plugin.start_calibration_sweep(); + assert!(plugin.sweep.is_none()); + assert!( + plugin.calibration_status.contains("leased"), + "{}", + plugin.calibration_status + ); + } + + fn synthetic_fit( + span: f64, + max_code: u16, + edit: impl Fn(&mut calibration::SweepPoint, usize), + ) -> calibration::TransferFit { + let points: Vec = calibration::sweep_codes(max_code, 49, false) + .into_iter() + .enumerate() + .map(|(index, (code, direction))| { + let u = (std::f64::consts::PI * (f64::from(code) - 300.0) / 1_720.0) + .sin() + .powi(2); + let mut point = calibration::SweepPoint { + code, + direction, + volts: 0.098 + span * u, + peak_to_peak_volts: 0.001, + clipped: false, + }; + edit(&mut point, index); + point + }) + .collect(); + calibration::fit_transfer( + &points, + f64::from(max_code), + calibration::DetectorGeometry::RejectedComplement, + ) + .expect("fits") + } + + /// A stray sample inflates the RMS residual several fold while leaving the + /// fitted period accurate. Dropping the wild points keeps the reported + /// residual describing the curve instead of the worst sample. + #[test] + fn a_stray_point_is_dropped_instead_of_ruining_the_fit() { + let clean = synthetic_fit(-0.090, 4_095, |_, _| {}); + let strayed = synthetic_fit(-0.090, 4_095, |point, index| { + if index == 20 { + point.volts += 0.09; + } + }); + + assert_eq!(clean.rejected_points, 0); + assert_eq!(strayed.rejected_points, 1, "the stray should be dropped"); + assert!( + (strayed.v_pi_dac - clean.v_pi_dac).abs() < 5.0, + "Vpi moved from {} to {}", + clean.v_pi_dac, + strayed.v_pi_dac + ); + assert!( + strayed.quality < 0.02, + "residual still dominated by the stray: {:.1}%", + strayed.quality * 100.0 + ); + // The plot still shows every measured point, stray included. + assert_eq!(strayed.points.len(), 49); + } + + /// A poor residual is the operator's call, made against the plot — it warns + /// but never blocks, because a stray sample can inflate it while the fitted + /// lobe stays good. The one genuinely meaningless case, a lobe that does not + /// fit inside the commandable range, is refused by the fit itself. + #[test] + fn a_scattered_or_clipped_fit_warns_but_still_applies() { + let mut plugin = live_plugin(); + plugin.fit = Some(synthetic_fit(-0.090, 4_095, |point, index| { + point.clipped = point.code < 100; + if index % 7 == 0 { + point.volts += 0.004; + } + })); + + let warnings = plugin.fit_warnings().join(" | "); + assert!( + warnings.contains("end of the detector's range"), + "{warnings}" + ); + // Rail-touching points are a caveat on the reported extrema, not a + // verdict on the lobe, and the advice must not send the operator to add + // attenuation when the detector is against its *bottom* rail. + assert!( + warnings.contains("V_null and V_peak are unaffected"), + "{warnings}" + ); + assert!(!warnings.contains("attenuation"), "{warnings}"); + + plugin.set_setting("calibrate_apply", json!(true)).unwrap(); + assert!( + plugin.calibration_id.is_some(), + "{}", + plugin.calibration_status + ); + assert!( + (plugin.v_peak_dac - plugin.v_null_dac - 860).abs() <= 10, + "Vpi {}", + plugin.v_peak_dac - plugin.v_null_dac + ); + } + + /// Noisy points must not be reported as a drifting cell. + /// + /// Two independent passes over one curve already differ by `1.128 σ` on + /// average, so a bare 5 % cut fires on any bench whose points are not far + /// quieter than that — and it did, on a real recorded sweep whose cell was + /// not drifting at all. + #[test] + fn hysteresis_at_the_noise_floor_is_not_reported_as_drift() { + // A two-pass sweep of one 90 mV lobe, scattered by an alternating ±4 mV + // that is noise and nothing else. The raw metric reads well past + // WARN_HYSTERESIS; the passes still disagree by nothing but their own + // noise, so there is no drift to report. + let two_pass = |offset_descending: f64| -> calibration::TransferFit { + let points: Vec = calibration::sweep_codes(4_095, 49, true) + .into_iter() + .map(|(code, direction)| { + let u = (std::f64::consts::PI * (f64::from(code) - 300.0) / 1_720.0) + .sin() + .powi(2); + let scatter = 0.012 * calibration::scatter(code, direction); + let drift = if direction == calibration::Direction::Descending { + offset_descending + } else { + 0.0 + }; + calibration::SweepPoint { + code, + direction, + volts: 0.098 - 0.090 * u + scatter + drift, + peak_to_peak_volts: 0.001, + clipped: false, + } + }) + .collect(); + calibration::fit_transfer( + &points, + 4_095.0, + calibration::DetectorGeometry::RejectedComplement, + ) + .expect("fits") + }; + + let mut plugin = live_plugin(); + let scattered = two_pass(0.0); + assert!( + scattered.hysteresis.expect("both directions") > WARN_HYSTERESIS, + "the raw metric must be past the bare threshold for this test to mean anything" + ); + plugin.fit = Some(scattered); + assert!( + !plugin.fit_warnings().join(" | ").contains("drifting"), + "{}", + plugin.fit_warnings().join(" | ") + ); + + // A systematic offset between the passes is real drift and must still + // be called out. + plugin.fit = Some(two_pass(-0.030)); + assert!( + plugin.fit_warnings().join(" | ").contains("drifting"), + "{}", + plugin.fit_warnings().join(" | ") + ); + } + + fn calibration_button(plugin: &StageAModulationPlugin, key: &str) -> SettingKind { + plugin + .settings_schema() + .sections + .iter() + .flat_map(|section| section.items.iter()) + .find(|item| item.key == key) + .unwrap_or_else(|| panic!("{key} is in the schema")) + .kind + .clone() + } + + /// The UI mirror renders the settings schema, and it never owns the device + /// link, a lease, a sweep, or a fit. Gating `enabled` on any of those + /// disables the buttons permanently — the operator can never start. + #[test] + fn calibration_buttons_are_offered_on_the_ui_mirror() { + let mut mirror = StageAModulationPlugin::default(); + assert_eq!(mirror.runtime_role, PluginRuntimeRole::UiMirror); + mirror.port_hint = "mock".into(); + + for key in ["calibrate", "calibrate_apply"] { + assert!( + matches!( + calibration_button(&mirror, key), + SettingKind::Button { enabled: false } + ), + "{key} should be off before the operator asks to connect" + ); + } + + mirror.set_setting("connect", json!(true)).unwrap(); + // The mirror deliberately never opens the port... + assert!(mirror.link.is_none()); + // ...but the buttons must still be pressable, because the worker — not + // the mirror — owns the link and enforces the real interlocks. + for key in ["calibrate", "calibrate_apply"] { + assert!( + matches!( + calibration_button(&mirror, key), + SettingKind::Button { enabled: true } + ), + "{key} is disabled on the mirror, so it can never be pressed" + ); + } + } + + /// A press is transported mirror → worker as a monotonic counter. The + /// worker adopts the counter it first sees as a baseline so a reload does + /// not replay old presses — but that baseline must not swallow the + /// operator's first real press. + #[test] + fn a_forwarded_press_reaches_a_freshly_loaded_worker() { + let mut mirror = StageAModulationPlugin::default(); + let mut worker = live_plugin(); + worker.port_hint = "mock".into(); + worker.set_setting("connect", json!(true)).unwrap(); + wait_until(&worker, Duration::from_secs(2), |p| p.device_connected()); + + // The host syncs the settings snapshot before anything is clicked. + let sync = |worker: &mut StageAModulationPlugin, mirror: &StageAModulationPlugin| { + let value = mirror.get_setting("calibrate").expect("exported"); + worker.set_setting("calibrate", value).unwrap(); + }; + sync(&mut worker, &mirror); + assert!( + worker.sweep.is_none(), + "a plain sync must not start a sweep" + ); + + // First real click on the mirror, then the next settings sync. + mirror.set_setting("calibrate", json!(true)).unwrap(); + sync(&mut worker, &mirror); + assert!( + worker.sweep.is_some(), + "the operator's first press never reached the worker" + ); + + // Re-syncing the same counter must not re-trigger. + sync(&mut worker, &mirror); + assert!(worker.sweep.is_some()); + // A second click stops it, proving the toggle survives the transport. + mirror.set_setting("calibrate", json!(true)).unwrap(); + sync(&mut worker, &mirror); + assert!( + worker.sweep.is_none(), + "second press should abort the sweep" + ); + } + + #[test] + fn the_curve_view_shows_the_configured_lobe_before_any_measurement() { + let mut plugin = live_plugin(); + plugin.set_setting("v_null_dac", json!(400)).unwrap(); + plugin.set_setting("v_peak_dac", json!(1_300)).unwrap(); + let curve = plugin.curve_dataset(); + // Normalised until something has actually been measured. + assert!(curve.y_label.contains("normalised")); + let names: Vec<&str> = curve.lines.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["configured lobe", "V_null", "V_peak"]); + let lobe = &curve.lines[0].points; + // Minimum at V_null, maximum one half-wave-voltage span later. + let at = |code: f64| { + lobe.iter() + .min_by(|a, b| (a.x - code).abs().total_cmp(&(b.x - code).abs())) + .expect("sampled") + .y + }; + assert!(at(400.0) < 0.01, "u at V_null = {}", at(400.0)); + assert!(at(1_300.0) > 0.99, "u at V_null+Vπ = {}", at(1_300.0)); + } + + /// The bench failure of 2026-07-28: brightest light at `u = 0.5` and a + /// null at `u = 1`, because the *code* of the maximum was entered where a + /// distance from `V_null` was expected. With both endpoints being codes, + /// `u` cannot turn over — `u = 1` lands on the measured maximum. + #[test] + fn u_rises_all_the_way_to_the_measured_maximum() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::Const; + // Codes read off the bench: dimmest at 1600, brightest at 3200. + plugin.v_null_dac = 1_600; + plugin.v_peak_dac = 3_200; + + let truth = waveform::LobeInversion { + v_null_dac: 1_600.0, + v_pi_dac: 1_600.0, + }; + let mut previous = f64::MIN; + for step in 1..=100 { + plugin.operating_point = f64::from(step) / 100.0; + let (_, _, hold) = plugin.dac_band().expect("every u is drivable"); + let light = truth.u_for_dac(hold as f64); + assert!( + light >= previous - 1e-6, + "light turned over at u = {}: {light}", + plugin.operating_point + ); + previous = light; + } + assert!(previous > 0.999, "u = 1 is not the maximum: {previous}"); + plugin.operating_point = 1.0; + assert_eq!(plugin.dac_band().unwrap().2, 3_200); + } + + #[test] + fn a_stored_quarter_wave_migrates_to_the_peak_code() { + // Configs written before the endpoint form hold `v_pi_dac`, a distance. + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.v_null_dac = 1_630; + plugin + .set_setting("v_pi_dac", json!(860)) + .expect("migrates"); + assert_eq!(plugin.v_peak_dac, 2_490); + // And it is gone from the schema, so nothing new is authored against it. + let keys: Vec = plugin + .settings_schema() + .sections + .iter() + .flat_map(|section| section.items.iter().map(|item| item.key.clone())) + .collect(); + assert!(!keys.iter().any(|key| key == "v_pi_dac")); + } + + #[test] + fn the_status_pane_spells_out_where_u_lands() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.v_null_dac = 1_630; + plugin.v_peak_dac = 2_490; + let status = plugin + .status_entries() + .iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" | "); + assert!(status.contains("half-wave span 860 codes"), "{status}"); + assert!(status.contains("0 → 1630"), "{status}"); + assert!(status.contains("1 → 2490"), "{status}"); + + // A pair entered running downward is reported as folded, not silently + // driven on a branch the operator did not name. + plugin.v_null_dac = 3_000; + plugin.v_peak_dac = 2_000; + let status = plugin + .status_entries() + .iter() + .filter_map(|entry| match entry { + StatusEntry::Text(text) => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" | "); + assert!(status.contains("folded"), "{status}"); + assert!(status.contains("1 → 2000"), "{status}"); + } + + #[test] + fn calibrated_const_hold_spans_the_full_lobe_without_a_headroom() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::Const; + plugin.v_null_dac = 1_630; + plugin.v_peak_dac = 2_490; + plugin.depth_a = 0.5; // must be irrelevant for a constant hold + + // u = 1 holds exactly at V_null + Vπ (previously rejected because + // the modulated band u·e^{a/2} > 1 was demanded even for CONST). + plugin.operating_point = 1.0; + let (lo, hi, hold) = plugin.dac_band().expect("full-lobe hold"); + assert_eq!((lo, hi, hold), (2_490, 2_490, 2_490)); + + // The user's measured low point: dac_for_u(0.01) ≈ 1685. + plugin.operating_point = 0.01; + let (_, _, hold) = plugin.dac_band().expect("low hold"); + assert_eq!(hold, 1_685); + + // Modulating modes still require the ±a/2 headroom. + plugin.mode = Mode::Sine; + plugin.operating_point = 1.0; + assert!(plugin.dac_band().is_err()); + } + + #[test] + fn optical_linear_sine_uses_linear_not_logarithmic_headroom() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLinearSine; + plugin.v_null_dac = 400; + plugin.v_peak_dac = 1_300; + plugin.operating_point = 0.4; + plugin.depth_a = 2.0; + + // Linear target: u_hi = 0.4 * (1 + tanh(1)) ≈ 0.705, which fits. + // Reusing log-sine endpoints would incorrectly test + // 0.4 * exp(1) ≈ 1.087 and reject this valid drive. + let (_, hi, _) = plugin.dac_band().expect("linear target fits the lobe"); + let expected = plugin + .lobe_inversion() + .expect("a real lobe") + .dac_for_u(0.4 * (1.0 + 1.0_f64.tanh())) + .round() as i64; + assert_eq!(hi, expected); + } + + #[test] + fn control_state_publishes_exact_optical_drive_provenance() { + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.v_null_dac = 400; + plugin.v_peak_dac = 1_300; + plugin.operating_point = 0.4; + plugin.depth_a = 1.0; + plugin.calibration_id = Some("cal-test".into()); + + let state = plugin.control_state(); + let drive = state.optical_drive.expect("optical provenance"); + assert_eq!(drive.target, OpticalTargetV1::LogSine); + assert_eq!(drive.requested_mean_u_milli, 400); + assert_eq!( + drive.internal_u_milli, + (waveform::log_sine_geometric_pedestal(0.4, 1.0) * 1_000.0).round() as u32 + ); + assert_eq!( + drive.resolved_mean_u_milli, + (waveform::log_sine_cycle_mean(f64::from(drive.internal_u_milli) / 1_000.0, 1.0,) + * 1_000.0) + .round() as u32 + ); + assert_eq!(drive.depth_a_milli, 1_000); + assert_eq!(drive.v_null_dac, 400); + assert_eq!(drive.v_peak_dac, 1_300); + assert_eq!(state.calibration_id.as_deref(), Some("cal-test")); + } + + #[test] + fn an_out_of_range_operating_point_is_clamped_not_rejected() { + // Refusing the edit and snapping the control back is what made these + // two coupled controls feel broken: the operator was told a value they + // were not editing was wrong, with no indication of where the boundary + // is. The edit lands on the boundary instead, and the drive stays + // arm-able the whole time. + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::Sine; + plugin.v_null_dac = 1_630; + plugin.v_peak_dac = 2_490; + plugin.depth_a = 0.5; + plugin.operating_point = 0.5; + + plugin + .set_setting("operating_point", json!(1.0)) + .expect("an out-of-range operating point is still accepted"); + let range = plugin.achievable().expect("calibrated range"); + assert!( + (plugin.operating_point - range.max_mean_u).abs() < 1e-9, + "ū landed on {} rather than the boundary {}", + plugin.operating_point, + range.max_mean_u + ); + // Clamped, so the drive is buildable — no stale rejection left behind. + assert!(plugin.drive_command().is_ok()); + assert!(plugin.last_error.is_none(), "{:?}", plugin.last_error); + + // CONST modulates nothing, so the full lobe is reachable again. + plugin.set_setting("mode", json!(0)).expect("CONST"); + plugin + .set_setting("operating_point", json!(1.0)) + .expect("CONST maps u directly"); + assert_eq!(plugin.dac_band().unwrap(), (2_490, 2_490, 2_490)); + } + + #[test] + fn every_mode_stays_selectable_whatever_the_parameters_are() { + // The reported bug: with a leftover `a` from another lobe, selecting an + // optical mode snapped the dropdown back. A mode is a statement of + // intent — it always takes, and the parameters follow it. + let mut plugin = live_plugin(); + plugin.method = DriveMethod::Calibrated; + plugin.v_null_dac = 1_630; + plugin.v_peak_dac = 2_490; + plugin.operating_point = 1.0; + plugin.depth_a = 6.0; + + for index in 0..Mode::VARIANTS.len() { + plugin + .set_setting("mode", json!(index)) + .unwrap_or_else(|error| panic!("mode {index} refused: {error}")); + assert_eq!(plugin.mode, Mode::VARIANTS[index]); + assert!( + plugin.drive_command().is_ok(), + "{} left an unbuildable drive: a={} ū={}", + plugin.mode.name(), + plugin.depth_a, + plugin.operating_point + ); + } + } + + #[test] + fn calibrated_const_sends_the_expected_codes_to_the_board() { + 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.device_connected() + }); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::Const; + plugin.v_null_dac = 1_630; + plugin.v_peak_dac = 2_490; + + plugin + .set_setting("operating_point", json!(1.0)) + .expect("full lobe"); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner.shared.state.lock().unwrap().board_code == Some(2_490) + }); + + plugin + .set_setting("operating_point", json!(0.01)) + .expect("low point"); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner.shared.state.lock().unwrap().board_code == Some(1_685) + }); + plugin.disconnect(); + } + + #[test] + fn ui_armed_drive_publishes_a_board_echo_acknowledged_target() { + 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.device_connected() + }); + // Arm a sine purely through the operator settings — no lease, no + // service request. Consumers (A1) must still see the frequency. + plugin.set_setting("mode", json!(1)).unwrap(); // Sine + plugin.set_setting("frequency_hz", json!(5.0)).unwrap(); + plugin.set_setting("level", json!(1_000)).unwrap(); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .board_mod + .starts_with("SINE") + }); + let snapshot = plugin.control_state(); + let target = snapshot.acknowledged.expect("board-echo target"); + assert_eq!(target.revision, SemanticRevision(0)); + match target.waveform.expect("waveform") { + WaveformV1::Periodic { + frequency_millihz, .. + } => assert_eq!(frequency_millihz, 5_000), + other => panic!("expected periodic waveform, got {other:?}"), + } + plugin.disconnect(); + } + + #[test] + fn ending_a_lease_restores_the_operators_armed_optical_depth() { + 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.device_connected() + }); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.calibration_id = Some("cal-test".into()); + plugin.depth_a = 0.4; // what the operator armed + + let acquire = service_request( + &plugin, + 60, + "stage-a-a1", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + + // Two sweep points: only the first must be remembered as "armed". + for (id, milli) in [(61_u64, 900_u32), (62, 1_250)] { + let point = service_request( + &plugin, + id, + "stage-a-a1", + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: milli, + }, + None, + ); + let reply = plugin.handle_service_request(&point, &live_execution()); + assert!( + matches!(reply.outcome, PluginServiceOutcome::Accepted { .. }), + "sweep point {milli} rejected: {:?}", + reply.outcome + ); + } + assert!( + (plugin.depth_a - 1.25).abs() < 1e-9, + "sweep drives the depth" + ); + + plugin.end_lease(); + assert!( + (plugin.depth_a - 0.4).abs() < 1e-9, + "armed depth not restored: {}", + plugin.depth_a + ); + assert!(plugin.armed_depth_a.is_none()); + plugin.disconnect(); + } + + #[test] + fn a_decimal_frequency_echo_still_yields_millihertz() { + // Firmware echoing "10000.0" used to parse as u64 -> None, which + // published frequency_millihz: 0 and cost A1 its fallback period. + let mut state = DeviceState::default(); + let mut fields = BTreeMap::new(); + fields.insert("mod_wave".to_owned(), "SINE".to_owned()); + fields.insert("mod_level".to_owned(), "2000".to_owned()); + fields.insert("mod_min".to_owned(), "100".to_owned()); + fields.insert("mod_freq_mhz".to_owned(), "10000.0".to_owned()); + apply_reply_fields(&mut state, &fields); + assert_eq!(state.board_freq_millihz, Some(10_000)); + + // The integer form keeps working. + fields.insert("mod_freq_mhz".to_owned(), "7500".to_owned()); + apply_reply_fields(&mut state, &fields); + assert_eq!(state.board_freq_millihz, Some(7_500)); + } + + #[test] + fn set_optical_depth_requires_lease_and_a_calibrated_drive() { + 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.device_connected() + }); + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.calibration_id = Some("cal-test".into()); + + // Without a lease the retarget is refused. + let unleased = service_request( + &plugin, + 30, + "stage-a-a1", + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: 1_250, + }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&unleased, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + + let acquire = service_request( + &plugin, + 31, + "stage-a-a1", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + + let retarget = service_request( + &plugin, + 32, + "stage-a-a1", + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: 1_250, + }, + None, + ); + let outcome = plugin + .handle_service_request(&retarget, &live_execution()) + .outcome; + assert!( + matches!(outcome, PluginServiceOutcome::Accepted { .. }), + "retarget outcome: {outcome:?}" + ); + assert!((plugin.depth_a - 1.25).abs() < 1e-9); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .board_mod + .starts_with("WARP") + }); + + // The manual DAC band cannot express an optical depth. + plugin.method = DriveMethod::Manual; + let manual = service_request( + &plugin, + 33, + "stage-a-a1", + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: 1_000, + }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&manual, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + plugin.disconnect(); + } + + #[test] + fn lease_acquire_is_idempotent_and_exclusive_without_frames() { + let mut plugin = live_plugin(); + let acquire = service_request( + &plugin, + 10, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + let first = plugin.handle_service_request(&acquire, &live_execution()); + let expiry = plugin.lease.as_ref().unwrap().expires_at_unix_ms; + let duplicate = plugin.handle_service_request(&acquire, &live_execution()); + assert_eq!(first, duplicate); + assert_eq!(plugin.lease.as_ref().unwrap().expires_at_unix_ms, expiry); + assert!(plugin.set_setting("level", json!(1)).is_err()); + + let conflict = service_request( + &plugin, + 11, + "workflow-b", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&conflict, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + } + + #[test] + fn prepare_safe_off_and_release_publish_terminal_ack_before_lease_loss() { + 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.device_connected() + }); + + let acquire = service_request( + &plugin, + 20, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + 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, + }, + }, + Some(1), + ); + let initial = plugin.handle_service_request(&prepare, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = initial.outcome else { + panic!("prepare 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 + .state + .lock() + .unwrap() + .last_response + .as_ref() + .is_some_and(|response| { + response.common.request_id.0 == 21 + && response.common.outcome == RequestOutcomeV1::Applied + }) + }); + let terminal = plugin.handle_service_request(&prepare, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = terminal.outcome else { + panic!("terminal prepare rejected"); + }; + let response: ModulationResponseV1 = serde_json::from_value(payload).unwrap(); + assert_eq!( + response.common.acknowledged_revision, + Some(SemanticRevision(1)) + ); + + let safe_off = service_request( + &plugin, + 22, + "workflow-a", + ModulationCommandV1::SafeOff { + reason: "test".into(), + }, + Some(2), + ); + plugin.handle_service_request(&safe_off, &live_execution()); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .acknowledged + .as_ref() + .is_some_and(|target| { + target.revision == SemanticRevision(2) + && target.waveform == Some(WaveformV1::Off) + }) + }); + + let release = service_request( + &plugin, + 23, + "workflow-a", + ModulationCommandV1::ReleaseLease { + safe_off: true, + reason: "done".into(), + }, + Some(3), + ); + plugin.handle_service_request(&release, &live_execution()); + wait_until(&plugin, Duration::from_secs(2), |owner| { + owner + .shared + .state + .lock() + .unwrap() + .last_response + .as_ref() + .is_some_and(|response| { + response.common.request_id.0 == 23 + && response.common.outcome == RequestOutcomeV1::Applied + }) + }); + plugin.apply_execution_context(&live_execution()); + let snapshot = plugin.control_state(); + assert!( + snapshot.lease.is_some(), + "terminal ACK snapshot retains lease" + ); + let duplicate = plugin.handle_service_request(&release, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = duplicate.outcome else { + panic!("release duplicate rejected"); + }; + let response: ModulationResponseV1 = serde_json::from_value(payload).unwrap(); + assert_eq!(response.common.outcome, RequestOutcomeV1::Applied); + plugin.apply_execution_context(&live_execution()); + assert!(plugin.lease.is_none(), "lease clears after ACK publication"); + plugin.disconnect(); + } + + #[test] + fn lease_expiry_and_effect_revocation_fail_closed_without_frames() { + 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.device_connected() + }); + let acquire = service_request( + &plugin, + 30, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + plugin.lease.as_mut().unwrap().expires_at_unix_ms = now_unix_ms().saturating_sub(1); + plugin.apply_execution_context(&live_execution()); + assert!(plugin.lease.is_none()); + assert!(plugin + .last_error + .as_deref() + .is_some_and(|message| message.contains("lease expired"))); + + let acquire = service_request( + &plugin, + 31, + "workflow-a", + ModulationCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + assert!(plugin.lease.is_some()); + plugin.apply_execution_context(&ExecutionContext::fail_closed()); + assert!(plugin.link.is_none()); + assert!(plugin.lease.is_none()); + } + + #[test] + fn a2_commands_match_the_firmware_grammar_and_use_lobe_span() { + let config = A2AcquisitionConfigV1 { + mean_u_milli: 300, + depth_a_milli: 450, + frequency_millihz: 500, + min_half_us: 100_000, + v_null_dac: 100, + v_peak_dac: 1_000, + comparator_threshold_dac: 1_500, + comparator_hysteresis: 1, + comparator_invert: true, + sample_rate_hz: 500_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + }; + validate_a2_configuration(&config).unwrap(); + assert_eq!( + String::from_utf8(a2_config_command(&config).encode(1).unwrap()).unwrap(), + "@1 CONFIG mode=A2 rate_hz=500000 block_samples=256 raw=1 summary=1\n" + ); + assert_eq!( + String::from_utf8(a2_comparator_command(&config).encode(2).unwrap()).unwrap(), + "@2 CMP thr=1500 hyst=1 invert=1\n" + ); + let drive = String::from_utf8(a2_log_square_command(&config).encode(3).unwrap()).unwrap(); + assert!(drive.contains("wave=LOG_SQUARE")); + assert!(drive.contains("v_null=100 v_pi=900"), "{drive}"); + assert!(drive.contains("min_half_us=100000"), "{drive}"); + + let mut unsafe_threshold = config; + unsafe_threshold.comparator_threshold_dac = 0; + assert!(validate_a2_configuration(&unsafe_threshold).is_err()); + } +} diff --git a/plugins/stage-a-modulation/src/protocol_validation_tests.rs b/plugins/stage-a-modulation/src/protocol_validation_tests.rs new file mode 100644 index 0000000..3c5a89a --- /dev/null +++ b/plugins/stage-a-modulation/src/protocol_validation_tests.rs @@ -0,0 +1,349 @@ +//! End-to-end static validation of the A1 bench protocols against the same +//! coupled optical-drive calculations the modulation owner uses at runtime. + +use std::path::Path; + +use augur_plugin_api::{ + ExecutionContext, ExecutionMode, Plugin, PluginRuntimeRole, PluginServiceOutcome, + PluginServiceRequest, +}; +use augur_plugin_stage_a_a1::protocol::{parse_csv, ProtocolPoint}; +use serde_json::Value; +use stage_a_plugin_contract::{ + ClientId, LeaseId, ModulationCommandV1, ModulationRequestV1, RunId, + PLUGIN_ID_STAGE_A_MODULATION, SERVICE_STAGE_A_MODULATION_CONTROL_V1, +}; + +use super::waveform::{ + log_sine_geometric_pedestal, LobeInversion, OpticalDrive, OpticalTarget, PeakLaw, + DAC_FULL_SCALE, DEPTH_A_MAX, DEPTH_A_MIN, MEAN_U_MIN, +}; +use super::{now_unix_ms, DriveMethod, Mode, StageAModulationPlugin}; + +// A conservative policy of the qualified A1 protocol set, in addition to the +// modulation owner's measured-lobe and DAC ceilings. +const PEAK_U_GUARD: f64 = 0.90; + +struct Fixture { + name: &'static str, + csv: &'static str, + expected_points: usize, +} + +fn fixtures() -> [Fixture; 5] { + [ + Fixture { + name: "a1_triage_90min.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_triage_90min.csv"), + expected_points: 40, + }, + Fixture { + name: "a1_stufe1_bode_dc.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe1_bode_dc.csv"), + expected_points: 73, + }, + Fixture { + name: "a1_stufe2_bode_u010.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u010.csv"), + expected_points: 47, + }, + Fixture { + name: "a1_stufe2_bode_u045.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u045.csv"), + expected_points: 47, + }, + Fixture { + name: "a1_stufe2_flussleiter.csv", + csv: include_str!("../../stage-a-a1/protocols/a1_stufe2_flussleiter.csv"), + expected_points: 231, + }, + ] +} + +/// The applied bench calibration and DAC ceiling used to qualify the files. +/// Endpoint rounding mirrors `apply_calibration`; resolving the pair mirrors +/// the modulation owner's `lobe_inversion` path. +fn qualified_lobe() -> (LobeInversion, f64) { + let calibration: Value = + serde_json::from_str(include_str!("../testdata/pockels-20260730-083123.json")) + .expect("recorded Pockels calibration JSON"); + let number = |key: &str| { + calibration[key] + .as_f64() + .unwrap_or_else(|| panic!("calibration has no numeric {key}")) + }; + let v_null = number("v_null_dac"); + let v_peak = v_null + number("v_pi_dac"); + let inversion = + LobeInversion::resolve(v_null.round(), v_peak.round(), f64::from(DAC_FULL_SCALE)) + .expect("recorded calibration resolves to a drivable lobe") + .inversion; + (inversion, number("max_level")) +} + +fn milli(value: f64) -> f64 { + (value * 1_000.0).round() / 1_000.0 +} + +fn live_execution() -> ExecutionContext { + ExecutionContext { + mode: ExecutionMode::LiveCapture, + effects_allowed: true, + session_id: Some("a1-protocol-validation".into()), + } +} + +fn service_request( + plugin: &StageAModulationPlugin, + id: u64, + command: ModulationCommandV1, +) -> PluginServiceRequest { + let mut payload = ModulationRequestV1::new( + stage_a_plugin_contract::RequestId(id), + ClientId::from("stage-a.a1"), + command, + ); + payload.target_owner_instance = Some(plugin.owner_instance.clone()); + payload.run_id = Some(RunId::from("a1-protocol-validation")); + payload.lease_id = Some(LeaseId::from("a1-protocol-validation")); + payload.issued_at_unix_ms = now_unix_ms(); + PluginServiceRequest { + request_id: id, + source_plugin_id: "stage-a.a1".into(), + target_plugin_id: PLUGIN_ID_STAGE_A_MODULATION.into(), + service: SERVICE_STAGE_A_MODULATION_CONTROL_V1.into(), + payload: serde_json::to_value(payload).expect("serializing modulation request"), + } +} + +fn qualified_service_owner() -> StageAModulationPlugin { + let calibration: Value = + serde_json::from_str(include_str!("../testdata/pockels-20260730-083123.json")) + .expect("recorded Pockels calibration JSON"); + let number = |key: &str| { + calibration[key] + .as_f64() + .unwrap_or_else(|| panic!("calibration has no numeric {key}")) + }; + + let mut plugin = StageAModulationPlugin::default(); + plugin.runtime_role = PluginRuntimeRole::LiveWorker; + plugin.effects_allowed = true; + plugin.port_hint = "mock".into(); + plugin.connect_requested = true; + plugin.max_level = number("max_level").round() as i64; + plugin.method = DriveMethod::Calibrated; + plugin.mode = Mode::OpticalLogSine; + plugin.frequency_hz = 0.10; + plugin.depth_a = 1.70; + plugin.operating_point = 0.30; + plugin.v_null_dac = number("v_null_dac").round() as i64; + plugin.v_peak_dac = (number("v_null_dac") + number("v_pi_dac")).round() as i64; + plugin.calibration_id = Some("pockels-20260730-083123".into()); + plugin.connect(); + assert!( + plugin.link.is_some(), + "mock modulation owner did not connect" + ); + plugin +} + +fn assert_service_accepts( + plugin: &mut StageAModulationPlugin, + request_id: u64, + command: ModulationCommandV1, + context: &str, +) { + let request = service_request(plugin, request_id, command); + let reply = plugin.handle_service_request(&request, &live_execution()); + assert!( + matches!(reply.outcome, PluginServiceOutcome::Accepted { .. }), + "{context}: production modulation service rejected the request: {:?}", + reply.outcome + ); +} + +/// Rebuilds the exact optical-log-sine command the owner would send after the +/// A1 service has rounded all three protocol coordinates to milli-units. +fn assert_drive_is_accepted( + fixture: &str, + point: usize, + mean_u: f64, + frequency_hz: f64, + depth_a: f64, + inversion: LobeInversion, + max_code: f64, +) { + let mean_u = milli(mean_u); + let frequency_millihz = (frequency_hz * 1_000.0).round() as u64; + let frequency_hz = frequency_millihz as f64 / 1_000.0; + let depth_a = milli(depth_a); + let context = format!( + "{fixture} point {}: ū={mean_u:.3}, f={frequency_hz:.3}, a={depth_a:.3}", + point + 1 + ); + + assert!( + (MEAN_U_MIN..=1.0).contains(&mean_u), + "{context}: mean_u is outside the modulation service range" + ); + assert!( + (DEPTH_A_MIN..=DEPTH_A_MAX).contains(&depth_a), + "{context}: depth_a is outside the modulation service range" + ); + assert!( + stage_a_plugin_contract::drive_frequency_supported(frequency_millihz), + "{context}: frequency is outside the plugin/firmware range" + ); + + let law = PeakLaw::LogSine; + let u_max = inversion.peak_intensity_ceiling(max_code); + let peak_u = law.peak(mean_u, depth_a); + assert!( + depth_a <= law.max_depth_for_mean(mean_u, u_max) + 1e-9, + "{context}: a exceeds the coupled max-depth calculation" + ); + assert!( + mean_u <= law.max_mean_for_depth(depth_a, u_max) + 1e-9, + "{context}: mean_u exceeds the coupled max-mean calculation" + ); + assert!( + peak_u <= PEAK_U_GUARD + 1e-9, + "{context}: peak u={peak_u:.6} exceeds the protocol guard {PEAK_U_GUARD:.2}" + ); + + // The service publishes the Bessel-normalized geometric pedestal in + // milli-units. Test the rounded table, not an ideal higher-precision one. + let pedestal_u = milli(log_sine_geometric_pedestal(mean_u, depth_a)); + let table = OpticalDrive { + target: OpticalTarget::LogSine, + depth_a, + operating_point: pedestal_u, + inversion, + } + .warp_table() + .unwrap_or_else(|error| panic!("{context}: firmware warp would be refused: {error}")); + let highest_code = table.iter().copied().max().unwrap_or(0); + assert!( + f64::from(highest_code) <= max_code, + "{context}: warp needs DAC {highest_code}, above configured max {max_code:.0}" + ); +} + +/// The protocol sends mean, frequency and depth as three ordered service +/// requests. Validate the intermediate states too: a valid final `(ū, a)` is +/// not enough if changing `ū` first would be rejected against the previous a. +fn assert_protocol_transitions_are_accepted( + fixture: &str, + points: &[ProtocolPoint], + inversion: LobeInversion, + max_code: f64, +) { + // Required pre-flight state in the protocol comments/UI: the armed depth + // must not exceed the largest depth the file will request. + let (mut frequency_hz, mut depth_a) = (0.10, 1.70); + for (index, point) in points.iter().enumerate() { + assert_drive_is_accepted( + fixture, + index, + point.mean_u, + frequency_hz, + depth_a, + inversion, + max_code, + ); + let mean_u = point.mean_u; + assert_drive_is_accepted( + fixture, + index, + mean_u, + point.frequency_hz, + depth_a, + inversion, + max_code, + ); + frequency_hz = point.frequency_hz; + assert_drive_is_accepted( + fixture, + index, + mean_u, + frequency_hz, + point.depth_a, + inversion, + max_code, + ); + depth_a = point.depth_a; + } +} + +/// Runs the parsed rows through the real modulation service boundary. This is +/// deliberately in addition to the named policy assertions above: a change in +/// lease, mode, lobe, rounding, `drive_command` or service sequencing must make +/// the laboratory fixtures fail here rather than drift from production. +fn assert_production_service_accepts_protocol(fixture: &str, points: &[ProtocolPoint]) { + let mut plugin = qualified_service_owner(); + let mut request_id = 1; + assert_service_accepts( + &mut plugin, + request_id, + ModulationCommandV1::AcquireLease { ttl_ms: 60_000 }, + fixture, + ); + + for (index, point) in points.iter().enumerate() { + let context = format!("{fixture} point {}", index + 1); + for command in [ + ModulationCommandV1::SetOperatingPoint { + mean_u_milli: (point.mean_u * 1_000.0).round() as u32, + }, + ModulationCommandV1::SetDriveFrequency { + frequency_millihz: (point.frequency_hz * 1_000.0).round() as u64, + }, + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: (point.depth_a * 1_000.0).round() as u32, + }, + ] { + request_id += 1; + assert_service_accepts(&mut plugin, request_id, command, &context); + } + } + plugin.disconnect(); +} + +#[test] +fn shipped_a1_protocols_parse_and_every_retarget_is_drivable() { + let (inversion, max_code) = qualified_lobe(); + for fixture in fixtures() { + let protocol = parse_csv(fixture.csv) + .unwrap_or_else(|error| panic!("{} does not parse: {error}", fixture.name)); + assert_eq!( + protocol.points.len(), + fixture.expected_points, + "{} changed recording count", + fixture.name + ); + assert_protocol_transitions_are_accepted( + fixture.name, + &protocol.points, + inversion, + max_code, + ); + assert_production_service_accepts_protocol(fixture.name, &protocol.points); + + // On the bench these files live in Playground. When that sibling tree + // exists, make drift from the versioned, shipped fixture a test failure. + let live = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../Playground/protocols") + .join(fixture.name); + if live.is_file() { + let live_text = std::fs::read_to_string(&live) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", live.display())); + assert_eq!( + live_text, + fixture.csv, + "{} differs from the protocol shipped and validated by the plugin", + live.display() + ); + } + } +} diff --git a/plugins/stage-a-modulation/src/waveform.rs b/plugins/stage-a-modulation/src/waveform.rs new file mode 100644 index 0000000..06d9b7e --- /dev/null +++ b/plugins/stage-a-modulation/src/waveform.rs @@ -0,0 +1,889 @@ +//! Optical-target DAC warp-table synthesis for the Pockels/PBS modulator. +//! +//! On one monotonic Pockels/PBS lobe the excitation transfer is +//! `I(V) = I_floor + (I_ceil - I_floor) · sin²(α (V - V_null))`, with +//! `α = π / (2 Vπ)`. The manufacturer likewise describes the amplitude +//! modulator as `sin²`; a 50 % bias only *approximately* linearises the small +//! signal. A pure DAC sine therefore does **not** produce a sinusoidal optical +//! target — it must be pre-warped by inverting the transfer: +//! +//! ```text +//! u(t) = (I_d(t) - I_floor) / (I_ceil - I_floor) // normalised target +//! V(u) = V_null + (2 Vπ / π) · arcsin(√u) // increasing lobe +//! ``` +//! +//! Two optical targets are supported (the drive picks one): +//! - [`OpticalTarget::LogSine`] — `ln I_d = ln I_g + (a/2) sin ωt`, the clean A1 +//! input because the event camera responds to changes in `ln I`. +//! - [`OpticalTarget::LinearSine`] — `I_d = I_c (1 + m sin ωt)`, `m = tanh(a/2)`. +//! +//! The lobe is configured as the two **DAC codes an operator can observe** — +//! where the light is dimmest (`V_null`) and where it is brightest (`V_peak`) — +//! and `Vπ` is derived from the pair by [`LobeInversion::resolve`]. The engineer +//! should not rely on nominal `Vπ` but sweep settled constant DAC codes, measure +//! the actual optical transfer, and freeze those two codes. A fully measured +//! lookup table can replace this analytic inversion later behind the same +//! interface. + +use std::f64::consts::PI; + +/// Warp-table length played back over one modulation period. +pub const WARP_TABLE_LEN: usize = 256; +/// Full-scale DAC code (12-bit). +pub const DAC_FULL_SCALE: u16 = 4_095; + +/// Shallowest optical depth the UI offers. Below this the warp table is +/// indistinguishable from a constant drive. +pub const DEPTH_A_MIN: f64 = 0.01; +/// Deepest optical depth the UI offers, before the lobe is consulted. +pub const DEPTH_A_MAX: f64 = 6.0; +/// Dimmest cycle-mean lobe point the UI offers. +pub const MEAN_U_MIN: f64 = 0.01; + +/// Modified Bessel function `I₀(x)` for the Stage-A depth range (`|x| ≤ 3`). +/// +/// The positive power series converges rapidly here and avoids adding a +/// special-functions dependency to the plugin/firmware parameter path. +fn modified_bessel_i0(x: f64) -> f64 { + let y = 0.25 * x * x; + let mut sum = 1.0; + let mut term = 1.0; + for k in 1..=32 { + term *= y / (k as f64 * k as f64); + sum += term; + if term <= f64::EPSILON * sum { + break; + } + } + sum +} + +/// Geometric pedestal that makes a log-sine's cycle-mean normalized lobe +/// coordinate equal `mean_u`: +/// +/// `u(t) = u_g exp[(a/2) sin(ωt)]`, `u_g = mean_u / I₀(a/2)`. +pub fn log_sine_geometric_pedestal(mean_u: f64, depth_a: f64) -> f64 { + mean_u / modified_bessel_i0(0.5 * depth_a) +} + +pub fn log_sine_cycle_mean(pedestal_u: f64, depth_a: f64) -> f64 { + pedestal_u * modified_bessel_i0(0.5 * depth_a) +} + +/// Optical intensity target the drive should reproduce, swung around the +/// dimensionless lobe point `u`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpticalTarget { + /// Recommended A1 log-intensity sine in normalized, floor-subtracted lobe + /// coordinate: `ln u = ln u_g + (a/2) sin ωt`. + LogSine, + /// Literal linear-intensity sine: `u = u_c (1 + m sin ωt)`, + /// `m = tanh(a/2)`. + LinearSine, +} + +/// Frozen inversion of one monotonic Pockels/PBS lobe, in DAC codes. +/// +/// Built from the two codes an operator can actually observe on the bench via +/// [`LobeInversion::resolve`], never from a typed-in distance — see the error +/// type for why. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LobeInversion { + /// DAC code where the excitation light is at its minimum (`sin² = 0`). + pub v_null_dac: f64, + /// DAC-code half-wave-voltage span from `v_null` to the excitation maximum. + pub v_pi_dac: f64, +} + +/// One monotonic lobe resolved from a measured `(min, max)` pair of codes. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResolvedLobe { + pub inversion: LobeInversion, + /// The observed pair ran *downward* in code, so the drive uses the + /// equivalent ascending branch — the one that rises into the very maximum + /// that was measured. Worth reporting: the codes driven are not the ones + /// the operator typed. + pub folded: bool, +} + +/// Why two observed codes do not name a drivable lobe. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LobeError { + /// The two codes coincide: no measurable lobe, so nothing to invert. + Degenerate { code: f64 }, + /// Neither the observed branch nor its ascending equivalent fits inside + /// `0..=max_code`. + Unreachable { + v_null: f64, + v_peak: f64, + max_code: f64, + }, +} + +impl std::fmt::Display for LobeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Degenerate { code } => write!( + f, + "V_null and V_peak are both {code:.0}: sweep the DAC and read off the codes where \ + the light is dimmest and brightest" + ), + Self::Unreachable { + v_null, + v_peak, + max_code, + } => write!( + f, + "no monotonic lobe between V_null {v_null:.0} and V_peak {v_peak:.0} fits inside \ + 0..={max_code:.0}; raise the max limit or pick a lobe further down the range" + ), + } + } +} + +impl std::error::Error for LobeError {} + +impl LobeInversion { + /// Resolves the two codes an operator can *observe* — the DAC code at the + /// excitation minimum and the one at the excitation maximum — into the + /// ascending lobe the drive inverts. + /// + /// Both inputs are absolute codes, deliberately. The earlier form paired an + /// absolute `V_null` with `Vπ` as a *distance* from it, and a distance is + /// not what an operator reads off a sweep: entering the brightest **code** + /// as `Vπ` doubles the half wave whenever the null sits near half the peak + /// code, which puts maximum light at `u ≈ 0.5` and a null back at + /// `u = 1`. Two observed codes cannot be mixed up that way, and they make + /// `u = 1` land exactly on the measured maximum by construction. + pub fn resolve(v_null: f64, v_peak: f64, max_code: f64) -> Result { + if !v_null.is_finite() || !v_peak.is_finite() { + return Err(LobeError::Degenerate { code: v_null }); + } + let span = v_peak - v_null; + // Sub-code separation is meaningless on a 12-bit DAC. + if span.abs() < 1.0 { + return Err(LobeError::Degenerate { code: v_null }); + } + let v_pi_dac = span.abs(); + let fits = |null: f64| null >= -0.5 && null + v_pi_dac <= max_code + 0.5; + // `sin²` repeats every `2Vπ` and every branch is a mirror of its + // neighbour, so a pair measured running downward in code names the same + // physical lobe as the ascending branch one full period below — which + // ends on the maximum that was actually measured. Prefer that one; fall + // back to the branch rising out of the observed null only if it is what + // fits inside the commandable range. + let (v_null_dac, folded) = if span > 0.0 && fits(v_null) { + (v_null, false) + } else if fits(v_peak - v_pi_dac) { + (v_peak - v_pi_dac, true) + } else if fits(v_null) { + (v_null, true) + } else { + return Err(LobeError::Unreachable { + v_null, + v_peak, + max_code, + }); + }; + Ok(ResolvedLobe { + inversion: Self { + v_null_dac: v_null_dac.clamp(0.0, (max_code - v_pi_dac).max(0.0)), + v_pi_dac, + }, + folded, + }) + } + + /// DAC code at the excitation maximum: where normalized lobe coordinate + /// `u = 1` lands. + pub fn v_peak_dac(&self) -> f64 { + self.v_null_dac + self.v_pi_dac + } + + /// Normalised optical intensity produced by `code` on the configured lobe: + /// `u = sin²(π(code - V_null) / (2 Vπ))`. + pub fn u_for_dac(&self, code: f64) -> f64 { + let alpha = PI / (2.0 * self.v_pi_dac); + (alpha * (code - self.v_null_dac)).sin().powi(2) + } + + /// DAC code producing normalised optical intensity `u ∈ [0, 1]` on the + /// increasing lobe. + pub fn dac_for_u(&self, u: f64) -> f64 { + self.v_null_dac + (2.0 * self.v_pi_dac / PI) * u.clamp(0.0, 1.0).sqrt().asin() + } + + /// Highest normalised intensity a drive may peak at without exceeding the + /// operator's DAC ceiling `max_code`. + /// + /// `u = 1` sits at `v_peak`; a ceiling below that clips the lobe short, and + /// the drive has to stay under whatever `u` the ceiling code produces. + pub fn peak_intensity_ceiling(&self, max_code: f64) -> f64 { + if max_code >= self.v_peak_dac() { + return 1.0; + } + if max_code <= self.v_null_dac { + return 0.0; + } + self.u_for_dac(max_code) + } +} + +/// How the peak normalised intensity of a drive follows from its requested +/// cycle mean `ū` and depth `a`. +/// +/// Every calibrated mode has one of these, and they are the *only* thing that +/// limits `a` and `ū`: the swing has to stay under the top of the lobe (and +/// under the operator's DAC ceiling, expressed as the same `u_max`). Solving +/// one relation for each variable in turn gives the achievable ranges the UI +/// shows — and clamps against, instead of refusing the edit and snapping the +/// control back, which told the operator nothing about where the boundary was. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeakLaw { + /// A constant hold modulates nothing, so the peak *is* the mean and `a` + /// does not enter. + Constant, + /// Bare DAC sine/square on a calibrated band: `ū · e^{a/2}`. + LogSwing, + /// [`OpticalTarget::LogSine`], whose pedestal preserves the cycle mean: + /// `ū · e^{a/2} / I₀(a/2)`. + LogSine, + /// [`OpticalTarget::LinearSine`]: `ū · (1 + tanh(a/2))`. + LinearSine, +} + +impl PeakLaw { + pub fn of(target: OpticalTarget) -> Self { + match target { + OpticalTarget::LogSine => Self::LogSine, + OpticalTarget::LinearSine => Self::LinearSine, + } + } + + /// Peak normalised intensity of the drive, in lobe coordinate. + pub fn peak(self, mean_u: f64, depth_a: f64) -> f64 { + let depth_a = depth_a.max(0.0); + mean_u * self.swing(depth_a) + } + + /// Factor the peak sits above the requested cycle mean. Monotonically + /// non-decreasing in `a` in every variant, which is what makes the + /// inversions below well defined. + fn swing(self, depth_a: f64) -> f64 { + match self { + Self::Constant => 1.0, + Self::LogSwing => (0.5 * depth_a).exp(), + Self::LogSine => (0.5 * depth_a).exp() / modified_bessel_i0(0.5 * depth_a), + Self::LinearSine => 1.0 + (0.5 * depth_a).tanh(), + } + } + + /// Deepest `a` expressible at this cycle mean under the ceiling `u_max`. + pub fn max_depth_for_mean(self, mean_u: f64, u_max: f64) -> f64 { + // Written through `partial_cmp` so a NaN is rejected rather than + // silently passing a negated comparison. + let usable = |value: f64| value.partial_cmp(&0.0) == Some(std::cmp::Ordering::Greater); + if !usable(mean_u) || !usable(u_max) || mean_u > u_max { + return 0.0; + } + let headroom = u_max / mean_u; + match self { + // Nothing swings, so the UI limit is the only bound. + Self::Constant => DEPTH_A_MAX, + Self::LogSwing => (2.0 * headroom.ln()).clamp(0.0, DEPTH_A_MAX), + // Below twice the mean the swing never reaches the ceiling. + Self::LinearSine => { + let m = headroom - 1.0; + if m >= 1.0 { + DEPTH_A_MAX + } else { + (2.0 * m.atanh()).clamp(0.0, DEPTH_A_MAX) + } + } + // No closed form (I₀ grows like e^x/√(2πx)), but `swing` is + // monotonic, so bisect it. + Self::LogSine => { + if self.swing(DEPTH_A_MAX) <= headroom { + return DEPTH_A_MAX; + } + let (mut lo, mut hi) = (0.0_f64, DEPTH_A_MAX); + for _ in 0..64 { + let mid = 0.5 * (lo + hi); + if self.swing(mid) <= headroom { + lo = mid; + } else { + hi = mid; + } + } + lo + } + } + } + + /// Brightest cycle mean the requested depth leaves room for, under the same + /// ceiling. The counterpart of [`PeakLaw::max_depth_for_mean`]. + pub fn max_mean_for_depth(self, depth_a: f64, u_max: f64) -> f64 { + if u_max.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { + return 0.0; + } + (u_max / self.swing(depth_a.max(0.0))).clamp(0.0, 1.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OpticalDrive { + pub target: OpticalTarget, + /// Optical log-modulation depth `a = ln(I_max / I_min)`, must be positive. + pub depth_a: f64, + /// Dimensionless, floor-subtracted lobe point in `(0, 1]`. It is the + /// geometric pedestal `u_g` for [`OpticalTarget::LogSine`] and the + /// arithmetic centre `u_c` for [`OpticalTarget::LinearSine`]. This is not + /// the physical A1 flux point `I_k`. + pub operating_point: f64, + pub inversion: LobeInversion, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WarpError { + /// `a` is not finite or not positive. + InvalidDepth, + /// The operating point is not in `(0, 1]`. + InvalidOperatingPoint, + /// `Vπ` is not finite or not positive. + InvalidInversion, + /// The peak optical target exceeds the lobe ceiling: the internal + /// pedestal/centre is too bright for this depth and would saturate. + Saturates { peak: f64 }, + /// A computed DAC code falls outside `0..=4095`: the inversion parameters do + /// not fit the requested depth on this lobe. Clamping would silently distort + /// the optical target, so the drive is refused instead. + OutOfRange { index: usize, code: f64 }, +} + +impl std::fmt::Display for WarpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidDepth => f.write_str("optical depth a must be finite and positive"), + Self::InvalidOperatingPoint => f.write_str("operating point must be in (0, 1]"), + Self::InvalidInversion => f.write_str("Vπ must be finite and positive"), + Self::Saturates { peak } => write!( + f, + "peak optical target u = {peak:.3} exceeds the lobe ceiling; lower a or the operating point" + ), + Self::OutOfRange { index, code } => write!( + f, + "warp sample {index} = {code:.1} DAC leaves 0..=4095; reduce a or re-measure the lobe" + ), + } + } +} + +impl std::error::Error for WarpError {} + +impl OpticalDrive { + /// Derives the target-law parameters that make an optical waveform span + /// the intensities produced by the supplied DAC band. + pub fn from_dac_band( + target: OpticalTarget, + inversion: LobeInversion, + lo: f64, + hi: f64, + ) -> Self { + let u_lo = inversion.u_for_dac(lo); + let u_hi = inversion.u_for_dac(hi); + let (operating_point, depth_a) = match target { + OpticalTarget::LogSine => ((u_lo * u_hi).sqrt(), (u_hi / u_lo).ln()), + OpticalTarget::LinearSine => { + let operating_point = 0.5 * (u_lo + u_hi); + let modulation = (u_hi - u_lo) / (u_hi + u_lo); + (operating_point, 2.0 * modulation.atanh()) + } + }; + Self { + target, + depth_a, + operating_point, + inversion, + } + } + + /// Normalised optical target `u(φ)` for phase fraction `φ ∈ [0, 1)`, swung + /// around the internal `u_g`/`u_c` point (not peak-normalised). + pub fn normalised_intensity(&self, phase: f64) -> f64 { + let sine = (2.0 * PI * phase).sin(); + match self.target { + // ln u = ln u_g + (a/2) sin ωt. + OpticalTarget::LogSine => self.operating_point * (0.5 * self.depth_a * sine).exp(), + // u = u_c (1 + m sin ωt), m = tanh(a/2). + OpticalTarget::LinearSine => { + let m = (0.5 * self.depth_a).tanh(); + self.operating_point * (1.0 + m * sine) + } + } + } + + /// Peak normalised optical target over one period. + fn peak_intensity(&self) -> f64 { + match self.target { + OpticalTarget::LogSine => self.operating_point * (0.5 * self.depth_a).exp(), + OpticalTarget::LinearSine => self.operating_point * (1.0 + (0.5 * self.depth_a).tanh()), + } + } + + /// Builds the `WARP_TABLE_LEN`-entry DAC warp table for one period. + pub fn warp_table(&self) -> Result, WarpError> { + if !self.depth_a.is_finite() || self.depth_a <= 0.0 { + return Err(WarpError::InvalidDepth); + } + if !self.operating_point.is_finite() + || !(0.0..=1.0).contains(&self.operating_point) + || self.operating_point <= 0.0 + { + return Err(WarpError::InvalidOperatingPoint); + } + if !self.inversion.v_pi_dac.is_finite() || self.inversion.v_pi_dac <= 0.0 { + return Err(WarpError::InvalidInversion); + } + let peak = self.peak_intensity(); + if peak > 1.0 + 1e-9 { + return Err(WarpError::Saturates { peak }); + } + let mut table = Vec::with_capacity(WARP_TABLE_LEN); + for index in 0..WARP_TABLE_LEN { + let phase = index as f64 / WARP_TABLE_LEN as f64; + let code = self.inversion.dac_for_u(self.normalised_intensity(phase)); + if !code.is_finite() || code < -0.5 || code > f64::from(DAC_FULL_SCALE) + 0.5 { + return Err(WarpError::OutOfRange { index, code }); + } + table.push(code.round().clamp(0.0, f64::from(DAC_FULL_SCALE)) as u16); + } + Ok(table) + } +} + +/// Forward Pockels/PBS transfer used to verify a warp table reproduces the +/// intended optical target: `u = sin²(α (code - V_null))`, `α = π / (2 Vπ)`. +#[cfg(test)] +pub fn lobe_transmission(code: f64, inversion: &LobeInversion) -> f64 { + inversion.u_for_dac(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inversion() -> LobeInversion { + // Null at code 200, one half-wave-voltage span later at peak light. + LobeInversion { + v_null_dac: 200.0, + v_pi_dac: 1_600.0, + } + } + + /// Peak-normalised operating point (max light at the lobe ceiling) so the + /// range/round-trip assertions exercise the full swing. + fn peak_operating_point(target: OpticalTarget, depth_a: f64) -> f64 { + match target { + OpticalTarget::LogSine => (-0.5 * depth_a).exp(), + OpticalTarget::LinearSine => 1.0 / (1.0 + (0.5 * depth_a).tanh()), + } + } + + fn drive(target: OpticalTarget, depth_a: f64) -> OpticalDrive { + OpticalDrive { + target, + depth_a, + operating_point: peak_operating_point(target, depth_a), + inversion: inversion(), + } + } + + #[test] + fn two_observed_codes_put_the_light_maximum_at_u_one() { + // The property the endpoint form exists to guarantee: whatever pair of + // codes was measured, u = 1 lands on the measured maximum, u = 0 on + // the measured minimum, and nothing turns over in between. + for (null, peak) in [(200.0, 1_800.0), (0.0, 4_095.0), (1_600.0, 3_200.0)] { + let lobe = LobeInversion::resolve(null, peak, 4_095.0).expect("a real lobe"); + assert!(!lobe.folded); + let inversion = lobe.inversion; + assert!((inversion.dac_for_u(1.0) - peak).abs() < 1e-9); + assert!((inversion.dac_for_u(0.0) - null).abs() < 1e-9); + assert!((inversion.u_for_dac(peak) - 1.0).abs() < 1e-9); + let mut previous = f64::MIN; + for step in 0..=100 { + let u = f64::from(step) / 100.0; + let light = inversion.u_for_dac(inversion.dac_for_u(u)); + assert!(light >= previous - 1e-9, "light turned over at u = {u}"); + previous = light; + } + } + } + + #[test] + fn the_brightest_code_typed_as_v_pi_is_what_used_to_peak_at_half() { + // Regression witness for the bench report of 2026-07-28. With the null + // at half the brightest code, feeding the *absolute* brightest code in + // as the half-wave-voltage distance peaks the light at u = 0.5 and + // returns it to the null at u = 1 — exactly what was observed. + let (null, peak) = (1_600.0, 3_200.0); + let truth = LobeInversion::resolve(null, peak, 4_095.0) + .expect("a real lobe") + .inversion; + let mistake = LobeInversion { + v_null_dac: null, + v_pi_dac: peak, // the distance field filled with a code + }; + let light = |u: f64| truth.u_for_dac(mistake.dac_for_u(u)); + assert!(light(0.5) > 0.99, "peak light at u = 0.5: {}", light(0.5)); + assert!(light(1.0) < 0.01, "null light at u = 1: {}", light(1.0)); + // And the endpoint form is immune to the same typo, because there is no + // distance to type: the brightest code *is* the field. + assert!((truth.u_for_dac(truth.dac_for_u(1.0)) - 1.0).abs() < 1e-9); + } + + #[test] + fn a_pair_measured_downward_folds_onto_the_branch_into_the_same_peak() { + // Peak below null: the same physical lobe, approached from below. The + // ascending equivalent must end on the measured maximum. + let lobe = LobeInversion::resolve(3_000.0, 2_000.0, 4_095.0).expect("a real lobe"); + assert!(lobe.folded); + assert_eq!(lobe.inversion.v_pi_dac, 1_000.0); + assert!((lobe.inversion.v_peak_dac() - 2_000.0).abs() < 1e-9); + assert!(lobe.inversion.v_null_dac >= 0.0); + } + + #[test] + fn a_downward_pair_with_no_room_below_rises_out_of_the_observed_null() { + // 500 → 100 would fold to a null at −300; the branch above the observed + // null is the one that fits. + let lobe = LobeInversion::resolve(500.0, 100.0, 4_095.0).expect("a real lobe"); + assert!(lobe.folded); + assert_eq!(lobe.inversion.v_null_dac, 500.0); + assert_eq!(lobe.inversion.v_peak_dac(), 900.0); + } + + #[test] + fn refuses_a_degenerate_or_unreachable_pair() { + assert!(matches!( + LobeInversion::resolve(1_000.0, 1_000.0, 4_095.0), + Err(LobeError::Degenerate { .. }) + )); + // A lobe wider than the commandable range fits nowhere. + assert!(matches!( + LobeInversion::resolve(0.0, 3_000.0, 2_000.0), + Err(LobeError::Unreachable { .. }) + )); + } + + #[test] + fn tables_stay_inside_the_dac_range_for_both_targets() { + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + let table = drive(target, 1.0).warp_table().expect("in range"); + assert_eq!(table.len(), WARP_TABLE_LEN); + assert!(table.iter().all(|&code| code <= DAC_FULL_SCALE)); + } + } + + #[test] + fn warp_table_reproduces_the_optical_target_through_the_sin2_transfer() { + // Feeding the warp codes back through the sin² lobe must recover the + // intended normalised intensity: that is the whole point of the warp. + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + let drive = drive(target, 0.8); + let table = drive.warp_table().expect("in range"); + for (index, &code) in table.iter().enumerate() { + let phase = index as f64 / WARP_TABLE_LEN as f64; + let recovered = lobe_transmission(f64::from(code), &drive.inversion); + let target_u = drive.normalised_intensity(phase); + assert!( + (recovered - target_u).abs() < 5e-3, + "{target:?} phase {phase}: recovered {recovered} vs target {target_u}" + ); + } + } + } + + #[test] + fn measured_log_contrast_matches_the_requested_depth_for_log_sine() { + // The optical min/max of a log-sine table give back a = ln(max/min). + let drive = drive(OpticalTarget::LogSine, 1.2); + let table = drive.warp_table().expect("in range"); + let intensities: Vec = table + .iter() + .map(|&code| lobe_transmission(f64::from(code), &drive.inversion)) + .collect(); + let max = intensities.iter().cloned().fold(f64::MIN, f64::max); + let min = intensities.iter().cloned().fold(f64::MAX, f64::min); + let measured_a = (max / min).ln(); + assert!((measured_a - 1.2).abs() < 0.05, "measured a = {measured_a}"); + } + + #[test] + fn drive_derived_from_a_dac_band_recovers_that_optical_span() { + let inversion = inversion(); + let lo = 600.0; + let hi = 1_500.0; + let expected_lo = inversion.u_for_dac(lo); + let expected_hi = inversion.u_for_dac(hi); + + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + let drive = OpticalDrive::from_dac_band(target, inversion, lo, hi); + let table = drive.warp_table().expect("manual band is valid"); + let recovered: Vec = table + .iter() + .map(|&code| inversion.u_for_dac(f64::from(code))) + .collect(); + let recovered_lo = recovered.iter().copied().fold(f64::MAX, f64::min); + let recovered_hi = recovered.iter().copied().fold(f64::MIN, f64::max); + assert!( + (recovered_lo - expected_lo).abs() < 5e-3, + "{target:?}: recovered lower intensity {recovered_lo} vs {expected_lo}" + ); + assert!( + (recovered_hi - expected_hi).abs() < 5e-3, + "{target:?}: recovered upper intensity {recovered_hi} vs {expected_hi}" + ); + } + } + + #[test] + fn deeper_depth_gives_more_optical_contrast() { + let contrast = |a: f64| { + let drive = drive(OpticalTarget::LinearSine, a); + let table = drive.warp_table().expect("in range"); + let intensities: Vec = table + .iter() + .map(|&code| lobe_transmission(f64::from(code), &drive.inversion)) + .collect(); + let max = intensities.iter().cloned().fold(f64::MIN, f64::max); + let min = intensities.iter().cloned().fold(f64::MAX, f64::min); + (max / min).ln() + }; + assert!(contrast(1.0) > contrast(0.5)); + } + + #[test] + fn rejects_invalid_depth_and_inversion() { + assert_eq!( + drive(OpticalTarget::LogSine, 0.0).warp_table(), + Err(WarpError::InvalidDepth) + ); + let mut bad = drive(OpticalTarget::LogSine, 1.0); + bad.inversion.v_pi_dac = 0.0; + assert_eq!(bad.warp_table(), Err(WarpError::InvalidInversion)); + } + + #[test] + fn refuses_an_inversion_that_overruns_the_lobe() { + // The reachable optical maximum sits at v_null + Vπ; pushing that past + // the top rail must be refused rather than silently clamped. + let drive = OpticalDrive { + target: OpticalTarget::LogSine, + depth_a: 1.0, + operating_point: peak_operating_point(OpticalTarget::LogSine, 1.0), + inversion: LobeInversion { + v_null_dac: 200.0, + v_pi_dac: 4_000.0, // peak light would land at code 4200 + }, + }; + assert!(matches!( + drive.warp_table(), + Err(WarpError::OutOfRange { .. }) + )); + } + + #[test] + fn refuses_an_operating_point_too_bright_for_the_depth() { + let drive = OpticalDrive { + target: OpticalTarget::LogSine, + depth_a: 1.0, + operating_point: 0.9, // 0.9 * exp(0.5) = 1.48 > 1 -> saturates + inversion: inversion(), + }; + assert!(matches!( + drive.warp_table(), + Err(WarpError::Saturates { .. }) + )); + } + + #[test] + fn fixed_internal_log_pedestal_stays_at_phase_zero_while_sweeping_a() { + // The low-level OpticalDrive takes the geometric pedestal u_g. At + // phase 0 (sin = 0), that pedestal stays put while contrast grows. + // The plugin wrapper adjusts u_g with I₀(a/2) when its requested + // cycle-mean ū is held fixed. + let u_g = 0.3; + let drive = |a: f64| OpticalDrive { + target: OpticalTarget::LogSine, + depth_a: a, + operating_point: u_g, + inversion: inversion(), + }; + for a in [0.2, 0.6, 1.0] { + // At phase 0 the log-sine sits exactly at the operating point. + assert!((drive(a).normalised_intensity(0.0) - u_g).abs() < 1e-12); + let table = drive(a).warp_table().expect("in range"); + let intensities: Vec = table + .iter() + .map(|&code| lobe_transmission(f64::from(code), &inversion())) + .collect(); + let max = intensities.iter().cloned().fold(f64::MIN, f64::max); + let min = intensities.iter().cloned().fold(f64::MAX, f64::min); + assert!(((max / min).ln() - a).abs() < 0.05, "a={a}"); + } + } + + #[test] + fn bessel_normalization_keeps_the_log_sine_cycle_mean() { + for depth_a in [0.2, 0.4, 1.0, 2.0, 6.0] { + let mean_u = 0.2; + let pedestal = log_sine_geometric_pedestal(mean_u, depth_a); + let sample_mean = (0..65_536) + .map(|index| { + let phase = 2.0 * PI * index as f64 / 65_536.0; + pedestal * (0.5 * depth_a * phase.sin()).exp() + }) + .sum::() + / 65_536.0; + assert!( + (sample_mean - mean_u).abs() < 1e-12, + "a={depth_a}: mean={sample_mean}" + ); + } + } +} + +#[cfg(test)] +mod range_tests { + use super::*; + + /// The whole point of the range helpers: what they report as the boundary + /// has to be exactly where `warp_table` stops accepting the drive. If they + /// disagree, the UI either offers a drive that is refused or hides one that + /// would work. + fn table_is_buildable(target: OpticalTarget, mean_u: f64, depth_a: f64) -> bool { + let inversion = LobeInversion { + v_null_dac: 200.0, + v_pi_dac: 1_600.0, + }; + let operating_point = match target { + OpticalTarget::LogSine => log_sine_geometric_pedestal(mean_u, depth_a), + OpticalTarget::LinearSine => mean_u, + }; + OpticalDrive { + target, + depth_a, + operating_point, + inversion, + } + .warp_table() + .is_ok() + } + + #[test] + fn the_reported_max_depth_is_exactly_where_the_table_stops_building() { + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + for mean_u in [0.2, 0.5, 0.8, 0.95] { + let max_a = PeakLaw::of(target).max_depth_for_mean(mean_u, 1.0); + if max_a >= DEPTH_A_MAX { + continue; + } + assert!( + table_is_buildable(target, mean_u, max_a - 1e-4), + "{target:?} mean_u={mean_u} refused a just inside the reported max {max_a}" + ); + assert!( + !table_is_buildable(target, mean_u, max_a + 1e-2), + "{target:?} mean_u={mean_u} accepted a past the reported max {max_a}" + ); + } + } + } + + #[test] + fn the_reported_max_mean_is_exactly_where_the_table_stops_building() { + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + for depth_a in [0.1, 0.5, 1.5, 3.0] { + let max_u = PeakLaw::of(target).max_mean_for_depth(depth_a, 1.0); + assert!( + table_is_buildable(target, max_u - 1e-4, depth_a), + "{target:?} a={depth_a} refused a mean just inside the reported max {max_u}" + ); + if max_u < 1.0 - 1e-3 { + assert!( + !table_is_buildable(target, max_u + 1e-2, depth_a), + "{target:?} a={depth_a} accepted a mean past the reported max {max_u}" + ); + } + } + } + } + + #[test] + fn the_two_helpers_are_inverses_of_each_other() { + for target in [OpticalTarget::LogSine, OpticalTarget::LinearSine] { + for mean_u in [0.3, 0.6, 0.9] { + let max_a = PeakLaw::of(target).max_depth_for_mean(mean_u, 1.0); + if max_a >= DEPTH_A_MAX { + continue; + } + let back = PeakLaw::of(target).max_mean_for_depth(max_a, 1.0); + assert!( + (back - mean_u).abs() < 1e-4, + "{target:?}: mean {mean_u} → a {max_a} → mean {back}" + ); + } + } + } + + #[test] + fn a_dac_ceiling_below_v_peak_lowers_the_reachable_intensity() { + let inversion = LobeInversion { + v_null_dac: 200.0, + v_pi_dac: 1_600.0, + }; + // The ceiling at the peak code imposes no limit at all. + assert_eq!(inversion.peak_intensity_ceiling(1_800.0), 1.0); + assert_eq!(inversion.peak_intensity_ceiling(4_095.0), 1.0); + // Halfway up the lobe in code is sin²(π/4) = 0.5 in intensity. + let half = inversion.peak_intensity_ceiling(1_000.0); + assert!( + (half - 0.5).abs() < 1e-9, + "u at the half-span code = {half}" + ); + // A ceiling at or below the null leaves nothing drivable. + assert_eq!(inversion.peak_intensity_ceiling(200.0), 0.0); + } + + #[test] + fn the_log_swing_law_matches_the_calibrated_dac_sine_band() { + // A calibrated DAC_SINE/SQUARE spans u in [ū·e^{-a/2}, ū·e^{+a/2}], so + // its ceiling is reached at exactly a = 2 ln(u_max/ū). + let law = PeakLaw::LogSwing; + let max_a = law.max_depth_for_mean(0.5, 1.0); + assert!((max_a - 2.0 * 2.0_f64.ln()).abs() < 1e-9, "max a = {max_a}"); + assert!((law.peak(0.5, max_a) - 1.0).abs() < 1e-9); + assert!((law.max_mean_for_depth(max_a, 1.0) - 0.5).abs() < 1e-9); + } + + #[test] + fn a_constant_hold_is_limited_only_by_its_own_brightness() { + // CONST modulates nothing, so `a` must not restrict it — requiring the + // modulated band here is what used to freeze a calibrated constant + // drive at its last accepted code. + let law = PeakLaw::Constant; + assert_eq!(law.max_depth_for_mean(1.0, 1.0), DEPTH_A_MAX); + assert_eq!(law.max_mean_for_depth(5.0, 1.0), 1.0); + assert_eq!(law.peak(0.8, 3.0), 0.8); + } + + #[test] + fn a_mean_above_the_ceiling_reports_no_usable_depth() { + // Not a panic and not a silently huge number: the operator has to see + // that this operating point is simply out of reach. + assert_eq!(PeakLaw::LogSine.max_depth_for_mean(0.9, 0.5), 0.0); + assert_eq!(PeakLaw::LinearSine.max_depth_for_mean(0.9, 0.5), 0.0); + assert_eq!(PeakLaw::LogSwing.max_depth_for_mean(0.9, 0.5), 0.0); + } +} diff --git a/plugins/stage-a-modulation/testdata/pockels-20260730-083123.json b/plugins/stage-a-modulation/testdata/pockels-20260730-083123.json new file mode 100644 index 0000000..aa4d030 --- /dev/null +++ b/plugins/stage-a-modulation/testdata/pockels-20260730-083123.json @@ -0,0 +1,705 @@ +{ + "anchor_note": "detector_volts_at_null is a lower bound on the total-power anchor I_tot, not the anchor: on the reject port the residual transmitted floor is not separable from it", + "calibration_id": "pockels-20260730-083123", + "detector_geometry": "REJECT PORT (PD falls as light rises)", + "detector_volts_at_null": 0.05832880721662921, + "detector_volts_at_peak": 0.0075745061683017215, + "hysteresis": 0.25679725274851173, + "lobe_coverage": 4.796857893386575, + "max_level": 3000, + "points": [ + { + "clipped": true, + "code": 0, + "direction": "up", + "peak_to_peak_volts": 0.004835164835164834, + "volts": 0.002619047619047619 + }, + { + "clipped": true, + "code": 63, + "direction": "up", + "peak_to_peak_volts": 0.0016117216117216115, + "volts": 0.002216117216117216 + }, + { + "clipped": true, + "code": 125, + "direction": "up", + "peak_to_peak_volts": 0.004835164835164834, + "volts": 0.002619047619047619 + }, + { + "clipped": true, + "code": 188, + "direction": "up", + "peak_to_peak_volts": 0.006446886446886447, + "volts": 0.004230769230769231 + }, + { + "clipped": true, + "code": 250, + "direction": "up", + "peak_to_peak_volts": 0.002417582417582417, + "volts": 0.0034249084249084244 + }, + { + "clipped": true, + "code": 313, + "direction": "up", + "peak_to_peak_volts": 0.01128205128205128, + "volts": 0.006849816849816849 + }, + { + "clipped": false, + "code": 375, + "direction": "up", + "peak_to_peak_volts": 0.05399267399267399, + "volts": 0.044120879120879114 + }, + { + "clipped": false, + "code": 438, + "direction": "up", + "peak_to_peak_volts": 0.05399267399267399, + "volts": 0.04230769230769231 + }, + { + "clipped": false, + "code": 500, + "direction": "up", + "peak_to_peak_volts": 0.025787545787545788, + "volts": 0.024175824175824177 + }, + { + "clipped": false, + "code": 563, + "direction": "up", + "peak_to_peak_volts": 0.029816849816849816, + "volts": 0.0558058608058608 + }, + { + "clipped": false, + "code": 625, + "direction": "up", + "peak_to_peak_volts": 0.004835164835164834, + "volts": 0.06366300366300366 + }, + { + "clipped": false, + "code": 688, + "direction": "up", + "peak_to_peak_volts": 0.02498168498168498, + "volts": 0.0543956043956044 + }, + { + "clipped": false, + "code": 750, + "direction": "up", + "peak_to_peak_volts": 0.012087912087912088, + "volts": 0.06043956043956044 + }, + { + "clipped": false, + "code": 813, + "direction": "up", + "peak_to_peak_volts": 0.020146520146520148, + "volts": 0.055 + }, + { + "clipped": false, + "code": 875, + "direction": "up", + "peak_to_peak_volts": 0.03465201465201465, + "volts": 0.039285714285714285 + }, + { + "clipped": false, + "code": 938, + "direction": "up", + "peak_to_peak_volts": 0.02256410256410256, + "volts": 0.05983516483516483 + }, + { + "clipped": false, + "code": 1000, + "direction": "up", + "peak_to_peak_volts": 0.05479853479853479, + "volts": 0.05177655677655677 + }, + { + "clipped": true, + "code": 1063, + "direction": "up", + "peak_to_peak_volts": 0.010476190476190476, + "volts": 0.007051282051282051 + }, + { + "clipped": true, + "code": 1125, + "direction": "up", + "peak_to_peak_volts": 0.041098901098901096, + "volts": 0.021153846153846155 + }, + { + "clipped": true, + "code": 1188, + "direction": "up", + "peak_to_peak_volts": 0.008864468864468863, + "volts": 0.00402930402930403 + }, + { + "clipped": true, + "code": 1250, + "direction": "up", + "peak_to_peak_volts": 0.03304029304029304, + "volts": 0.01672161172161172 + }, + { + "clipped": true, + "code": 1313, + "direction": "up", + "peak_to_peak_volts": 0.03223443223443224, + "volts": 0.0139010989010989 + }, + { + "clipped": true, + "code": 1375, + "direction": "up", + "peak_to_peak_volts": 0.01531135531135531, + "volts": 0.009267399267399268 + }, + { + "clipped": false, + "code": 1438, + "direction": "up", + "peak_to_peak_volts": 0.017728937728937726, + "volts": 0.01652014652014652 + }, + { + "clipped": false, + "code": 1500, + "direction": "up", + "peak_to_peak_volts": 0.008864468864468863, + "volts": 0.029816849816849816 + }, + { + "clipped": true, + "code": 1563, + "direction": "up", + "peak_to_peak_volts": 0.021758241758241755, + "volts": 0.008864468864468863 + }, + { + "clipped": true, + "code": 1625, + "direction": "up", + "peak_to_peak_volts": 0.04996336996336996, + "volts": 0.01631868131868132 + }, + { + "clipped": false, + "code": 1688, + "direction": "up", + "peak_to_peak_volts": 0.03948717948717948, + "volts": 0.03485347985347985 + }, + { + "clipped": false, + "code": 1750, + "direction": "up", + "peak_to_peak_volts": 0.014505494505494505, + "volts": 0.06064102564102564 + }, + { + "clipped": false, + "code": 1813, + "direction": "up", + "peak_to_peak_volts": 0.029816849816849816, + "volts": 0.058424908424908426 + }, + { + "clipped": false, + "code": 1875, + "direction": "up", + "peak_to_peak_volts": 0.041098901098901096, + "volts": 0.053791208791208786 + }, + { + "clipped": false, + "code": 1938, + "direction": "up", + "peak_to_peak_volts": 0.021758241758241755, + "volts": 0.04714285714285714 + }, + { + "clipped": false, + "code": 2000, + "direction": "up", + "peak_to_peak_volts": 0.02498168498168498, + "volts": 0.0554029304029304 + }, + { + "clipped": false, + "code": 2063, + "direction": "up", + "peak_to_peak_volts": 0.012893772893772894, + "volts": 0.06023809523809523 + }, + { + "clipped": false, + "code": 2125, + "direction": "up", + "peak_to_peak_volts": 0.02820512820512821, + "volts": 0.05036630036630037 + }, + { + "clipped": false, + "code": 2188, + "direction": "up", + "peak_to_peak_volts": 0.025787545787545788, + "volts": 0.05822344322344322 + }, + { + "clipped": false, + "code": 2250, + "direction": "up", + "peak_to_peak_volts": 0.038681318681318674, + "volts": 0.05076923076923076 + }, + { + "clipped": false, + "code": 2313, + "direction": "up", + "peak_to_peak_volts": 0.03223443223443224, + "volts": 0.02296703296703297 + }, + { + "clipped": false, + "code": 2375, + "direction": "up", + "peak_to_peak_volts": 0.014505494505494505, + "volts": 0.009670329670329669 + }, + { + "clipped": true, + "code": 2438, + "direction": "up", + "peak_to_peak_volts": 0.008864468864468863, + "volts": 0.004835164835164834 + }, + { + "clipped": false, + "code": 2500, + "direction": "up", + "peak_to_peak_volts": 0.03626373626373627, + "volts": 0.02478021978021978 + }, + { + "clipped": true, + "code": 2563, + "direction": "up", + "peak_to_peak_volts": 0.03465201465201465, + "volts": 0.02095238095238095 + }, + { + "clipped": false, + "code": 2625, + "direction": "up", + "peak_to_peak_volts": 0.02336996336996337, + "volts": 0.014505494505494505 + }, + { + "clipped": false, + "code": 2688, + "direction": "up", + "peak_to_peak_volts": 0.004835164835164834, + "volts": 0.01672161172161172 + }, + { + "clipped": true, + "code": 2750, + "direction": "up", + "peak_to_peak_volts": 0.007252747252747252, + "volts": 0.005641025641025641 + }, + { + "clipped": true, + "code": 2813, + "direction": "up", + "peak_to_peak_volts": 0.037875457875457874, + "volts": 0.02195970695970696 + }, + { + "clipped": true, + "code": 2875, + "direction": "up", + "peak_to_peak_volts": 0.03948717948717948, + "volts": 0.013095238095238096 + }, + { + "clipped": false, + "code": 2938, + "direction": "up", + "peak_to_peak_volts": 0.040293040293040296, + "volts": 0.037875457875457874 + }, + { + "clipped": false, + "code": 3000, + "direction": "up", + "peak_to_peak_volts": 0.031428571428571424, + "volts": 0.04976190476190476 + }, + { + "clipped": false, + "code": 3000, + "direction": "down", + "peak_to_peak_volts": 0.02901098901098901, + "volts": 0.05238095238095238 + }, + { + "clipped": false, + "code": 2938, + "direction": "down", + "peak_to_peak_volts": 0.008864468864468863, + "volts": 0.0552014652014652 + }, + { + "clipped": false, + "code": 2875, + "direction": "down", + "peak_to_peak_volts": 0.041098901098901096, + "volts": 0.02498168498168498 + }, + { + "clipped": false, + "code": 2813, + "direction": "down", + "peak_to_peak_volts": 0.03545787545787545, + "volts": 0.021758241758241755 + }, + { + "clipped": false, + "code": 2750, + "direction": "down", + "peak_to_peak_volts": 0.008864468864468863, + "volts": 0.02880952380952381 + }, + { + "clipped": false, + "code": 2688, + "direction": "down", + "peak_to_peak_volts": 0.012087912087912088, + "volts": 0.011886446886446888 + }, + { + "clipped": true, + "code": 2625, + "direction": "down", + "peak_to_peak_volts": 0.02095238095238095, + "volts": 0.014706959706959706 + }, + { + "clipped": true, + "code": 2563, + "direction": "down", + "peak_to_peak_volts": 0.01128205128205128, + "volts": 0.004835164835164834 + }, + { + "clipped": true, + "code": 2500, + "direction": "down", + "peak_to_peak_volts": 0.0427106227106227, + "volts": 0.017527472527472526 + }, + { + "clipped": true, + "code": 2438, + "direction": "down", + "peak_to_peak_volts": 0.02820512820512821, + "volts": 0.014706959706959706 + }, + { + "clipped": false, + "code": 2375, + "direction": "down", + "peak_to_peak_volts": 0.05479853479853479, + "volts": 0.0408974358974359 + }, + { + "clipped": false, + "code": 2313, + "direction": "down", + "peak_to_peak_volts": 0.01128205128205128, + "volts": 0.011483516483516485 + }, + { + "clipped": false, + "code": 2250, + "direction": "down", + "peak_to_peak_volts": 0.018534798534798533, + "volts": 0.05983516483516483 + }, + { + "clipped": false, + "code": 2188, + "direction": "down", + "peak_to_peak_volts": 0.038681318681318674, + "volts": 0.039285714285714285 + }, + { + "clipped": false, + "code": 2125, + "direction": "down", + "peak_to_peak_volts": 0.02336996336996337, + "volts": 0.052985347985347986 + }, + { + "clipped": false, + "code": 2063, + "direction": "down", + "peak_to_peak_volts": 0.02336996336996337, + "volts": 0.045128205128205125 + }, + { + "clipped": false, + "code": 2000, + "direction": "down", + "peak_to_peak_volts": 0.01531135531135531, + "volts": 0.058424908424908426 + }, + { + "clipped": false, + "code": 1938, + "direction": "down", + "peak_to_peak_volts": 0.006446886446886447, + "volts": 0.06124542124542124 + }, + { + "clipped": false, + "code": 1875, + "direction": "down", + "peak_to_peak_volts": 0.007252747252747252, + "volts": 0.06326007326007327 + }, + { + "clipped": false, + "code": 1813, + "direction": "down", + "peak_to_peak_volts": 0.013699633699633696, + "volts": 0.06386446886446887 + }, + { + "clipped": false, + "code": 1750, + "direction": "down", + "peak_to_peak_volts": 0.03948717948717948, + "volts": 0.04049450549450549 + }, + { + "clipped": false, + "code": 1688, + "direction": "down", + "peak_to_peak_volts": 0.038681318681318674, + "volts": 0.02860805860805861 + }, + { + "clipped": false, + "code": 1625, + "direction": "down", + "peak_to_peak_volts": 0.02659340659340659, + "volts": 0.04452380952380952 + }, + { + "clipped": true, + "code": 1563, + "direction": "down", + "peak_to_peak_volts": 0.045128205128205125, + "volts": 0.017124542124542126 + }, + { + "clipped": true, + "code": 1500, + "direction": "down", + "peak_to_peak_volts": 0.016923076923076923, + "volts": 0.007051282051282051 + }, + { + "clipped": false, + "code": 1438, + "direction": "down", + "peak_to_peak_volts": 0.012087912087912088, + "volts": 0.02195970695970696 + }, + { + "clipped": true, + "code": 1375, + "direction": "down", + "peak_to_peak_volts": 0.01128205128205128, + "volts": 0.006648351648351648 + }, + { + "clipped": false, + "code": 1313, + "direction": "down", + "peak_to_peak_volts": 0.005641025641025641, + "volts": 0.009670329670329669 + }, + { + "clipped": true, + "code": 1250, + "direction": "down", + "peak_to_peak_volts": 0.03465201465201465, + "volts": 0.01631868131868132 + }, + { + "clipped": true, + "code": 1188, + "direction": "down", + "peak_to_peak_volts": 0.027399267399267395, + "volts": 0.010677655677655676 + }, + { + "clipped": true, + "code": 1125, + "direction": "down", + "peak_to_peak_volts": 0.005641025641025641, + "volts": 0.003626373626373626 + }, + { + "clipped": false, + "code": 1063, + "direction": "down", + "peak_to_peak_volts": 0.04351648351648351, + "volts": 0.04230769230769231 + }, + { + "clipped": false, + "code": 1000, + "direction": "down", + "peak_to_peak_volts": 0.007252747252747252, + "volts": 0.011886446886446888 + }, + { + "clipped": false, + "code": 938, + "direction": "down", + "peak_to_peak_volts": 0.04351648351648351, + "volts": 0.02901098901098901 + }, + { + "clipped": false, + "code": 875, + "direction": "down", + "peak_to_peak_volts": 0.006446886446886447, + "volts": 0.06426739926739927 + }, + { + "clipped": false, + "code": 813, + "direction": "down", + "peak_to_peak_volts": 0.01611721611721612, + "volts": 0.042912087912087914 + }, + { + "clipped": false, + "code": 750, + "direction": "down", + "peak_to_peak_volts": 0.012087912087912088, + "volts": 0.06043956043956044 + }, + { + "clipped": false, + "code": 688, + "direction": "down", + "peak_to_peak_volts": 0.019340659340659337, + "volts": 0.05661172161172161 + }, + { + "clipped": false, + "code": 625, + "direction": "down", + "peak_to_peak_volts": 0.02659340659340659, + "volts": 0.0554029304029304 + }, + { + "clipped": false, + "code": 563, + "direction": "down", + "peak_to_peak_volts": 0.04593406593406593, + "volts": 0.03747252747252747 + }, + { + "clipped": false, + "code": 500, + "direction": "down", + "peak_to_peak_volts": 0.05963369963369963, + "volts": 0.04956043956043956 + }, + { + "clipped": true, + "code": 438, + "direction": "down", + "peak_to_peak_volts": 0.027399267399267395, + "volts": 0.010073260073260074 + }, + { + "clipped": false, + "code": 375, + "direction": "down", + "peak_to_peak_volts": 0.06285714285714285, + "volts": 0.031025641025641024 + }, + { + "clipped": false, + "code": 313, + "direction": "down", + "peak_to_peak_volts": 0.006446886446886447, + "volts": 0.062454212454212454 + }, + { + "clipped": true, + "code": 250, + "direction": "down", + "peak_to_peak_volts": 0.005641025641025641, + "volts": 0.004835164835164834 + }, + { + "clipped": false, + "code": 188, + "direction": "down", + "peak_to_peak_volts": 0.002417582417582417, + "volts": 0.005439560439560439 + }, + { + "clipped": true, + "code": 125, + "direction": "down", + "peak_to_peak_volts": 0.0016117216117216115, + "volts": 0.003223443223443223 + }, + { + "clipped": true, + "code": 63, + "direction": "down", + "peak_to_peak_volts": 0.002417582417582417, + "volts": 0.002417582417582417 + }, + { + "clipped": true, + "code": 0, + "direction": "down", + "peak_to_peak_volts": 0.0016117216117216115, + "volts": 0.002014652014652015 + } + ], + "port": "auto", + "quality": 0.2227805649633928, + "rejected_points": 0, + "rms_residual_volts": 0.011307071861868518, + "span_volts": -0.05075430104832749, + "v_null_dac": 711.9225837107243, + "v_pi_dac": 625.4093964584814 +} \ No newline at end of file diff --git a/plugins/stage-a-photodiode/Cargo.toml b/plugins/stage-a-photodiode/Cargo.toml new file mode 100644 index 0000000..e6b4c6a --- /dev/null +++ b/plugins/stage-a-photodiode/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "augur-plugin-stage-a-photodiode" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Stage-A photodiode readout (SMA5/pin 18/A4) from the Teensy stream port: raw values or excitation power I_exc = I_tot - I_pd." + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +augur-plugin-api.workspace = true +serde_json.workspace = true +serialport.workspace = true +# `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" } + +[dev-dependencies] +augur-plugin-stage-a-a1 = { path = "../stage-a-a1" } diff --git a/plugins/stage-a-photodiode/README.md b/plugins/stage-a-photodiode/README.md new file mode 100644 index 0000000..a04b711 --- /dev/null +++ b/plugins/stage-a-photodiode/README.md @@ -0,0 +1,83 @@ +# Stage-A Photodiode + +Live readout of the photodiode on **board SMA5 → Teensy pin 18 / analog input A4**, from the +free-running PDA1 `SamplesU16` stream the `stage-a-controller` firmware (0.4.0+) emits on its +**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`. + +## 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** — 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 + Pockels transfer sweep drives through that null by construction, so running it once teaches the + 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. + +## Ports + +**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 + +This plugin is the sole owner of the Teensy photodiode stream port. Workflow +plugins control named recordings through the versioned +`stage_a.photodiode.control.v1` service and consume bounded +`stage_a.photodiode_summary.v1` snapshots. They never open the serial port or +receive raw sample arrays through the control plane; finalized PDQ files remain +the replay and analysis source of truth. + +The snapshot's `stream.level` block carries the settled detector level in **raw** +detector volts — the ADC map only, never the RAW/EXCITATION display transform and +never the optical geometry transform. It is averaged over a fixed **20 ms** +owned here and independent of the chart's moving-average setting, because that +setting is a display preference and this is a measurement: deriving one from the +other let a default of four samples publish 8 µs per point at 500 kSa/s and +report a clean Pockels calibration as a 22 % residual +([ADR 019](../../docs/adr/019-stage-a-calibration-measures-its-own-window.md)). +It +also reports the window's peak-to-peak spread and the sample index it ends at, +so a consumer can prove a reading was taken *after* it changed something without +a shared clock. Unlike `optical_summary` it never refuses: it stays present +while the window clips (flagged), because the Pockels transfer sweep needs a +reading exactly where the reject-port detector is brightest. + +When `optical_summary` *is* refused, `optical_unavailable` on the same snapshot +carries the reason, so a consumer that gates on `a` can name the gate instead of +reporting absence. Clip detection is span-relative — the near-rail margin is +capped at 5 % of the window's own peak-to-peak span, so this detector's 0.5–15 mV +operating range is not mistaken for a waveform truncating at code 0. See +[ADR 017](../../docs/adr/017-stage-a-rail-detection-and-withheld-a-reasons.md). diff --git a/plugins/stage-a-photodiode/plugin.toml b/plugins/stage-a-photodiode/plugin.toml new file mode 100644 index 0000000..f773a84 --- /dev/null +++ b/plugins/stage-a-photodiode/plugin.toml @@ -0,0 +1,8 @@ +id = "stage-a.photodiode" +name = "Stage-A Photodiode" +version = "0.4.0" +description = "Live photodiode readout (SMA5/pin 18/A4) from the Teensy stream port: raw values or excitation power I_exc = I_tot - I_pd." +domain = "stage-a" +library = "augur_plugin_stage_a_photodiode" +phase = "frame_only" +min_augur_version = "1.0.0" diff --git a/plugins/stage-a-photodiode/src/lib.rs b/plugins/stage-a-photodiode/src/lib.rs new file mode 100644 index 0000000..e5072f7 --- /dev/null +++ b/plugins/stage-a-photodiode/src/lib.rs @@ -0,0 +1,5518 @@ +//! Stage-A photodiode readout. +//! +//! Reads the free-running PDA1 binary frame stream the `stage-a-controller` +//! firmware (0.4.0+, `USB_DUAL_SERIAL`) emits on its **second** USB serial +//! port: `SamplesU16` frames at `pd_stream_rate_hz` (20 kSa/s default) from +//! the photodiode on board SMA5 → Teensy pin 18 / A4. The port carries no +//! commands, so opening it is side-effect free; the command port is owned by +//! `stage-a-modulation`. While a command-port acquisition runs the firmware +//! mirrors its blocks here (flag 0x0001) — every rate change or sample-index +//! jump is treated as a segment restart. +//! +//! Two display modes: +//! - **RAW**: the ADC code and its voltage (`V = code · 3.3 / 4095`); +//! - **EXCITATION**: the photodiode sits behind the PBS in the excitation +//! path and sees the light removed from the beam, `I_pd = I_tot − I_exc`. +//! Given the user-set reference `I_tot` (in photodiode volts), the plugin +//! shows `I_exc = I_tot − V_pd`. +//! +//! The chart decimates the visible window into min/mean/max envelope buckets +//! and overlays a moving average whose window is either a fixed sample count +//! 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::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use augur_plugin_api::PathDialogKind; +use augur_plugin_api::{ + export_plugin, EventStoreHandle, HostContext, HostDatasetDescriptor, HostDatasetKind, + HostOutput, HostViewDescriptor, HostViewKind, HostViewPlacement, HostViewRegistry, Plugin, + PluginControlContext, PluginControlSnapshot, PluginFrame, PluginRuntimeRole, + PluginServiceOutcome, PluginServiceReply, PluginServiceRequest, Series1dLine, Series1dPoint, + Series1dV1, SettingItem, SettingKind, SettingsSchema, SettingsSection, StatusEntry, + TableColumn, TableColumnData, TableColumnValues, TableDatasetV1, TableSchema, TableValueType, +}; +use serde_json::{json, Value}; +use stage_a_io::{ + estimate_contrast, near_rail_margin, AdcCalibration, ContrastGeometry, EstimateError, + FrameParser, ParseEvent, PdqWriter, StreamIntegrity, +}; +use stage_a_plugin_contract::{ + ClientId, ConnectionStateV1, FreshnessV1, LeaseId, LeaseSnapshotV1, OwnerInstanceId, + PdqFinalizedReceiptV1, PdqReceiptV1, PdqStartSpecV1, PdqStartedReceiptV1, PdqTerminationV1, + PhotodiodeCalibrationV1, PhotodiodeCommandV1, PhotodiodeDarkReferenceV1, + PhotodiodeDarkSourceV1, PhotodiodeLevelV1, PhotodiodeOpticalSummaryV1, PhotodiodePlacementV1, + PhotodiodeRequestV1, PhotodiodeResponseV1, PhotodiodeStreamV1, PhotodiodeSummaryV1, + RequestOutcomeV1, ResponseCommonV1, RunId, SampleRangeV1, SemanticRevision, ServiceErrorCodeV1, + ServiceErrorV1, Sha256V1, StreamIntegrityV1, SynchronizationV1, UnsyncedReasonV1, + CONTRACT_VERSION_V1, CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, PLUGIN_ID_STAGE_A_PHOTODIODE, + SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, +}; + +const SERIES_DATASET_ID: &str = "stage-a-photodiode.series"; +const SPECTRUM_DATASET_ID: &str = "stage-a-photodiode.spectrum"; +const SPECTRUM_VIEW_ID: &str = "stage-a-photodiode.spectrum.view"; +const SERIES_VIEW_ID: &str = "stage-a-photodiode.series.view"; +const STATUS_DATASET_ID: &str = "stage-a-photodiode.status"; +const STATUS_VIEW_ID: &str = "stage-a-photodiode.status.view"; + +const ADC_FULL_SCALE_VOLTS: f64 = 3.3; +const ADC_MAX_CODE: f64 = 4_095.0; +/// Default monitor cache, in seconds of samples at the active stream rate +/// (user-settable 1–130 s). +const DEFAULT_CACHE_SECONDS: f64 = 20.0; +const MAX_CACHE_SECONDS: f64 = 130.0; +/// Absolute sample cap: 16 M samples = 32 s at the firmware's 500 kSa/s +/// stream rate (32 MiB of codes + ~2 MiB of summary cells). +const RING_MAX_SAMPLES: usize = 16_000_000; +/// Raw samples per incremental summary cell (min/max/sum), the unit both +/// chart decimation and the moving average combine instead of raw rescans. +const SUMMARY_CELL: usize = 64; +/// Envelope buckets per rendered chart line; keeps the plot payload bounded +/// no matter how many raw samples the window covers. +const MAX_PLOT_BUCKETS: usize = 1_000; +/// Spectrum FFT window bounds: 16384 samples ≈ 0.8 s at 20 kSa/s +/// (Δf ≈ 1.2 Hz); below 256 samples a spectrum is not meaningful. +const SPECTRUM_MIN_SAMPLES: usize = 256; +const SPECTRUM_MAX_SAMPLES: usize = 16_384; +/// The firmware's default stream rate; the mock mirrors it. +const MOCK_RATE_HZ: u32 = 20_000; +/// Floor on the trailing samples used for the live optical log-contrast `a`, +/// and the fallback window when no phase-0 markers give a period. Sized like +/// the spectrum window: 16 384 samples ≈ 0.82 s at 20 kSa/s. +#[cfg(test)] +const CONTRAST_WINDOW_SAMPLES: usize = 16_384; +/// Whole modulation cycles the contrast window is sized to cover. +/// +/// `a` is a *peak-to-peak* quantity, so a window shorter than one cycle sees +/// only an arc of the waveform and under-reports it — and a consumer that +/// divides by the measured `a` (A1's `a₀` lock) then inflates its drive against +/// that bias. A fixed 0.82 s window is below one cycle for every `f < 1.2 Hz`, +/// i.e. exactly the sub-hertz plateau reference the A1 protocol needs. The +/// markers give the period on the same sample clock, so size the window from +/// them instead. +const CONTRAST_WINDOW_CYCLES: f64 = 8.0; +const MOCK_BLOCK_SAMPLES: usize = 256; +/// Cap on retained phase-0 markers (bounds the overlay + frequency window). +const MAX_MARKERS: usize = 4_096; +/// Mock phase-0 marker period in samples (20 kSa/s / 40 = 500 Hz modulation). +const MOCK_MARKER_PERIOD_SAMPLES: u64 = 40; +/// Seconds of samples the **published** level averages over, independent of the +/// chart's averaging setting. One full mains period: a boxcar of exactly this +/// length nulls 50 Hz and every harmonic of it. See +/// [`StageAPhotodiodePlugin::level_window_samples`] and ADR 019. +const LEVEL_WINDOW_SECONDS: f64 = 0.020; +const REQUEST_CACHE_LIMIT: usize = 256; +const MIN_LEASE_TTL_MS: u64 = 1_000; +const MAX_LEASE_TTL_MS: u64 = 60_000; +const SNAPSHOT_VALID_FOR_MS: u64 = 2_000; +static OWNER_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +fn code_to_volts(code: f64) -> f64 { + code * ADC_FULL_SCALE_VOLTS / ADC_MAX_CODE +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Raw, + Excitation, +} + +const PHOTODIODE_PLACEMENTS: [PhotodiodePlacementV1; 3] = [ + PhotodiodePlacementV1::RejectedPort, + PhotodiodePlacementV1::CameraPath, + PhotodiodePlacementV1::EmissionPath, +]; + +fn placement_name(placement: PhotodiodePlacementV1) -> &'static str { + match placement { + PhotodiodePlacementV1::RejectedPort => "PBS rejected port", + PhotodiodePlacementV1::CameraPath => "camera path (direct)", + PhotodiodePlacementV1::EmissionPath => "emission path (direct fluorescence)", + } +} + +fn placement_from_name(name: &str) -> Option { + PHOTODIODE_PLACEMENTS + .into_iter() + .find(|placement| placement_name(*placement) == name) +} + +impl Mode { + const VARIANTS: [Mode; 2] = [Mode::Raw, Mode::Excitation]; + + fn name(self) -> &'static str { + match self { + Self::Raw => "RAW", + Self::Excitation => "EXCITATION", + } + } + + fn from_name(name: &str) -> Option { + Self::VARIANTS.into_iter().find(|m| m.name() == name) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimeAxis { + /// Scrolling view: x = seconds before the newest sample (ends at 0). + BeforeNow, + /// Fixed view: x = seconds since the segment start on the device clock — + /// a frozen plot reads as absolute positions, not implied motion. + Segment, +} + +impl TimeAxis { + const VARIANTS: [TimeAxis; 2] = [TimeAxis::BeforeNow, TimeAxis::Segment]; + + fn name(self) -> &'static str { + match self { + Self::BeforeNow => "BEFORE NOW", + Self::Segment => "SEGMENT TIME", + } + } + + fn from_name(name: &str) -> Option { + Self::VARIANTS.into_iter().find(|axis| axis.name() == name) + } + + fn label(self) -> &'static str { + match self { + Self::BeforeNow => "time before now [s]", + Self::Segment => "segment time [s]", + } + } +} + +struct SharedState { + /// Sample rate of the current segment (from the frame headers). + rate_hz: u32, + /// Device sample index of `samples.front()` within the current segment. + ring_first_index: u64, + samples: VecDeque, + /// Incremental 64:1 summaries: `cells[i]` covers deque offsets + /// `[i·CELL, (i+1)·CELL)`. Kept aligned by evicting whole cells, so the + /// chart and moving average never rescan the raw window — at 500 kSa/s a + /// full-window rescan per repaint would not be viable. + cells: VecDeque, + /// Phase-0 marker sample indices (device clock) still inside the ring, from + /// `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. + /// + /// The retained markers alone cannot measure a period longer than the ring: + /// once the ring holds less than one cycle it holds at most one marker, so + /// the mean spacing is undefined exactly where knowing the period matters + /// most. Markers arrive one at a time, so remember the interval as it goes + /// past instead of trying to recover it from what survived eviction. + last_marker_index: Option, + marker_period_estimate: Option, + latest: Option, + /// Cumulative firmware-side drop counter (latest header value). + device_dropped: u32, + crc_failures: u64, + resync_bytes: u64, + /// Segment restarts observed (rate changes, index jumps, reconnects). + segments: u64, + /// Monitor-cache length driving ring eviction (user setting). + cache_seconds: f64, + /// Highest smoothed detector level observed since the port was opened, in + /// raw ADC codes. **This is the total-power anchor `I_tot`.** + /// + /// The detector sits on the PBS reject port and reads the complement + /// `I_pd = I_tot − I_exc`, so it is brightest exactly where the excitation + /// is fully extinguished — and there `I_pd = I_tot`. Nothing has to be + /// typed in: the Pockels transfer sweep walks the DAC across the whole + /// lobe, which lands on the excitation null by construction, so the anchor + /// is learned by the calibration the operator already runs (ADR 024). + /// + /// Latched over completed [`SUMMARY_CELL`]-sample cells, never over raw + /// samples: one noise spike must not become the anchor every later `a` is + /// divided against. + observed_peak_code: Option, + error: Option, + last_update_unix_ms: u64, +} + +/// min/max/sum over exactly [`SUMMARY_CELL`] consecutive raw samples. +#[derive(Clone, Copy)] +struct SummaryCell { + min: u16, + max: u16, + sum: u32, +} + +/// Accumulated min/max/sum/count over an arbitrary sample range. +#[derive(Clone, Copy)] +struct RangeSummary { + min: u16, + max: u16, + sum: u64, + count: usize, +} + +impl RangeSummary { + fn mean(&self) -> f64 { + if self.count == 0 { + return 0.0; + } + self.sum as f64 / self.count as f64 + } +} + +impl Default for SharedState { + fn default() -> Self { + Self { + rate_hz: 0, + ring_first_index: 0, + samples: VecDeque::new(), + cells: VecDeque::new(), + markers: VecDeque::new(), + comparator_markers: VecDeque::new(), + last_marker_index: None, + marker_period_estimate: None, + latest: None, + device_dropped: 0, + crc_failures: 0, + resync_bytes: 0, + segments: 0, + cache_seconds: DEFAULT_CACHE_SECONDS, + observed_peak_code: None, + error: None, + last_update_unix_ms: 0, + } + } +} + +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 { + 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, + /// sample-index jump (drops, acquisition handover), reconnect — restarts + /// the ring: within a segment `index / rate` is a consistent time base. + fn ingest(&mut self, first_index: u64, rate_hz: u32, device_dropped: u32, codes: &[u16]) { + if codes.is_empty() { + return; + } + let expected = self.ring_first_index + self.samples.len() as u64; + let continuous = + !self.samples.is_empty() && rate_hz == self.rate_hz && first_index == expected; + if !continuous { + if !self.samples.is_empty() { + self.segments += 1; + } + 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.ring_first_index = first_index; + self.rate_hz = rate_hz; + } + self.samples.extend(codes.iter().copied()); + self.latest = codes.last().copied(); + self.device_dropped = device_dropped; + self.last_update_unix_ms = now_unix_ms(); + + // Summarize every newly completed cell. + while (self.cells.len() + 1) * SUMMARY_CELL <= self.samples.len() { + let start = self.cells.len() * SUMMARY_CELL; + let mut cell = SummaryCell { + min: u16::MAX, + max: u16::MIN, + sum: 0, + }; + for &code in self.samples.range(start..start + SUMMARY_CELL) { + cell.min = cell.min.min(code); + cell.max = cell.max.max(code); + cell.sum += u32::from(code); + } + // Learn the total-power anchor as we go. The latch survives segment + // restarts on purpose: a rate change, a drop or an acquisition + // handover does not move the optics, and the calibration sweep that + // teaches the anchor is followed by exactly such a handover. + let cell_mean = f64::from(cell.sum) / SUMMARY_CELL as f64; + if self.observed_peak_code.is_none_or(|peak| cell_mean > peak) { + self.observed_peak_code = Some(cell_mean); + } + self.cells.push_back(cell); + } + + // Evict whole cells only, keeping the cell/offset alignment intact; + // the ring may exceed its capacity by up to one cell. + let excess = self + .samples + .len() + .saturating_sub(self.ring_capacity(rate_hz)); + let evict_cells = excess / SUMMARY_CELL; + if evict_cells > 0 { + let evict = evict_cells * SUMMARY_CELL; + self.samples.drain(..evict); + self.cells.drain(..evict_cells); + self.ring_first_index += evict as u64; + } + // Drop markers that fell out of the retained ring window. + while self + .markers + .front() + .is_some_and(|&index| index < self.ring_first_index) + { + 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 { + return; + } + if self + .markers + .back() + .is_some_and(|&last| last == sample_index) + { + return; // ignore duplicate stamps + } + if let Some(previous) = self.last_marker_index { + if sample_index > previous { + self.marker_period_estimate = Some((sample_index - previous) as f64); + } + } + self.last_marker_index = Some(sample_index); + self.markers.push_back(sample_index); + while self.markers.len() > MAX_MARKERS { + self.markers.pop_front(); + } + 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. + /// + /// `a` is peak-to-peak, so the window has to span whole cycles: sized to + /// [`CONTRAST_WINDOW_CYCLES`] of the marker-measured period, floored at + /// [`CONTRAST_WINDOW_SAMPLES`] so nothing gets shorter than today at high + /// `f`, and capped by what the ring actually retains. `covered_cycles` is + /// `None` when there is no period to measure against — then the caller can + /// only fall back to the fixed window and say so. + #[cfg(test)] + fn contrast_window(&self) -> (usize, Option) { + let available = self.samples.len(); + let Some(period) = self.marker_period_samples() else { + return (available.min(CONTRAST_WINDOW_SAMPLES), None); + }; + let wanted = (period * CONTRAST_WINDOW_CYCLES).ceil() as usize; + let window = wanted.max(CONTRAST_WINDOW_SAMPLES).min(available); + (window, Some(window as f64 / period)) + } + + /// Mean marker spacing in samples, i.e. the modulation period on the device + /// clock — the trigger *defining* the frequency. `None` with < 2 markers. + fn marker_period_samples(&self) -> Option { + if self.markers.len() < 2 { + // Below one retained cycle only the remembered interval is left. + return self.marker_period_estimate; + } + let first = *self.markers.front()?; + let last = *self.markers.back()?; + let spans = (self.markers.len() - 1) as f64; + let period = last.saturating_sub(first) as f64 / spans; + (period > 0.0).then_some(period) + } + + /// 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). + fn range_summary(&self, start: usize, end: usize) -> RangeSummary { + let end = end.min(self.samples.len()); + let mut summary = RangeSummary { + min: u16::MAX, + max: u16::MIN, + sum: 0, + count: 0, + }; + if start >= end { + return summary; + } + summary.count = end - start; + let covered = self.cells.len() * SUMMARY_CELL; + let mut i = start; + + // Raw head up to the next cell boundary. + let head_end = (i.div_ceil(SUMMARY_CELL) * SUMMARY_CELL) + .min(end) + .min(covered.max(i)); + if head_end > i { + for &code in self.samples.range(i..head_end) { + summary.min = summary.min.min(code); + summary.max = summary.max.max(code); + summary.sum += u64::from(code); + } + i = head_end; + } + // Whole cells. + while i + SUMMARY_CELL <= end.min(covered) { + let cell = self.cells[i / SUMMARY_CELL]; + summary.min = summary.min.min(cell.min); + summary.max = summary.max.max(cell.max); + summary.sum += u64::from(cell.sum); + i += SUMMARY_CELL; + } + // Raw tail (past the last whole cell in range, or past `covered`). + for &code in self.samples.range(i..end) { + summary.min = summary.min.min(code); + summary.max = summary.max.max(code); + summary.sum += u64::from(code); + } + summary + } +} + +/// One active disk recording: every clean `SamplesU16` frame is appended +/// verbatim to a `.pdq` file; `stop` writes the JSON sidecar next to it. +struct RecordingSink { + writer: PdqWriter, + pdq_path: PathBuf, + sidecar_path: PathBuf, + pdq_path_label: String, + sidecar_path_label: String, + run_id: RunId, + opened_at_unix_ms: u64, + stream_epoch: u64, + first_sample_index: Option, + metadata: BTreeMap, + started_slug: String, + samples_written: u64, + write_error: Option, + /// Integrity counters at recording start, so the sidecar reports deltas + /// for exactly the recorded span. + start_crc_failures: u64, + start_resync_bytes: u64, + start_device_dropped: u32, + start_segments: u64, +} + +impl RecordingSink { + fn started_receipt(&self) -> PdqStartedReceiptV1 { + PdqStartedReceiptV1 { + run_id: self.run_id.clone(), + pdq_path: self.pdq_path_label.clone(), + sidecar_path: self.sidecar_path_label.clone(), + opened_at_unix_ms: self.opened_at_unix_ms, + stream_epoch: self.stream_epoch, + first_sample_index: self.first_sample_index, + } + } +} + +type SharedRecording = Arc>>; + +fn record_frame(recording: &SharedRecording, frame: &stage_a_io::Frame, samples: usize) { + let Ok(mut slot) = recording.lock() else { + return; + }; + let Some(sink) = slot.as_mut() else { + return; + }; + 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}")), + } +} + +/// `YYYYmmdd_HHMMSS` in UTC without a date-time dependency (Howard Hinnant's +/// civil-from-days algorithm). +fn timestamp_slug() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = (seconds / 86_400) as i64; + let (secs_of_day, z) = ((seconds % 86_400) as u32, days + 719_468); + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe as i64 + 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; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + format!( + "{year:04}{month:02}{day:02}_{:02}{:02}{:02}", + secs_of_day / 3_600, + (secs_of_day / 60) % 60, + secs_of_day % 60 + ) +} + +/// Background reader owning the stream port (or the mock generator). +struct Reader { + stop: Arc, + join: Option>, +} + +impl Reader { + fn spawn_serial( + path: String, + shared: Arc>, + generation: Arc, + recording: SharedRecording, + ) -> 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)); + let thread_stop = Arc::clone(&stop); + let join = std::thread::Builder::new() + .name("stage-a-photodiode".into()) + .spawn(move || read_frames(port, &shared, &generation, &recording, &thread_stop)) + .expect("spawning the photodiode reader thread must succeed"); + Ok(Self { + stop, + join: Some(join), + }) + } + + /// Hardware-free source: synthesizes a noisy 5 Hz sine around 1 V in + /// firmware-sized blocks at the firmware's default stream rate. + fn spawn_mock( + shared: Arc>, + generation: Arc, + recording: SharedRecording, + ) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + let join = std::thread::Builder::new() + .name("stage-a-photodiode-mock".into()) + .spawn(move || { + let start = Instant::now(); + let mut next_index: u64 = 0; + let mut sequence: u32 = 0; + while !thread_stop.load(Ordering::Relaxed) { + let target = (start.elapsed().as_secs_f64() * f64::from(MOCK_RATE_HZ)) as u64; + let mut produced = false; + while next_index + MOCK_BLOCK_SAMPLES as u64 <= target { + let codes: Vec = (0..MOCK_BLOCK_SAMPLES) + .map(|i| mock_code(next_index + i as u64)) + .collect(); + // Recordings capture real wire frames; synthesize the + // identical framing so mock recordings parse the same. + record_frame( + &recording, + &mock_sample_frame(sequence, next_index, &codes), + codes.len(), + ); + sequence = sequence.wrapping_add(1); + if let Ok(mut state) = shared.lock() { + state.ingest(next_index, MOCK_RATE_HZ, 0, &codes); + // Synthesize phase-0 markers on the device clock so the + // trigger overlay and frequency work without hardware. + let block_end = next_index + MOCK_BLOCK_SAMPLES as u64; + let mut marker = + next_index.next_multiple_of(MOCK_MARKER_PERIOD_SAMPLES); + while marker < block_end { + state.push_marker(marker); + marker += MOCK_MARKER_PERIOD_SAMPLES; + } + } + next_index += MOCK_BLOCK_SAMPLES as u64; + produced = true; + } + if produced { + generation.fetch_add(1, Ordering::Relaxed); + } + std::thread::sleep(Duration::from_millis(5)); + } + }) + .expect("spawning the mock photodiode thread must succeed"); + Self { + stop, + join: Some(join), + } + } +} + +fn mock_sample_frame(sequence: u32, first_index: u64, codes: &[u16]) -> stage_a_io::Frame { + let payload: Vec = codes.iter().flat_map(|c| c.to_le_bytes()).collect(); + stage_a_io::Frame::build( + stage_a_io::FrameHeader { + version: stage_a_io::wire::PROTOCOL_VERSION, + frame_type: stage_a_io::FrameType::SamplesU16, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: first_index, + sample_rate_hz: MOCK_RATE_HZ, + dropped_samples: 0, + crc32: 0, + }, + payload, + ) +} + +/// Deterministic mock sample: 1 V ± 0.5 V sine at 5 Hz plus ~20 mV of hash +/// noise, so the moving-average indicator has something to smooth. +fn mock_code(index: u64) -> u16 { + let t = index as f64 / f64::from(MOCK_RATE_HZ); + let mut hash = index.wrapping_mul(0x9E37_79B9_7F4A_7C15); + hash ^= hash >> 33; + let noise = (hash as f64 / u64::MAX as f64) - 0.5; + let volts = 1.0 + 0.5 * (2.0 * std::f64::consts::PI * 5.0 * t).sin() + 0.04 * noise; + (volts * ADC_MAX_CODE / ADC_FULL_SCALE_VOLTS).clamp(0.0, ADC_MAX_CODE) as u16 +} + +impl Drop for Reader { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +fn read_frames( + mut port: Box, + shared: &Mutex, + generation: &AtomicU64, + recording: &SharedRecording, + stop: &AtomicBool, +) { + let mut parser = FrameParser::default(); + let mut buf = [0_u8; 4_096]; + while !stop.load(Ordering::Relaxed) { + let read = match port.read(&mut buf) { + // A 0-byte read is EOF (e.g. a yanked USB device before the OS + // surfaces an error). Spinning here burns a core while the UI + // still says "reading", so back off and let the timeout path + // report the stall. + Ok(0) => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Ok(read) => read, + Err(err) if err.kind() == std::io::ErrorKind::TimedOut => continue, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(err) => { + if let Ok(mut state) = shared.lock() { + state.error = Some(format!("stream read failed: {err}")); + } + generation.fetch_add(1, Ordering::Relaxed); + return; + } + }; + parser.extend(&buf[..read]); + let mut changed = false; + while let Some(event) = parser.next_event() { + changed |= ingest_parse_event(event, shared, recording); + } + if changed { + generation.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Applies one parsed stream event to the ring and to any active recording. +/// Split out of [`read_frames`] so the recording/ingest contract is testable +/// without a serial port. Returns whether anything observable changed. +fn ingest_parse_event( + event: ParseEvent, + shared: &Mutex, + recording: &SharedRecording, +) -> bool { + match event { + ParseEvent::Frame(frame) => { + if let Some(marker) = frame.marker() { + // Record before the early return: the phase-0 marker is what + // makes a recorded run phase-attributable offline, so it has + // to reach the .pdq as well as the live ring. It carries no + // samples, hence a sample count of 0. + record_frame(recording, &frame, 0); + if let Ok(mut state) = shared.lock() { + 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; + } + let Some(codes) = frame.samples() else { + return false; // Control/summary frames are not expected here. + }; + record_frame(recording, &frame, codes.len()); + if let Ok(mut state) = shared.lock() { + state.ingest( + frame.header.first_sample_index, + frame.header.sample_rate_hz, + frame.header.dropped_samples, + &codes, + ); + } + true + } + ParseEvent::Corruption { + skipped_bytes, + crc_failures, + } => { + if let Ok(mut state) = shared.lock() { + state.resync_bytes += skipped_bytes as u64; + state.crc_failures += crc_failures as u64; + } + true + } + } +} + +pub struct StageAPhotodiodePlugin { + enabled: bool, + runtime_role: PluginRuntimeRole, + effects_allowed: bool, + owner_instance: OwnerInstanceId, + lease: Option, + request_cache: VecDeque<(PluginServiceRequest, PluginServiceReply)>, + requested_revision: Option, + acknowledged_revision: Option, + last_response: Option, + last_finalized_recording: Option, + reader: Option, + shared: Arc>, + generation: Arc, + recording: SharedRecording, + last_error: Option, + /// One-line feedback about the most recent save/recording action. + last_save_note: Option, + // -- settings -- + 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, + data_dir: String, + // -- momentary-button press forwarding (see PressLatch) -- + press_save_snapshot: PressLatch, + press_capture_direct_dark: PressLatch, + press_use_manual_direct_dark: PressLatch, + press_record_start: PressLatch, + press_record_stop: PressLatch, +} + +/// Forwards momentary button presses across the host's UI-mirror → live-worker +/// settings snapshot. A click arrives as `true` on the clicked instance; the +/// other instance only ever sees the snapshot value from `get_setting`, so the +/// press is transported as a monotonic counter and a counter advance counts as +/// one press edge. The first counter a fresh instance sees is adopted silently +/// so a reloaded worker does not replay old presses. Without this, an +/// unguarded button `set_setting` fires on every settings sync — the +/// "snapshot files kept appearing" bug. +#[derive(Debug, Default, Clone, Copy)] +struct PressLatch { + counter: u64, + seen: Option, +} + +impl PressLatch { + /// Interprets a settings write to this button; returns true on a press edge. + 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) + } +} + +#[derive(Clone)] +struct ControlLease { + lease_id: LeaseId, + holder: ClientId, + run_id: Option, + 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 { + enabled: false, + runtime_role: PluginRuntimeRole::UiMirror, + effects_allowed: false, + owner_instance: OwnerInstanceId::new(format!( + "photodiode-{}-{}-{}", + std::process::id(), + now_unix_ms(), + OWNER_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )), + lease: None, + request_cache: VecDeque::new(), + requested_revision: None, + acknowledged_revision: None, + last_response: None, + last_finalized_recording: None, + reader: None, + shared: Arc::new(Mutex::new(SharedState::default())), + generation: Arc::new(AtomicU64::new(1)), + recording: Arc::new(Mutex::new(None)), + last_error: None, + last_save_note: None, + 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, + data_dir: String::new(), + press_save_snapshot: PressLatch::default(), + press_capture_direct_dark: PressLatch::default(), + press_use_manual_direct_dark: PressLatch::default(), + press_record_start: PressLatch::default(), + press_record_stop: PressLatch::default(), + } + } +} + +impl StageAPhotodiodePlugin { + fn connected(&self) -> bool { + self.reader.is_some() + } + + /// The ADC calibration handed to the contrast estimator. + /// + /// `dark_volts` is deliberately zero. A DC dark offset `D` cancels + /// *exactly* out of the rejected-complement contrast once the total-power + /// anchor is read from the same detector: the excitation is + /// `(I_tot,obs − D) − (v − D) = I_tot,obs − v`, with no `D` left in it. + /// Subtracting a separately entered dark from only one of the two sides is + /// what would bias `a` — which is why there is no dark setting any more + /// (ADR 024). + fn adc_calibration(&self) -> AdcCalibration { + AdcCalibration { + volts_per_code: ADC_FULL_SCALE_VOLTS / ADC_MAX_CODE, + offset_volts: 0.0, + dark_volts: 0.0, + full_scale_code: ADC_MAX_CODE as u16, + } + } + + 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 { + state.observed_peak_code.map(code_to_volts) + } + + /// [`Self::total_power_volts`] for callers that do not already hold the ring + /// lock (sidecars, status entries, the chart transform). + fn learned_anchor_volts(&self) -> Option { + let state = self.shared.lock().ok()?; + self.total_power_volts(&state) + } + + fn connect(&mut self) { + if self.reader.is_some() { + return; + } + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + self.last_error = Some("connection deferred: hardware effects are not allowed".into()); + return; + } + if let Ok(mut state) = self.shared.lock() { + *state = SharedState::default(); + } + self.last_error = None; + if self.port_hint == "mock" { + self.reader = Some(Reader::spawn_mock( + Arc::clone(&self.shared), + Arc::clone(&self.generation), + Arc::clone(&self.recording), + )); + return; + } + let path = if self.port_hint == "auto" { + match resolve_auto_port() { + Ok(path) => path, + Err(err) => { + self.last_error = Some(err); + return; + } + } + } else { + self.port_hint.clone() + }; + match Reader::spawn_serial( + path, + Arc::clone(&self.shared), + Arc::clone(&self.generation), + Arc::clone(&self.recording), + ) { + Ok(reader) => self.reader = Some(reader), + Err(err) => { + self.last_error = Some(err); + self.connect_requested = false; + } + } + self.generation.fetch_add(1, Ordering::Relaxed); + } + + fn disconnect(&mut self) { + self.reader = None; // Drop joins the thread. + self.generation.fetch_add(1, Ordering::Relaxed); + } + + fn recording_active(&self) -> bool { + self.recording + .lock() + .map(|slot| slot.is_some()) + .unwrap_or(false) + } + + fn resolved_data_dir(&self) -> Result { + if self.data_dir.trim().is_empty() { + return Err("set the data directory first (Data section)".into()); + } + Ok(PathBuf::from(self.data_dir.trim())) + } + + /// Resolves a workflow-owned relative evidence path beneath `root_override` + /// when the client named one, else beneath the configured data directory. + /// Existing or newly created parent components must be real directories, + /// never symlinks — that holds for either root. + fn resolve_control_path( + &self, + label: &str, + extension: &str, + root_override: Option<&str>, + ) -> Result { + let relative = Path::new(label); + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err( + "workflow recording paths must be non-empty relative paths without '..'".into(), + ); + } + if relative.extension().and_then(|value| value.to_str()) != Some(extension) { + return Err(format!("workflow path must use the .{extension} extension")); + } + + // A client-named root replaces the data directory entirely: a + // coordinated run keeps every file of one measurement together, and + // the owner's own Data section then has no bearing on it. + let root = match root_override.map(str::trim).filter(|root| !root.is_empty()) { + Some(root) => { + let root = PathBuf::from(root); + if !root.is_absolute() { + return Err("workflow recording root must be an absolute path".into()); + } + root + } + None => self.resolved_data_dir()?, + }; + std::fs::create_dir_all(&root) + .map_err(|err| format!("creating {} failed: {err}", root.display()))?; + let root = root + .canonicalize() + .map_err(|err| format!("resolving recording directory failed: {err}"))?; + let mut parent = root.clone(); + if let Some(relative_parent) = relative.parent() { + for component in relative_parent.components() { + let Component::Normal(name) = component else { + return Err("invalid workflow recording path".into()); + }; + parent.push(name); + match std::fs::symlink_metadata(&parent) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "workflow path crosses symlink {}", + parent.display() + )); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(format!("{} is not a directory", parent.display())); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(&parent).map_err(|err| { + format!("creating {} failed: {err}", parent.display()) + })?; + } + Err(err) => { + return Err(format!("checking {} failed: {err}", parent.display())); + } + } + let canonical = parent + .canonicalize() + .map_err(|err| format!("resolving {} failed: {err}", parent.display()))?; + if !canonical.starts_with(&root) { + return Err("workflow path escapes the recording directory".into()); + } + } + } + let candidate = root.join(relative); + if let Ok(metadata) = std::fs::symlink_metadata(&candidate) { + if metadata.file_type().is_symlink() { + return Err(format!( + "workflow target is a symlink: {}", + candidate.display() + )); + } + } + Ok(candidate) + } + + fn begin_named_recording( + &mut self, + run_id: RunId, + specification: &PdqStartSpecV1, + ) -> Result { + if !self.connected() { + return Err(service_error( + ServiceErrorCodeV1::NotConnected, + "photodiode stream is not connected", + true, + )); + } + if specification.metadata.len() > 64 + || specification + .metadata + .iter() + .any(|(key, value)| key.len() > 128 || value.len() > 1_024) + { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "recording metadata exceeds owner bounds", + false, + )); + } + let (rate_hz, stream_epoch) = self + .shared + .lock() + .map(|state| (state.rate_hz, state.segments)) + .unwrap_or((0, 0)); + if specification + .expected_sample_rate_hz + .is_some_and(|expected| rate_hz != 0 && expected != rate_hz) + || specification + .expected_stream_epoch + .is_some_and(|expected| expected != stream_epoch) + { + return Err(service_error( + ServiceErrorCodeV1::Integrity, + "live photodiode stream does not match the requested epoch or sample rate", + true, + )); + } + let root = specification.root_dir.as_deref(); + let pdq_path = self + .resolve_control_path(&specification.pdq_path, "pdq", root) + .map_err(|message| service_error(ServiceErrorCodeV1::InvalidPath, message, false))?; + let sidecar_path = self + .resolve_control_path(&specification.sidecar_path, "json", root) + .map_err(|message| service_error(ServiceErrorCodeV1::InvalidPath, message, false))?; + if pdq_path == sidecar_path { + return Err(service_error( + ServiceErrorCodeV1::InvalidPath, + "PDQ and sidecar paths must differ", + false, + )); + } + self.open_recording( + run_id, + pdq_path, + sidecar_path, + specification.pdq_path.clone(), + specification.sidecar_path.clone(), + specification.metadata.clone(), + true, + ) + .map_err(|message| service_error(ServiceErrorCodeV1::Io, message, false)) + } + + fn start_recording(&mut self) -> Result<(), String> { + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + return Err("recording is allowed only on the active live worker".into()); + } + if self.lease.is_some() { + return Err("manual recording is locked while a workflow lease is active".into()); + } + let dir = self.resolved_data_dir()?; + let slug = timestamp_slug(); + let pdq_path = dir.join(format!("pd_rec_{slug}.pdq")); + let sidecar_path = pdq_path.with_extension("json"); + self.open_recording( + RunId::new(format!("manual-{slug}")), + pdq_path.clone(), + sidecar_path.clone(), + pdq_path.to_string_lossy().into_owned(), + sidecar_path.to_string_lossy().into_owned(), + BTreeMap::new(), + false, + )?; + self.last_save_note = Some(format!("recording → {}", pdq_path.display())); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn open_recording( + &mut self, + run_id: RunId, + pdq_path: PathBuf, + sidecar_path: PathBuf, + pdq_path_label: String, + sidecar_path_label: String, + metadata: BTreeMap, + exclusive: bool, + ) -> Result { + if self.recording_active() { + return Err("a photodiode recording is already active".into()); + } + let writer = if exclusive { + PdqWriter::create_new(&pdq_path) + } else { + PdqWriter::create(&pdq_path) + } + .map_err(|err| format!("creating {} failed: {err}", pdq_path.display()))?; + let (crc, resync, dropped, segments) = match self.shared.lock() { + Ok(state) => ( + state.crc_failures, + state.resync_bytes, + state.device_dropped, + state.segments, + ), + Err(_) => (0, 0, 0, 0), + }; + let (stream_epoch, first_sample_index) = self + .shared + .lock() + .map(|state| { + ( + state.segments, + (!state.samples.is_empty()) + .then_some(state.ring_first_index + state.samples.len() as u64), + ) + }) + .unwrap_or((0, None)); + let opened_at_unix_ms = now_unix_ms(); + let started_slug = timestamp_slug(); + if exclusive { + let started = json!({ + "kind": "recording_in_progress", + "run_id": run_id.as_str(), + "opened_at_unix_ms": opened_at_unix_ms, + "pdq_path": pdq_path_label, + "metadata": metadata, + }); + if let Err(err) = write_json_new(&sidecar_path, &started) { + drop(writer); + let _ = std::fs::remove_file(&pdq_path); + return Err(err); + } + } + let sink = RecordingSink { + writer, + pdq_path: pdq_path.clone(), + sidecar_path, + pdq_path_label, + sidecar_path_label, + run_id, + opened_at_unix_ms, + stream_epoch, + first_sample_index, + metadata, + started_slug, + samples_written: 0, + write_error: None, + start_crc_failures: crc, + start_resync_bytes: resync, + start_device_dropped: dropped, + start_segments: segments, + }; + let receipt = sink.started_receipt(); + if let Ok(mut slot) = self.recording.lock() { + *slot = Some(sink); + } + self.generation.fetch_add(1, Ordering::Relaxed); + Ok(receipt) + } + + fn stop_recording(&mut self) -> Result<(), String> { + self.finalize_recording(PdqTerminationV1::OperatorStopped) + .map(|_| ()) + } + + fn finalize_recording( + &mut self, + termination: PdqTerminationV1, + ) -> Result, String> { + let Some(sink) = self.recording.lock().ok().and_then(|mut slot| slot.take()) else { + return Ok(None); + }; + let (rate_hz, crc, resync, dropped, segments) = match self.shared.lock() { + Ok(state) => ( + state.rate_hz, + state.crc_failures, + state.resync_bytes, + state.device_dropped, + state.segments, + ), + Err(_) => (0, 0, 0, 0, 0), + }; + let integrity = StreamIntegrity { + skipped_bytes: resync.saturating_sub(sink.start_resync_bytes), + crc_failures: crc.saturating_sub(sink.start_crc_failures), + 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 started = sink.started_slug.clone(); + let samples = sink.samples_written; + let pdq_path = sink.pdq_path.clone(); + let sidecar_path = sink.sidecar_path.clone(); + let run_id = sink.run_id.clone(); + let opened_at_unix_ms = sink.opened_at_unix_ms; + 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 + .writer + .finish(integrity) + .map_err(|err| format!("finishing recording failed: {err}"))?; + let contract_integrity = contract_integrity(summary.integrity, summary.sample_segments); + let receipt = PdqFinalizedReceiptV1 { + run_id: run_id.clone(), + pdq_path: pdq_path_label, + sidecar_path: sidecar_path_label, + opened_at_unix_ms, + finalized_at_unix_ms: now_unix_ms(), + file_size_bytes: summary.bytes_written, + sha256: Sha256V1::parse(summary.file_sha256_hex()) + .map_err(|err| format!("invalid recording digest: {err}"))?, + frames_written: summary.frames_written, + sample_frames_written: summary.sample_frames_written, + sample_range: summary.sample_range.map(|range| SampleRangeV1 { + first_sample_index: range.first_sample_index, + end_sample_index_exclusive: range.end_sample_index_exclusive, + sample_count: range.sample_count, + }), + sample_rate_hz: summary.sample_rate_hz, + segment_count: summary.sample_segments, + integrity: contract_integrity, + termination, + valid: summary.valid && write_error.is_none(), + }; + let sidecar = json!({ + "kind": "recording", + "run_id": run_id, + "started_utc": started, + "stopped_utc": timestamp_slug(), + "port": self.port_hint, + "sample_rate_hz": rate_hz, + "samples_written": samples, + "pdq_path": summary.path, + "pdq_frames": summary.frames_written, + "pdq_bytes": summary.bytes_written, + "pdq_crc32": summary.file_crc32, + "pdq_sha256": receipt.sha256.as_str(), + "metadata": metadata, + "termination": receipt.termination, + "adc": { "bits": 12, "full_scale_volts": ADC_FULL_SCALE_VOLTS }, + "display_mode": self.mode.name(), + "photodiode_placement": self.placement, + "splitter_fraction": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), + "direct_dark_volts": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.direct_dark_volts()).flatten(), + "direct_dark_reference": (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()).flatten(), + "total_power_volts": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.learned_anchor_volts()).flatten(), + "total_power_source": (self.placement == PhotodiodePlacementV1::RejectedPort) + .then_some("observed-peak"), + "integrity": { + "resync_bytes": summary.integrity.skipped_bytes, + "crc_failures": summary.integrity.crc_failures, + "segment_restarts": summary.integrity.sequence_gaps, + "device_dropped_samples": summary.integrity.dropped_samples, + }, + "valid": summary.valid && write_error.is_none(), + "write_error": write_error, + }); + write_json(&sidecar_path, &sidecar)?; + self.last_save_note = Some(format!( + "saved recording {} ({} samples)", + pdq_path.display(), + samples + )); + self.last_finalized_recording = Some(receipt.clone()); + self.generation.fetch_add(1, Ordering::Relaxed); + Ok(Some(receipt)) + } + + fn lease_snapshot(&self) -> Option { + self.lease.as_ref().map(|lease| LeaseSnapshotV1 { + lease_id: lease.lease_id.clone(), + holder: lease.holder.clone(), + expires_at_unix_ms: lease.expires_at_unix_ms, + run_id: lease.run_id.clone(), + }) + } + + fn require_lease(&self, request: &PhotodiodeRequestV1) -> Result<(), ServiceErrorV1> { + let lease = self.lease.as_ref().ok_or_else(|| { + service_error( + ServiceErrorCodeV1::LeaseRequired, + "the photodiode owner requires an active automation lease", + false, + ) + })?; + if now_unix_ms() > lease.expires_at_unix_ms { + return Err(service_error( + ServiceErrorCodeV1::LeaseExpired, + "the photodiode automation lease expired", + false, + )); + } + if request.lease_id.as_ref() != Some(&lease.lease_id) + || request.requester != lease.holder + || request.run_id != lease.run_id + { + return Err(service_error( + ServiceErrorCodeV1::LeaseMismatch, + "request lease, holder, or run does not match the active lease", + false, + )); + } + Ok(()) + } + + fn require_new_revision( + &self, + request: &PhotodiodeRequestV1, + ) -> Result { + let revision = request.requested_revision.ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "recording transitions require requested_revision", + false, + ) + })?; + if self + .requested_revision + .is_some_and(|current| revision <= current) + { + return Err(service_error( + ServiceErrorCodeV1::StaleRequest, + "requested_revision must be newer than the current photodiode state", + false, + )); + } + Ok(revision) + } + + fn immediate_response( + &mut self, + request: &PhotodiodeRequestV1, + receipt: Option, + ) -> PhotodiodeResponseV1 { + let response = PhotodiodeResponseV1 { + common: ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: request.request_id, + owner_instance: self.owner_instance.clone(), + run_id: request.run_id.clone(), + requested_revision: request.requested_revision, + acknowledged_revision: self.acknowledged_revision, + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(now_unix_ms()), + error: None, + }, + receipt, + }; + self.last_response = Some(response.clone()); + self.generation.fetch_add(1, Ordering::Relaxed); + response + } + + fn handle_photodiode_command( + &mut self, + request: &PhotodiodeRequestV1, + ) -> Result { + match &request.command { + PhotodiodeCommandV1::Connect => { + if self.lease.is_some() { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "connection cannot be changed while leased", + false, + )); + } + self.connect_requested = true; + self.connect(); + if !self.connected() { + return Err(service_error( + ServiceErrorCodeV1::Transport, + self.last_error + .clone() + .unwrap_or_else(|| "photodiode connection failed".into()), + true, + )); + } + Ok(self.immediate_response(request, None)) + } + PhotodiodeCommandV1::Disconnect { + finalize_recording, + reason, + } => { + if self.lease.is_some() { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "use ReleaseLease while the owner is leased", + false, + )); + } + let receipt = if *finalize_recording { + self.finalize_recording(PdqTerminationV1::OperatorStopped) + .map_err(|message| service_error(ServiceErrorCodeV1::Io, message, false))? + .map(PdqReceiptV1::Finalized) + } else { + None + }; + self.connect_requested = false; + self.disconnect(); + self.last_error = Some(format!("disconnected by service: {reason}")); + Ok(self.immediate_response(request, receipt)) + } + PhotodiodeCommandV1::AcquireLease { ttl_ms } => { + let lease_id = request.lease_id.clone().ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "AcquireLease requires lease_id", + false, + ) + })?; + if let Some(active) = &self.lease { + if active.lease_id != lease_id || active.holder != request.requester { + return Err(service_error( + ServiceErrorCodeV1::LeaseBusy, + "the photodiode owner is already leased", + true, + )); + } + } + self.lease = Some(ControlLease { + lease_id, + holder: request.requester.clone(), + run_id: request.run_id.clone(), + expires_at_unix_ms: lease_deadline(*ttl_ms), + }); + Ok(self.immediate_response(request, None)) + } + PhotodiodeCommandV1::RenewLease { ttl_ms } => { + self.require_lease(request)?; + if let Some(lease) = &mut self.lease { + lease.expires_at_unix_ms = lease_deadline(*ttl_ms); + } + Ok(self.immediate_response(request, None)) + } + PhotodiodeCommandV1::ReleaseLease { + finalize_recording, + reason, + } => { + self.require_lease(request)?; + let receipt = if *finalize_recording { + self.finalize_recording(PdqTerminationV1::OperatorStopped) + .map_err(|message| service_error(ServiceErrorCodeV1::Io, message, false))? + .map(PdqReceiptV1::Finalized) + } else if self.recording_active() { + return Err(service_error( + ServiceErrorCodeV1::InvalidCommand, + "cannot release a lease with an active recording unless it is finalized", + false, + )); + } else { + None + }; + self.lease = None; + self.last_error = Some(format!("automation lease released: {reason}")); + Ok(self.immediate_response(request, receipt)) + } + PhotodiodeCommandV1::BeginRecording { specification } => { + self.require_lease(request)?; + let revision = self.require_new_revision(request)?; + let run_id = request.run_id.clone().ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "BeginRecording requires run_id", + false, + ) + })?; + let started = self.begin_named_recording(run_id, specification)?; + self.requested_revision = Some(revision); + self.acknowledged_revision = Some(revision); + Ok(self.immediate_response(request, Some(PdqReceiptV1::Started(started)))) + } + PhotodiodeCommandV1::FinalizeRecording { termination } => { + self.require_lease(request)?; + let revision = self.require_new_revision(request)?; + let finalized = self + .finalize_recording(*termination) + .map_err(|message| service_error(ServiceErrorCodeV1::Io, message, false))? + .ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "no photodiode recording is active", + false, + ) + })?; + self.requested_revision = Some(revision); + self.acknowledged_revision = Some(revision); + Ok(self.immediate_response(request, Some(PdqReceiptV1::Finalized(finalized)))) + } + PhotodiodeCommandV1::AbortRecording { reason } => { + self.require_lease(request)?; + let revision = self.require_new_revision(request)?; + let finalized = self + .finalize_recording(PdqTerminationV1::Aborted) + .map_err(|message| service_error(ServiceErrorCodeV1::Io, message, false))? + .ok_or_else(|| { + service_error( + ServiceErrorCodeV1::InvalidCommand, + "no photodiode recording is active", + false, + ) + })?; + self.requested_revision = Some(revision); + self.acknowledged_revision = Some(revision); + self.last_error = Some(format!("recording aborted: {reason}")); + Ok(self.immediate_response(request, Some(PdqReceiptV1::Finalized(finalized)))) + } + } + } + + /// Live optical log-contrast `a` from a marker-bounded ring window. + /// + /// 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 + /// `a`, so letting a display toggle change its meaning would silently + /// retarget the sweep and write a wrong `measured_a` into every sidecar. + /// + /// `Err` — never a silent `None` — when there is no valid whole-cycle + /// window or no learned total-power anchor: the rejection reason is what + /// the status readout and the A1 panel render instead of `a`, so a withheld + /// `a` names the gate the operator has to fix. + fn optical_summary_result( + &self, + state: &SharedState, + ) -> Result { + 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 + .markers + .iter() + .copied() + .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(), + }); + } + + // End on the newest complete phase-0 boundary. Start at least two + // complete cycles earlier, then include up to the configured number of + // older complete cycles. At low frequency this grows with the measured + // period instead of truncating the waveform to a fixed sample count. + let end_index = *markers.last().expect("three markers checked"); + let desired_cycles = CONTRAST_WINDOW_CYCLES as usize; + let start_marker = markers.len().saturating_sub(desired_cycles + 1); + let start_index = markers[start_marker]; + let start = start_index.saturating_sub(state.ring_first_index) as usize; + let end = end_index.saturating_sub(state.ring_first_index) as usize; + let window: Vec = state.samples.range(start..end).copied().collect(); + 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 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 + .as_ref() + .and_then(|lease| lease.run_id.clone()) + .unwrap_or_else(|| RunId::from("live")); + Ok(PhotodiodeOpticalSummaryV1 { + run_id, + calibration: PhotodiodeCalibrationV1 { + 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: 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: 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, + excitation_max_volts: estimate.v_max_volts, + // The excitation minimum is measured from the same anchor the + // detector samples are, so it *is* the margin above the floor. + // Same number as `excitation_min_volts` by construction; kept + // because the contract publishes both. + excitation_headroom_volts: estimate.v_min_volts, + low_clip_fraction: estimate.low_clip_fraction, + high_clip_fraction: estimate.high_clip_fraction, + measured_frequency_hz: (state.rate_hz > 0).then(|| { + let cycles = markers.len() - 1 - start_marker; + let period_samples = end_index.saturating_sub(start_index) as f64 / cycles as f64; + f64::from(state.rate_hz) / period_samples + }), + fundamental_phase_rad: None, + total_harmonic_distortion: None, + window_seconds: Some(window_seconds), + covered_cycles, + }) + } + + /// [`Self::optical_summary_result`] reduced to the accepted value, for tests + /// that assert on `a` itself rather than on which gate refused it. + #[cfg(test)] + fn optical_summary(&self, state: &SharedState) -> Option { + self.optical_summary_result(state).ok() + } + + /// Locks the ring and returns the current optical log-contrast summary, + /// keeping the rejection reason so the caller can explain a withheld `a`. + fn latest_optical_result(&self) -> Option> { + let state = self.shared.lock().ok()?; + (!state.samples.is_empty()).then(|| self.optical_summary_result(&state)) + } + + fn control_summary(&self) -> PhotodiodeSummaryV1 { + let (stream, connection, observed_at, optical_summary, optical_unavailable) = + match self.shared.lock() { + Ok(state) => { + let sample_range = (!state.samples.is_empty()).then_some(SampleRangeV1 { + first_sample_index: state.ring_first_index, + end_sample_index_exclusive: state.ring_first_index + + state.samples.len() as u64, + sample_count: state.samples.len() as u64, + }); + // Publish the refusal reason alongside the absent summary: A1 + // gates the a₀ lock and the frequency sweep on `a`, and without + // this the operator only learns that `a` is missing, not which + // gate to fix. + let (optical_summary, optical_unavailable) = match (!state.samples.is_empty()) + .then(|| self.optical_summary_result(&state)) + { + Some(Ok(summary)) => (Some(summary), None), + Some(Err(error)) => (None, Some(error.to_string())), + None => (None, None), + }; + let level = self.current_level(&state); + let connection = if self.connected() { + ConnectionStateV1::Connected { + port_label: self.port_hint.clone(), + firmware_version: None, + } + } else if let Some(message) = + state.error.clone().or_else(|| self.last_error.clone()) + { + ConnectionStateV1::Faulted { message } + } else if self.connect_requested { + ConnectionStateV1::Connecting + } else { + ConnectionStateV1::Disconnected + }; + ( + PhotodiodeStreamV1 { + stream_epoch: state.segments, + sample_range, + sample_rate_hz: (state.rate_hz != 0).then_some(state.rate_hz), + latest_adc_code: state.latest, + integrity: StreamIntegrityV1 { + skipped_bytes: state.resync_bytes, + crc_failures: state.crc_failures, + sequence_gaps: state.segments, + dropped_samples: u64::from(state.device_dropped), + segment_restarts: state.segments, + truncated_bytes: 0, + }, + level, + }, + connection, + state.last_update_unix_ms, + optical_summary, + optical_unavailable, + ) + } + Err(_) => ( + PhotodiodeStreamV1 { + stream_epoch: 0, + sample_range: None, + sample_rate_hz: None, + latest_adc_code: None, + integrity: StreamIntegrityV1::default(), + level: None, + }, + ConnectionStateV1::Faulted { + message: "photodiode state lock poisoned".into(), + }, + 0, + None, + Some("photodiode state lock poisoned".to_owned()), + ), + }; + let active_recording = self + .recording + .lock() + .ok() + .and_then(|slot| slot.as_ref().map(RecordingSink::started_receipt)); + let synchronization = match ( + self.lease.as_ref().and_then(|lease| lease.run_id.clone()), + self.requested_revision, + self.acknowledged_revision, + ) { + (Some(run_id), Some(requested), Some(acknowledged)) if requested == acknowledged => { + SynchronizationV1::Synced { + run_id, + acknowledged_revision: acknowledged, + stream_epoch: Some(stream.stream_epoch), + } + } + (None, _, _) => SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::NoLease, + detail: None, + }, + _ => SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::RequestedRevisionNotAcknowledged, + detail: None, + }, + }; + PhotodiodeSummaryV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: self.owner_instance.clone(), + service_revision: self.generation.load(Ordering::Relaxed), + connection, + lease: self.lease_snapshot(), + active_run_id: self.lease.as_ref().and_then(|lease| lease.run_id.clone()), + requested_revision: self.requested_revision, + acknowledged_revision: self.acknowledged_revision, + stream, + // Automation clients need this to refuse a coordinated run before + // it starts the camera, instead of failing at BeginRecording. + data_dir: Some(self.data_dir.trim().to_owned()).filter(|folder| !folder.is_empty()), + active_recording, + last_finalized_recording: self.last_finalized_recording.clone(), + optical_summary, + placement: self.placement, + splitter_fraction: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then_some(self.splitter_fraction), + dark_reference: (self.placement != PhotodiodePlacementV1::RejectedPort) + .then(|| self.published_direct_dark()) + .flatten(), + optical_unavailable, + synchronization, + last_response: self.last_response.clone(), + freshness: FreshnessV1 { + observed_at_unix_ms: if observed_at == 0 { + now_unix_ms() + } else { + observed_at + }, + valid_for_ms: SNAPSHOT_VALID_FOR_MS, + }, + } + } + + fn expire_lease_if_needed(&mut self) { + if self + .lease + .as_ref() + .is_none_or(|lease| now_unix_ms() <= lease.expires_at_unix_ms) + { + return; + } + if let Err(error) = self.finalize_recording(PdqTerminationV1::LeaseExpired) { + self.last_error = Some(error); + } else { + self.last_error = Some("automation lease expired; recording finalized".into()); + } + self.lease = None; + self.generation.fetch_add(1, Ordering::Relaxed); + } + + fn apply_execution_context(&mut self, execution: &augur_plugin_api::ExecutionContext) { + let allowed = self.runtime_role == PluginRuntimeRole::LiveWorker + && execution.hardware_effects_allowed(); + self.effects_allowed = allowed; + if !allowed { + if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { + self.last_error = Some(error); + } + // 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 + // host can sample it, so the live worker never sees the request. + // `connect()` is already guarded on the role, so the intent alone + // is inert here; the worker acts on it below. + self.disconnect(); + self.lease = None; + return; + } + self.expire_lease_if_needed(); + if self.connect_requested && self.reader.is_none() { + self.connect(); + } + } + + /// Dumps the current monitor cache (ring) as CSV + JSON sidecar. Raw + /// codes and raw volts only — mode/reference land in the sidecar so + /// EXCITATION values stay derivable without baking display state into + /// the data. + fn save_cache_snapshot(&mut self) -> Result<(), String> { + if self.runtime_role != PluginRuntimeRole::LiveWorker || !self.effects_allowed { + return Err("saving is allowed only on the active live worker".into()); + } + let dir = self.resolved_data_dir()?; + let slug = timestamp_slug(); + let csv_path = dir.join(format!("pd_cache_{slug}.csv")); + // Copy the ring out under the lock and release it before touching the + // filesystem: holding it across up to RING_MAX_SAMPLES writeln! calls + // blocks the reader thread, overruns the serial input buffer and shows + // up as dropped samples plus a segment restart in any recording that is + // in flight. + let (samples, rate_hz, ring_first_index, cache_seconds, integrity) = { + let state = self + .shared + .lock() + .map_err(|_| "photodiode state lock poisoned".to_owned())?; + if state.samples.is_empty() || state.rate_hz == 0 { + return Err("no samples cached yet".into()); + } + let samples: Vec = state.samples.iter().copied().collect(); + let integrity = json!({ + "resync_bytes": state.resync_bytes, + "crc_failures": state.crc_failures, + "segment_restarts": state.segments, + "device_dropped_samples": state.device_dropped, + }); + ( + samples, + state.rate_hz, + state.ring_first_index, + state.cache_seconds, + integrity, + ) + }; + + std::fs::create_dir_all(&dir) + .map_err(|err| format!("creating {} failed: {err}", dir.display()))?; + let file = File::create(&csv_path) + .map_err(|err| format!("creating {} failed: {err}", csv_path.display()))?; + let mut writer = BufWriter::new(file); + let rate = f64::from(rate_hz); + writeln!(writer, "sample_index,t_s,code,volts") + .map_err(|err| format!("writing CSV failed: {err}"))?; + for (offset, &code) in samples.iter().enumerate() { + let index = ring_first_index + offset as u64; + writeln!( + writer, + "{index},{:.9},{code},{:.6}", + index as f64 / rate, + code_to_volts(f64::from(code)) + ) + .map_err(|err| format!("writing CSV failed: {err}"))?; + } + writer + .flush() + .map_err(|err| format!("writing CSV failed: {err}"))?; + + let sample_count = samples.len(); + let sidecar = json!({ + "kind": "cache_snapshot", + "created_utc": slug, + "port": self.port_hint, + "sample_rate_hz": rate_hz, + "samples": sample_count, + "first_sample_index": ring_first_index, + "cache_seconds": cache_seconds, + "csv_path": csv_path, + "adc": { "bits": 12, "full_scale_volts": ADC_FULL_SCALE_VOLTS }, + "display_mode": self.mode.name(), + "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, + }); + write_json(&csv_path.with_extension("json"), &sidecar)?; + self.last_save_note = Some(format!( + "saved cache {} ({sample_count} samples)", + csv_path.display() + )); + Ok(()) + } + + /// Value shown for one sample under the current mode, in volts. + /// + /// EXCITATION needs the learned total-power anchor to take the complement. + /// The anchor is latched from the first completed summary cell — a few + /// milliseconds after the stream opens — so the `None` arm is only ever the + /// 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, 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), + } + } + + /// Moving-average window in samples: either the fixed sample count or, + /// when a sync frequency is set, one full period of that frequency — + /// which makes the mean independent of the modulation phase. + fn avg_window_samples(&self, rate_hz: u32) -> usize { + if self.avg_sync_freq_hz > 0.0 && rate_hz > 0 { + (f64::from(rate_hz) / self.avg_sync_freq_hz) + .round() + .max(1.0) as usize + } else { + self.avg_samples.max(1) + } + } + + /// Mean of the newest `avg_window_samples` codes (fewer while filling). + fn current_average_code(&self, state: &SharedState) -> Option { + if state.samples.is_empty() { + return None; + } + let window = self + .avg_window_samples(state.rate_hz) + .min(state.samples.len()); + let start = state.samples.len() - window; + Some(state.range_summary(start, state.samples.len()).mean()) + } + + /// Samples the published level averages over: a fixed duration, deliberately + /// **not** [`Self::avg_window_samples`]. + /// + /// The chart's averaging is an operator preference; this window is a + /// measurement. Tying the two together made a display knob set the precision + /// of the Pockels calibration: at the bench's 500 kSa/s the default of four + /// samples published 8 µs of signal per settled `CONST` code, so every sweep + /// point carried ~12 mV of scatter against a 51 mV lobe and the fit reported + /// a perfect curve as 22 % residual (ADR 019). + /// + /// [`LEVEL_WINDOW_SECONDS`] is a duration rather than a sample count because + /// what averages noise down is time × bandwidth, not samples — and 20 ms in + /// particular is one full mains period, so a boxcar of that length has a null + /// at 50 Hz and every harmonic of it. + fn level_window_samples(&self, rate_hz: u32) -> usize { + if rate_hz == 0 { + return self.avg_window_samples(rate_hz); + } + ((f64::from(rate_hz) * LEVEL_WINDOW_SECONDS).round() as usize).max(1) + } + + /// Settled detector level over the measurement window, published on the + /// contract in **raw** detector volts — never `display_volts`, so a consumer + /// does not have to know the display mode, and never the optical geometry + /// transform, which needs an anchor this reading must not depend on. + /// + /// Deliberately fail-open where [`Self::optical_summary_result`] is + /// fail-closed: + /// a transfer-curve sweep needs a level exactly at the excitation null, + /// where the reject-port detector is brightest and may rail. Clipping is + /// reported rather than refused. + fn current_level(&self, state: &SharedState) -> Option { + if state.samples.is_empty() { + return None; + } + let window = self + .level_window_samples(state.rate_hz) + .min(state.samples.len()); + let start = state.samples.len() - window; + let summary = state.range_summary(start, state.samples.len()); + if summary.count == 0 { + return None; + } + let full_scale = ADC_MAX_CODE as u16; + // Span-relative, like the contrast estimator: this detector runs a few + // codes above zero, and a fixed margin calls every one of its windows + // truncated. + let margin = near_rail_margin(summary.max.saturating_sub(summary.min)); + Some(PhotodiodeLevelV1 { + mean_volts: code_to_volts(summary.mean()), + // `code_to_volts` is a pure scale, so it maps a code difference to + // a voltage difference directly. + peak_to_peak_volts: code_to_volts(f64::from(summary.max - summary.min)), + sample_count: summary.count as u64, + end_sample_index: state.ring_first_index + state.samples.len() as u64, + clipped: summary.min <= margin || summary.max >= full_scale.saturating_sub(margin), + }) + } + + fn series_dataset(&self) -> Series1dV1 { + let y_label = match self.mode { + Mode::Raw => "photodiode [V]", + Mode::Excitation => "excitation I_tot − I_pd [V]", + }; + let trace_name = match self.mode { + Mode::Raw => "photodiode", + Mode::Excitation => "excitation", + }; + let x_label = self.time_axis.label(); + let empty = |label: &str| Series1dV1 { + x_label: x_label.into(), + y_label: label.into(), + lines: vec![Series1dLine { + name: trace_name.into(), + points: Vec::new(), + }], + }; + let Ok(state) = self.shared.lock() else { + return empty(y_label); + }; + let total = state.samples.len(); + if total == 0 || state.rate_hz == 0 { + return empty(y_label); + } + let rate = f64::from(state.rate_hz); + + let visible = ((self.window_s.max(0.001) * rate) as usize) + .max(2) + .min(total); + let start = total - visible; + let latest_x_index = state.ring_first_index + total as u64 - 1; + let bucket_len = visible.div_ceil(MAX_PLOT_BUCKETS).max(1); + let decimating = bucket_len > 1; + + let avg_window = self.avg_window_samples(state.rate_hz); + let avg_enabled = avg_window > 1; + 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 }); + let mut max_points = Vec::with_capacity(if decimating { MAX_PLOT_BUCKETS + 1 } else { 0 }); + let mut avg_points = Vec::with_capacity(if avg_enabled { MAX_PLOT_BUCKETS + 1 } else { 0 }); + + // Every bucket (and every moving-average window) is combined from + // the incremental summary cells plus raw edge samples — the cost per + // rebuild is O(buckets · window/64), independent of the raw rate. + let mut bucket_start = start; + while bucket_start < total { + let bucket_end = (bucket_start + bucket_len).min(total); + let last = bucket_end - 1; + let bucket = state.range_summary(bucket_start, bucket_end); + let device_t = (state.ring_first_index + last as u64) as f64 / rate; + let x = match self.time_axis { + TimeAxis::BeforeNow => device_t - latest_x_index as f64 / rate, + TimeAxis::Segment => device_t, + }; + mean_points.push(Series1dPoint { + x, + y: self.display_volts(bucket.mean(), anchor), + }); + if decimating { + // EXCITATION inverts the axis, so min/max swap roles. + let (low, high) = ( + self.display_volts(f64::from(bucket.min), anchor), + self.display_volts(f64::from(bucket.max), anchor), + ); + min_points.push(Series1dPoint { + x, + y: low.min(high), + }); + max_points.push(Series1dPoint { + x, + y: low.max(high), + }); + } + if avg_enabled { + // Trailing window ending at this bucket's last sample; may + // reach before the visible slice (fewer while filling). + let window_start = (last + 1).saturating_sub(avg_window); + let window = state.range_summary(window_start, last + 1); + avg_points.push(Series1dPoint { + x, + y: self.display_volts(window.mean(), anchor), + }); + } + bucket_start = bucket_end; + } + + let mut lines = vec![Series1dLine { + name: trace_name.into(), + points: mean_points, + }]; + if decimating { + lines.push(Series1dLine { + name: "min".into(), + points: min_points, + }); + lines.push(Series1dLine { + name: "max".into(), + points: max_points, + }); + } + if avg_enabled { + lines.push(Series1dLine { + name: format!("avg ({avg_window} spl)"), + points: avg_points, + }); + } + // Opt-in phase-0 trigger overlay: one toggleable line drawing a vertical + // spike at each marker (up then back to a flat baseline between markers). + if self.show_markers && !state.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.markers.len() * 3); + for &index in &state.markers { + if index < first_visible || index > latest_x_index { + continue; + } + let x = x_for(index); + points.push(Series1dPoint { x, y: y_lo }); + points.push(Series1dPoint { x, y: y_hi }); + points.push(Series1dPoint { x, y: y_lo }); + } + if !points.is_empty() { + lines.push(Series1dLine { + name: "phase-0 trigger".into(), + points, + }); + } + } + } + Series1dV1 { + x_label: x_label.into(), + y_label: y_label.into(), + lines, + } + } + + /// Amplitude spectrum of the newest power-of-two window of raw samples + /// (Hann-windowed radix-2 FFT). Only computed while the spectrum window + /// is open — it has Window placement, and the host fetches datasets of + /// closed windows never. + fn spectrum_dataset(&self) -> Series1dV1 { + let empty = Series1dV1 { + x_label: "frequency [Hz]".into(), + y_label: "amplitude [V]".into(), + lines: vec![Series1dLine { + name: "spectrum".into(), + points: Vec::new(), + }], + }; + let Ok(state) = self.shared.lock() else { + return empty; + }; + let total = state.samples.len(); + if total < SPECTRUM_MIN_SAMPLES || state.rate_hz == 0 { + return empty; + } + let available = total.min(SPECTRUM_MAX_SAMPLES); + let n = if available.is_power_of_two() { + available + } else { + available.next_power_of_two() >> 1 + }; + let start = total - n; + let mut real: Vec = state + .samples + .range(start..) + .map(|&code| code_to_volts(f64::from(code))) + .collect(); + let rate = f64::from(state.rate_hz); + drop(state); + + let mean = real.iter().sum::() / n as f64; + // Hann window (coherent gain 0.5) on the demeaned signal. + for (i, value) in real.iter_mut().enumerate() { + let w = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * i as f64 / (n as f64 - 1.0)).cos()); + *value = (*value - mean) * w; + } + let mut imag = vec![0.0_f64; n]; + fft_radix2(&mut real, &mut imag); + + // One-sided amplitude: 2·|X|/(N·0.5); decimate bins by max-hold so + // narrow peaks survive the plot budget. + let bins = n / 2; + let bucket = bins.div_ceil(MAX_PLOT_BUCKETS).max(1); + let mut points = Vec::with_capacity(bins.div_ceil(bucket)); + let mut peak = 0.0_f64; + let mut peak_freq = 0.0_f64; + let mut in_bucket = 0_usize; + for k in 1..bins { + let amplitude = 2.0 * (real[k] * real[k] + imag[k] * imag[k]).sqrt() / (n as f64 * 0.5); + let freq = k as f64 * rate / n as f64; + if amplitude > peak { + peak = amplitude; + peak_freq = freq; + } + in_bucket += 1; + if in_bucket == bucket || k == bins - 1 { + points.push(Series1dPoint { + x: peak_freq, + y: peak, + }); + peak = 0.0; + // Seed the *next* bucket with its own first bin. Seeding with + // `freq` (the bin that just closed this bucket) put a flat + // bucket's point one bucket to the left. + peak_freq = (k + 1) as f64 * rate / n as f64; + in_bucket = 0; + } + } + Series1dV1 { + x_label: "frequency [Hz]".into(), + y_label: "amplitude [V]".into(), + lines: vec![Series1dLine { + name: format!("spectrum ({n} spl, Δf {:.2} Hz)", rate / n as f64), + points, + }], + } + } + + fn status_dataset(&self) -> TableDatasetV1 { + let (latest, rate_hz, average, integrity, stream_error, anchor) = match self.shared.lock() { + Ok(state) => ( + state.latest, + state.rate_hz, + self.current_average_code(&state), + format!( + "drops={} crc={} resync={} segments={}", + state.device_dropped, state.crc_failures, state.resync_bytes, state.segments + ), + state.error.clone(), + (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.total_power_volts(&state)) + .flatten(), + ), + Err(_) => (None, 0, None, String::new(), None, None), + }; + let state_text = if self.connected() { + format!("reading ({})", self.port_hint) + } else { + "disconnected".into() + }; + let rate_text = if rate_hz > 0 { + format!("{rate_hz} Sa/s") + } else { + "—".into() + }; + let (code_text, value_text) = match latest { + Some(sample) => ( + format!("{sample}"), + format!("{:.4} V", self.display_volts(f64::from(sample), anchor)), + ), + None => ("—".into(), "—".into()), + }; + let avg_text = match average { + Some(code) => { + let window = self.avg_window_samples(rate_hz); + format!( + "{:.4} V ({} spl ≈ {:.2} ms)", + self.display_volts(code, anchor), + window, + if rate_hz > 0 { + window as f64 * 1_000.0 / f64::from(rate_hz) + } else { + 0.0 + } + ) + } + None => "—".into(), + }; + let error = stream_error + .or_else(|| self.last_error.clone()) + .unwrap_or_default(); + let text_column = |id: &str, value: String| TableColumnData { + column_id: id.to_owned(), + values: TableColumnValues::String(vec![value]), + }; + TableDatasetV1 { + columns: vec![ + text_column("state", state_text), + text_column("mode", self.mode.name().to_owned()), + text_column("rate", rate_text), + text_column("code", code_text), + text_column("value", value_text), + text_column("avg", avg_text), + text_column("integrity", integrity), + text_column("error", error), + ], + } + } + + fn status_schema(&self) -> TableSchema { + let column = |id: &str, title: &str| TableColumn { + id: id.to_owned(), + title: title.to_owned(), + value_type: TableValueType::String, + }; + TableSchema { + columns: vec![ + column("state", "State"), + column("mode", "Mode"), + column("rate", "Rate"), + column("code", "ADC code"), + column("value", "Value"), + column("avg", "Moving avg"), + column("integrity", "Integrity"), + column("error", "Last error"), + ], + ..TableSchema::default() + } + } +} + +/// In-place iterative radix-2 Cooley–Tukey FFT. Lengths must be powers of +/// two; sized for the spectrum window (≤ 16384), where it runs in well under +/// a millisecond. +fn fft_radix2(real: &mut [f64], imag: &mut [f64]) { + let n = real.len(); + debug_assert!(n.is_power_of_two() && imag.len() == n); + // Bit-reversal permutation. + let mut j = 0_usize; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + real.swap(i, j); + imag.swap(i, j); + } + } + let mut len = 2_usize; + while len <= n { + let angle = -2.0 * std::f64::consts::PI / len as f64; + let (step_r, step_i) = (angle.cos(), angle.sin()); + for start in (0..n).step_by(len) { + let (mut w_r, mut w_i) = (1.0_f64, 0.0_f64); + for k in start..start + len / 2 { + let (even_r, even_i) = (real[k], imag[k]); + let (odd_r, odd_i) = ( + real[k + len / 2] * w_r - imag[k + len / 2] * w_i, + real[k + len / 2] * w_i + imag[k + len / 2] * w_r, + ); + real[k] = even_r + odd_r; + imag[k] = even_i + odd_i; + real[k + len / 2] = even_r - odd_r; + imag[k + len / 2] = even_i - odd_i; + let next_r = w_r * step_r - w_i * step_i; + w_i = w_r * step_i + w_i * step_r; + w_r = next_r; + } + } + len <<= 1; + } +} + +fn write_json(path: &Path, value: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|err| format!("serializing sidecar failed: {err}"))?; + std::fs::write(path, bytes).map_err(|err| format!("writing {} failed: {err}", path.display())) +} + +fn write_json_new(path: &Path, value: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|err| format!("serializing sidecar failed: {err}"))?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|err| format!("creating {} failed: {err}", path.display()))?; + file.write_all(&bytes) + .and_then(|()| file.flush()) + .map_err(|err| format!("writing {} failed: {err}", path.display())) +} + +fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn lease_deadline(ttl_ms: u64) -> u64 { + now_unix_ms().saturating_add(ttl_ms.clamp(MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS)) +} + +fn service_error( + code: ServiceErrorCodeV1, + message: impl Into, + retryable: bool, +) -> ServiceErrorV1 { + ServiceErrorV1 { + code, + message: message.into(), + retryable, + } +} + +fn contract_integrity(integrity: StreamIntegrity, segments: u64) -> StreamIntegrityV1 { + StreamIntegrityV1 { + skipped_bytes: integrity.skipped_bytes, + crc_failures: integrity.crc_failures, + sequence_gaps: integrity.sequence_gaps, + dropped_samples: integrity.dropped_samples, + segment_restarts: segments.saturating_sub(1), + truncated_bytes: 0, + } +} + +fn accepted_service_reply( + request: &PluginServiceRequest, + response: &PhotodiodeResponseV1, +) -> PluginServiceReply { + PluginServiceReply { + request_id: request.request_id, + source_plugin_id: request.source_plugin_id.clone(), + target_plugin_id: request.target_plugin_id.clone(), + service: request.service.clone(), + outcome: PluginServiceOutcome::Accepted { + payload: serde_json::to_value(response).unwrap_or(Value::Null), + }, + } +} + +fn rejected_service_reply( + request: &PluginServiceRequest, + code: impl Into, + message: impl Into, +) -> PluginServiceReply { + PluginServiceReply { + request_id: request.request_id, + source_plugin_id: request.source_plugin_id.clone(), + target_plugin_id: request.target_plugin_id.clone(), + service: request.service.clone(), + outcome: PluginServiceOutcome::Rejected { + code: code.into(), + message: message.into(), + }, + } +} + +fn serial_ports() -> Vec { + 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 +/// `SamplesU16` frames on exactly one of the enumerated ports, so listen +/// briefly on each. +fn resolve_auto_port() -> Result { + let candidates = serial_ports(); + if candidates.is_empty() { + return Err(stage_a_io::transport::no_candidate_ports_message()); + } + let mut saw_legacy_ascii = false; + for path in &candidates { + match probe_pd_stream(path) { + ProbeResult::Pda1SampleFrames => return Ok(path.clone()), + ProbeResult::LegacyAsciiStream => saw_legacy_ascii = true, + ProbeResult::Nothing => {} + } + } + if saw_legacy_ascii { + // The pre-0.4.0 firmware emits `PD code=… n=… t_ms=…` ASCII lines + // instead of PDA1 binary frames. This plugin dropped the ASCII path + // (three-repo lockstep), so the fix is a firmware flash, not a plugin + // setting — say so instead of a generic "no frames". + return Err(format!( + "found the legacy ASCII photodiode stream (pre-0.4.0 firmware) — flash \ + stage-a-controller 0.4.0+ so the stream port emits PDA1 binary frames \ + (tried {})", + candidates.join(", ") + )); + } + Err(format!( + "no port streamed PDA1 sample frames within 500 ms (tried {})", + candidates.join(", ") + )) +} + +/// What a brief listen on a candidate port revealed. +enum ProbeResult { + /// CRC-clean PDA1 `SamplesU16` frames — the 0.4.0+ stream port. + Pda1SampleFrames, + /// `PD code=… n=… t_ms=…` ASCII lines — the pre-0.4.0 stream port. + LegacyAsciiStream, + /// Nothing parsable (busy/command port, wrong device, or no data). + Nothing, +} + +/// Listens on `path` for up to 500 ms and classifies what it emits. The +/// command port emits frames too, but only control replies and acquisition +/// data — unsolicited sample frames identify the stream port. +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; + }; + let deadline = Instant::now() + Duration::from_millis(500); + let mut parser = FrameParser::default(); + let mut ascii_tail: Vec = Vec::with_capacity(256); + let mut buf = [0_u8; 4_096]; + while Instant::now() < deadline { + match port.read(&mut buf) { + Ok(read) if read > 0 => { + parser.extend(&buf[..read]); + while let Some(event) = parser.next_event() { + if let ParseEvent::Frame(frame) = event { + if frame.samples().is_some() { + return ProbeResult::Pda1SampleFrames; + } + } + } + // Sniff for the legacy ASCII line format in parallel; a valid + // `PD code=` prefix never appears inside PDA1 binary framing. + ascii_tail.extend_from_slice(&buf[..read]); + if String::from_utf8_lossy(&ascii_tail).contains("PD code=") { + return ProbeResult::LegacyAsciiStream; + } + if ascii_tail.len() > 512 { + ascii_tail.drain(..ascii_tail.len() - 256); + } + } + Ok(_) => {} + Err(err) + if err.kind() == std::io::ErrorKind::TimedOut + || err.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return ProbeResult::Nothing, + } + } + ProbeResult::Nothing +} + +/// The exact variant list the settings schema shows for the port enum — the +/// host exchanges enum settings as indices into this list. +fn port_variants() -> Vec { + let mut variants = vec!["mock".to_owned(), "auto".to_owned()]; + variants.extend( + stage_a_io::transport::candidate_ports() + .iter() + .map(stage_a_io::transport::PortInfo::variant), + ); + variants +} + +/// The path part of a port variant; the parenthesised USB label is display-only. +fn variant_path(variant: &str) -> &str { + variant.split_whitespace().next().unwrap_or(variant) +} + +/// Host enum widgets send the selected index; string names are also accepted +/// (tests, saved configs). +fn enum_choice(value: &Value, variants: &[String]) -> Result { + if let Some(index) = value.as_u64() { + return variants + .get(usize::try_from(index).map_err(|_| "index out of range".to_owned())?) + .cloned() + .ok_or_else(|| format!("enum index {index} out of range")); + } + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| "expected an enum index or name".to_owned()) +} + +impl Plugin for StageAPhotodiodePlugin { + fn name(&self) -> &'static str { + "Stage-A Photodiode" + } + + fn description(&self) -> &'static str { + "Live photodiode readout with explicit rejected-port, camera-path or emission-path optical geometry and coordinated PDQ recording." + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.connect_requested = false; + // Finalize an active recording so the .pdq/.json pair is complete + // even when the plugin is disabled mid-run. + let termination = if self.lease.is_some() { + PdqTerminationV1::Aborted + } else { + PdqTerminationV1::OperatorStopped + }; + if let Err(err) = self.finalize_recording(termination) { + self.last_error = Some(err); + } + self.disconnect(); + self.lease = None; + } + } + + fn set_runtime_role(&mut self, role: PluginRuntimeRole) { + self.runtime_role = role; + if role != PluginRuntimeRole::LiveWorker { + if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { + self.last_error = Some(error); + } + // Demoting to the UI mirror drops the hardware, not the operator's + // connect intent — see `apply_execution_context`. + self.disconnect(); + self.lease = None; + self.effects_allowed = false; + } + } + + fn reset(&mut self) { + if let Ok(mut state) = self.shared.lock() { + state.samples.clear(); + } + self.generation.fetch_add(1, Ordering::Relaxed); + } + + fn process_frame( + &mut self, + _frame: &PluginFrame<'_>, + _output: &mut HostOutput<'_>, + context: &mut HostContext<'_>, + _event_store: &EventStoreHandle<'_>, + ) { + if context.execution().mode == augur_plugin_api::ExecutionMode::Replay { + if let Err(error) = self.finalize_recording(PdqTerminationV1::Aborted) { + self.last_error = Some(error); + } + // Runs every replayed frame, so it must not clear the intent + // either — the port stays closed because `connect()` is guarded. + self.disconnect(); + self.lease = None; + } + } + + fn process_control(&mut self, context: &mut PluginControlContext<'_>) { + let execution = context.execution(); + self.apply_execution_context(&execution); + } + + fn handle_service_request( + &mut self, + request: &PluginServiceRequest, + execution: &augur_plugin_api::ExecutionContext, + ) -> PluginServiceReply { + if let Some((previous, reply)) = self.request_cache.iter().find(|(previous, _)| { + previous.source_plugin_id == request.source_plugin_id + && previous.request_id == request.request_id + }) { + return if previous == request { + reply.clone() + } else { + rejected_service_reply( + request, + "request_id_conflict", + "request ID was reused for a different photodiode payload", + ) + }; + } + + let reply = if request.target_plugin_id != PLUGIN_ID_STAGE_A_PHOTODIODE { + rejected_service_reply(request, "wrong_target", "wrong photodiode owner target") + } else if request.service != SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1 { + rejected_service_reply( + request, + "unsupported_service", + format!("unsupported photodiode service '{}'", request.service), + ) + } else if self.runtime_role != PluginRuntimeRole::LiveWorker + || !execution.hardware_effects_allowed() + { + rejected_service_reply( + request, + "effects_not_allowed", + "photodiode effects are allowed only on the active live worker", + ) + } else { + self.effects_allowed = true; + match serde_json::from_value::(request.payload.clone()) { + Err(error) => rejected_service_reply( + request, + "invalid_payload", + format!("invalid photodiode request: {error}"), + ), + Ok(payload) + if payload.contract_version != CONTRACT_VERSION_V1 + || payload.request_id.0 != request.request_id + || payload.requester.as_str() != request.source_plugin_id + || payload + .target_owner_instance + .as_ref() + .is_some_and(|owner| owner != &self.owner_instance) => + { + rejected_service_reply( + request, + "identity_mismatch", + "contract version, request, requester, or owner instance mismatch", + ) + } + Ok(payload) + if payload.issued_at_unix_ms != 0 + && (now_unix_ms().saturating_sub(payload.issued_at_unix_ms) > 120_000 + || payload.issued_at_unix_ms.saturating_sub(now_unix_ms()) + > 30_000) => + { + rejected_service_reply(request, "stale_request", "request timestamp is stale") + } + Ok(payload) => match self.handle_photodiode_command(&payload) { + Ok(response) => accepted_service_reply(request, &response), + Err(error) => rejected_service_reply( + request, + format!("{:?}", error.code).to_ascii_lowercase(), + error.message, + ), + }, + } + }; + self.request_cache + .push_back((request.clone(), reply.clone())); + while self.request_cache.len() > REQUEST_CACHE_LIMIT { + self.request_cache.pop_front(); + } + reply + } + + fn control_snapshots(&self) -> Vec { + vec![PluginControlSnapshot { + plugin_id: PLUGIN_ID_STAGE_A_PHOTODIODE.into(), + topic: CTX_STAGE_A_PHOTODIODE_SUMMARY_V1.into(), + revision: self.generation.load(Ordering::Relaxed).max(1), + payload: serde_json::to_value(self.control_summary()).unwrap_or(Value::Null), + }] + } + + fn settings_schema(&self) -> SettingsSchema { + let port_variants = port_variants(); + let port_default = port_variants + .iter() + .position(|p| variant_path(p) == self.port_hint) + .unwrap_or(0); + let mode_variants: Vec = + Mode::VARIANTS.iter().map(|m| m.name().to_owned()).collect(); + let mode_default = Mode::VARIANTS + .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 { + label: "Photodiode readout".into(), + 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\ + 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, + items: vec![ + SettingItem { + key: "port".into(), + label: "Port".into(), + tooltip: Some( + "auto (recommended) finds the Teensy port that is sending \ + photodiode samples. mock produces fake data for testing without \ + hardware." + .into(), + ), + kind: SettingKind::Enum { + variants: port_variants, + default: port_default, + }, + }, + SettingItem { + key: "connect".into(), + label: "Connect".into(), + tooltip: Some( + "Opens/closes the stream port (read-only, no camera required)." + .into(), + ), + kind: SettingKind::Bool { + 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(), + tooltip: Some( + "RAW shows what the detector reads. EXCITATION shows the \ + excitation beam instead (total power minus the detector \ + reading) — use this one to measure the modulation depth." + .into(), + ), + kind: SettingKind::Enum { + variants: mode_variants, + default: mode_default, + }, + }, + SettingItem { + key: "window_s".into(), + label: "Chart window".into(), + tooltip: Some( + "Seconds of history shown in the live chart. Short windows \ + (≤ 50 ms) resolve individual modulation cycles at 20 kSa/s." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.01, + max: 120.0, + speed: 0.05, + default: self.window_s, + }, + }, + SettingItem { + key: "avg_samples".into(), + label: "Average window".into(), + tooltip: Some( + "Moving-average window in samples (1 = off). Ignored while \ + 'Average sync frequency' is set." + .into(), + ), + kind: SettingKind::I64Drag { + min: 1, + max: 1_000_000, + default: self.avg_samples as i64, + }, + }, + SettingItem { + key: "avg_sync_freq_hz".into(), + label: "Average sync frequency".into(), + tooltip: Some( + "0 = off. When set to the modulation frequency (Hz), the moving \ + average spans exactly one full period (window = rate / f), so the \ + mean level no longer depends on the modulation phase." + .into(), + ), + kind: SettingKind::F64Drag { + min: 0.0, + max: 100_000.0, + speed: 1.0, + default: self.avg_sync_freq_hz, + }, + }, + SettingItem { + key: "time_axis".into(), + label: "Time axis".into(), + tooltip: Some( + "BEFORE NOW scrolls (x ends at 0); SEGMENT TIME shows absolute \ + seconds on the device clock — better for frozen plots and \ + cursor measurements." + .into(), + ), + kind: SettingKind::Enum { + variants: TimeAxis::VARIANTS + .iter() + .map(|axis| axis.name().to_owned()) + .collect(), + default: TimeAxis::VARIANTS + .iter() + .position(|axis| *axis == self.time_axis) + .unwrap_or(0), + }, + }, + SettingItem { + key: "show_markers".into(), + label: "Show phase-0 trigger markers".into(), + tooltip: Some( + "Overlay the firmware phase-0 markers (device-clock MARKER frames) \ + as a toggleable vertical curve. Also defines the modulation \ + frequency from the marker spacing." + .into(), + ), + kind: SettingKind::Bool { + default: self.show_markers, + }, + }, + ], + }, + SettingsSection { + label: "Data".into(), + description: Some( + "Recording the photodiode on its own. The A1 plugin drives its own \ + recordings and does not need anything here.\n\n\ + The last few seconds are always kept in memory for the chart; recording \ + writes everything to disk instead, so it can run as long as you have \ + space. Files store the raw readings, with the mode and total power in a \ + companion file." + .into(), + ), + default_open: false, + items: vec![ + SettingItem { + key: "data_dir".into(), + label: "Data directory".into(), + tooltip: Some( + "Where recordings and cache snapshots are written.".into(), + ), + kind: SettingKind::Path { + dialog: PathDialogKind::Directory, + default: self.data_dir.clone(), + }, + }, + SettingItem { + key: "cache_s".into(), + label: "Cache length".into(), + tooltip: Some( + "How many seconds of samples to keep in memory. This is also the \ + stretch the modulation depth is measured over, so it must cover \ + at least one full cycle of your slowest frequency — raise it if \ + A1 says the window is too short." + .into(), + ), + kind: SettingKind::F64Drag { + min: 1.0, + max: MAX_CACHE_SECONDS, + speed: 1.0, + default: self + .shared + .lock() + .map(|state| state.cache_seconds) + .unwrap_or(DEFAULT_CACHE_SECONDS), + }, + }, + SettingItem { + key: "record_start".into(), + label: "Start recording".into(), + tooltip: Some( + "Start appending every incoming sample frame to \ + pd_rec_.pdq. Disabled until a data directory \ + is selected." + .into(), + ), + kind: SettingKind::Button { + enabled: !self.data_dir.trim().is_empty(), + }, + }, + SettingItem { + key: "record_stop".into(), + label: "Stop recording".into(), + tooltip: Some( + "Stop the disk recording and write the JSON sidecar.".into(), + ), + kind: SettingKind::Button { enabled: true }, + }, + SettingItem { + key: "save_snapshot".into(), + label: "Save cache snapshot".into(), + tooltip: Some( + "Write the current cache once as pd_cache_.csv \ + (+ JSON sidecar). Disabled until a data directory is selected." + .into(), + ), + kind: SettingKind::Button { + enabled: !self.data_dir.trim().is_empty(), + }, + }, + ], + }, + ], + } + } + + fn get_setting(&self, key: &str) -> Option { + match key { + // Enum settings are exchanged as indices into the schema's + // variant list (see the host settings UI). + "port" => { + let index = port_variants() + .iter() + .position(|p| variant_path(p) == self.port_hint) + .unwrap_or(0); + Some(json!(index)) + } + "connect" => Some(json!(self.connect_requested)), + "placement" => Some(json!(PHOTODIODE_PLACEMENTS + .iter() + .position(|placement| *placement == self.placement) + .unwrap_or(0))), + "splitter_fraction" => Some(json!(self.splitter_fraction)), + "direct_dark_volts" => Some(json!(self.direct_dark_manual_volts)), + "use_manual_direct_dark" => Some(self.press_use_manual_direct_dark.value()), + "capture_direct_dark" => Some(self.press_capture_direct_dark.value()), + "mode" => { + let index = Mode::VARIANTS + .iter() + .position(|m| *m == self.mode) + .unwrap_or(0); + Some(json!(index)) + } + "window_s" => Some(json!(self.window_s)), + "avg_samples" => Some(json!(self.avg_samples)), + "show_markers" => Some(json!(self.show_markers)), + "avg_sync_freq_hz" => Some(json!(self.avg_sync_freq_hz)), + "time_axis" => { + let index = TimeAxis::VARIANTS + .iter() + .position(|axis| *axis == self.time_axis) + .unwrap_or(0); + Some(json!(index)) + } + "data_dir" => Some(json!(self.data_dir)), + "cache_s" => Some(json!(self + .shared + .lock() + .map(|state| state.cache_seconds) + .unwrap_or(DEFAULT_CACHE_SECONDS))), + // Kept for compatibility (tests, external tooling); not in the + // schema anymore, so it is never synced across instances. + "record" => Some(json!(self.recording_active())), + // Button presses are exported as monotonic counters so the host's + // settings snapshot transports them from the UI mirror to the + // live worker (see PressLatch). + "record_start" => Some(self.press_record_start.value()), + "record_stop" => Some(self.press_record_stop.value()), + "save_snapshot" => Some(self.press_save_snapshot.value()), + _ => None, + } + } + + fn set_setting(&mut self, key: &str, value: Value) -> Result<(), String> { + if self.lease.is_some() { + return Err(format!( + "manual setting '{key}' is locked while the photodiode owner is leased" + )); + } + match key { + "port" => { + self.port_hint = variant_path(&enum_choice(&value, &port_variants())?).to_owned(); + Ok(()) + } + "placement" => { + let names: Vec = PHOTODIODE_PLACEMENTS + .iter() + .map(|placement| placement_name(*placement).to_owned()) + .collect(); + let name = enum_choice(&value, &names)?; + let placement = placement_from_name(&name) + .ok_or_else(|| format!("unknown photodiode placement: {name}"))?; + if placement != self.placement { + self.placement = placement; + self.direct_dark_reference = None; + } + Ok(()) + } + "splitter_fraction" => { + let fraction = value.as_f64().ok_or("splitter_fraction must be a number")?; + if !(fraction.is_finite() && 0.0 < fraction && fraction <= 1.0) { + return Err("splitter_fraction must be in (0, 1]".into()); + } + self.splitter_fraction = fraction; + Ok(()) + } + "direct_dark_volts" => { + let volts = value.as_f64().ok_or("direct_dark_volts must be a number")?; + if !volts.is_finite() || !(0.0..ADC_FULL_SCALE_VOLTS).contains(&volts) { + return Err(format!( + "direct_dark_volts must be in [0, {ADC_FULL_SCALE_VOLTS})" + )); + } + self.direct_dark_manual_volts = volts; + Ok(()) + } + "use_manual_direct_dark" => { + if self.press_use_manual_direct_dark.accept(&value) { + if self.placement == PhotodiodePlacementV1::RejectedPort { + return Err("lamp-off dark is not used for the PBS rejected port".into()); + } + let captured_at_unix_ms = now_unix_ms(); + self.direct_dark_reference = Some(StoredDirectDarkReference { + dark_id: format!("manual@{captured_at_unix_ms}"), + source: PhotodiodeDarkSourceV1::Manual, + dark_volts: self.direct_dark_manual_volts, + captured_at_unix_ms, + }); + self.last_save_note = Some(format!( + "using manually entered dark: {:.6} V", + self.direct_dark_manual_volts + )); + } + Ok(()) + } + "capture_direct_dark" => { + if self.press_capture_direct_dark.accept(&value) { + if self.placement == PhotodiodePlacementV1::RejectedPort { + return Err("lamp-off dark is not used for the PBS rejected port".into()); + } + let level = self + .shared + .lock() + .ok() + .and_then(|state| self.current_level(&state)) + .ok_or("no settled photodiode level is available")?; + let volts = level.mean_volts; + let captured_at_unix_ms = now_unix_ms(); + self.direct_dark_reference = Some(StoredDirectDarkReference { + dark_id: format!( + "lamp-off@sample-{}@{captured_at_unix_ms}", + level.end_sample_index + ), + source: PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: volts, + captured_at_unix_ms, + }); + self.last_save_note = Some(format!("captured lamp-off dark: {volts:.6} V")); + } + Ok(()) + } + "connect" => { + let requested = value.as_bool().ok_or("connect must be a boolean")?; + self.connect_requested = requested; + if requested { + self.connect(); + } else { + self.disconnect(); + } + Ok(()) + } + "mode" => { + let mode_names: Vec = + Mode::VARIANTS.iter().map(|m| m.name().to_owned()).collect(); + let name = enum_choice(&value, &mode_names)?; + self.mode = Mode::from_name(&name) + .ok_or_else(|| format!("unknown mode: {name} (RAW/EXCITATION)"))?; + Ok(()) + } + "show_markers" => { + self.show_markers = value.as_bool().ok_or("show_markers must be a boolean")?; + Ok(()) + } + "window_s" => { + let seconds = value.as_f64().ok_or("window_s must be a number")?; + self.window_s = seconds.clamp(0.01, 120.0); + Ok(()) + } + "avg_samples" => { + let samples = value.as_i64().ok_or("avg_samples must be an integer")?; + self.avg_samples = samples.clamp(1, 1_000_000) as usize; + Ok(()) + } + "avg_sync_freq_hz" => { + let freq = value.as_f64().ok_or("avg_sync_freq_hz must be a number")?; + self.avg_sync_freq_hz = freq.clamp(0.0, 100_000.0); + Ok(()) + } + "time_axis" => { + let names: Vec = TimeAxis::VARIANTS + .iter() + .map(|axis| axis.name().to_owned()) + .collect(); + let name = enum_choice(&value, &names)?; + self.time_axis = TimeAxis::from_name(&name) + .ok_or_else(|| format!("unknown time axis: {name}"))?; + Ok(()) + } + "data_dir" => { + self.data_dir = value + .as_str() + .ok_or("data_dir must be a string")? + .to_owned(); + Ok(()) + } + "cache_s" => { + let seconds = value.as_f64().ok_or("cache_s must be a number")?; + if let Ok(mut state) = self.shared.lock() { + state.cache_seconds = seconds.clamp(1.0, MAX_CACHE_SECONDS); + } + Ok(()) + } + "record" => { + // Compatibility alias (not in the schema): direct boolean + // start/stop with the same edge-free semantics as before. + let requested = value.as_bool().ok_or("record must be a boolean")?; + let result = if requested { + self.start_recording() + } else { + self.stop_recording() + }; + if let Err(err) = result { + self.last_error = Some(err); + } else { + self.last_error = None; + } + self.generation.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + "record_start" => { + // Failures surface through status entries (like `connect`), + // so a missing data directory doesn't read as a broken UI. + if self.press_record_start.accept(&value) { + match self.start_recording() { + Ok(()) => self.last_error = None, + Err(err) => self.last_error = Some(err), + } + self.generation.fetch_add(1, Ordering::Relaxed); + } + Ok(()) + } + "record_stop" => { + if self.press_record_stop.accept(&value) { + match self.stop_recording() { + Ok(()) => self.last_error = None, + Err(err) => self.last_error = Some(err), + } + self.generation.fetch_add(1, Ordering::Relaxed); + } + Ok(()) + } + // Accepted and ignored so a config saved before ADR 024 still + // loads. The anchor is learned from the stream and dark cancels out + // of the complement, so there is nothing left for these to set. + "reference_volts" + | "reference_anchor_id" + | "reference_confirmed" + | "dark_volts" + | "capture_dark" => Ok(()), + "save_snapshot" => { + // Edge-guarded: the host re-applies the full settings snapshot + // on every sync, and an unguarded arm wrote one cache file per + // sync of *any* plugin's settings. + if self.press_save_snapshot.accept(&value) { + match self.save_cache_snapshot() { + Ok(()) => self.last_error = None, + Err(err) => self.last_error = Some(err), + } + self.generation.fetch_add(1, Ordering::Relaxed); + } + Ok(()) + } + _ => Err(format!("unknown setting: {key}")), + } + } + + fn status_entries(&self) -> Vec { + let mut entries = Vec::new(); + let (latest, rate_hz, average, stream_error, anchor) = match self.shared.lock() { + Ok(state) => ( + state.latest, + state.rate_hz, + self.current_average_code(&state), + state.error.clone(), + (self.placement == PhotodiodePlacementV1::RejectedPort) + .then(|| self.total_power_volts(&state)) + .flatten(), + ), + Err(_) => (None, 0, None, None, None), + }; + entries.push(StatusEntry::Text(if self.connected() { + if rate_hz > 0 { + format!("Photodiode: reading ({}) @ {rate_hz} Sa/s", self.port_hint) + } else { + format!("Photodiode: reading ({})", self.port_hint) + } + } else { + "Photodiode: disconnected".into() + })); + entries.push(StatusEntry::Text(match self.placement { + PhotodiodePlacementV1::RejectedPort => { + "Optics: PBS rejected port (I_tot complement)".into() + } + placement => match self.published_direct_dark() { + Some(dark) => format!( + "Optics: {} (PD fraction {:.1} %, dark {:.4} V, {:?}, age {:.1} s)", + placement_name(placement), + 100.0 * self.splitter_fraction, + dark.dark_volts, + dark.source, + dark.age_s + ), + None => format!( + "Optics: {} (PD fraction {:.1} %, lamp-off dark REQUIRED)", + placement_name(placement), + 100.0 * self.splitter_fraction + ), + }, + })); + if let Some(sample) = latest { + match self.mode { + Mode::Raw => entries.push(StatusEntry::Text(format!( + "PD: code={sample} ({:.4} V)", + code_to_volts(f64::from(sample)) + ))), + Mode::Excitation => entries.push(StatusEntry::Text(format!( + "Excitation: {:.4} V (I_tot={}, PD={:.4} V)", + self.display_volts(f64::from(sample), anchor), + match anchor { + Some(volts) => format!("{volts:.4} V"), + None => "learning…".into(), + }, + code_to_volts(f64::from(sample)) + ))), + } + } + if let Some(average) = average { + let window = self.avg_window_samples(rate_hz); + if window > 1 { + entries.push(StatusEntry::Text(format!( + "Avg ({window} spl): {:.4} V", + self.display_volts(average, anchor) + ))); + } + } + match self.latest_optical_result() { + // Always the excitation contrast: the geometry follows the bench, + // not the display mode. + Some(Ok(optical)) => { + entries.push(StatusEntry::Text(format!( + "a (local optical signal) = {:.3} (I {:.4}..{:.4} V)", + optical.measured_log_contrast, + optical.excitation_min_volts, + optical.excitation_max_volts + ))); + } + // A withheld `a` is a fail-closed refusal, not an absence of data: + // say which gate rejected the window so the operator can fix it. + Some(Err(error)) => { + entries.push(StatusEntry::Text(format!("a unavailable: {error}"))); + } + None => {} + } + if let Ok(state) = self.shared.lock() { + if let Some(period_samples) = state.marker_period_samples() { + let hz = f64::from(state.rate_hz.max(1)) / period_samples; + entries.push(StatusEntry::Text(format!( + "Trigger: {} markers, f = {hz:.3} Hz", + state.markers.len() + ))); + } + } + if self.recording_active() { + let (samples, path) = self + .recording + .lock() + .ok() + .and_then(|slot| { + slot.as_ref() + .map(|sink| (sink.samples_written, sink.pdq_path.display().to_string())) + }) + .unwrap_or((0, String::new())); + let seconds = if rate_hz > 0 { + samples as f64 / f64::from(rate_hz) + } else { + 0.0 + }; + entries.push(StatusEntry::Text(format!("● REC {seconds:.1} s → {path}"))); + } else if let Some(note) = &self.last_save_note { + entries.push(StatusEntry::Text(note.clone())); + } + if let Some(error) = stream_error.or_else(|| self.last_error.clone()) { + entries.push(StatusEntry::Text(format!("Error: {error}"))); + } + entries + } + + fn host_views(&self) -> HostViewRegistry { + HostViewRegistry { + datasets: vec![ + HostDatasetDescriptor { + id: SERIES_DATASET_ID.into(), + title: "Photodiode trace".into(), + kind: HostDatasetKind::Series1dV1, + empty_message: "No photodiode samples yet — connect the stream port.".into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: SPECTRUM_DATASET_ID.into(), + title: "Photodiode spectrum".into(), + kind: HostDatasetKind::Series1dV1, + empty_message: "Not enough samples for a spectrum yet — connect the stream \ + port and wait a moment." + .into(), + display: None, + relations: Vec::new(), + }, + HostDatasetDescriptor { + id: STATUS_DATASET_ID.into(), + title: "Photodiode readout".into(), + kind: HostDatasetKind::TableV1(self.status_schema()), + empty_message: "Photodiode readout idle.".into(), + display: None, + relations: Vec::new(), + }, + ], + views: vec![ + HostViewDescriptor { + id: SERIES_VIEW_ID.into(), + title: "Photodiode".into(), + dataset_id: SERIES_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::LineSeriesWindow, + }, + HostViewDescriptor { + id: SPECTRUM_VIEW_ID.into(), + title: "PD Spectrum".into(), + dataset_id: SPECTRUM_DATASET_ID.into(), + placement: HostViewPlacement::Window, + kind: HostViewKind::LineSeriesWindow, + }, + HostViewDescriptor { + id: STATUS_VIEW_ID.into(), + title: "Photodiode readout".into(), + dataset_id: STATUS_DATASET_ID.into(), + placement: HostViewPlacement::AnalysisPanel, + kind: HostViewKind::CompactTable, + }, + ], + actions: Vec::new(), + } + } + + fn host_view_dataset(&self, dataset_id: &str) -> Option> { + match dataset_id { + SERIES_DATASET_ID => serde_json::to_vec(&self.series_dataset()).ok(), + SPECTRUM_DATASET_ID => serde_json::to_vec(&self.spectrum_dataset()).ok(), + STATUS_DATASET_ID => serde_json::to_vec(&self.status_dataset()).ok(), + _ => None, + } + } + + fn host_view_dataset_generation(&self, dataset_id: &str) -> u64 { + match dataset_id { + SERIES_DATASET_ID | SPECTRUM_DATASET_ID | STATUS_DATASET_ID => { + self.generation.load(Ordering::Relaxed).max(1) + } + _ => 0, + } + } +} + +export_plugin!(StageAPhotodiodePlugin); + +#[cfg(test)] +mod tests { + use super::*; + use augur_plugin_api::{ExecutionContext, ExecutionMode}; + use stage_a_io::{Frame, FrameHeader, FrameType}; + + fn live_execution() -> ExecutionContext { + ExecutionContext { + mode: ExecutionMode::LiveCapture, + effects_allowed: true, + session_id: Some("test".into()), + } + } + + fn live_plugin() -> StageAPhotodiodePlugin { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_runtime_role(PluginRuntimeRole::LiveWorker); + plugin.effects_allowed = true; + plugin + } + + /// Pins the learned total-power anchor to a known `I_tot`, standing in for + /// the excitation null the Pockels sweep drives the detector through. + fn anchor_at(state: &mut SharedState, volts: f64) { + state.observed_peak_code = Some(volts * ADC_MAX_CODE / ADC_FULL_SCALE_VOLTS); + } + + /// A clean rejected-port sine: the detector swings around `center` while + /// the excitation is its complement against `I_tot`. + fn rejected_port_samples(center: f64, amplitude: f64, count: usize) -> Vec { + (0..count) + .map(|i| { + let phase = 2.0 * std::f64::consts::PI * (i as f64) * 8.0 / count as f64; + (center + amplitude * phase.sin()) + .round() + .clamp(0.0, 4_095.0) as u16 + }) + .collect() + } + + /// [`rejected_port_samples`] ingested into a ring, with one phase-0 marker + /// per cycle when `mark_cycles` — the estimator sizes its window from them. + fn rejected_port_state( + center: f64, + amplitude: f64, + count: usize, + mark_cycles: bool, + ) -> SharedState { + let mut state = SharedState::default(); + state.ingest( + 0, + 20_000, + 0, + &rejected_port_samples(center, amplitude, count), + ); + if mark_cycles { + // `rejected_port_samples` puts 8 whole cycles in `count` samples. + let period = (count / 8) as u64; + for cycle in 0..=8 { + state.push_marker(cycle * period); + } + } + state + } + + /// A slow sine streamed for `total` samples into a ring that only retains + /// `retained` of them, with one phase-0 marker per cycle delivered as the + /// stream goes past — so markers are evicted exactly as they are on the + /// bench when the period outgrows the monitor cache. + fn slow_sine_state(period_samples: u64, retained: usize, total: usize) -> SharedState { + let mut state = SharedState { + cache_seconds: retained as f64 / 20_000.0, + ..Default::default() + }; + let block = 4_000; + let mut index = 0usize; + while index < total { + let end = (index + block).min(total); + let codes: Vec = (index..end) + .map(|i| { + let phase = 2.0 * std::f64::consts::PI * (i as f64) / period_samples as f64; + (1_600.0 + 700.0 * phase.sin()).round() as u16 + }) + .collect(); + state.ingest(index as u64, 20_000, 0, &codes); + let mut marker = index.next_multiple_of(period_samples as usize) as u64; + while (marker as usize) <= end { + state.push_marker(marker); + marker += period_samples; + } + index = end; + } + state + } + + /// The bug an A1 survey paid for a recording at a time: a cache length left + /// at its default is shorter than one cycle of a sub-hertz rung, so the + /// estimator saw no whole cycle, `a` was withheld, and the sidecar was + /// refused *after* the recording had already run its full duration. The ring + /// grows to the drive now, so the same stream publishes an `a`. + #[test] + fn a_cache_shorter_than_the_drive_no_longer_starves_the_estimator() { + let plugin = live_plugin(); + // 0.5 Hz at 20 kSa/s = 40 000 samples per cycle, against a cache set to + // hold 0.6 of one. + let mut state = slow_sine_state(40_000, 24_000, 200_000); + anchor_at(&mut state, 3.0); + let summary = plugin + .optical_summary_result(&state) + .expect("the ring sizes itself to the marker period"); + assert!( + summary.covered_cycles.expect("cycles") >= 2.0, + "window covers {:?} cycles", + summary.covered_cycles + ); + } + + #[test] + fn a_window_shorter_than_one_cycle_withholds_a_instead_of_under_reporting_it() { + // `a` is peak-to-peak. Below one full cycle the robust extrema see an + // arc of the sine, so `a` comes out low — and A1's a₀ lock divides by + // it, inflating its drive against a bias it cannot see. Fail closed. + // + // A cache too short for the drive is no longer the way to get here — + // the ring follows the period. What is left is a drive whose cycles have + // not gone by yet: the first phase-0 stamp after a retarget or a segment + // restart bounds no whole cycle at all. + let plugin = live_plugin(); + + // 0.5 Hz at 20 kSa/s = 40 000 samples per cycle, one marker seen. + let mut partial = slow_sine_state(40_000, 24_000, 200_000); + anchor_at(&mut partial, 3.0); + partial.markers.drain(1..); + let error = plugin + .optical_summary_result(&partial) + .expect_err("a partial cycle must not publish an a"); + assert!( + matches!(error, EstimateError::IncompleteModulationCycles { .. }), + "unexpected rejection: {error:?}" + ); + + // Two whole cycles of the same drive: published, and the window is + // reported so a consumer can wait it out before trusting a re-read. + let mut whole = slow_sine_state(40_000, 80_000, 200_000); + anchor_at(&mut whole, 3.0); + let summary = plugin + .optical_summary(&whole) + .expect("two whole cycles estimate"); + let expected = ((3.0_f64 - (1_600.0 - 700.0) * (3.3 / 4_095.0)) + / (3.0 - (1_600.0 + 700.0) * (3.3 / 4_095.0))) + .ln(); + assert!( + (summary.measured_log_contrast - expected).abs() < 0.02, + "a={} expected~{expected}", + summary.measured_log_contrast + ); + assert!((summary.measured_frequency_hz.expect("markers") - 0.5).abs() < 0.01); + // The window is whole cycles, and says how many, so a consumer can wait + // it out before trusting a re-read. + let cycles = summary.covered_cycles.expect("cycles"); + assert!(cycles >= 2.0, "covered only {cycles} cycles"); + assert!( + (summary.window_seconds.expect("window") - cycles * 2.0).abs() < 0.01, + "window {:?} is not {cycles} cycles at 0.5 Hz", + summary.window_seconds + ); + } + + #[test] + fn the_contrast_window_grows_to_cover_whole_cycles_at_low_frequency() { + // A fixed 16 384-sample window is 0.82 s: below one cycle for every + // f < 1.2 Hz, which is where the A1 plateau reference lives. + let fast = rejected_port_state(1_600.0, 700.0, 4_096, true); + let (window, cycles) = fast.contrast_window(); + assert_eq!(window, 4_096, "high f keeps the whole retained ring"); + assert!(cycles.expect("markers") >= 8.0); + + let slow = slow_sine_state(40_000, 400_000, 400_000); + let (window, cycles) = slow.contrast_window(); + assert_eq!( + window, + (CONTRAST_WINDOW_CYCLES as usize) * 40_000, + "the window is sized from the marker period, not fixed" + ); + assert!((cycles.expect("markers") - CONTRAST_WINDOW_CYCLES).abs() < 0.01); + + // Without markers there is no period to size against: fall back to the + // fixed window and report no cycle count rather than guess one. + let mut unmarked = slow_sine_state(40_000, 400_000, 400_000); + unmarked.markers.clear(); + unmarked.marker_period_estimate = None; + assert_eq!(unmarked.contrast_window(), (CONTRAST_WINDOW_SAMPLES, None)); + } + + #[test] + fn published_contrast_is_the_excitation_contrast_in_both_display_modes() { + // The detector sits behind the PBS reject port whatever the operator + // is plotting, so a display toggle must not move a published + // scientific quantity. A1's amplitude sweep settles on this value. + let mut plugin = live_plugin(); + let mut state = rejected_port_state(1_600.0, 700.0, 4_096, true); + anchor_at(&mut state, 3.0); + + plugin.mode = Mode::Raw; + let raw = plugin.optical_summary(&state).expect("raw display"); + plugin.mode = Mode::Excitation; + let excitation = plugin.optical_summary(&state).expect("excitation display"); + + assert_eq!(raw.measured_log_contrast, excitation.measured_log_contrast); + assert!(raw + .calibration + .anchor_id + .as_deref() + .is_some_and(|anchor| anchor.starts_with("observed-peak@"))); + assert_eq!(raw.calibration.anchor_id, excitation.calibration.anchor_id); + assert!((raw.calibration.total_power_volts.expect("I_tot") - 3.0).abs() < 1e-12); + assert!((raw.measured_frequency_hz.expect("marker frequency") - 39.0625).abs() < 1e-12); + + // And it really is the complement contrast, not ln(v_max/v_min) of the + // detector trace. + let detector_direct = ((1_600.0_f64 + 700.0) / (1_600.0 - 700.0)).ln(); + assert!( + (raw.measured_log_contrast - detector_direct).abs() > 0.1, + "published a={} collapsed to the detector-direct contrast", + raw.measured_log_contrast + ); + } + + #[test] + fn emission_path_measures_direct_contrast_without_i_tot() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + splitter_fraction: 0.5, + direct_dark_reference: Some(StoredDirectDarkReference { + dark_id: "lamp-off@test".into(), + source: PhotodiodeDarkSourceV1::MeasuredLampOff, + dark_volts: 0.0, + captured_at_unix_ms: now_unix_ms(), + }), + ..StageAPhotodiodePlugin::default() + }; + let state = rejected_port_state(1_600.0, 700.0, 4_096, true); + let optical = plugin + .optical_summary_result(&state) + .expect("direct fluorescence contrast"); + + assert_eq!(optical.placement, PhotodiodePlacementV1::EmissionPath); + assert_eq!(optical.splitter_fraction, Some(0.5)); + assert!(optical.measured_log_contrast.is_finite()); + assert!(optical.measured_log_contrast > 0.0); + assert!(optical.calibration.anchor_id.is_none()); + assert!(optical.calibration.total_power_volts.is_none()); + assert_eq!( + optical + .calibration + .dark_reference + .as_ref() + .map(|reference| reference.source), + Some(PhotodiodeDarkSourceV1::MeasuredLampOff) + ); + + // Even a completely empty rejected-port anchor must not gate a direct + // path measurement. + plugin.shared = Arc::new(Mutex::new(state)); + let summary = plugin.control_summary(); + assert_eq!(summary.placement, PhotodiodePlacementV1::EmissionPath); + assert_eq!(summary.splitter_fraction, Some(0.5)); + assert!(summary.optical_summary.is_some()); + } + + #[test] + fn direct_path_refuses_contrast_until_dark_is_explicit() { + let plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + ..StageAPhotodiodePlugin::default() + }; + let state = rejected_port_state(1_600.0, 700.0, 4_096, true); + + assert_eq!( + plugin.optical_summary_result(&state), + Err(EstimateError::MissingDirectDarkReference) + ); + } + + #[test] + fn manual_direct_dark_is_marked_manual() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + ..StageAPhotodiodePlugin::default() + }; + plugin + .set_setting("direct_dark_volts", json!(0.012)) + .expect("manual dark draft"); + assert!(plugin.published_direct_dark().is_none()); + plugin + .set_setting("use_manual_direct_dark", json!(true)) + .expect("activate manual dark"); + + let reference = plugin + .published_direct_dark() + .expect("manual reference published"); + assert_eq!(reference.source, PhotodiodeDarkSourceV1::Manual); + assert!(reference.dark_id.starts_with("manual@")); + assert_eq!(reference.dark_volts, 0.012); + } + + #[test] + fn captured_direct_dark_names_the_measured_sample_window() { + let mut plugin = StageAPhotodiodePlugin { + placement: PhotodiodePlacementV1::EmissionPath, + shared: Arc::new(Mutex::new(rejected_port_state(16.0, 2.0, 4_096, true))), + ..StageAPhotodiodePlugin::default() + }; + plugin + .set_setting("capture_direct_dark", json!(true)) + .expect("capture dark"); + + let reference = plugin + .published_direct_dark() + .expect("measured reference published"); + assert_eq!(reference.source, PhotodiodeDarkSourceV1::MeasuredLampOff); + assert!(reference.dark_id.starts_with("lamp-off@sample-4096@")); + assert!(reference.captured_at_unix_ms > 0); + assert!(reference.age_s >= 0.0); + } + + #[test] + fn optical_contrast_requires_a_learned_anchor_and_complete_cycles() { + let plugin = live_plugin(); + let mut state = rejected_port_state(1_600.0, 700.0, 4_096, true); + + state.observed_peak_code = None; + assert_eq!( + plugin.optical_summary_result(&state), + Err(EstimateError::MissingTotalPowerAnchor) + ); + + anchor_at(&mut state, 3.0); + state.markers = VecDeque::from([0, 512]); + assert!(matches!( + plugin.optical_summary_result(&state), + Err(EstimateError::IncompleteModulationCycles { + marker_count: 2, + max_samples: 4_096, + }) + )); + } + + #[test] + fn a_withheld_contrast_publishes_its_reason_on_the_contract() { + // A1 gates the a₀ lock and the frequency ladder on `a`. When `a` is + // refused, the reason has to travel with the absent summary or the only + // thing the operator can read is that it is missing. + let plugin = live_plugin(); + { + let mut state = plugin.shared.lock().expect("state"); + *state = rejected_port_state(1_600.0, 700.0, 4_096, true); + state.observed_peak_code = None; + } + + let summary = plugin.control_summary(); + assert!(summary.optical_summary.is_none()); + assert_eq!( + summary.optical_unavailable.as_deref(), + Some(EstimateError::MissingTotalPowerAnchor.to_string().as_str()) + ); + + { + let mut state = plugin.shared.lock().expect("state"); + anchor_at(&mut state, 3.0); + } + let summary = plugin.control_summary(); + assert!(summary.optical_summary.is_some()); + assert!( + summary.optical_unavailable.is_none(), + "an accepted a must not also carry a refusal: {:?}", + summary.optical_unavailable + ); + } + + #[test] + fn the_millivolt_scale_reject_port_detector_still_yields_a() { + // The bench detector operates around 0.5–15 mV, inside the bottom ~20 + // codes of the 12-bit range. That is not the bottom rail, so `a` must be + // published rather than refused as clipped. + let plugin = live_plugin(); + { + let mut state = plugin.shared.lock().expect("state"); + // Detector swinging between ~0.6 and ~18.6 codes = 0.5..15 mV. + *state = rejected_port_state(9.6, 9.0, 4_096, true); + anchor_at(&mut state, 0.015_5); + } + + let summary = plugin.control_summary(); + assert!( + summary.optical_unavailable.is_none(), + "a millivolt-scale window must not be refused: {:?}", + summary.optical_unavailable + ); + let optical = summary.optical_summary.expect("a is published"); + assert!( + optical.measured_log_contrast > 0.0 && optical.measured_log_contrast.is_finite(), + "a = {}", + optical.measured_log_contrast + ); + } + + #[test] + fn the_anchor_is_learned_from_the_brightest_reading_the_detector_takes() { + // The excitation null the Pockels sweep drives through is where the + // reject-port detector reads I_tot. Nothing is entered by hand. + let plugin = live_plugin(); + { + let mut state = plugin.shared.lock().expect("state"); + // A sweep peak, then ordinary modulation well below it. + state.ingest(0, 20_000, 0, &[3_000; SUMMARY_CELL]); + state.ingest(SUMMARY_CELL as u64, 20_000, 0, &[1_200; SUMMARY_CELL * 4]); + } + + let anchor = plugin.learned_anchor_volts().expect("anchor learned"); + assert!( + (anchor - code_to_volts(3_000.0)).abs() < 1e-9, + "anchor {anchor} did not latch on the sweep peak" + ); + } + + #[test] + fn a_single_spike_cannot_become_the_anchor() { + // The latch runs on completed 64-sample cell means, so one outlier + // sample cannot pin I_tot high for every later `a`. + let plugin = live_plugin(); + { + let mut state = plugin.shared.lock().expect("state"); + let mut codes = vec![1_000u16; SUMMARY_CELL]; + codes[7] = 4_095; + state.ingest(0, 20_000, 0, &codes); + } + + let anchor = plugin.learned_anchor_volts().expect("anchor learned"); + assert!( + anchor < code_to_volts(1_100.0), + "a single spike pulled the anchor to {anchor}" + ); + } + + /// The anchor is learned from the detector's own stream, so before any + /// Pockels sweep has driven the excitation to its null the only thing it + /// has seen is the modulation itself — and then the "total power" is barely + /// above the signal. That must refuse, not publish an enormous `a`: the + /// excitation minimum would be dominated by the anchor's own error rather + /// than by the light. + #[test] + fn a_modulation_only_anchor_refuses_instead_of_reporting_a_huge_contrast() { + let plugin = live_plugin(); + // 8 cycles in 4096 samples at 20 kSa/s = ~39 Hz, and a slow 1 Hz case. + for (count, label) in [(4_096usize, "39 Hz"), (160_000, "1 Hz")] { + let state = rejected_port_state(1_600.0, 700.0, count, true); + // No `anchor_at`: whatever `ingest` learned from this trace alone. + match plugin.optical_summary_result(&state) { + // Named, not just "some error": a refusal for want of cycles + // would make this test pass without exercising the anchor at + // all. + Err(EstimateError::TotalPowerBelowSignal { .. }) => {} + Err(other) => panic!("{label}: refused for the wrong reason: {other:?}"), + Ok(summary) => panic!( + "{label}: published a = {} from an anchor that never saw the excitation \ + null (headroom {} V)", + summary.measured_log_contrast, summary.excitation_headroom_volts + ), + } + } + } + + #[test] + fn a_dc_dark_offset_cancels_out_of_the_complement() { + // Both sides of `I_exc = I_tot - I_pd` are readings from the same + // DC-coupled detector, so a dark offset appears in both and cancels + // exactly. That is why there is no dark setting: there is nothing for + // it to correct, and correcting only one side is the actual bug. + let plugin = live_plugin(); + + let contrast_with_offset = |offset: f64| { + let mut state = rejected_port_state(1_600.0 + offset, 700.0, 4_096, true); + // I_tot is read by the same detector, so it carries the offset too. + anchor_at(&mut state, 3.0 + code_to_volts(offset)); + plugin + .optical_summary(&state) + .expect("estimate") + .measured_log_contrast + }; + + let baseline = contrast_with_offset(0.0); + let offset = contrast_with_offset(200.0); + assert!( + (baseline - offset).abs() < 1e-9, + "dark did not cancel: {baseline} vs {offset}" + ); + } + + #[test] + fn a_dark_offset_on_only_one_side_would_bias_the_contrast() { + // Guards the invariance above against a regression that dark-corrects + // the detector but leaves the anchor raw (or vice versa). + let calibration = AdcCalibration { + volts_per_code: ADC_FULL_SCALE_VOLTS / ADC_MAX_CODE, + offset_volts: 0.0, + dark_volts: 0.05, + full_scale_code: ADC_MAX_CODE as u16, + }; + let samples: Vec = rejected_port_samples(1_600.0, 700.0, 4_096); + let consistent = estimate_contrast( + &samples, + &calibration, + ContrastGeometry::RejectedComplement { + total_power_volts: 3.0 - 0.05, + }, + ) + .expect("consistent"); + let asymmetric = estimate_contrast( + &samples, + &calibration, + ContrastGeometry::RejectedComplement { + total_power_volts: 3.0, + }, + ) + .expect("anchor left raw"); + assert!( + (consistent.a - asymmetric.a).abs() > 1e-3, + "the asymmetry must be observable, else this test proves nothing" + ); + } + + #[test] + fn the_removed_anchor_settings_still_load_from_an_old_config() { + // A config written before ADR 024 must not fail to load; the keys are + // accepted and ignored. + let mut plugin = live_plugin(); + for (key, value) in [ + ("reference_volts", json!(2.9)), + ("reference_anchor_id", json!("itot-old")), + ("reference_confirmed", json!(true)), + ("dark_volts", json!(0.05)), + ] { + plugin + .set_setting(key, value) + .unwrap_or_else(|error| panic!("{key} rejected: {error}")); + } + assert!(plugin.get_setting("reference_volts").is_none()); + } + + #[test] + fn the_ui_mirror_keeps_the_operators_connect_intent() { + // The mirror runs `apply_execution_context` every control tick. If it + // clears the intent, the host samples `connect` as false and the live + // worker never opens the port. + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_runtime_role(PluginRuntimeRole::UiMirror); + plugin.set_setting("connect", json!(true)).expect("connect"); + assert!(plugin.connect_requested); + + plugin.apply_execution_context(&live_execution()); + assert!( + plugin.connect_requested, + "the mirror cleared the connect intent" + ); + assert_eq!(plugin.get_setting("connect"), Some(json!(true))); + // ...but it must not have actually opened anything. + assert!(!plugin.connected()); + } + + fn service_request( + plugin: &StageAPhotodiodePlugin, + id: u64, + requester: &str, + command: PhotodiodeCommandV1, + revision: Option, + ) -> PluginServiceRequest { + let mut payload = PhotodiodeRequestV1::new( + stage_a_plugin_contract::RequestId(id), + ClientId::from(requester), + command, + ); + payload.target_owner_instance = Some(plugin.owner_instance.clone()); + payload.run_id = Some(RunId::from("run-a")); + payload.lease_id = Some(LeaseId::from("lease-a")); + payload.requested_revision = revision.map(SemanticRevision); + payload.issued_at_unix_ms = now_unix_ms(); + PluginServiceRequest { + request_id: id, + source_plugin_id: requester.into(), + target_plugin_id: PLUGIN_ID_STAGE_A_PHOTODIODE.into(), + service: SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1.into(), + payload: serde_json::to_value(payload).unwrap(), + } + } + + fn sample_frame(sequence: u32, first_index: u64, rate_hz: u32, codes: &[u16]) -> Vec { + let payload: Vec = codes.iter().flat_map(|c| c.to_le_bytes()).collect(); + Frame::build( + FrameHeader { + version: stage_a_io::wire::PROTOCOL_VERSION, + frame_type: FrameType::SamplesU16, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: first_index, + sample_rate_hz: rate_hz, + dropped_samples: 0, + crc32: 0, + }, + payload, + ) + .to_bytes() + } + + fn ingest_bytes(state: &mut SharedState, bytes: &[u8]) { + let mut parser = FrameParser::default(); + parser.extend(bytes); + while let Some(event) = parser.next_event() { + match event { + ParseEvent::Frame(frame) => { + let codes = frame.samples().expect("sample frame"); + state.ingest( + frame.header.first_sample_index, + frame.header.sample_rate_hz, + frame.header.dropped_samples, + &codes, + ); + } + ParseEvent::Corruption { .. } => panic!("clean test stream"), + } + } + } + + #[test] + fn ingests_contiguous_frames_and_restarts_on_gaps() { + let mut state = SharedState::default(); + ingest_bytes(&mut state, &sample_frame(0, 0, 20_000, &[1, 2, 3, 4])); + ingest_bytes(&mut state, &sample_frame(1, 4, 20_000, &[5, 6])); + assert_eq!(state.samples.len(), 6); + assert_eq!(state.ring_first_index, 0); + assert_eq!(state.segments, 0); + assert_eq!(state.latest, Some(6)); + + // A sample-index jump (dropped block, acquisition handover) restarts + // the segment instead of silently misaligning the time base. + ingest_bytes(&mut state, &sample_frame(2, 100, 20_000, &[7, 8])); + assert_eq!(state.samples.len(), 2); + assert_eq!(state.ring_first_index, 100); + assert_eq!(state.segments, 1); + + // So does a rate change (mirrored acquisition at another rate). + ingest_bytes(&mut state, &sample_frame(3, 102, 50_000, &[9])); + assert_eq!(state.samples.len(), 1); + assert_eq!(state.rate_hz, 50_000); + assert_eq!(state.segments, 2); + } + + #[test] + fn phase0_markers_define_frequency_and_evict_with_the_ring() { + // Ring holds 1 s = 20_000 samples at 20 kSa/s. + let mut state = SharedState { + cache_seconds: 1.0, + ..SharedState::default() + }; + // 500 Hz modulation: markers every 40 samples. + ingest_bytes(&mut state, &sample_frame(0, 0, 20_000, &[100; 40])); + state.push_marker(0); + state.push_marker(40); + state.push_marker(80); + assert_eq!(state.markers.len(), 3); + let period = state.marker_period_samples().expect("period"); + assert!((period - 40.0).abs() < 1e-9); + let hz = f64::from(state.rate_hz) / period; + assert!((hz - 500.0).abs() < 1e-6, "hz={hz}"); + + // Duplicate stamps are ignored, and markers before the ring start too. + state.push_marker(80); + state.ring_first_index = 60; + state.push_marker(40); // now below the ring start + assert_eq!(state.markers.len(), 3); + } + + /// The A1 protocols' 0.075 Hz floor at the bench's 500 kSa/s: two whole + /// cycles are 26.7 s, and the 20 s default cache cannot hold them. The ring + /// has to follow the drive down on its own — a survey that only learns at + /// the end of a 267 s recording that no `a` was retained has already spent + /// the bench time. + #[test] + fn ring_follows_the_drive_down_to_sub_hertz() { + let rate = 500_000_u32; + let mut state = SharedState { + rate_hz: rate, + ..SharedState::default() + }; + assert_eq!( + state.ring_capacity(rate), + 10_000_000, + "with no markers the operator's 20 s default stands" + ); + + // Two markers 0.075 Hz apart are all it takes to know the period. + let period = (f64::from(rate) / 0.075) as u64; + state.push_marker(0); + state.push_marker(period); + + let capacity = state.ring_capacity(rate); + assert_eq!(capacity, RING_MAX_SAMPLES, "sized up to the absolute cap"); + assert!( + capacity as u64 >= 2 * period, + "two cycles at 0.075 Hz need {} samples, ring holds {capacity}", + 2 * period + ); + + // Back up at 8.7 Hz the ring returns to the operator's cache length. + let fast = (f64::from(rate) / 8.7) as u64; + state.push_marker(period + fast); + assert_eq!(state.ring_capacity(rate), 10_000_000); + } + + #[test] + fn ring_is_bounded_by_duration() { + let mut state = SharedState::default(); + let rate = 1_000; // capacity = cache_seconds (20 s default) × rate + let cap = state.ring_capacity(rate); + assert_eq!(cap, 20_000, "default cache is 20 s"); + let block: Vec = (0..1_000).map(|i| (i % 4_096) as u16).collect(); + let mut index = 0_u64; + for _ in 0..(cap / block.len() + 5) { + state.ingest(index, rate, 0, &block); + index += block.len() as u64; + } + // Whole-cell eviction may leave up to one summary cell of slack. + assert!( + state.samples.len() >= cap && state.samples.len() < cap + SUMMARY_CELL, + "len {} vs cap {cap}", + state.samples.len() + ); + assert_eq!( + state.ring_first_index + state.samples.len() as u64, + index, + "eviction keeps indexes aligned" + ); + assert_eq!( + state.ring_first_index % SUMMARY_CELL as u64, + 0, + "eviction preserves cell alignment" + ); + assert_eq!(state.segments, 0, "eviction is not a discontinuity"); + } + + #[test] + fn moving_average_window_follows_the_sync_frequency() { + let mut plugin = StageAPhotodiodePlugin::default(); + assert_eq!(plugin.avg_window_samples(20_000), 4, "sample default"); + plugin + .set_setting("avg_samples", json!(16)) + .expect("valid setting"); + assert_eq!(plugin.avg_window_samples(20_000), 16); + // One full period of a 2 kHz modulation at 20 kSa/s = 10 samples. + plugin + .set_setting("avg_sync_freq_hz", json!(2_000.0)) + .expect("valid setting"); + assert_eq!(plugin.avg_window_samples(20_000), 10); + // Faster than the sample rate clamps to a single sample. + plugin + .set_setting("avg_sync_freq_hz", json!(50_000.0)) + .expect("valid setting"); + assert_eq!(plugin.avg_window_samples(20_000), 1); + } + + #[test] + fn current_average_uses_the_newest_window() { + let plugin = StageAPhotodiodePlugin::default(); // window = 4 samples + let mut state = SharedState::default(); + state.ingest(0, 20_000, 0, &[0, 0, 0, 0, 100, 200, 300, 400]); + let average = plugin.current_average_code(&state).expect("has samples"); + assert!((average - 250.0).abs() < 1e-9); + } + + /// Codes for a full measurement window at `rate_hz`, all at `code`. + fn level_window_codes(rate_hz: u32, code: u16) -> Vec { + vec![code; (f64::from(rate_hz) * LEVEL_WINDOW_SECONDS).round() as usize] + } + + #[test] + fn published_level_is_raw_volts_and_survives_clipping() { + let mut plugin = StageAPhotodiodePlugin::default(); + let mut codes = level_window_codes(20_000, 200); + codes.extend([100, 200, 300, 400]); + let mut state = SharedState::default(); + state.ingest(0, 20_000, 0, &codes); + + let level = plugin.current_level(&state).expect("has samples"); + // 400 samples at 20 kSa/s = the full 20 ms window, ending on the newest + // sample — not the four the chart happens to be smoothing over. + assert_eq!(level.sample_count, 400); + assert_eq!(level.end_sample_index, codes.len() as u64); + assert!(!level.clipped); + + // EXCITATION display must not leak into the published level: it stays + // the raw detector reading whatever the operator is looking at. + plugin.set_setting("mode", json!(1)).expect("excitation"); + let raw_again = plugin.current_level(&state).expect("has samples"); + assert_eq!(raw_again.mean_volts, level.mean_volts); + + // At the rail the optical summary refuses; the level must not, because + // that is exactly where a transfer sweep needs a reading. + let mut railed = SharedState::default(); + railed.ingest(0, 20_000, 0, &level_window_codes(20_000, 4_095)); + let clipped = plugin.current_level(&railed).expect("still reports"); + assert!(clipped.clipped); + assert!(plugin.optical_summary(&railed).is_none()); + } + + #[test] + fn the_published_level_window_ignores_the_chart_averaging_setting() { + // The sweep's precision is a measurement property. Deriving it from the + // chart's averaging made a display knob decide it: at the bench's + // 500 kSa/s the default of four samples published 8 µs per settled CONST + // code, and a clean Pockels curve came back as a 22 % residual (ADR 019). + let mut plugin = StageAPhotodiodePlugin::default(); + let mut state = SharedState::default(); + state.ingest(0, 500_000, 0, &level_window_codes(500_000, 300)); + + let level = plugin.current_level(&state).expect("has samples"); + assert_eq!(level.sample_count, 10_000, "20 ms at 500 kSa/s"); + + for avg in [1, 4, 4_096] { + plugin + .set_setting("avg_samples", json!(avg)) + .expect("valid"); + assert_eq!( + plugin + .current_level(&state) + .expect("has samples") + .sample_count, + level.sample_count, + "avg_samples = {avg} moved the published window" + ); + } + plugin + .set_setting("avg_sync_freq_hz", json!(10.0)) + .expect("valid"); + assert_eq!( + plugin + .current_level(&state) + .expect("has samples") + .sample_count, + level.sample_count, + "the sync-averaging setting moved the published window" + ); + } + + #[test] + fn a_millivolt_scale_level_is_not_reported_as_railed() { + // The reject-port detector's dark end sits a few codes above zero. A + // fixed rail margin called every one of those windows clipped, which the + // transfer fit then reported as "add attenuation and re-measure". + let plugin = StageAPhotodiodePlugin::default(); + let rate = 500_000; + let window = (f64::from(rate) * LEVEL_WINDOW_SECONDS).round() as usize; + // Swinging between codes 6 and 26 — clear of the rail at both ends. + let codes: Vec = (0..window) + .map(|index| if index % 2 == 0 { 6 } else { 26 }) + .collect(); + let mut state = SharedState::default(); + state.ingest(0, rate, 0, &codes); + assert!(!plugin.current_level(&state).expect("has samples").clipped); + + // Driven into the bottom rail, the refusal must survive. + let railed: Vec = (0..window) + .map(|index| if index % 2 == 0 { 0 } else { 26 }) + .collect(); + let mut state = SharedState::default(); + state.ingest(0, rate, 0, &railed); + assert!(plugin.current_level(&state).expect("has samples").clipped); + } + + #[test] + fn series_dataset_decimates_with_envelope_and_average() { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_setting("window_s", json!(120.0)).unwrap(); + plugin.set_setting("avg_samples", json!(50)).unwrap(); + { + let mut state = plugin.shared.lock().unwrap(); + let codes: Vec = (0..40_000_u32).map(|i| (i % 4_000) as u16).collect(); + state.ingest(0, 20_000, 0, &codes); + } + let series = plugin.series_dataset(); + let names: Vec<&str> = series.lines.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["photodiode", "min", "max", "avg (50 spl)"]); + for line in &series.lines { + assert!( + line.points.len() <= MAX_PLOT_BUCKETS + 1, + "{} has {} points", + line.name, + line.points.len() + ); + assert!(!line.points.is_empty()); + } + // min ≤ mean ≤ max, and x is "seconds before now" ending at 0. + let (mean, min, max) = (&series.lines[0], &series.lines[1], &series.lines[2]); + for ((m, lo), hi) in mean.points.iter().zip(&min.points).zip(&max.points) { + assert!(lo.y <= m.y + 1e-9 && m.y <= hi.y + 1e-9); + } + let last_x = mean.points.last().unwrap().x; + assert!(last_x.abs() < 1e-9, "trace ends at now, got {last_x}"); + } + + #[test] + fn short_windows_render_raw_samples_without_envelope() { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_setting("window_s", json!(0.01)).unwrap(); // 200 samples at 20 kSa/s + plugin.set_setting("avg_samples", json!(1)).unwrap(); // average off + { + let mut state = plugin.shared.lock().unwrap(); + let codes: Vec = (0..1_000_u32).map(|i| (i % 4_000) as u16).collect(); + state.ingest(0, 20_000, 0, &codes); + } + let series = plugin.series_dataset(); + let names: Vec<&str> = series.lines.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["photodiode"], "no envelope, no average"); + assert_eq!(series.lines[0].points.len(), 200); + } + + #[test] + fn excitation_mode_inverts_against_the_learned_anchor() { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_setting("mode", json!("EXCITATION")).unwrap(); + // I_pd = 0.5 V → I_exc = I_tot − I_pd = 1.5 V. + let code = 0.5 * ADC_MAX_CODE / ADC_FULL_SCALE_VOLTS; + assert!((plugin.display_volts(code, Some(2.0)) - 1.5).abs() < 1e-9); + // Before the anchor is learned there is nothing to take a complement + // against, so the raw reading is shown rather than a wrong one. + assert!((plugin.display_volts(code, None) - 0.5).abs() < 1e-9); + // RAW mode shows the measured voltage itself. + plugin.set_setting("mode", json!("RAW")).unwrap(); + assert!((plugin.display_volts(code, Some(2.0)) - 0.5).abs() < 1e-9); + } + + #[test] + fn mock_reader_fills_the_ring_and_series() { + let mut plugin = StageAPhotodiodePlugin { + port_hint: "mock".into(), + ..live_plugin() + }; + plugin.connect(); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let count = plugin.shared.lock().unwrap().samples.len(); + if count >= MOCK_BLOCK_SAMPLES { + break; + } + assert!(Instant::now() < deadline, "mock reader produced no data"); + std::thread::sleep(Duration::from_millis(5)); + } + let series = plugin.series_dataset(); + assert!(!series.lines[0].points.is_empty()); + assert_eq!(plugin.shared.lock().unwrap().rate_hz, MOCK_RATE_HZ); + let generation = plugin.generation.load(Ordering::Relaxed); + assert!(generation > 1); + plugin.disconnect(); + } + + /// The summary cells must agree exactly with a naive raw scan for + /// arbitrary ranges, including after whole-cell eviction. + #[test] + fn range_summary_matches_naive_scans() { + let mut state = SharedState { + cache_seconds: 1.0, // capacity 1000 at rate 1000 → forces eviction + ..SharedState::default() + }; + let mut hash: u64 = 0x243F_6A88_85A3_08D3; + let mut next = || { + hash ^= hash << 13; + hash ^= hash >> 7; + hash ^= hash << 17; + (hash % 4_096) as u16 + }; + let mut index = 0_u64; + for _ in 0..7 { + let block: Vec = (0..333).map(|_| next()).collect(); + state.ingest(index, 1_000, 0, &block); + index += block.len() as u64; + } + assert!(state.samples.len() <= 1_000 + SUMMARY_CELL, "evicted"); + assert!(!state.cells.is_empty()); + + let len = state.samples.len(); + for (start, end) in [ + (0, len), + (0, 1), + (1, SUMMARY_CELL), + (SUMMARY_CELL - 1, SUMMARY_CELL + 1), + (7, 500), + (130, 131), + (len - 3, len), + (len / 3, 2 * len / 3), + ] { + let summary = state.range_summary(start, end); + let raw: Vec = state.samples.range(start..end).copied().collect(); + assert_eq!(summary.count, raw.len(), "count for {start}..{end}"); + assert_eq!( + summary.min, + raw.iter().copied().min().unwrap(), + "min for {start}..{end}" + ); + assert_eq!( + summary.max, + raw.iter().copied().max().unwrap(), + "max for {start}..{end}" + ); + assert_eq!( + summary.sum, + raw.iter().map(|&c| u64::from(c)).sum::(), + "sum for {start}..{end}" + ); + } + } + + #[test] + fn spectrum_finds_a_synthesized_tone() { + let plugin = StageAPhotodiodePlugin::default(); + let rate = 20_000_u32; + // 1 kHz, 0.4 V amplitude around 1 V — well inside the ADC range. + let codes: Vec = (0..16_384_u64) + .map(|i| { + let t = i as f64 / f64::from(rate); + let volts = 1.0 + 0.4 * (2.0 * std::f64::consts::PI * 1_000.0 * t).sin(); + (volts * ADC_MAX_CODE / ADC_FULL_SCALE_VOLTS) as u16 + }) + .collect(); + plugin.shared.lock().unwrap().ingest(0, rate, 0, &codes); + let spectrum = plugin.spectrum_dataset(); + let points = &spectrum.lines[0].points; + assert!(!points.is_empty()); + let peak = points + .iter() + .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap()) + .unwrap(); + assert!( + (peak.x - 1_000.0).abs() < 5.0, + "peak at {} Hz, expected 1 kHz", + peak.x + ); + assert!( + (peak.y - 0.4).abs() < 0.05, + "peak amplitude {} V, expected ≈0.4 V", + peak.y + ); + } + + #[test] + fn segment_time_axis_uses_absolute_device_time() { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_setting("avg_samples", json!(1)).unwrap(); + plugin + .set_setting("time_axis", json!("SEGMENT TIME")) + .unwrap(); + { + let mut state = plugin.shared.lock().unwrap(); + state.ingest(40_000, 20_000, 0, &[1, 2, 3, 4]); + } + let series = plugin.series_dataset(); + assert_eq!(series.x_label, "segment time [s]"); + let first = series.lines[0].points.first().unwrap(); + // Sample index 40_000 at 20 kSa/s = 2 s into the segment. + assert!((first.x - 2.0).abs() < 1e-6, "got {}", first.x); + // Default mode still ends at zero. + plugin + .set_setting("time_axis", json!("BEFORE NOW")) + .unwrap(); + let series = plugin.series_dataset(); + assert!(series.lines[0].points.last().unwrap().x.abs() < 1e-9); + } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "stage-a-photodiode-{tag}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + fn marker_frame(sequence: u32, sample_index: u64) -> Frame { + let mut payload = Vec::with_capacity(16); + payload.extend_from_slice(&sample_index.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); // tick_us + payload.push(1); // level + payload.push(stage_a_io::wire::MARKER_SOURCE_PHASE0); // source + payload.extend_from_slice(&[0, 0]); // reserved + Frame::build( + FrameHeader { + version: stage_a_io::wire::PROTOCOL_VERSION, + frame_type: FrameType::Marker, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: sample_index, + sample_rate_hz: MOCK_RATE_HZ, + dropped_samples: 0, + crc32: 0, + }, + payload, + ) + } + + #[test] + fn comparator_markers_never_enter_the_a1_phase_ring() { + let shared = Arc::new(Mutex::new(SharedState::default())); + let recording: SharedRecording = Arc::new(Mutex::new(None)); + { + let mut state = shared.lock().unwrap(); + state.ingest(0, MOCK_RATE_HZ, 0, &[100, 200, 300, 400]); + } + let mut frame = marker_frame(1, 2); + frame.payload[13] = stage_a_io::wire::MARKER_SOURCE_COMPARATOR; + assert!(ingest_parse_event( + ParseEvent::Frame(frame), + &shared, + &recording, + )); + let state = shared.lock().unwrap(); + assert!(state.markers.is_empty()); + assert_eq!(state.comparator_markers.back().copied(), Some((2, 1))); + } + + #[test] + fn phase_zero_markers_are_written_into_the_recording() { + // Without the marker frames a recorded run cannot be phase-attributed + // offline, which is the whole point of the .pdq evidence file. + let dir = temp_dir("marker-record"); + let pdq_path = dir.join("run.pdq"); + let shared = Arc::new(Mutex::new(SharedState::default())); + let recording: SharedRecording = Arc::new(Mutex::new(Some(RecordingSink { + writer: PdqWriter::create(&pdq_path).expect("create pdq"), + pdq_path: pdq_path.clone(), + sidecar_path: dir.join("run.json"), + pdq_path_label: "run.pdq".into(), + sidecar_path_label: "run.json".into(), + run_id: RunId::from("test"), + opened_at_unix_ms: 0, + stream_epoch: 0, + first_sample_index: None, + metadata: BTreeMap::new(), + started_slug: "slug".into(), + samples_written: 0, + write_error: None, + start_crc_failures: 0, + start_resync_bytes: 0, + start_device_dropped: 0, + start_segments: 0, + }))); + + let codes = [100_u16, 200, 300, 400]; + assert!(ingest_parse_event( + ParseEvent::Frame(mock_sample_frame(0, 0, &codes)), + &shared, + &recording, + )); + assert!(ingest_parse_event( + ParseEvent::Frame(marker_frame(1, 2)), + &shared, + &recording, + )); + + // The marker still reaches the live ring... + assert_eq!( + shared.lock().unwrap().markers.iter().copied().last(), + Some(2) + ); + // ...and the sample count is unaffected by the marker frame. + let sink = recording.lock().unwrap().take().expect("sink"); + assert_eq!(sink.samples_written, codes.len() as u64); + sink.writer + .finish(StreamIntegrity::default()) + .expect("finish pdq"); + + let mut reader = stage_a_io::PdqReader::open(&pdq_path).expect("open pdq"); + let mut frame_types = Vec::new(); + while let Some(event) = reader.next_event().expect("read event") { + if let stage_a_io::PdqReadEvent::Frame(frame) = event { + frame_types.push(frame.header.frame_type); + } + } + assert!( + frame_types.contains(&FrameType::Marker), + "the .pdq holds no marker frame: {frame_types:?}" + ); + assert!(frame_types.contains(&FrameType::SamplesU16)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn cache_snapshot_writes_csv_and_sidecar() { + let dir = temp_dir("snapshot"); + let mut plugin = live_plugin(); + plugin + .set_setting("data_dir", json!(dir.display().to_string())) + .unwrap(); + { + let mut state = plugin.shared.lock().unwrap(); + state.ingest(10, 20_000, 0, &[100, 200, 300]); + } + plugin.set_setting("save_snapshot", json!(true)).unwrap(); + assert!(plugin.last_error.is_none(), "{:?}", plugin.last_error); + + let mut csv_files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "csv")) + .collect(); + assert_eq!(csv_files.len(), 1); + let csv_path = csv_files.pop().unwrap(); + let csv = std::fs::read_to_string(&csv_path).unwrap(); + let mut lines = csv.lines(); + assert_eq!(lines.next(), Some("sample_index,t_s,code,volts")); + let first = lines.next().unwrap(); + assert!(first.starts_with("10,0.000500000,100,"), "{first}"); + assert_eq!(csv.lines().count(), 4, "header + 3 samples"); + + let sidecar: Value = + serde_json::from_slice(&std::fs::read(csv_path.with_extension("json")).unwrap()) + .unwrap(); + assert_eq!(sidecar["kind"], "cache_snapshot"); + assert_eq!(sidecar["sample_rate_hz"], 20_000); + assert_eq!(sidecar["samples"], 3); + + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn forwarded_snapshot_counter_saves_exactly_once() { + let dir = temp_dir("snapshot-forwarded"); + let mut plugin = live_plugin(); + plugin + .set_setting("data_dir", json!(dir.display().to_string())) + .unwrap(); + { + let mut state = plugin.shared.lock().unwrap(); + state.ingest(10, 20_000, 0, &[100, 200, 300]); + } + let csv_count = |dir: &std::path::Path| { + std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "csv")) + .count() + }; + // First forwarded counter is the baseline a fresh worker adopts. + plugin.set_setting("save_snapshot", json!(2)).unwrap(); + assert_eq!(csv_count(&dir), 0, "baseline must not save"); + // One press on the mirror advances the counter by one → one file. + plugin.set_setting("save_snapshot", json!(3)).unwrap(); + assert_eq!(csv_count(&dir), 1); + // The host re-applies the same snapshot on every settings sync of any + // plugin — this used to write one file per sync. + plugin.set_setting("save_snapshot", json!(3)).unwrap(); + plugin.set_setting("save_snapshot", json!(3)).unwrap(); + assert_eq!(csv_count(&dir), 1, "re-applied snapshots must not save"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn record_buttons_start_and_stop_the_disk_recording() { + let dir = temp_dir("record-buttons"); + let mut plugin = live_plugin(); + plugin + .set_setting("data_dir", json!(dir.display().to_string())) + .unwrap(); + plugin.set_setting("record_start", json!(true)).unwrap(); + assert!(plugin.recording_active()); + // Idle stop is a no-op, an active stop finalizes. + plugin.set_setting("record_stop", json!(true)).unwrap(); + assert!(!plugin.recording_active()); + assert!(plugin.last_error.is_none(), "{:?}", plugin.last_error); + plugin.set_setting("record_stop", json!(true)).unwrap(); + assert!(plugin.last_error.is_none()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn snapshot_without_data_dir_reports_an_error() { + let mut plugin = live_plugin(); + plugin.set_setting("save_snapshot", json!(true)).unwrap(); + assert!(plugin + .last_error + .as_deref() + .is_some_and(|err| err.contains("data directory"))); + } + + #[test] + fn recording_tees_frames_to_pdq_and_writes_a_sidecar() { + let dir = temp_dir("recording"); + let mut plugin = live_plugin(); + plugin + .set_setting("data_dir", json!(dir.display().to_string())) + .unwrap(); + plugin.set_setting("record", json!(true)).unwrap(); + assert!(plugin.recording_active()); + assert_eq!(plugin.get_setting("record"), Some(json!(true))); + + // The reader thread path: every parsed frame is teed to the sink. + let frame = mock_sample_frame(0, 0, &[1, 2, 3, 4]); + record_frame(&plugin.recording, &frame, 4); + { + let mut state = plugin.shared.lock().unwrap(); + state.ingest(0, MOCK_RATE_HZ, 0, &[1, 2, 3, 4]); + } + + plugin.set_setting("record", json!(false)).unwrap(); + assert!(!plugin.recording_active()); + assert!(plugin.last_error.is_none(), "{:?}", plugin.last_error); + + let pdq_path: std::path::PathBuf = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.path())) + .find(|p| p.extension().is_some_and(|ext| ext == "pdq")) + .expect("pdq written"); + assert_eq!(std::fs::read(&pdq_path).unwrap(), frame.to_bytes()); + + let sidecar: Value = + serde_json::from_slice(&std::fs::read(pdq_path.with_extension("json")).unwrap()) + .unwrap(); + assert_eq!(sidecar["kind"], "recording"); + assert_eq!(sidecar["samples_written"], 4); + assert_eq!(sidecar["pdq_frames"], 1); + assert_eq!(sidecar["valid"], true); + + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn cache_length_setting_drives_ring_capacity() { + let mut plugin = StageAPhotodiodePlugin::default(); + plugin.set_setting("cache_s", json!(2.0)).unwrap(); + assert_eq!(plugin.get_setting("cache_s"), Some(json!(2.0))); + let mut state = plugin.shared.lock().unwrap(); + assert_eq!(state.ring_capacity(1_000), 2_000); + let block: Vec = vec![1; 1_000]; + for i in 0..5_u64 { + let first = i * 1_000; + state.ingest(first, 1_000, 0, &block); + } + // Whole-cell eviction may leave up to one summary cell of slack. + assert!( + state.samples.len() >= 2_000 && state.samples.len() < 2_000 + SUMMARY_CELL, + "len {}", + state.samples.len() + ); + } + + /// The host settings UI exchanges enum values as indices into the + /// schema's variant list (radio buttons send `json!(index)`). + #[test] + fn enum_settings_round_trip_as_indices() { + let mut plugin = StageAPhotodiodePlugin::default(); + // Mode: index 1 = EXCITATION. + plugin + .set_setting("mode", json!(1)) + .expect("index accepted"); + assert_eq!(plugin.mode, Mode::Excitation); + assert_eq!(plugin.get_setting("mode"), Some(json!(1))); + // Port: index 1 = "auto" (variants start with mock, auto). + plugin + .set_setting("port", json!(1)) + .expect("index accepted"); + assert_eq!(plugin.port_hint, "auto"); + assert_eq!(plugin.get_setting("port"), Some(json!(1))); + assert!(plugin.set_setting("mode", json!(99)).is_err()); + // String names keep working (tests, saved configs). + plugin + .set_setting("mode", json!("RAW")) + .expect("name accepted"); + assert_eq!(plugin.mode, Mode::Raw); + } + + #[test] + fn ui_mirror_never_opens_the_stream_or_writes_recordings() { + let dir = temp_dir("ui-mirror"); + let mut plugin = StageAPhotodiodePlugin { + port_hint: "mock".into(), + data_dir: dir.display().to_string(), + ..Default::default() + }; + plugin.set_setting("connect", json!(true)).unwrap(); + plugin.set_setting("record", json!(true)).unwrap(); + assert!(!plugin.connected()); + assert!(!plugin.recording_active()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn service_is_idempotent_and_enforces_exclusive_leases_without_frames() { + let mut plugin = live_plugin(); + let acquire = service_request( + &plugin, + 1, + "workflow-a", + PhotodiodeCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + let first = plugin.handle_service_request(&acquire, &live_execution()); + let expiry = plugin.lease.as_ref().unwrap().expires_at_unix_ms; + let duplicate = plugin.handle_service_request(&acquire, &live_execution()); + assert_eq!(first, duplicate); + assert_eq!(plugin.lease.as_ref().unwrap().expires_at_unix_ms, expiry); + + let conflict = service_request( + &plugin, + 2, + "workflow-b", + PhotodiodeCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&conflict, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + assert!(plugin.set_setting("mode", json!("RAW")).is_err()); + } + + /// A workflow client that names its own recording root gets the PDQ written + /// there, and the owner's Data directory stops being involved at all — that + /// is what lets one coordinated run keep every file in one folder. + #[test] + fn a_client_named_root_overrides_the_data_directory() { + let dir = temp_dir("client-root"); + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + // Deliberately unset: it must not be consulted. + plugin.data_dir = String::new(); + plugin.connect(); + let acquire = service_request( + &plugin, + 20, + "workflow-a", + PhotodiodeCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&acquire, &live_execution()) + .outcome, + PluginServiceOutcome::Accepted { .. } + )); + + let begin = service_request( + &plugin, + 21, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "A1-row/run_pd.pdq".into(), + sidecar_path: "A1-row/run_pd.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: Some(dir.display().to_string()), + }, + }, + Some(1), + ); + assert!( + matches!( + plugin + .handle_service_request(&begin, &live_execution()) + .outcome, + PluginServiceOutcome::Accepted { .. } + ), + "a client-named root must not need the owner's data directory" + ); + assert!(dir.join("A1-row/run_pd.pdq").is_file()); + + // Traversal is still refused below a client-named root. + let escape = service_request( + &plugin, + 22, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "../escape.pdq".into(), + sidecar_path: "A1-row/escape.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: Some(dir.display().to_string()), + }, + }, + Some(2), + ); + assert!(matches!( + plugin + .handle_service_request(&escape, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + + // A relative root is refused outright. + let relative_root = service_request( + &plugin, + 23, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "A1-row/other_pd.pdq".into(), + sidecar_path: "A1-row/other_pd.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: Some("relative/root".into()), + }, + }, + Some(3), + ); + assert!(matches!( + plugin + .handle_service_request(&relative_root, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn named_recording_rejects_unsafe_paths_and_returns_final_receipt() { + let dir = temp_dir("named"); + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + plugin.data_dir = dir.display().to_string(); + plugin.connect(); + let acquire = service_request( + &plugin, + 10, + "workflow-a", + PhotodiodeCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + assert!(matches!( + plugin + .handle_service_request(&acquire, &live_execution()) + .outcome, + PluginServiceOutcome::Accepted { .. } + )); + + let unsafe_begin = service_request( + &plugin, + 11, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "../escape.pdq".into(), + sidecar_path: "run/escape.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: None, + }, + }, + Some(1), + ); + assert!(matches!( + plugin + .handle_service_request(&unsafe_begin, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + + let begin = service_request( + &plugin, + 12, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "A1/run-a_pd.pdq".into(), + sidecar_path: "A1/run-a_pd.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::from([("workflow".into(), "A1".into())]), + root_dir: None, + }, + }, + Some(1), + ); + let begin_reply = plugin.handle_service_request(&begin, &live_execution()); + assert!(matches!( + begin_reply.outcome, + PluginServiceOutcome::Accepted { .. } + )); + assert_eq!( + plugin.handle_service_request(&begin, &live_execution()), + begin_reply, + "duplicate begin must not open a second file" + ); + record_frame(&plugin.recording, &mock_sample_frame(9, 0, &[1, 2, 3]), 3); + + let finalize = service_request( + &plugin, + 13, + "workflow-a", + PhotodiodeCommandV1::FinalizeRecording { + termination: PdqTerminationV1::Completed, + }, + Some(2), + ); + let reply = plugin.handle_service_request(&finalize, &live_execution()); + let PluginServiceOutcome::Accepted { payload } = reply.outcome else { + panic!("finalize rejected"); + }; + let response: PhotodiodeResponseV1 = serde_json::from_value(payload).unwrap(); + let Some(PdqReceiptV1::Finalized(receipt)) = response.receipt else { + panic!("missing finalized receipt"); + }; + assert_eq!(receipt.sha256.as_str().len(), 64); + assert!(receipt.file_size_bytes > 0); + assert!(dir.join(&receipt.pdq_path).is_file()); + assert!(dir.join(&receipt.sidecar_path).is_file()); + + let collision = service_request( + &plugin, + 14, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: receipt.pdq_path.clone(), + sidecar_path: receipt.sidecar_path.clone(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: None, + }, + }, + Some(3), + ); + assert!(matches!( + plugin + .handle_service_request(&collision, &live_execution()) + .outcome, + PluginServiceOutcome::Rejected { .. } + )); + plugin.disconnect(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn effects_revocation_finalizes_and_disconnects_without_a_frame() { + let dir = temp_dir("revoked"); + let mut plugin = live_plugin(); + plugin.port_hint = "mock".into(); + plugin.data_dir = dir.display().to_string(); + plugin.connect(); + let acquire = service_request( + &plugin, + 20, + "workflow-a", + PhotodiodeCommandV1::AcquireLease { ttl_ms: 10_000 }, + None, + ); + plugin.handle_service_request(&acquire, &live_execution()); + let begin = service_request( + &plugin, + 21, + "workflow-a", + PhotodiodeCommandV1::BeginRecording { + specification: PdqStartSpecV1 { + pdq_path: "revoked/run.pdq".into(), + sidecar_path: "revoked/run.json".into(), + expected_sample_rate_hz: None, + expected_stream_epoch: None, + metadata: BTreeMap::new(), + root_dir: None, + }, + }, + Some(1), + ); + plugin.handle_service_request(&begin, &live_execution()); + assert!(plugin.recording_active()); + + plugin.apply_execution_context(&ExecutionContext::fail_closed()); + assert!(!plugin.connected()); + assert!(!plugin.recording_active()); + assert!(plugin.lease.is_none()); + assert_eq!( + plugin + .last_finalized_recording + .as_ref() + .unwrap() + .termination, + PdqTerminationV1::Aborted + ); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/plugins/stage-a-photodiode/src/protocol_validation_tests.rs b/plugins/stage-a-photodiode/src/protocol_validation_tests.rs new file mode 100644 index 0000000..9fce2ae --- /dev/null +++ b/plugins/stage-a-photodiode/src/protocol_validation_tests.rs @@ -0,0 +1,89 @@ +//! Verifies that the A1 protocol minima fit the production photodiode ring at +//! the 500 kSa/s bench rate, with the cache length left at its default. + +use augur_plugin_stage_a_a1::protocol::parse_csv; + +use super::{SharedState, DEFAULT_CACHE_SECONDS, RING_MAX_SAMPLES}; + +const BENCH_RATE_HZ: u32 = 500_000; + +#[test] +fn shipped_a1_protocols_retain_two_cycles_at_their_lowest_frequency() { + let fixtures = [ + ( + "a1_triage_90min.csv", + include_str!("../../stage-a-a1/protocols/a1_triage_90min.csv"), + ), + ( + "a1_stufe1_bode_dc.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe1_bode_dc.csv"), + ), + ( + "a1_stufe2_bode_u010.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u010.csv"), + ), + ( + "a1_stufe2_bode_u045.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_bode_u045.csv"), + ), + ( + "a1_stufe2_flussleiter.csv", + include_str!("../../stage-a-a1/protocols/a1_stufe2_flussleiter.csv"), + ), + ]; + + for (name, csv) in fixtures { + let protocol = parse_csv(csv).unwrap_or_else(|error| panic!("{name}: {error}")); + let lowest_hz = protocol + .points + .iter() + .map(|point| point.frequency_hz) + .fold(f64::INFINITY, f64::min); + let required_samples = (2.0 * f64::from(BENCH_RATE_HZ) / lowest_hz).ceil() as usize; + + // The operator sets nothing: the ring sizes itself from the marker + // period once the drive has stamped two phase-0 markers at the file's + // lowest rung. + let mut ring = SharedState { + cache_seconds: DEFAULT_CACHE_SECONDS, + rate_hz: BENCH_RATE_HZ, + ..SharedState::default() + }; + let period = (f64::from(BENCH_RATE_HZ) / lowest_hz) as u64; + ring.push_marker(0); + ring.push_marker(period); + + let capacity = ring.ring_capacity(BENCH_RATE_HZ); + assert!(capacity <= RING_MAX_SAMPLES); + assert!( + required_samples <= capacity, + "{name}: two cycles at {lowest_hz} Hz need {required_samples} samples, but the ring \ + sized itself to {capacity}" + ); + + // Every row must run long enough for the optical summary's required + // three phase markers (two complete cycles), not only the lowest one. + for (index, point) in protocol.points.iter().enumerate() { + let recorded_cycles = point.frequency_hz * point.duration_s as f64; + assert!( + recorded_cycles >= 2.0, + "{name} point {} records only {recorded_cycles:.3} cycles", + index + 1 + ); + } + + // The regression witness: without the marker period, the same default + // cache is far too short for these files. That gap is what used to be + // an operator precondition, and what a whole survey failed on. + if lowest_hz < 0.1 { + let unmarked = SharedState { + cache_seconds: DEFAULT_CACHE_SECONDS, + ..SharedState::default() + }; + assert!( + required_samples > unmarked.ring_capacity(BENCH_RATE_HZ), + "{name}: witness no longer proves the 20 s default is insufficient on its own" + ); + } + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..544a2fa --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +[toolchain] +# Must match augur-rs/rust-toolchain.toml. Plugins are cdylibs that the host +# dlopens into its own process, so the compiler that builds them and the +# compiler that builds augur-gui have to agree — an unpinned "stable" here +# means CI can hand the bench a bundle built against a different std. +# Bump this together with augur-rs, in its own commit. +channel = "1.95.0" +components = ["clippy", "rustfmt"] diff --git a/scripts/install-built-plugins.sh b/scripts/install-built-plugins.sh index 80a3cbc..4822be9 100755 --- a/scripts/install-built-plugins.sh +++ b/scripts/install-built-plugins.sh @@ -96,6 +96,21 @@ find_library_path() { return 1 } +rewrite_macos_install_name() { + local installed_library_path="$1" + + if [[ "$(uname -s)" != "Darwin" ]]; then + return 0 + fi + + if ! command -v install_name_tool >/dev/null 2>&1; then + echo "warning: install_name_tool not found; leaving ${installed_library_path} with Cargo's build-path dylib id" >&2 + return 0 + fi + + install_name_tool -id "@loader_path/$(basename "${installed_library_path}")" "${installed_library_path}" +} + library_extension="$(library_extension)" mkdir -p "${dest_dir}" @@ -128,7 +143,16 @@ for plugin_dir in "${repo_root}"/plugins/*; do install_dir="${dest_dir}/${plugin_id}" mkdir -p "${install_dir}" cp "${manifest_path}" "${install_dir}/plugin.toml" - cp "${library_path}" "${install_dir}/$(basename "${library_path}")" + installed_library_path="${install_dir}/$(basename "${library_path}")" + cp "${library_path}" "${installed_library_path}" + rewrite_macos_install_name "${installed_library_path}" + # Operator-facing example files a plugin ships alongside its library (A1's + # recording protocols). The bench has the installed folder, not the repo, + # so an example the settings panel points at has to travel with the plugin. + if [[ -d "${plugin_dir}/protocols" ]]; then + rm -rf "${install_dir}/protocols" + cp -R "${plugin_dir}/protocols" "${install_dir}/protocols" + fi echo "Installed ${plugin_id} -> ${install_dir}" installed=$((installed + 1)) done diff --git a/stage-a-io/Cargo.toml b/stage-a-io/Cargo.toml new file mode 100644 index 0000000..7a8d9fa --- /dev/null +++ b/stage-a-io/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "stage-a-io" +description = "Shared Stage-A Teensy I/O: PDA1 wire protocol, serial client, PDQ writer, run sidecars, and the calibrated optical-contrast estimator" +edition.workspace = true +license.workspace = true +version.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +serialport = { version = "4", optional = true } + +[features] +default = ["hardware"] +# Real serial-port transport. Disable for pure-analysis / CI builds. +hardware = ["dep:serialport"] diff --git a/stage-a-io/src/client.rs b/stage-a-io/src/client.rs new file mode 100644 index 0000000..0b018bb --- /dev/null +++ b/stage-a-io/src/client.rs @@ -0,0 +1,363 @@ +//! Typed request/response client over a [`Transport`]. +//! +//! Sends `@ VERB …` commands and demultiplexes the PDA1 frame stream +//! into (a) the matching control reply, (b) async control notices, and +//! (c) data frames (samples / summaries / markers). On a reply timeout the +//! **identical** line (same `seq`) is resent; firmware caches recent replies, +//! so retries are idempotent by construction. + +use std::collections::BTreeMap; +use std::io; +use std::time::{Duration, Instant}; + +use crate::protocol::{Command, ControlMessage, ProtocolError}; +use crate::transport::Transport; +use crate::wire::{Frame, FrameParser, FrameType, ParseEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct StreamIntegrity { + pub skipped_bytes: u64, + pub crc_failures: u64, + pub sequence_gaps: u64, + pub dropped_samples: u64, +} + +impl StreamIntegrity { + /// A run is valid only while the stream shows zero corruption. + pub fn is_clean(&self) -> bool { + self.skipped_bytes == 0 + && self.crc_failures == 0 + && self.sequence_gaps == 0 + && self.dropped_samples == 0 + } +} + +#[derive(Debug)] +pub enum ClientError { + Io(io::Error), + Protocol(ProtocolError), + /// The device replied `-seq ERR …`. + Device { + code: String, + detail: String, + }, + /// No matching reply within the timeout across all retries. + Timeout { + verb: String, + retries: u32, + }, +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "transport I/O failed: {err}"), + Self::Protocol(err) => write!(f, "protocol violation: {err}"), + Self::Device { code, detail } => { + write!(f, "device rejected command: code={code} detail={detail}") + } + Self::Timeout { verb, retries } => { + write!(f, "no reply to {verb} after {retries} retries") + } + } + } +} + +impl std::error::Error for ClientError {} + +impl From for ClientError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +impl From for ClientError { + fn from(err: ProtocolError) -> Self { + Self::Protocol(err) + } +} + +/// Non-reply traffic observed while waiting for or between replies. +#[derive(Debug, Clone, PartialEq)] +pub enum DeviceEvent { + Data(Frame), + Async { + name: String, + fields: BTreeMap, + }, +} + +pub struct StageAClient { + transport: T, + parser: FrameParser, + next_sequence: u32, + last_frame_sequence: Option, + integrity: StreamIntegrity, + pending_events: Vec, + reply_timeout: Duration, + max_retries: u32, + read_buf: Vec, +} + +impl StageAClient { + pub fn new(transport: T) -> Self { + Self { + transport, + parser: FrameParser::default(), + next_sequence: 1, + last_frame_sequence: None, + integrity: StreamIntegrity::default(), + pending_events: Vec::new(), + reply_timeout: Duration::from_millis(500), + max_retries: 2, + read_buf: vec![0_u8; 16 * 1024], + } + } + + pub fn with_reply_timeout(mut self, timeout: Duration) -> Self { + self.reply_timeout = timeout; + self + } + + pub fn integrity(&self) -> StreamIntegrity { + self.integrity + } + + /// Sends a command and waits for its `+seq OK` reply, retrying the + /// identical line on timeout. Data/async frames arriving in between are + /// queued for [`StageAClient::poll_events`]. + pub fn request(&mut self, command: &Command) -> Result, ClientError> { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + let line = command.encode(sequence)?; + + for _attempt in 0..=self.max_retries { + self.transport.write_all(&line)?; + let deadline = Instant::now() + self.reply_timeout; + while Instant::now() < deadline { + self.pump()?; + if let Some(reply) = self.take_reply(sequence)? { + return Ok(reply); + } + std::thread::sleep(Duration::from_millis(1)); + } + } + Err(ClientError::Timeout { + verb: command.verb.clone(), + retries: self.max_retries, + }) + } + + /// Drains any pending non-reply device traffic (data frames, async + /// notices) without blocking. + pub fn poll_events(&mut self) -> Result, ClientError> { + self.pump()?; + Ok(std::mem::take(&mut self.pending_events)) + } + + fn pump(&mut self) -> Result<(), ClientError> { + let n = self.transport.read(&mut self.read_buf)?; + if n > 0 { + self.parser.extend(&self.read_buf[..n]); + } + while let Some(event) = self.parser.next_event() { + match event { + ParseEvent::Corruption { + skipped_bytes, + crc_failures, + } => { + self.integrity.skipped_bytes += skipped_bytes as u64; + self.integrity.crc_failures += crc_failures as u64; + } + ParseEvent::Frame(frame) => self.accept_frame(frame), + } + } + Ok(()) + } + + fn accept_frame(&mut self, frame: Frame) { + if let Some(last) = self.last_frame_sequence { + let expected = last.wrapping_add(1); + if frame.header.sequence != expected { + self.integrity.sequence_gaps += 1; + } + } + self.last_frame_sequence = Some(frame.header.sequence); + if frame.header.dropped_samples > 0 { + self.integrity.dropped_samples = u64::from(frame.header.dropped_samples); + } + + match frame.header.frame_type { + FrameType::Control => { + // Classify control payloads immediately so async notices + // (e.g. the watchdog `!FAULT`) surface through poll_events + // even when no request is in flight. Replies stay queued as + // raw frames for take_reply to match by sequence. + match frame.control_text().map(ControlMessage::parse) { + Some(Ok(ControlMessage::Async { name, fields })) => { + self.pending_events + .push(DeviceEvent::Async { name, fields }); + } + Some(Ok(_)) => self.pending_events.push(DeviceEvent::Data(frame)), + // Non-UTF8 or malformed control payload: corruption. + _ => self.integrity.skipped_bytes += frame.payload.len() as u64, + } + } + _ => self.pending_events.push(DeviceEvent::Data(frame)), + } + } + + fn take_reply( + &mut self, + sequence: u32, + ) -> Result>, ClientError> { + let mut result = None; + let mut remaining = Vec::with_capacity(self.pending_events.len()); + for event in std::mem::take(&mut self.pending_events) { + if result.is_some() { + remaining.push(event); + continue; + } + let DeviceEvent::Data(frame) = &event else { + remaining.push(event); + continue; + }; + let Some(text) = frame.control_text() else { + remaining.push(event); + continue; + }; + match ControlMessage::parse(text) { + Ok(ControlMessage::Ok { + sequence: reply_seq, + fields, + }) if reply_seq == sequence => { + result = Some(Ok(fields)); + } + Ok(ControlMessage::Err { + sequence: reply_seq, + code, + detail, + }) if reply_seq == sequence => { + result = Some(Err(ClientError::Device { code, detail })); + } + // Stale replies to earlier (retried) sequences are dropped. + // Async / malformed payloads never reach here — accept_frame + // classifies them before queueing. + _ => {} + } + } + self.pending_events = remaining; + match result { + Some(Ok(fields)) => Ok(Some(fields)), + Some(Err(err)) => Err(err), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::MockController; + use crate::transport::MockLink; + + #[test] + fn request_reply_round_trip_with_hello() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + let mut client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(100)); + + let handle = std::thread::spawn(move || controller.serve_n_commands(1)); + let reply = client + .request(&Command::new("HELLO").field("protocol", 1)) + .expect("HELLO replies"); + handle.join().expect("mock thread joins"); + + assert_eq!(reply.get("protocol").map(String::as_str), Some("1")); + assert!(client.integrity().is_clean()); + } + + #[test] + fn timeout_retries_are_idempotent_via_reply_cache() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + controller.drop_first_reply(); + let mut client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(50)); + + // The controller swallows the first reply; the client must resend the + // identical sequence and accept the cached second reply. The mock + // panics if a retried sequence re-executes the operation. + let handle = std::thread::spawn(move || { + controller.serve_n_commands(2); + controller + }); + let reply = client + .request(&Command::new("STATUS")) + .expect("retried STATUS succeeds"); + let controller = handle.join().expect("mock thread joins"); + + assert_eq!(reply.get("state").map(String::as_str), Some("SAFE_IDLE")); + assert_eq!(controller.executions(), 1); + } + + #[test] + fn device_error_reply_surfaces_code_and_detail() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + let mut client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(100)); + + let handle = std::thread::spawn(move || controller.serve_n_commands(1)); + let err = client + .request( + &Command::new("CONFIG") + .field("mode", "A9") + .field("rate_hz", 20_000), + ) + .expect_err("invalid mode is rejected"); + handle.join().expect("mock thread joins"); + + match err { + ClientError::Device { code, detail } => { + assert_eq!(code, "RANGE"); + assert_eq!(detail, "invalid_mode"); + } + other => panic!("expected device error, got {other:?}"), + } + } + + #[test] + fn watchdog_fault_surfaces_as_async_event_without_a_request_in_flight() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + let mut client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(100)); + + controller.emit_watchdog_fault(); + let events = client.poll_events().expect("poll"); + match events.as_slice() { + [DeviceEvent::Async { name, fields }] => { + assert_eq!(name, "FAULT"); + assert_eq!(fields.get("code").map(String::as_str), Some("WATCHDOG")); + assert_eq!(fields.get("state").map(String::as_str), Some("SAFE_IDLE")); + } + other => panic!("expected one async FAULT event, got {other:?}"), + } + assert!(client.integrity().is_clean()); + } + + #[test] + fn overrun_frames_invalidate_integrity() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + let mut client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(100)); + + controller.emit_summary_with_drops(3); + client.poll_events().expect("poll"); + assert!(!client.integrity().is_clean()); + assert_eq!(client.integrity().dropped_samples, 3); + } +} diff --git a/stage-a-io/src/estimator.rs b/stage-a-io/src/estimator.rs new file mode 100644 index 0000000..4657c0d --- /dev/null +++ b/stage-a-io/src/estimator.rs @@ -0,0 +1,503 @@ +//! Calibrated optical log-contrast estimator. +//! +//! `a = ln(I_exc,max / I_exc,min)` is defined by the *excitation light*, never +//! by the commanded DAC excursion: the Pockels-cell V→T response is non-linear, +//! so the photodiode ADC trace is the only valid source of `a` +//! (knowledge base: `methodology/camera-calibration.md`, "define `a` from +//! the light, not the drive"). +//! +//! The detector geometry matters. When the photodiode sits behind the PBS +//! reject port it measures the *rejected complement* `I_pd = I_tot - I_exc`, +//! so the peak detector ratio is **not** the excitation contrast. The caller +//! selects the geometry via [`ContrastGeometry`]: +//! - [`ContrastGeometry::Direct`] — the detector already sees the excitation +//! intensity (e.g. the plugin's EXCITATION display, `I_tot - I_pd`), so +//! `a = ln(v_max / v_min)`. +//! - [`ContrastGeometry::RejectedComplement`] — the detector sees the rejected +//! light (the plugin's RAW display), so +//! `a = ln((I_tot - v_min) / (I_tot - v_max))`. +//! +//! The estimator therefore: +//! - converts ADC codes to volts through a characterised affine calibration, +//! - subtracts the dark level (the detector is DC-coupled; `a` needs true +//! levels including DC), +//! - takes robust percentile extrema rather than raw min/max so single-code +//! noise spikes do not bias the contrast, +//! - refuses to produce a value at all when the window clips (top/bottom of +//! the ADC range), has no headroom above dark, or the total-power anchor is +//! below the measured signal — a wrong `a` is worse than no `a`. +//! +//! Rail detection is *span-relative*: the near-rail margin is capped at a small +//! fraction of the window's own peak-to-peak span, so a detector operating a few +//! millivolts above zero is not mistaken for one truncating at the bottom rail. +//! The rails themselves stay guarded at every gain. + +use serde::{Deserialize, Serialize}; + +/// Affine ADC calibration plus dark level, all in physical units. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AdcCalibration { + /// Volts per ADC code (gain of the whole front end into the ADC). + pub volts_per_code: f64, + /// Voltage at code 0. + pub offset_volts: f64, + /// Dark level (light blocked), in volts after the affine map. + pub dark_volts: f64, + /// Full-scale code (4095 for the Teensy 12-bit ADC). + pub full_scale_code: u16, +} + +impl Default for AdcCalibration { + fn default() -> Self { + Self { + volts_per_code: 3.3 / 4_095.0, + offset_volts: 0.0, + dark_volts: 0.0, + full_scale_code: 4_095, + } + } +} + +impl AdcCalibration { + pub fn code_to_volts(&self, code: u16) -> f64 { + self.offset_volts + f64::from(code) * self.volts_per_code + } +} + +/// Optical geometry of the detector relative to the excitation beam. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum ContrastGeometry { + /// The detector already measures the excitation intensity, so + /// `a = ln(v_max / v_min)`. + Direct, + /// The detector sits behind the PBS reject port and measures the rejected + /// complement `I_pd = I_tot - I_exc`. `total_power_volts` is the + /// dark-corrected total power `I_tot`; the excitation contrast is + /// `a = ln((I_tot - v_min) / (I_tot - v_max))`. + RejectedComplement { total_power_volts: f64 }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ContrastEstimate { + /// Peak-to-peak excitation log-contrast `a = ln(I_exc,max / I_exc,min)` + /// (dark-corrected, geometry-resolved). + pub a: f64, + /// Excitation intensity extrema in volts after the geometry transform. + pub v_min_volts: f64, + pub v_max_volts: f64, + /// Fraction of samples at or below code 0 + margin. + pub low_clip_fraction: f64, + /// Fraction of samples at or above full scale - margin. + pub high_clip_fraction: f64, + pub sample_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EstimateError { + /// The rejected-complement geometry has no explicitly confirmed, + /// traceable total-power anchor. + MissingTotalPowerAnchor, + /// A direct camera/emission-path measurement has no explicitly captured + /// or manually supplied blocked-light reference. + MissingDirectDarkReference, + /// No marker-bounded window containing at least two complete modulation + /// cycles fits inside the retained sample budget. + IncompleteModulationCycles { + marker_count: usize, + max_samples: usize, + }, + /// Fewer samples than the estimator can use robustly. + TooFewSamples { count: usize, minimum: usize }, + /// The window touches the ADC rails — `a` would be silently wrong. + Clipped { + low_fraction_permille: u32, + high_fraction_permille: u32, + }, + /// The dark-corrected minimum is not positive: no optical headroom. + NoHeadroomAboveDark, + /// The rejected-complement total-power anchor `I_tot` is not above the + /// measured detector maximum, so the excitation minimum would be + /// non-positive: the anchor is wrong or the light is not the complement. + TotalPowerBelowSignal { + total_power_volts: f64, + detector_max_volts: f64, + }, + /// The window is shorter than one full modulation cycle, so the robust + /// extrema only see an arc of the waveform and `a` would be a + /// phase-dependent *under*-estimate. + /// + /// Constructed by the caller — [`estimate_contrast`] is given codes, not a + /// period, and sizing the window against the drive is the caller's job. + /// It is in this enum because it belongs with the other fail-closed + /// reasons a consumer has to render. + WindowShorterThanCycle { + covered_cycles: f64, + window_seconds: f64, + }, +} + +impl std::fmt::Display for EstimateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingTotalPowerAnchor => f.write_str( + "no total power I_tot has been observed yet — let the photodiode stream for a \ + moment; it learns I_tot from the brightest reading it sees, which the Pockels \ + transfer sweep produces exactly", + ), + Self::MissingDirectDarkReference => f.write_str( + "no lamp-off dark reference has been captured for the direct photodiode path — \ + physically block the light and press Capture lamp-off dark, or explicitly enter \ + a manual value", + ), + Self::IncompleteModulationCycles { + marker_count, + max_samples, + } => write!( + f, + "no stretch of samples covers two whole modulation cycles between triggers \ + ({marker_count} trigger(s) in the last {max_samples} samples) — lower the \ + frequency, or raise the photodiode cache length" + ), + Self::TooFewSamples { count, minimum } => write!( + f, + "only {count} samples have arrived so far, and {minimum} are needed — wait a \ + moment, or check the photodiode stream is running" + ), + Self::Clipped { + low_fraction_permille, + high_fraction_permille, + } => write!( + f, + "the signal is hitting the ends of the detector's range \ + ({low_fraction_permille}‰ at the bottom, {high_fraction_permille}‰ at the top) \ + — lower the drive amplitude or the detector gain" + ), + Self::NoHeadroomAboveDark => f.write_str( + "the signal never rises above the dark level — check the dark level is right and \ + that light is reaching the detector", + ), + Self::TotalPowerBelowSignal { + total_power_volts, + detector_max_volts, + } => write!( + f, + "the excitation never dims below the brightest the detector has been \ + (I_tot {total_power_volts:.4} V vs. {detector_max_volts:.4} V now), so there is \ + no complement left to take a contrast of — run the Pockels transfer sweep so the \ + detector sees the excitation null and learns the real I_tot" + ), + Self::WindowShorterThanCycle { + covered_cycles, + window_seconds, + } => write!( + f, + "the photodiode only watches {window_seconds:.2} s at a time, which is \ + {covered_cycles:.2} of a modulation cycle — it needs at least one whole cycle, \ + so raise the photodiode cache length" + ), + } + } +} + +impl std::error::Error for EstimateError {} + +pub const MIN_SAMPLES: usize = 64; +/// Codes within this margin of the rails count as clipped — but never more +/// than [`CLIP_MARGIN_SPAN_FRACTION`] of the window's own span. +pub const CLIP_MARGIN_CODES: u16 = 4; +/// Largest share of the observed peak-to-peak span the rail margin may claim. +/// +/// The margin exists to catch a waveform that is *about* to truncate at a rail, +/// which only makes sense while it is small compared to the signal. The Stage-A +/// reject-port detector operates around 0.5–15 mV, i.e. inside the bottom ~20 +/// codes of the 12-bit range, where a fixed 4-code margin covers a third of a +/// perfectly good sine and refused every millivolt-scale window as clipped. +/// Capping it against the span keeps the guard on volt-scale signals, and +/// leaves the true rails (code 0 and full scale) guarded at every gain. +const CLIP_MARGIN_SPAN_FRACTION: f64 = 0.05; +/// Reject the window when more than 1‰ of samples clip. +pub const MAX_CLIP_FRACTION: f64 = 0.001; +/// Robust extrema: 1st / 99th percentile. +const LOW_PERCENTILE: f64 = 0.01; +const HIGH_PERCENTILE: f64 = 0.99; + +/// Rail margin for a window whose observed excursion is `span_codes`: the fixed +/// code margin, shrunk so it can never swallow a signal that legitimately sits +/// close to a rail. Returns 0 for spans narrower than +/// `1 / CLIP_MARGIN_SPAN_FRACTION` codes, which leaves exactly the rails +/// themselves classified as clipped. +/// +/// Shared with the photodiode owner's published level, which faces the same +/// question one window at a time: the Stage-A reject-port detector runs a few +/// codes above zero, and a fixed margin calls every one of those windows +/// truncated. +pub fn near_rail_margin(span_codes: u16) -> u16 { + let allowed = (f64::from(span_codes) * CLIP_MARGIN_SPAN_FRACTION).floor(); + allowed.min(f64::from(CLIP_MARGIN_CODES)) as u16 +} + +/// [`near_rail_margin`] for a window still held as raw codes. +fn clip_margin_codes(codes: &[u16]) -> u16 { + let (min, max) = codes.iter().fold((u16::MAX, u16::MIN), |(lo, hi), &code| { + (lo.min(code), hi.max(code)) + }); + near_rail_margin(max.saturating_sub(min)) +} + +/// Estimates the excitation log-contrast from one settled, phase-attributed +/// ADC window. The window must span at least a few full modulation cycles; +/// enforcing that is the caller's job (it knows the drive frequency). The +/// `geometry` selects whether the codes are the excitation intensity directly +/// or the rejected complement measured behind the PBS reject port. +pub fn estimate_contrast( + codes: &[u16], + calibration: &AdcCalibration, + geometry: ContrastGeometry, +) -> Result { + if codes.len() < MIN_SAMPLES { + return Err(EstimateError::TooFewSamples { + count: codes.len(), + minimum: MIN_SAMPLES, + }); + } + + let margin = clip_margin_codes(codes); + let low_clip_threshold = margin; + let high_clip_threshold = calibration.full_scale_code.saturating_sub(margin); + let low_clipped = codes.iter().filter(|&&c| c <= low_clip_threshold).count(); + let high_clipped = codes.iter().filter(|&&c| c >= high_clip_threshold).count(); + let low_clip_fraction = low_clipped as f64 / codes.len() as f64; + let high_clip_fraction = high_clipped as f64 / codes.len() as f64; + if low_clip_fraction > MAX_CLIP_FRACTION || high_clip_fraction > MAX_CLIP_FRACTION { + return Err(EstimateError::Clipped { + low_fraction_permille: (low_clip_fraction * 1_000.0).round() as u32, + high_fraction_permille: (high_clip_fraction * 1_000.0).round() as u32, + }); + } + + let mut sorted = codes.to_vec(); + sorted.sort_unstable(); + let low_code = percentile(&sorted, LOW_PERCENTILE); + let high_code = percentile(&sorted, HIGH_PERCENTILE); + + // Dark-corrected detector volts at the robust extrema. + let detector_low = calibration.code_to_volts(low_code) - calibration.dark_volts; + let detector_high = calibration.code_to_volts(high_code) - calibration.dark_volts; + + // Resolve the excitation extrema from the detector geometry. + let (exc_min, exc_max) = match geometry { + ContrastGeometry::Direct => { + if detector_low <= 0.0 { + return Err(EstimateError::NoHeadroomAboveDark); + } + (detector_low, detector_high) + } + ContrastGeometry::RejectedComplement { total_power_volts } => { + // The most transmitted excitation coincides with the least rejected + // light (detector_low), and vice versa. + if total_power_volts <= detector_high { + return Err(EstimateError::TotalPowerBelowSignal { + total_power_volts, + detector_max_volts: detector_high, + }); + } + ( + total_power_volts - detector_high, + total_power_volts - detector_low, + ) + } + }; + + Ok(ContrastEstimate { + a: (exc_max / exc_min).ln(), + v_min_volts: exc_min, + v_max_volts: exc_max, + low_clip_fraction, + high_clip_fraction, + sample_count: codes.len(), + }) +} + +fn percentile(sorted: &[u16], q: f64) -> u16 { + let index = ((sorted.len() - 1) as f64 * q).round() as usize; + sorted[index.min(sorted.len() - 1)] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sine_codes(center: f64, amplitude: f64, n: usize) -> Vec { + (0..n) + .map(|i| { + let phase = 2.0 * std::f64::consts::PI * (i as f64) * 7.0 / n as f64; + (center + amplitude * phase.sin()) + .round() + .clamp(0.0, 4_095.0) as u16 + }) + .collect() + } + + #[test] + fn recovers_known_contrast_from_synthetic_sine() { + let calibration = AdcCalibration { + dark_volts: 40.0 * (3.3 / 4_095.0), + ..AdcCalibration::default() + }; + // center 2048, amplitude 900 -> dark-corrected V ratio: + let codes = sine_codes(2_048.0, 900.0, 4_096); + let estimate = estimate_contrast(&codes, &calibration, ContrastGeometry::Direct) + .expect("clean window estimates"); + + let expected = ((2_048.0_f64 + 900.0 - 40.0) / (2_048.0 - 900.0 - 40.0)).ln(); + assert!( + (estimate.a - expected).abs() < 0.01, + "a={} expected~{expected}", + estimate.a + ); + assert!(estimate.low_clip_fraction == 0.0 && estimate.high_clip_fraction == 0.0); + } + + #[test] + fn direct_and_rejected_complement_recover_the_same_excitation_contrast() { + // Excitation is a clean sine between exc_min and exc_max; the reject + // port sees the complement I_tot - I_exc. Both geometries must recover + // the same excitation log-contrast a = ln(exc_max / exc_min). + let calibration = AdcCalibration::default(); + let volts_per_code = calibration.volts_per_code; + let total_power_volts = 3_600.0 * volts_per_code; + let exc_center = 1_600.0; + let exc_amplitude = 900.0; + + let excitation_codes = sine_codes(exc_center, exc_amplitude, 4_096); + let rejected_codes: Vec = excitation_codes.iter().map(|&code| 3_600 - code).collect(); + + let direct = estimate_contrast(&excitation_codes, &calibration, ContrastGeometry::Direct) + .expect("direct excitation window"); + let rejected = estimate_contrast( + &rejected_codes, + &calibration, + ContrastGeometry::RejectedComplement { total_power_volts }, + ) + .expect("rejected complement window"); + + let expected = ((exc_center + exc_amplitude) / (exc_center - exc_amplitude)).ln(); + assert!((direct.a - expected).abs() < 0.01, "direct a={}", direct.a); + assert!( + (rejected.a - direct.a).abs() < 0.01, + "rejected a={} direct a={}", + rejected.a, + direct.a + ); + } + + #[test] + fn rejected_complement_rejects_a_total_power_anchor_below_the_signal() { + let calibration = AdcCalibration::default(); + let codes = sine_codes(2_048.0, 900.0, 2_048); + // Anchor far below the detector maximum (~2948 codes). + let err = estimate_contrast( + &codes, + &calibration, + ContrastGeometry::RejectedComplement { + total_power_volts: 1_000.0 * calibration.volts_per_code, + }, + ) + .expect_err("anchor below signal must be rejected"); + assert!(matches!(err, EstimateError::TotalPowerBelowSignal { .. })); + } + + #[test] + fn rejects_clipped_windows() { + // Amplitude pushes past full scale -> clipping at the top rail. + let codes = sine_codes(3_500.0, 900.0, 2_048); + let err = estimate_contrast(&codes, &AdcCalibration::default(), ContrastGeometry::Direct) + .expect_err("clipped window must be rejected"); + assert!(matches!(err, EstimateError::Clipped { .. })); + } + + #[test] + fn rejects_windows_without_dark_headroom() { + let calibration = AdcCalibration { + dark_volts: 1_300.0 * (3.3 / 4_095.0), + ..AdcCalibration::default() + }; + // Minimum (2048-900=1148) sits below the dark level (1300). + let codes = sine_codes(2_048.0, 900.0, 2_048); + let err = estimate_contrast(&codes, &calibration, ContrastGeometry::Direct) + .expect_err("no headroom above dark must be rejected"); + assert_eq!(err, EstimateError::NoHeadroomAboveDark); + } + + #[test] + fn rejects_short_windows() { + let err = estimate_contrast( + &[100; 10], + &AdcCalibration::default(), + ContrastGeometry::Direct, + ) + .expect_err("short window rejected"); + assert!(matches!(err, EstimateError::TooFewSamples { .. })); + } + + #[test] + fn single_sample_spikes_do_not_bias_the_contrast() { + let mut codes = sine_codes(2_048.0, 500.0, 4_096); + codes[7] = 4_000; // one hot spike, below the 1 - 99 percentile weight + let clean = estimate_contrast( + &sine_codes(2_048.0, 500.0, 4_096), + &AdcCalibration::default(), + ContrastGeometry::Direct, + ) + .expect("clean"); + let spiked = + estimate_contrast(&codes, &AdcCalibration::default(), ContrastGeometry::Direct) + .expect("spiked"); + assert!((clean.a - spiked.a).abs() < 0.005); + } + + #[test] + fn accepts_the_millivolt_scale_reject_port_window() { + // The Stage-A reject-port detector operates around 0.5–15 mV, i.e. the + // whole waveform lives inside the bottom ~20 codes of the 12-bit range + // (0.806 mV per code). None of those codes is the bottom rail, so the + // window must estimate rather than be refused as clipped. + let calibration = AdcCalibration::default(); + let per_code = calibration.volts_per_code; + let detector_low = 0.000_5; + let detector_high = 0.015; + let center = (detector_high + detector_low) / 2.0 / per_code; + let amplitude = (detector_high - detector_low) / 2.0 / per_code; + let codes = sine_codes(center, amplitude, 4_096); + let total_power_volts = 0.015_5; + + let estimate = estimate_contrast( + &codes, + &calibration, + ContrastGeometry::RejectedComplement { total_power_volts }, + ) + .expect("a millivolt-scale reject-port window must estimate"); + assert!( + estimate.a > 0.0 && estimate.a.is_finite(), + "a = {}", + estimate.a + ); + } + + #[test] + fn still_rejects_a_window_pinned_at_the_bottom_rail() { + // Same millivolt scale, but driven below zero: the waveform truncates + // at code 0 and `a` would be biased high, so the refusal must survive + // the span-relative margin. + let codes = sine_codes(4.0, 9.0, 4_096); + let err = estimate_contrast( + &codes, + &AdcCalibration::default(), + ContrastGeometry::RejectedComplement { + total_power_volts: 0.015_5, + }, + ) + .expect_err("a rail-pinned window must be refused"); + assert!(matches!(err, EstimateError::Clipped { .. }), "{err:?}"); + } +} diff --git a/stage-a-io/src/lib.rs b/stage-a-io/src/lib.rs new file mode 100644 index 0000000..5e17e58 --- /dev/null +++ b/stage-a-io/src/lib.rs @@ -0,0 +1,56 @@ +//! # stage-a-io +//! +//! Shared research-owned I/O library for the Stage-A bench plugins +//! (currently `stage-a-modulation`; the future A1–A3 experiment plugins +//! build on it too — see ADR 006). +//! +//! Scope, per the Stage-A control-software specification: +//! - the v1 ASCII command grammar and PDA1 binary frame format (wire- +//! compatible with `stage-a-controller/include/wire_protocol.h`), +//! - a typed serial client with idempotent sequence retries and stream- +//! integrity accounting (CRC failures, resync skips, sequence gaps, +//! ADC overruns — any of which invalidates a measurement point), +//! - a bounded background I/O worker so plugin `process_frame()` never +//! blocks on serial, +//! - streaming `.pdq` write/replay with CRC32, SHA-256, byte/frame counts, +//! contiguous sample-range receipts, plus the JSON run sidecar, +//! - the calibrated optical log-contrast estimator (`a` is measured light, +//! never the commanded DAC excursion), +//! - a mock controller for tests and hardware-free development. +//! +//! This crate deliberately contains **no** experiment policy (sweeps, +//! bisection, fits live in the protocol plugins) and **no** augur types — +//! it is plain I/O + numerics, testable without a host. + +pub mod client; +pub mod estimator; +pub mod mock; +pub mod pdq; +pub mod protocol; +mod sha256; +pub mod sidecar; +pub mod transport; +pub mod wire; + +pub use client::{ClientError, DeviceEvent, StageAClient, StreamIntegrity}; +pub use estimator::{ + estimate_contrast, near_rail_margin, AdcCalibration, ContrastEstimate, ContrastGeometry, + EstimateError, +}; +pub use mock::{MockController, MockState, MockWave}; +pub use pdq::{ + inspect_pdq, PdqReadEvent, PdqReadSummary, PdqReader, PdqSampleRange, PdqSummary, PdqWriter, +}; +pub use protocol::{Command, ControlMessage, ProtocolError}; +pub use sha256::Sha256Digest; +pub use sidecar::{DetectorLoad, IntegrityRecord, RunSidecar, TriggerSource}; +#[cfg(feature = "hardware")] +pub use transport::SerialTransport; +pub use transport::{MockLink, MockTransport, Transport}; +pub use wire::{ + Frame, FrameHeader, FrameParser, FrameType, MarkerPayload, ParseEvent, SummaryPayload, + MARKER_SOURCE_PHASE0, +}; +pub use worker::{IoWorker, WorkerOutput, WorkerRequest}; + +pub mod worker; diff --git a/stage-a-io/src/mock.rs b/stage-a-io/src/mock.rs new file mode 100644 index 0000000..e3aabb0 --- /dev/null +++ b/stage-a-io/src/mock.rs @@ -0,0 +1,964 @@ +//! Mock Stage-A controller for tests and hardware-free plugin development. +//! +//! Mirrors firmware 0.3.0 (`stage-a-controller/src/main.cpp`) faithfully: +//! the same verbs (`HELLO`, `STATUS`, `CONFIG`, `START`, `STOP`, `PING`, +//! `MOD`), the same state machine (`SAFE_IDLE` → `CONFIGURED` → `RUNNING`), +//! the same error codes/details (`PROTOCOL`, `RANGE`, `STATE`, `SYNTAX`, +//! `VERB`), the same single-entry idempotent reply cache, and rejection of +//! unknown `CONFIG` fields — which is the host's feature-detection +//! mechanism, so it must never be papered over here. `MOD` is set-and-hold +//! exactly like the firmware: `STOP` does not touch the modulation state. +//! +//! [`MockController::with_waveform_extension`] additionally models the +//! *proposed* v2 waveform firmware (`stage-a-controller/docs/features/` +//! `waveform-drive.md`): `wave`/`freq_mhz`/`center_dac`/`amplitude_dac` +//! CONFIG fields, a `capabilities` HELLO entry, and synthetic photodiode +//! blocks derived from the configured drive through a Pockels-like sin² +//! transfer — commanded DAC amplitude maps *non-linearly* to optical +//! contrast, exactly why `a` must be measured, never assumed. + +use crate::protocol::ControlMessage; +use crate::transport::Transport; +use crate::wire::{Frame, FrameHeader, FrameType, SummaryPayload, PROTOCOL_VERSION}; + +pub const MOCK_MAX_RATE_HZ: u32 = 100_000; +pub const MOCK_MAX_BLOCK_SAMPLES: u32 = 256; +/// Proposed v2 waveform ceiling (matches the drive UI bound: 200 kHz). +pub const MOCK_MAX_FREQ_MHZ: u32 = 200_000_000; +/// Firmware 0.3.0 `MOD` frequency window (`board_config.h`). +pub const MOCK_MOD_MIN_FREQ_MHZ: u32 = 10; +pub const MOCK_MOD_MAX_FREQ_MHZ: u32 = 2_000_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MockState { + SafeIdle, + Configured, + Running, +} + +impl MockState { + fn name(self) -> &'static str { + match self { + Self::SafeIdle => "SAFE_IDLE", + Self::Configured => "CONFIGURED", + Self::Running => "RUNNING", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MockWave { + Sine, + Square, + Saw, +} + +impl MockWave { + /// Normalised waveform value in [-1, 1] at cycle phase `t` in [0, 1). + fn value(self, t: f64) -> f64 { + match self { + Self::Sine => (2.0 * std::f64::consts::PI * t).sin(), + Self::Square => { + if t < 0.5 { + 1.0 + } else { + -1.0 + } + } + Self::Saw => 2.0 * t - 1.0, + } + } +} + +/// Optical warp parameters accepted on `MOD wave=WARP` (firmware rebuilds the +/// DAC table from these; the mock only validates them). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct WarpParams { + target: Option, + a_milli: u32, + u_k_milli: u32, + v_null: u32, + v_pi: u32, +} + +#[derive(Debug, Clone, PartialEq)] +struct MockConfig { + mode: String, + rate_hz: u32, + block_samples: u32, + raw: bool, + summary: bool, + // v2 waveform extension (None until configured). + wave: Option, + freq_mhz: u32, + center_dac: u32, + amplitude_dac: u32, +} + +impl Default for MockConfig { + fn default() -> Self { + Self { + mode: "A1".into(), + rate_hz: 20_000, + block_samples: 256, + raw: true, + summary: true, + wave: None, + freq_mhz: 0, + center_dac: 2_048, + amplitude_dac: 0, + } + } +} + +pub struct MockController { + transport: T, + state: MockState, + config: MockConfig, + /// v2 waveform CONFIG fields accepted (proposed firmware) instead of + /// rejected as `unknown_config_field` (firmware 0.2.0). + waveform_extension: bool, + /// Firmware caches exactly one reply (`cached_request_sequence`). + cached_reply: Option<(u32, String)>, + executed_sequences: Vec, + executions: u32, + drop_next_reply: bool, + out_sequence: u32, + line_buffer: Vec, + sample_index: u64, + // Firmware 0.3.0 MOD state (set-and-hold, independent of acquisition). + mod_wave: &'static str, + mod_level: u32, + mod_min: u32, + mod_freq_mhz: u32, + mod_code: u32, + /// Synthetic optics for [`MockController::emit_configured_block`]: + /// photodiode code = dark + span * sin²(π/2 · drive/4095). + pub synth_dark_code: f64, + pub synth_span_codes: f64, + // Legacy direct-sine synthesis (emit_sine_block). + pub synth_center: f64, + pub synth_amplitude: f64, +} + +impl MockController { + pub fn new(transport: T) -> Self { + Self { + transport, + state: MockState::SafeIdle, + config: MockConfig::default(), + waveform_extension: false, + cached_reply: None, + executed_sequences: Vec::new(), + executions: 0, + drop_next_reply: false, + out_sequence: 0, + line_buffer: Vec::new(), + sample_index: 0, + mod_wave: "OFF", + mod_level: 0, + mod_min: 0, + mod_freq_mhz: 0, + mod_code: 0, + synth_dark_code: 40.0, + synth_span_codes: 3_800.0, + synth_center: 2_048.0, + synth_amplitude: 900.0, + } + } + + /// Enables the proposed v2 waveform command surface. + pub fn with_waveform_extension(mut self) -> Self { + self.waveform_extension = true; + self + } + + /// Swallow the next reply (simulates a lost USB packet) — the client + /// must retry with the identical sequence. + pub fn drop_first_reply(&mut self) { + self.drop_next_reply = true; + } + + pub fn state(&self) -> MockState { + self.state + } + + /// Commands actually executed (idempotent retries excluded). + pub fn executions(&self) -> u32 { + self.executions + } + + /// Handles all complete command lines already received, without + /// blocking — for long-lived in-process mock threads (e.g. a plugin's + /// hardware-free `mock` port). + pub fn poll_commands(&mut self) { + let mut buf = [0_u8; 1024]; + loop { + let read = self.transport.read(&mut buf).unwrap_or(0); + if read == 0 { + break; + } + self.line_buffer.extend_from_slice(&buf[..read]); + } + while let Some(pos) = self.line_buffer.iter().position(|&b| b == b'\n') { + let line: Vec = self.line_buffer.drain(..=pos).collect(); + if let Ok(text) = std::str::from_utf8(&line) { + let text = text.trim_end().to_owned(); + self.handle_line(&text); + } + } + } + + /// Wall-clock duration one configured sample block spans — the cadence + /// at which a live mock should call [`Self::emit_configured_block`]. + pub fn block_period(&self) -> std::time::Duration { + let rate = self.config.rate_hz.max(1); + std::time::Duration::from_micros( + u64::from(self.config.block_samples) * 1_000_000 / u64::from(rate), + ) + } + + /// Serves exactly `n` command lines (counting retries), then returns. + pub fn serve_n_commands(&mut self, n: usize) { + let mut served = 0; + let mut buf = [0_u8; 1024]; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while served < n && std::time::Instant::now() < deadline { + let read = self.transport.read(&mut buf).unwrap_or(0); + if read == 0 { + std::thread::sleep(std::time::Duration::from_millis(1)); + continue; + } + self.line_buffer.extend_from_slice(&buf[..read]); + while let Some(pos) = self.line_buffer.iter().position(|&b| b == b'\n') { + let line: Vec = self.line_buffer.drain(..=pos).collect(); + if let Ok(text) = std::str::from_utf8(&line) { + self.handle_line(text.trim_end()); + } + served += 1; + if served >= n { + break; + } + } + } + } + + fn handle_line(&mut self, line: &str) { + let Some(rest) = line.strip_prefix('@') else { + self.send_control("-0 ERR code=SYNTAX detail=expected_sequence_and_verb"); + return; + }; + let mut parts = rest.split_ascii_whitespace(); + let Some(sequence) = parts.next().and_then(|s| s.parse::().ok()) else { + self.send_control("-0 ERR code=SYNTAX detail=invalid_sequence"); + return; + }; + // Idempotent retry: replay the cached reply without re-executing. + if let Some((cached_seq, cached)) = &self.cached_reply { + if *cached_seq == sequence { + let payload = cached.clone(); + self.send_control(&payload); + return; + } + } + assert!( + !self.executed_sequences.contains(&sequence), + "sequence {sequence} re-executed — idempotency broken" + ); + + let verb = parts.next().unwrap_or(""); + // Preserve wire order: firmware validates fields as encountered. + let fields: Vec<(String, String)> = parts + .filter_map(|part| { + let (key, value) = part.split_once('=')?; + Some((key.to_owned(), value.to_owned())) + }) + .collect(); + + self.executions += 1; + self.executed_sequences.push(sequence); + let reply = self.execute(verb, &fields, sequence); + self.cached_reply = Some((sequence, reply.clone())); + if self.drop_next_reply { + self.drop_next_reply = false; + return; + } + self.send_control(&reply); + } + + fn execute(&mut self, verb: &str, fields: &[(String, String)], sequence: u32) -> String { + let field = |key: &str| { + fields + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + match verb { + "HELLO" => { + if field("protocol") != Some("1") { + return format!("-{sequence} ERR code=PROTOCOL detail=requires_v1"); + } + let capabilities = if self.waveform_extension { + " capabilities=MOD,PDSTREAM,WAVE" + } else { + " capabilities=MOD,PDSTREAM" + }; + format!( + "+{sequence} OK protocol=1 firmware=0.3.0-mock board=MOCK adc_bits=12 \ + max_rate_hz={MOCK_MAX_RATE_HZ} dac=AD5628 dac_bus=SPI1 dac_cs=29 \ + dac_channel=1.4 dac_address=3 pd_pin=A4{capabilities}" + ) + } + "STATUS" => format!( + "+{sequence} OK state={} mode={} rate_hz={} block_samples={} raw={} summary={} \ + sample_index={} dropped=0 marker_drops=0 dac=1.4/3 code={} mod_wave={} \ + mod_level={} mod_min={} mod_freq_mhz={}", + self.state.name(), + self.config.mode, + self.config.rate_hz, + self.config.block_samples, + u8::from(self.config.raw), + u8::from(self.config.summary), + self.sample_index, + self.mod_code, + self.mod_wave, + self.mod_level, + self.mod_min, + self.mod_freq_mhz, + ), + "CONFIG" => self.execute_config(fields, sequence), + "MOD" => self.execute_mod(fields, sequence), + "START" => { + if self.state != MockState::Configured { + return format!("-{sequence} ERR code=STATE detail=configure_before_start"); + } + self.state = MockState::Running; + format!("+{sequence} OK state=RUNNING") + } + // Firmware ignores extra STOP tokens (e.g. reason=…). + "STOP" => { + self.state = MockState::SafeIdle; + format!("+{sequence} OK state=SAFE_IDLE") + } + "PING" => format!("+{sequence} OK watchdog=refreshed"), + _ => format!("-{sequence} ERR code=VERB detail=unsupported_command"), + } + } + + fn execute_config(&mut self, fields: &[(String, String)], sequence: u32) -> String { + if self.state == MockState::Running { + return format!("-{sequence} ERR code=STATE detail=stop_before_config"); + } + let err = |code: &str, detail: &str| format!("-{sequence} ERR code={code} detail={detail}"); + let mut next = self.config.clone(); + let mut saw_mode = false; + let mut saw_rate = false; + for (key, value) in fields { + match key.as_str() { + "mode" => { + saw_mode = true; + if !matches!(value.as_str(), "A1" | "A2" | "A3") { + return err("RANGE", "invalid_mode"); + } + next.mode = value.clone(); + } + "rate_hz" => { + saw_rate = true; + match value.parse::() { + Ok(rate) if (100..=MOCK_MAX_RATE_HZ).contains(&rate) => { + next.rate_hz = rate; + } + _ => return err("RANGE", "invalid_rate_hz"), + } + } + "block_samples" => match value.parse::() { + Ok(block) if (1..=MOCK_MAX_BLOCK_SAMPLES).contains(&block) => { + next.block_samples = block; + } + _ => return err("RANGE", "invalid_block_samples"), + }, + "raw" => match value.as_str() { + "0" => next.raw = false, + "1" => next.raw = true, + _ => return err("RANGE", "invalid_raw_flag"), + }, + "summary" => match value.as_str() { + "0" => next.summary = false, + "1" => next.summary = true, + _ => return err("RANGE", "invalid_summary_flag"), + }, + "wave" if self.waveform_extension => { + next.wave = Some(match value.as_str() { + "SINE" => MockWave::Sine, + "SQUARE" => MockWave::Square, + "SAW" => MockWave::Saw, + _ => return err("RANGE", "invalid_wave"), + }); + } + "freq_mhz" if self.waveform_extension => match value.parse::() { + Ok(freq) if (1..=MOCK_MAX_FREQ_MHZ).contains(&freq) => { + next.freq_mhz = freq; + } + _ => return err("RANGE", "invalid_freq_mhz"), + }, + "center_dac" if self.waveform_extension => match value.parse::() { + Ok(center) if center <= 4_095 => next.center_dac = center, + _ => return err("RANGE", "invalid_center_dac"), + }, + "amplitude_dac" if self.waveform_extension => match value.parse::() { + Ok(amplitude) if amplitude <= 2_047 => next.amplitude_dac = amplitude, + _ => return err("RANGE", "invalid_amplitude_dac"), + }, + // Firmware 0.2.0 rejects unknown fields — the host relies + // on this for feature detection. Never accept silently. + _ => return err("SYNTAX", "unknown_config_field"), + } + } + if !saw_mode || !saw_rate || (!next.raw && !next.summary) { + return err("SYNTAX", "mode_rate_and_output_required"); + } + if next.wave.is_some() + && (next.center_dac + next.amplitude_dac > 4_095 + || next.center_dac < next.amplitude_dac) + { + return err("RANGE", "amplitude_exceeds_range"); + } + self.config = next; + self.state = MockState::Configured; + format!( + "+{sequence} OK state=CONFIGURED mode={} rate_hz={} block_samples={} raw={} \ + summary={} backend=mock", + self.config.mode, + self.config.rate_hz, + self.config.block_samples, + u8::from(self.config.raw), + u8::from(self.config.summary), + ) + } + + /// Firmware 0.3.0 `MOD` handler: same field grammar, validation order, + /// error details, and reply shape as `main.cpp`. + fn execute_mod(&mut self, fields: &[(String, String)], sequence: u32) -> String { + let err = |code: &str, detail: &str| format!("-{sequence} ERR code={code} detail={detail}"); + let mut wave: Option<&'static str> = None; + let mut level = 0_u32; + let mut saw_level = false; + let mut min_level = 0_u32; + let mut freq_mhz = 0_u32; + let mut saw_freq = false; + let mut warp: WarpParams = WarpParams::default(); + for (key, value) in fields { + match key.as_str() { + "wave" => { + wave = Some(match value.as_str() { + "OFF" => "OFF", + "CONST" => "CONST", + "SINE" => "SINE", + "SQUARE" => "SQUARE", + "WARP" => "WARP", + _ => return err("RANGE", "invalid_wave"), + }); + } + "level" => match value.parse::() { + Ok(parsed) if parsed <= 4_095 => { + level = parsed; + saw_level = true; + } + _ => return err("RANGE", "invalid_level"), + }, + "min" => match value.parse::() { + Ok(parsed) if parsed <= 4_095 => min_level = parsed, + _ => return err("RANGE", "invalid_min"), + }, + "freq_mhz" => match value.parse::() { + Ok(parsed) => { + freq_mhz = parsed; + saw_freq = true; + } + _ => return err("RANGE", "invalid_freq_mhz"), + }, + // Optical warp parameters (wave=WARP): the firmware rebuilds the + // 256-entry DAC table from these; the mock only validates them. + "target" => match value.as_str() { + "LOG_SINE" | "LINEAR_SINE" => warp.target = Some(value.clone()), + _ => return err("RANGE", "invalid_target"), + }, + "a_milli" => match value.parse::() { + Ok(parsed) if parsed > 0 => warp.a_milli = parsed, + _ => return err("RANGE", "invalid_a"), + }, + "u_k_milli" => match value.parse::() { + Ok(parsed) if (1..=1_000).contains(&parsed) => warp.u_k_milli = parsed, + _ => return err("RANGE", "invalid_u_k"), + }, + "v_null" => match value.parse::() { + Ok(parsed) if parsed <= 4_095 => warp.v_null = parsed, + _ => return err("RANGE", "invalid_v_null"), + }, + "v_pi" => match value.parse::() { + Ok(parsed) if (1..=4_095).contains(&parsed) => warp.v_pi = parsed, + _ => return err("RANGE", "invalid_v_pi"), + }, + _ => return err("SYNTAX", "unknown_mod_field"), + } + } + let Some(wave) = wave else { + return err("SYNTAX", "wave_required"); + }; + if wave == "WARP" { + if !saw_freq { + return err("SYNTAX", "freq_mhz_required"); + } + if warp.target.is_none() { + return err("SYNTAX", "target_required"); + } + if warp.u_k_milli == 0 { + return err("SYNTAX", "u_k_required"); + } + if warp.v_null + warp.v_pi > 4_095 { + return err("RANGE", "warp_exceeds_range"); + } + if !(MOCK_MOD_MIN_FREQ_MHZ..=MOCK_MOD_MAX_FREQ_MHZ).contains(&freq_mhz) { + return err("RANGE", "mod_rejected"); + } + self.mod_wave = "WARP"; + self.mod_min = warp.v_null; + self.mod_level = warp.v_null + warp.v_pi; + self.mod_code = warp.v_null; + self.mod_freq_mhz = freq_mhz; + return format!( + "+{sequence} OK mod_wave=WARP mod_level={} mod_min={} mod_freq_mhz={} code={} target={}", + self.mod_level, + self.mod_min, + self.mod_freq_mhz, + self.mod_code, + warp.target.unwrap_or_default() + ); + } + let periodic = wave == "SINE" || wave == "SQUARE"; + if wave != "OFF" && !saw_level { + return err("SYNTAX", "level_required"); + } + if periodic && !saw_freq { + return err("SYNTAX", "freq_mhz_required"); + } + if min_level > level { + return err("RANGE", "min_above_level"); + } + if periodic && !(MOCK_MOD_MIN_FREQ_MHZ..=MOCK_MOD_MAX_FREQ_MHZ).contains(&freq_mhz) { + return err("RANGE", "mod_rejected"); + } + if wave == "OFF" { + level = 0; + min_level = 0; + freq_mhz = 0; + } + self.mod_wave = wave; + self.mod_level = level; + self.mod_min = if wave == "CONST" { level } else { min_level }; + self.mod_freq_mhz = if periodic { freq_mhz } else { 0 }; + // Same initial output as the firmware engine: CONST/OFF hold level, + // square starts low, sine starts at the center. + self.mod_code = match wave { + "SQUARE" => self.mod_min, + "SINE" => (self.mod_min + self.mod_level) / 2, + _ => level, + }; + format!( + "+{sequence} OK mod_wave={} mod_level={} mod_min={} mod_freq_mhz={} code={}", + self.mod_wave, self.mod_level, self.mod_min, self.mod_freq_mhz, self.mod_code + ) + } + + fn send_control(&mut self, payload: &str) { + let frame = self.build_frame(FrameType::Control, payload.as_bytes().to_vec(), 0, 0); + let bytes = frame.to_bytes(); + let _ = self.transport.write_all(&bytes); + } + + /// Emits the watchdog fault notice and drops to `SAFE_IDLE`, exactly as + /// the firmware does after 1.5 s without host contact. + pub fn emit_watchdog_fault(&mut self) { + self.state = MockState::SafeIdle; + self.send_control("!FAULT code=WATCHDOG state=SAFE_IDLE"); + } + + fn build_frame( + &mut self, + frame_type: FrameType, + payload: Vec, + sample_rate_hz: u32, + dropped_samples: u32, + ) -> Frame { + self.out_sequence = self.out_sequence.wrapping_add(1); + Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type, + flags: 0, + sequence: self.out_sequence, + payload_bytes: 0, + first_sample_index: self.sample_index, + sample_rate_hz, + dropped_samples, + crc32: 0, + }, + payload, + ) + } + + fn emit_codes_block(&mut self, codes: &[u16], rate_hz: u32, raw: bool, summary: bool) { + let mut min_code = u16::MAX; + let mut max_code = 0_u16; + let mut sum = 0_u64; + let mut payload = Vec::with_capacity(codes.len() * 2); + for &code in codes { + min_code = min_code.min(code); + max_code = max_code.max(code); + sum += u64::from(code); + payload.extend_from_slice(&code.to_le_bytes()); + } + if raw { + let frame = self.build_frame(FrameType::SamplesU16, payload, rate_hz, 0); + let bytes = frame.to_bytes(); + let _ = self.transport.write_all(&bytes); + } + if summary { + let summary_payload = SummaryPayload { + min_code, + max_code, + sample_count: codes.len() as u32, + sum_codes: sum, + first_tick_us: 0, + last_tick_us: ((codes.len() as f64 / f64::from(rate_hz)) * 1e6) as u32, + }; + let frame = self.build_frame(FrameType::Summary, summary_payload.encode(), rate_hz, 0); + let bytes = frame.to_bytes(); + let _ = self.transport.write_all(&bytes); + } + self.sample_index += codes.len() as u64; + } + + /// Emits one photodiode block synthesized from the *configured* v2 + /// drive: DAC waveform → Pockels-like sin² intensity transfer → ADC + /// codes. Without a configured `wave` (or with `amplitude_dac = 0`) the + /// output is the flat unmodulated level at `center_dac`. + pub fn emit_configured_block(&mut self) { + if self.state != MockState::Running { + return; + } + let config = self.config.clone(); + let rate = f64::from(config.rate_hz); + let freq_hz = f64::from(config.freq_mhz) / 1_000.0; + let codes: Vec = (0..config.block_samples as u64) + .map(|i| { + let t = (self.sample_index + i) as f64 / rate; + let shape = match (config.wave, config.amplitude_dac) { + (Some(wave), amplitude) if amplitude > 0 && freq_hz > 0.0 => { + wave.value((t * freq_hz).fract()) + } + _ => 0.0, + }; + let drive = f64::from(config.center_dac) + f64::from(config.amplitude_dac) * shape; + let transmission = (std::f64::consts::FRAC_PI_2 * drive / 4_095.0) + .sin() + .powi(2); + (self.synth_dark_code + self.synth_span_codes * transmission) + .round() + .clamp(0.0, 4_095.0) as u16 + }) + .collect(); + self.emit_codes_block(&codes, config.rate_hz, config.raw, config.summary); + } + + /// Emits one synthetic sinusoidal sample block (`SamplesU16` + + /// `Summary`), bypassing the drive model — codes = center + A·sin. + pub fn emit_sine_block(&mut self, samples: usize, rate_hz: u32, freq_hz: f64) { + let codes: Vec = (0..samples as u64) + .map(|i| { + let t = (self.sample_index + i) as f64 / f64::from(rate_hz); + (self.synth_center + + self.synth_amplitude * (2.0 * std::f64::consts::PI * freq_hz * t).sin()) + .round() + .clamp(0.0, 4_095.0) as u16 + }) + .collect(); + self.emit_codes_block(&codes, rate_hz, true, true); + } + + /// Emits a summary frame carrying a nonzero overrun counter. + pub fn emit_summary_with_drops(&mut self, dropped: u32) { + let summary = SummaryPayload { + min_code: 0, + max_code: 0, + sample_count: 0, + sum_codes: 0, + first_tick_us: 0, + last_tick_us: 0, + }; + let frame = self.build_frame(FrameType::Summary, summary.encode(), 20_000, dropped); + let bytes = frame.to_bytes(); + let _ = self.transport.write_all(&bytes); + } +} + +/// Convenience for tests that need a parsed view of a control payload. +pub fn parse_control(text: &str) -> Option { + ControlMessage::parse(text).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::MockLink; + + fn request(controller: &mut MockController, line: &str) { + let mut bytes = line.as_bytes().to_vec(); + bytes.push(b'\n'); + // Feed the line directly through the device-side buffer path. + controller.line_buffer.extend_from_slice(&bytes); + while let Some(pos) = controller.line_buffer.iter().position(|&b| b == b'\n') { + let line: Vec = controller.line_buffer.drain(..=pos).collect(); + let text = std::str::from_utf8(&line).unwrap().trim_end().to_owned(); + controller.handle_line(&text); + } + } + + fn last_control_text(host: &mut crate::transport::MockTransport) -> String { + let mut parser = crate::wire::FrameParser::default(); + let mut buf = [0_u8; 4096]; + let mut last = None; + loop { + let n = crate::transport::Transport::read(host, &mut buf).unwrap(); + if n == 0 { + break; + } + parser.extend(&buf[..n]); + } + while let Some(event) = parser.next_event() { + if let crate::wire::ParseEvent::Frame(frame) = event { + if let Some(text) = frame.control_text() { + last = Some(text.to_owned()); + } + } + } + last.expect("a control frame was emitted") + } + + #[test] + fn matches_firmware_state_machine_and_error_details() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()); + + // START before CONFIG → STATE error, firmware detail string. + request(&mut controller, "@1 START"); + assert!(last_control_text(&mut host).contains("code=STATE detail=configure_before_start")); + + // Valid CONFIG, then START, then CONFIG while running is rejected. + request(&mut controller, "@2 CONFIG mode=A1 rate_hz=20000"); + assert!(last_control_text(&mut host).starts_with("+2 OK state=CONFIGURED")); + request(&mut controller, "@3 START"); + assert_eq!(controller.state(), MockState::Running); + request(&mut controller, "@4 CONFIG mode=A1 rate_hz=20000"); + assert!(last_control_text(&mut host).contains("code=STATE detail=stop_before_config")); + + // STOP always succeeds and ignores extra fields. + request(&mut controller, "@5 STOP reason=test"); + assert_eq!(controller.state(), MockState::SafeIdle); + } + + #[test] + fn firmware_v1_rejects_waveform_fields_as_unknown() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()); + + request( + &mut controller, + "@1 CONFIG mode=A1 wave=SINE freq_mhz=1000000 rate_hz=20000", + ); + assert!(last_control_text(&mut host).contains("code=SYNTAX detail=unknown_config_field")); + } + + #[test] + fn hello_requires_protocol_v1_and_advertises_capabilities() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()); + request(&mut controller, "@1 HELLO"); + assert!(last_control_text(&mut host).contains("code=PROTOCOL detail=requires_v1")); + request(&mut controller, "@2 HELLO protocol=1"); + assert!(last_control_text(&mut host).contains("capabilities=MOD,PDSTREAM")); + + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()).with_waveform_extension(); + request(&mut controller, "@1 HELLO protocol=1"); + assert!(last_control_text(&mut host).contains("capabilities=MOD,PDSTREAM,WAVE")); + } + + #[test] + fn mod_command_validates_and_holds_across_stop() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()); + + // Validation mirrors the firmware error details. + request(&mut controller, "@1 MOD level=1000"); + assert!(last_control_text(&mut host).contains("code=SYNTAX detail=wave_required")); + request(&mut controller, "@2 MOD wave=SINE level=1000"); + assert!(last_control_text(&mut host).contains("code=SYNTAX detail=freq_mhz_required")); + request( + &mut controller, + "@3 MOD wave=SQUARE level=100 min=200 freq_mhz=1000", + ); + assert!(last_control_text(&mut host).contains("code=RANGE detail=min_above_level")); + request( + &mut controller, + "@4 MOD wave=SINE level=1000 freq_mhz=99000000", + ); + assert!(last_control_text(&mut host).contains("code=RANGE detail=mod_rejected")); + + // CONST applies immediately; STATUS echoes it; STOP does not clear it. + request(&mut controller, "@5 MOD wave=CONST level=1234"); + assert!(last_control_text(&mut host) + .contains("mod_wave=CONST mod_level=1234 mod_min=1234 mod_freq_mhz=0 code=1234")); + request(&mut controller, "@6 STOP"); + request(&mut controller, "@7 STATUS"); + let status = last_control_text(&mut host); + assert!(status.contains("code=1234"), "{status}"); + assert!(status.contains("mod_wave=CONST"), "{status}"); + + // Square starts at the min threshold; OFF drops to zero. + request( + &mut controller, + "@8 MOD wave=SQUARE level=2000 min=500 freq_mhz=10000", + ); + assert!(last_control_text(&mut host) + .contains("mod_wave=SQUARE mod_level=2000 mod_min=500 mod_freq_mhz=10000 code=500")); + request(&mut controller, "@9 MOD wave=OFF"); + assert!(last_control_text(&mut host) + .contains("mod_wave=OFF mod_level=0 mod_min=0 mod_freq_mhz=0 code=0")); + } + + #[test] + fn mod_warp_validates_optical_parameters_and_reports_the_lobe_range() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()); + + request(&mut controller, "@1 MOD wave=WARP freq_mhz=10000"); + assert!(last_control_text(&mut host).contains("code=SYNTAX detail=target_required")); + + // Operating point is required. + request( + &mut controller, + "@2 MOD wave=WARP freq_mhz=10000 target=LOG_SINE a_milli=800 v_null=200 v_pi=1600", + ); + assert!(last_control_text(&mut host).contains("code=SYNTAX detail=u_k_required")); + + // V_null + Vπ overruns the DAC top rail. + request( + &mut controller, + "@3 MOD wave=WARP freq_mhz=10000 target=LOG_SINE a_milli=800 u_k_milli=500 v_null=200 v_pi=4000", + ); + assert!(last_control_text(&mut host).contains("code=RANGE detail=warp_exceeds_range")); + + // A valid log-sine warp holds and reports the reachable code range. + request( + &mut controller, + "@4 MOD wave=WARP freq_mhz=10000 target=LOG_SINE a_milli=800 u_k_milli=500 v_null=200 v_pi=1600", + ); + let ok = last_control_text(&mut host); + assert!( + ok.contains("mod_wave=WARP mod_level=1800 mod_min=200 mod_freq_mhz=10000 code=200 target=LOG_SINE"), + "{ok}" + ); + } + + #[test] + fn waveform_extension_validates_drive_bounds() { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()).with_waveform_extension(); + + request( + &mut controller, + "@1 CONFIG mode=A1 rate_hz=20000 wave=TRIANGLE freq_mhz=1000000", + ); + assert!(last_control_text(&mut host).contains("code=RANGE detail=invalid_wave")); + + request( + &mut controller, + "@2 CONFIG mode=A1 rate_hz=20000 wave=SINE freq_mhz=1000000 center_dac=3000 \ + amplitude_dac=2000", + ); + assert!(last_control_text(&mut host).contains("code=RANGE detail=amplitude_exceeds_range")); + + request( + &mut controller, + "@3 CONFIG mode=A1 rate_hz=20000 wave=SAW freq_mhz=1000000 center_dac=2048 \ + amplitude_dac=512", + ); + assert!(last_control_text(&mut host).starts_with("+3 OK state=CONFIGURED")); + } + + #[test] + fn configured_drive_synthesizes_nonlinear_pockels_response() { + let contrast_for_amplitude = |amplitude: u32| -> f64 { + let link = MockLink::new(); + let mut host = link.host_end(); + let mut controller = MockController::new(link.device_end()).with_waveform_extension(); + request( + &mut controller, + &format!( + "@1 CONFIG mode=A1 rate_hz=20000 wave=SINE freq_mhz=100000 center_dac=2048 \ + amplitude_dac={amplitude}" + ), + ); + request(&mut controller, "@2 START"); + let _ = last_control_text(&mut host); + for _ in 0..8 { + controller.emit_configured_block(); + } + + let mut parser = crate::wire::FrameParser::default(); + let mut buf = [0_u8; 65_536]; + loop { + let n = crate::transport::Transport::read(&mut host, &mut buf).unwrap(); + if n == 0 { + break; + } + parser.extend(&buf[..n]); + } + let mut codes = Vec::new(); + while let Some(event) = parser.next_event() { + if let crate::wire::ParseEvent::Frame(frame) = event { + if let Some(samples) = frame.samples() { + codes.extend(samples); + } + } + } + let estimate = crate::estimator::estimate_contrast( + &codes, + &crate::estimator::AdcCalibration { + dark_volts: 40.0 * 3.3 / 4_095.0, + ..Default::default() + }, + crate::estimator::ContrastGeometry::Direct, + ) + .expect("clean synthetic window"); + estimate.a + }; + + let a_small = contrast_for_amplitude(512); + let a_double = contrast_for_amplitude(1_024); + assert!(a_small > 0.0 && a_double > a_small); + // sin² transfer: doubling the DAC amplitude must NOT double a. + assert!( + (a_double / a_small - 2.0).abs() > 0.05, + "a_small={a_small} a_double={a_double} — response looks linear" + ); + } +} diff --git a/stage-a-io/src/pdq.rs b/stage-a-io/src/pdq.rs new file mode 100644 index 0000000..164ebf9 --- /dev/null +++ b/stage-a-io/src/pdq.rs @@ -0,0 +1,556 @@ +//! Streaming `.pdq` persistence, replay, and evidence receipts. +//! +//! Writers preserve every clean PDA1 frame verbatim and finalize the file +//! with CRC32, SHA-256, byte/frame counts, and a contiguous device sample +//! range when one exists. Readers incrementally recover the same frames, +//! report corruption/truncated tails, and produce an independently computed +//! summary suitable for replay verification. + +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; + +use crate::client::StreamIntegrity; +use crate::sha256::{Sha256, Sha256Digest}; +use crate::wire::{Crc32, Frame, FrameParser, FrameType, ParseEvent}; + +const READ_BUFFER_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdqSampleRange { + pub first_sample_index: u64, + pub end_sample_index_exclusive: u64, + pub sample_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PdqSummary { + pub path: PathBuf, + pub frames_written: u64, + pub sample_frames_written: u64, + pub samples_written: u64, + pub bytes_written: u64, + /// CRC32 over the complete file contents, retained for compatibility + /// with existing sidecars and quick local checks. + pub file_crc32: u32, + /// SHA-256 over the complete file contents for immutable run receipts. + pub file_sha256: Sha256Digest, + /// Present only when every sample frame belongs to one contiguous, + /// constant-rate device-index segment. + pub sample_range: Option, + pub sample_rate_hz: Option, + pub sample_segments: u64, + pub integrity: StreamIntegrity, + pub valid: bool, +} + +impl PdqSummary { + pub fn file_sha256_hex(&self) -> String { + self.file_sha256.to_hex() + } +} + +pub struct PdqWriter { + path: PathBuf, + file: BufWriter, + frames_written: u64, + bytes_written: u64, + running_crc: Crc32, + running_sha256: Sha256, + tracker: FrameTracker, +} + +impl PdqWriter { + pub fn create(path: impl AsRef) -> io::Result { + Self::create_with(path.as_ref(), false) + } + + /// Creates a new evidence file without replacing an existing run. + pub fn create_new(path: impl AsRef) -> io::Result { + Self::create_with(path.as_ref(), true) + } + + fn create_with(path: &Path, exclusive: bool) -> io::Result { + let path = path.to_owned(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = OpenOptions::new() + .write(true) + .create(true) + .create_new(exclusive) + .truncate(!exclusive) + .open(&path)?; + Ok(Self { + file: BufWriter::new(file), + path, + frames_written: 0, + bytes_written: 0, + running_crc: Crc32::default(), + running_sha256: Sha256::default(), + tracker: FrameTracker::default(), + }) + } + + pub fn write_frame(&mut self, frame: &Frame) -> io::Result<()> { + let bytes = frame.to_bytes(); + self.file.write_all(&bytes)?; + self.frames_written += 1; + self.bytes_written += bytes.len() as u64; + self.running_crc.update(&bytes); + self.running_sha256.update(&bytes); + self.tracker.observe(frame); + Ok(()) + } + + /// Flushes and closes the file, returning everything needed for a named + /// finalized receipt. Integrity observed by the live transport is merged + /// fail-closed with discontinuities inferable from the written frames. + pub fn finish(mut self, mut integrity: StreamIntegrity) -> io::Result { + self.file.flush()?; + integrity.sequence_gaps = integrity + .sequence_gaps + .max(self.tracker.frame_sequence_gaps); + integrity.dropped_samples = integrity + .dropped_samples + .max(self.tracker.dropped_samples_delta()); + let sample_range = self.tracker.contiguous_sample_range(); + let valid = integrity.is_clean() + && self.tracker.malformed_sample_frames == 0 + && self.tracker.sample_segments <= 1; + Ok(PdqSummary { + file_crc32: self.running_crc.finalize(), + file_sha256: self.running_sha256.finalize(), + path: self.path, + frames_written: self.frames_written, + sample_frames_written: self.tracker.sample_frames, + samples_written: self.tracker.samples, + bytes_written: self.bytes_written, + sample_range, + sample_rate_hz: self.tracker.uniform_sample_rate(), + sample_segments: self.tracker.sample_segments, + integrity, + valid, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PdqReadEvent { + Frame(Frame), + Corruption { + skipped_bytes: usize, + crc_failures: usize, + }, + TruncatedTail { + bytes: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PdqReadSummary { + pub frames_read: u64, + pub sample_frames_read: u64, + pub samples_read: u64, + pub bytes_read: u64, + pub file_crc32: u32, + pub file_sha256: Sha256Digest, + pub sample_range: Option, + pub sample_rate_hz: Option, + pub sample_segments: u64, + pub malformed_sample_frames: u64, + pub truncated_bytes: u64, + pub integrity: StreamIntegrity, + pub valid: bool, +} + +impl PdqReadSummary { + pub fn file_sha256_hex(&self) -> String { + self.file_sha256.to_hex() + } +} + +/// Incremental PDA1 file reader. `next_event` preserves corruption notices +/// instead of silently skipping them, allowing replay to continue while the +/// final summary remains invalid. +pub struct PdqReader { + reader: R, + parser: FrameParser, + buffer: Vec, + eof: bool, + tail_reported: bool, + frames_read: u64, + bytes_read: u64, + running_crc: Crc32, + running_sha256: Sha256, + tracker: FrameTracker, + integrity: StreamIntegrity, + truncated_bytes: u64, +} + +impl PdqReader { + pub fn open(path: impl AsRef) -> io::Result { + File::open(path).map(Self::new) + } +} + +impl PdqReader { + pub fn new(reader: R) -> Self { + Self { + reader, + parser: FrameParser::default(), + buffer: vec![0; READ_BUFFER_BYTES], + eof: false, + tail_reported: false, + frames_read: 0, + bytes_read: 0, + running_crc: Crc32::default(), + running_sha256: Sha256::default(), + tracker: FrameTracker::default(), + integrity: StreamIntegrity::default(), + truncated_bytes: 0, + } + } + + pub fn next_event(&mut self) -> io::Result> { + loop { + if let Some(event) = self.parser.next_event() { + return Ok(Some(match event { + ParseEvent::Frame(frame) => { + self.frames_read += 1; + self.tracker.observe(&frame); + PdqReadEvent::Frame(frame) + } + ParseEvent::Corruption { + skipped_bytes, + crc_failures, + } => { + self.integrity.skipped_bytes += skipped_bytes as u64; + self.integrity.crc_failures += crc_failures as u64; + PdqReadEvent::Corruption { + skipped_bytes, + crc_failures, + } + } + })); + } + + if self.eof { + if !self.tail_reported && self.parser.buffered_len() > 0 { + self.tail_reported = true; + let bytes = self.parser.discard_buffered(); + self.truncated_bytes += bytes as u64; + self.integrity.skipped_bytes += bytes as u64; + return Ok(Some(PdqReadEvent::TruncatedTail { bytes })); + } + return Ok(None); + } + + let read = match self.reader.read(&mut self.buffer) { + Ok(read) => read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + if read == 0 { + self.eof = true; + continue; + } + let bytes = &self.buffer[..read]; + self.bytes_read += read as u64; + self.running_crc.update(bytes); + self.running_sha256.update(bytes); + self.parser.extend(bytes); + } + } + + /// Drains the remaining file and returns an independently verified + /// summary. This can follow any number of prior `next_event` calls. + pub fn finish(mut self) -> io::Result { + while self.next_event()?.is_some() {} + self.integrity.sequence_gaps = self.tracker.frame_sequence_gaps; + self.integrity.dropped_samples = self.tracker.dropped_samples_delta(); + let sample_range = self.tracker.contiguous_sample_range(); + let valid = self.integrity.is_clean() + && self.truncated_bytes == 0 + && self.tracker.malformed_sample_frames == 0 + && self.tracker.sample_segments <= 1; + Ok(PdqReadSummary { + frames_read: self.frames_read, + sample_frames_read: self.tracker.sample_frames, + samples_read: self.tracker.samples, + bytes_read: self.bytes_read, + file_crc32: self.running_crc.finalize(), + file_sha256: self.running_sha256.finalize(), + sample_range, + sample_rate_hz: self.tracker.uniform_sample_rate(), + sample_segments: self.tracker.sample_segments, + malformed_sample_frames: self.tracker.malformed_sample_frames, + truncated_bytes: self.truncated_bytes, + integrity: self.integrity, + valid, + }) + } +} + +pub fn inspect_pdq(path: impl AsRef) -> io::Result { + PdqReader::open(path)?.finish() +} + +#[derive(Default)] +struct FrameTracker { + previous_frame_sequence: Option, + frame_sequence_gaps: u64, + sample_frames: u64, + samples: u64, + first_sample_index: Option, + last_sample_end: Option, + previous_sample_end: Option, + first_sample_rate_hz: Option, + previous_sample_rate_hz: Option, + sample_rate_changed: bool, + sample_segments: u64, + malformed_sample_frames: u64, + first_dropped_samples: Option, + last_dropped_samples: Option, +} + +impl FrameTracker { + fn observe(&mut self, frame: &Frame) { + if let Some(previous) = self.previous_frame_sequence { + if frame.header.sequence != previous.wrapping_add(1) { + self.frame_sequence_gaps += 1; + } + } + self.previous_frame_sequence = Some(frame.header.sequence); + self.first_dropped_samples + .get_or_insert(frame.header.dropped_samples); + self.last_dropped_samples = Some(frame.header.dropped_samples); + + if frame.header.frame_type != FrameType::SamplesU16 { + return; + } + if !frame.payload.len().is_multiple_of(2) { + self.malformed_sample_frames += 1; + return; + } + let count = (frame.payload.len() / 2) as u64; + if count == 0 { + return; + } + let first = frame.header.first_sample_index; + let end = first.saturating_add(count); + let starts_new_segment = self.previous_sample_end.is_none() + || self.previous_sample_end != Some(first) + || self.previous_sample_rate_hz != Some(frame.header.sample_rate_hz); + if starts_new_segment { + self.sample_segments += 1; + } + if self + .first_sample_rate_hz + .is_some_and(|rate| rate != frame.header.sample_rate_hz) + { + self.sample_rate_changed = true; + } + self.first_sample_rate_hz + .get_or_insert(frame.header.sample_rate_hz); + self.previous_sample_rate_hz = Some(frame.header.sample_rate_hz); + self.first_sample_index.get_or_insert(first); + self.last_sample_end = Some(end); + self.previous_sample_end = Some(end); + self.sample_frames += 1; + self.samples += count; + } + + fn dropped_samples_delta(&self) -> u64 { + match (self.first_dropped_samples, self.last_dropped_samples) { + (Some(first), Some(last)) => u64::from(last.saturating_sub(first)), + _ => 0, + } + } + + fn uniform_sample_rate(&self) -> Option { + (!self.sample_rate_changed) + .then_some(self.first_sample_rate_hz) + .flatten() + } + + fn contiguous_sample_range(&self) -> Option { + if self.sample_segments != 1 || self.malformed_sample_frames > 0 { + return None; + } + Some(PdqSampleRange { + first_sample_index: self.first_sample_index?, + end_sample_index_exclusive: self.last_sample_end?, + sample_count: self.samples, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::{FrameHeader, PROTOCOL_VERSION}; + use std::io::Cursor; + + fn control_frame(sequence: u32) -> Frame { + Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::Control, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: 0, + sample_rate_hz: 0, + dropped_samples: 0, + crc32: 0, + }, + format!("+{sequence} OK").into_bytes(), + ) + } + + fn sample_frame(sequence: u32, first_index: u64, rate_hz: u32, codes: &[u16]) -> Frame { + Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::SamplesU16, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: first_index, + sample_rate_hz: rate_hz, + dropped_samples: 0, + crc32: 0, + }, + codes.iter().flat_map(|code| code.to_le_bytes()).collect(), + ) + } + + fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "stage-a-io-pdq-{tag}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn writer_and_reader_agree_on_digest_size_and_sample_range() { + let dir = temp_dir("receipt"); + let path = dir.join("run.pdq"); + let frames = [ + sample_frame(10, 1_000, 20_000, &[1, 2, 3]), + sample_frame(11, 1_003, 20_000, &[4, 5]), + ]; + let mut writer = PdqWriter::create(&path).expect("create pdq"); + for frame in &frames { + writer.write_frame(frame).expect("write frame"); + } + let written = writer + .finish(StreamIntegrity::default()) + .expect("finish writer"); + let read = inspect_pdq(&path).expect("inspect pdq"); + + assert!(written.valid && read.valid); + assert_eq!(written.frames_written, 2); + assert_eq!(written.sample_frames_written, 2); + assert_eq!(written.samples_written, 5); + assert_eq!(written.bytes_written, read.bytes_read); + assert_eq!(written.file_crc32, read.file_crc32); + assert_eq!(written.file_sha256, read.file_sha256); + assert_eq!(written.file_sha256_hex().len(), 64); + assert_eq!( + written.sample_range, + Some(PdqSampleRange { + first_sample_index: 1_000, + end_sample_index_exclusive: 1_005, + sample_count: 5, + }) + ); + assert_eq!(written.sample_range, read.sample_range); + assert_eq!(written.sample_rate_hz, Some(20_000)); + + std::fs::remove_dir_all(dir).expect("cleanup"); + } + + #[test] + fn reader_streams_frames_and_reports_crc_corruption() { + let first = control_frame(1).to_bytes(); + let mut corrupt = control_frame(2).to_bytes(); + let last = corrupt.len() - 1; + corrupt[last] ^= 0x80; + let third = control_frame(3).to_bytes(); + let bytes: Vec = first.into_iter().chain(corrupt).chain(third).collect(); + let mut reader = PdqReader::new(Cursor::new(bytes)); + let mut frames = Vec::new(); + let mut saw_corruption = false; + while let Some(event) = reader.next_event().expect("read event") { + match event { + PdqReadEvent::Frame(frame) => frames.push(frame.header.sequence), + PdqReadEvent::Corruption { crc_failures, .. } => { + saw_corruption |= crc_failures > 0; + } + PdqReadEvent::TruncatedTail { .. } => {} + } + } + assert_eq!(frames, [1, 3]); + assert!(saw_corruption); + let summary = reader.finish().expect("finish after iteration"); + assert!(!summary.valid); + assert_eq!(summary.integrity.crc_failures, 1); + assert_eq!(summary.integrity.sequence_gaps, 1); + } + + #[test] + fn truncated_tail_is_visible_and_invalid() { + let mut bytes = sample_frame(1, 0, 20_000, &[1, 2, 3]).to_bytes(); + bytes.extend_from_slice(b"PDA"); + let mut reader = PdqReader::new(Cursor::new(bytes)); + let mut truncated = 0; + while let Some(event) = reader.next_event().expect("read") { + if let PdqReadEvent::TruncatedTail { bytes } = event { + truncated += bytes; + } + } + assert_eq!(truncated, 3); + let summary = reader.finish().expect("finish"); + assert_eq!(summary.truncated_bytes, 3); + assert!(!summary.valid); + } + + #[test] + fn discontinuous_samples_have_no_contiguous_range() { + let first = sample_frame(4, 100, 20_000, &[1, 2]).to_bytes(); + let second = sample_frame(5, 900, 50_000, &[3, 4]).to_bytes(); + let bytes: Vec = first.into_iter().chain(second).collect(); + let summary = PdqReader::new(Cursor::new(bytes)) + .finish() + .expect("inspect"); + assert_eq!(summary.sample_segments, 2); + assert_eq!(summary.sample_range, None); + assert_eq!(summary.sample_rate_hz, None); + assert!(!summary.valid); + } + + #[test] + fn explicit_integrity_faults_invalidate_writer_but_keep_the_file() { + let dir = temp_dir("invalid"); + let path = dir.join("run.pdq"); + let mut writer = PdqWriter::create(&path).expect("create pdq"); + writer.write_frame(&control_frame(1)).expect("write frame"); + let summary = writer + .finish(StreamIntegrity { + dropped_samples: 5, + ..StreamIntegrity::default() + }) + .expect("finish pdq"); + + assert!(!summary.valid); + assert!(path.exists(), "evidence file is preserved"); + std::fs::remove_dir_all(dir).expect("cleanup"); + } +} diff --git a/stage-a-io/src/protocol.rs b/stage-a-io/src/protocol.rs new file mode 100644 index 0000000..254130f --- /dev/null +++ b/stage-a-io/src/protocol.rs @@ -0,0 +1,226 @@ +//! ASCII command / control-reply grammar (host → Teensy and CONTROL frame +//! payloads), per the Stage-A serial protocol v1: +//! +//! ```text +//! Host request: @ key=value key=value\n +//! CONTROL reply payload: + OK key=value ... +//! CONTROL reply payload: - ERR code= detail= +//! Async CONTROL payload: ! key=value ... +//! ``` +//! +//! Commands are printable ASCII, max 192 bytes, integer values only. + +use std::collections::BTreeMap; +use std::fmt; + +pub const MAX_COMMAND_BYTES: usize = 192; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Command { + pub verb: String, + /// Ordered key=value fields (insertion order is preserved on the wire; + /// a BTreeMap would silently reorder, so use a Vec of pairs). + pub fields: Vec<(String, String)>, +} + +impl Command { + pub fn new(verb: &str) -> Self { + Self { + verb: verb.to_owned(), + fields: Vec::new(), + } + } + + pub fn field(mut self, key: &str, value: impl fmt::Display) -> Self { + self.fields.push((key.to_owned(), value.to_string())); + self + } + + /// Encodes `@ VERB k=v ...\n`, validating the printable-ASCII and + /// length constraints. + pub fn encode(&self, sequence: u32) -> Result, ProtocolError> { + let mut line = format!("@{sequence} {}", self.verb); + for (key, value) in &self.fields { + line.push(' '); + line.push_str(key); + line.push('='); + line.push_str(value); + } + line.push('\n'); + if line.len() > MAX_COMMAND_BYTES { + return Err(ProtocolError::CommandTooLong(line.len())); + } + if !line + .bytes() + .all(|b| b == b'\n' || (0x20..=0x7E).contains(&b)) + { + return Err(ProtocolError::NonPrintable); + } + Ok(line.into_bytes()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ControlMessage { + /// `+ OK key=value ...` + Ok { + sequence: u32, + fields: BTreeMap, + }, + /// `- ERR code= detail=` + Err { + sequence: u32, + code: String, + detail: String, + }, + /// `! key=value ...` + Async { + name: String, + fields: BTreeMap, + }, +} + +impl ControlMessage { + pub fn parse(text: &str) -> Result { + let text = text.trim_end_matches(['\r', '\n']); + let mut parts = text.split_ascii_whitespace(); + let head = parts.next().ok_or(ProtocolError::EmptyControl)?; + match head.as_bytes().first() { + Some(b'+') => { + let sequence = head[1..] + .parse() + .map_err(|_| ProtocolError::BadSequence(head.to_owned()))?; + let ok = parts.next(); + if ok != Some("OK") { + return Err(ProtocolError::Malformed(text.to_owned())); + } + Ok(Self::Ok { + sequence, + fields: parse_fields(parts), + }) + } + Some(b'-') => { + let sequence = head[1..] + .parse() + .map_err(|_| ProtocolError::BadSequence(head.to_owned()))?; + let err = parts.next(); + if err != Some("ERR") { + return Err(ProtocolError::Malformed(text.to_owned())); + } + let fields = parse_fields(parts); + Ok(Self::Err { + sequence, + code: fields.get("code").cloned().unwrap_or_default(), + detail: fields.get("detail").cloned().unwrap_or_default(), + }) + } + Some(b'!') => Ok(Self::Async { + name: head[1..].to_owned(), + fields: parse_fields(parts), + }), + _ => Err(ProtocolError::Malformed(text.to_owned())), + } + } + + pub fn sequence(&self) -> Option { + match self { + Self::Ok { sequence, .. } | Self::Err { sequence, .. } => Some(*sequence), + Self::Async { .. } => None, + } + } +} + +fn parse_fields<'a>(parts: impl Iterator) -> BTreeMap { + parts + .filter_map(|part| { + let (key, value) = part.split_once('=')?; + Some((key.to_owned(), value.to_owned())) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolError { + CommandTooLong(usize), + NonPrintable, + EmptyControl, + BadSequence(String), + Malformed(String), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandTooLong(len) => { + write!(f, "command is {len} bytes (max {MAX_COMMAND_BYTES})") + } + Self::NonPrintable => f.write_str("command contains non-printable bytes"), + Self::EmptyControl => f.write_str("empty control payload"), + Self::BadSequence(head) => write!(f, "unparseable sequence in {head:?}"), + Self::Malformed(text) => write!(f, "malformed control payload {text:?}"), + } + } +} + +impl std::error::Error for ProtocolError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_commands_with_ordered_fields() { + let cmd = Command::new("CONFIG") + .field("mode", "A1") + .field("freq_mhz", 12_500) + .field("center_dac", 2_048) + .field("amplitude_dac", 512); + assert_eq!( + String::from_utf8(cmd.encode(3).expect("encodes")).unwrap(), + "@3 CONFIG mode=A1 freq_mhz=12500 center_dac=2048 amplitude_dac=512\n" + ); + } + + #[test] + fn rejects_oversized_and_non_printable_commands() { + let long = Command::new("X").field("k", "y".repeat(200)); + assert!(matches!( + long.encode(1), + Err(ProtocolError::CommandTooLong(_)) + )); + let bad = Command::new("X").field("k", "\u{7f}"); + assert!(matches!(bad.encode(1), Err(ProtocolError::NonPrintable))); + } + + #[test] + fn parses_ok_err_and_async_payloads() { + let ok = ControlMessage::parse("+12 OK state=ARMED rev=4").expect("ok parses"); + match ok { + ControlMessage::Ok { sequence, fields } => { + assert_eq!(sequence, 12); + assert_eq!(fields.get("rev").map(String::as_str), Some("4")); + } + other => panic!("unexpected {other:?}"), + } + + let err = + ControlMessage::parse("-13 ERR code=BOUNDS detail=amplitude_dac").expect("err parses"); + assert_eq!( + err, + ControlMessage::Err { + sequence: 13, + code: "BOUNDS".into(), + detail: "amplitude_dac".into() + } + ); + + let async_msg = ControlMessage::parse("!APPLIED rev=4").expect("async parses"); + match async_msg { + ControlMessage::Async { name, fields } => { + assert_eq!(name, "APPLIED"); + assert_eq!(fields.get("rev").map(String::as_str), Some("4")); + } + other => panic!("unexpected {other:?}"), + } + } +} diff --git a/stage-a-io/src/sha256.rs b/stage-a-io/src/sha256.rs new file mode 100644 index 0000000..8447d31 --- /dev/null +++ b/stage-a-io/src/sha256.rs @@ -0,0 +1,259 @@ +//! Small streaming SHA-256 implementation used to finalize PDQ evidence. +//! +//! Keeping this implementation local avoids adding a crypto dependency to +//! the hardware-facing crate. It implements only unkeyed SHA-256 and is +//! tested against the FIPS 180-4 example vectors. + +use std::fmt; + +const INITIAL_STATE: [u32; 8] = [ + 0x6a09_e667, + 0xbb67_ae85, + 0x3c6e_f372, + 0xa54f_f53a, + 0x510e_527f, + 0x9b05_688c, + 0x1f83_d9ab, + 0x5be0_cd19, +]; + +const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a_2f98, + 0x7137_4491, + 0xb5c0_fbcf, + 0xe9b5_dba5, + 0x3956_c25b, + 0x59f1_11f1, + 0x923f_82a4, + 0xab1c_5ed5, + 0xd807_aa98, + 0x1283_5b01, + 0x2431_85be, + 0x550c_7dc3, + 0x72be_5d74, + 0x80de_b1fe, + 0x9bdc_06a7, + 0xc19b_f174, + 0xe49b_69c1, + 0xefbe_4786, + 0x0fc1_9dc6, + 0x240c_a1cc, + 0x2de9_2c6f, + 0x4a74_84aa, + 0x5cb0_a9dc, + 0x76f9_88da, + 0x983e_5152, + 0xa831_c66d, + 0xb003_27c8, + 0xbf59_7fc7, + 0xc6e0_0bf3, + 0xd5a7_9147, + 0x06ca_6351, + 0x1429_2967, + 0x27b7_0a85, + 0x2e1b_2138, + 0x4d2c_6dfc, + 0x5338_0d13, + 0x650a_7354, + 0x766a_0abb, + 0x81c2_c92e, + 0x9272_2c85, + 0xa2bf_e8a1, + 0xa81a_664b, + 0xc24b_8b70, + 0xc76c_51a3, + 0xd192_e819, + 0xd699_0624, + 0xf40e_3585, + 0x106a_a070, + 0x19a4_c116, + 0x1e37_6c08, + 0x2748_774c, + 0x34b0_bcb5, + 0x391c_0cb3, + 0x4ed8_aa4a, + 0x5b9c_ca4f, + 0x682e_6ff3, + 0x748f_82ee, + 0x78a5_636f, + 0x84c8_7814, + 0x8cc7_0208, + 0x90be_fffa, + 0xa450_6ceb, + 0xbef9_a3f7, + 0xc671_78f2, +]; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Sha256Digest([u8; 32]); + +impl Sha256Digest { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(64); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_hex()) + } +} + +impl fmt::Debug for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Sha256Digest({self})") + } +} + +pub(crate) struct Sha256 { + state: [u32; 8], + buffer: [u8; 64], + buffer_len: usize, + bytes_seen: u64, +} + +impl Default for Sha256 { + fn default() -> Self { + Self { + state: INITIAL_STATE, + buffer: [0; 64], + buffer_len: 0, + bytes_seen: 0, + } + } +} + +impl Sha256 { + pub(crate) fn update(&mut self, mut bytes: &[u8]) { + self.bytes_seen = self.bytes_seen.wrapping_add(bytes.len() as u64); + if self.buffer_len > 0 { + let fill = (64 - self.buffer_len).min(bytes.len()); + self.buffer[self.buffer_len..self.buffer_len + fill].copy_from_slice(&bytes[..fill]); + self.buffer_len += fill; + bytes = &bytes[fill..]; + if self.buffer_len == 64 { + let block = self.buffer; + self.compress(&block); + self.buffer_len = 0; + } else { + return; + } + } + + while bytes.len() >= 64 { + let block: &[u8; 64] = bytes[..64].try_into().expect("exact SHA-256 block"); + self.compress(block); + bytes = &bytes[64..]; + } + self.buffer[..bytes.len()].copy_from_slice(bytes); + self.buffer_len = bytes.len(); + } + + pub(crate) fn finalize(mut self) -> Sha256Digest { + let bit_len = self.bytes_seen.wrapping_mul(8); + self.buffer[self.buffer_len] = 0x80; + self.buffer_len += 1; + if self.buffer_len > 56 { + self.buffer[self.buffer_len..].fill(0); + let block = self.buffer; + self.compress(&block); + self.buffer = [0; 64]; + } else { + self.buffer[self.buffer_len..56].fill(0); + } + self.buffer[56..64].copy_from_slice(&bit_len.to_be_bytes()); + let block = self.buffer; + self.compress(&block); + + let mut digest = [0; 32]; + for (chunk, word) in digest.chunks_exact_mut(4).zip(self.state) { + chunk.copy_from_slice(&word.to_be_bytes()); + } + Sha256Digest(digest) + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut schedule = [0_u32; 64]; + for (word, bytes) in schedule[..16].iter_mut().zip(block.chunks_exact(4)) { + *word = u32::from_be_bytes(bytes.try_into().expect("four-byte word")); + } + for index in 16..64 { + let x = schedule[index - 15]; + let y = schedule[index - 2]; + let sigma0 = x.rotate_right(7) ^ x.rotate_right(18) ^ (x >> 3); + let sigma1 = y.rotate_right(17) ^ y.rotate_right(19) ^ (y >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(sigma0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(sigma1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state; + for (word, constant) in schedule.into_iter().zip(ROUND_CONSTANTS) { + let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ (!e & g); + let temp1 = h + .wrapping_add(sum1) + .wrapping_add(choose) + .wrapping_add(constant) + .wrapping_add(word); + let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = sum0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + for (state, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *state = state.wrapping_add(value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest(chunks: &[&[u8]]) -> String { + let mut sha = Sha256::default(); + for chunk in chunks { + sha.update(chunk); + } + sha.finalize().to_hex() + } + + #[test] + fn matches_fips_vectors_and_fragmentation() { + assert_eq!( + digest(&[b""]), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + digest(&[b"a", b"b", b"c"]), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + digest(&[b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"]), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } +} diff --git a/stage-a-io/src/sidecar.rs b/stage-a-io/src/sidecar.rs new file mode 100644 index 0000000..0ebd036 --- /dev/null +++ b/stage-a-io/src/sidecar.rs @@ -0,0 +1,226 @@ +//! Run sidecar (manifest): everything needed to reproduce or audit one +//! Stage-A recording, written as JSON next to the camera RAW / PDQ files. +//! +//! Per the control-software spec, each recording sidecar includes the run +//! ID, plugin/firmware/protocol versions, raw PDQ path and checksum, +//! ADC/front-end calibration, load, configured and measured sample cadence, +//! drop/CRC counters, the ACKed configuration revision, bias set, optical +//! configuration, flux point, measured `a`, and trigger source. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::client::StreamIntegrity; +use crate::estimator::{AdcCalibration, ContrastEstimate}; +use crate::pdq::PdqSummary; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TriggerSource { + /// Teensy waveform phase-0 sync TTL (A1/A3 drive fiducial). + DrivePhase0, + /// Photodiode → comparator 50 % crossing (A2 light fiducial). + Comparator, + /// No hardware trigger wired; software phase recovery in use. + None, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DetectorLoad { + /// 50 Ω — A1/A2 (speed over signal). + FiftyOhm, + /// Characterised high-Z load — A3 only ($f \ll f_c$). + HighZ { nominal_ohms: u64 }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSidecar { + pub run_id: String, + pub protocol: String, + pub created_utc: String, + + pub plugin_name: String, + pub plugin_version: String, + pub firmware_version: String, + pub wire_protocol_version: u8, + + /// Path + CRC32 of the raw PDQ photodiode file. + pub pdq_path: PathBuf, + pub pdq_crc32: u32, + pub pdq_frames: u64, + /// Path of the camera RAW recording this run belongs to, if any. + pub camera_raw_path: Option, + + pub adc_calibration: AdcCalibration, + pub detector_load: DetectorLoad, + pub configured_sample_rate_hz: u32, + pub measured_sample_rate_hz: Option, + + pub integrity: IntegrityRecord, + /// Overall validity — false on any drop/CRC/sequence/cadence fault or + /// estimator rejection. An invalid point is re-measured, never patched. + pub valid: bool, + + /// ACKed controller configuration (verbatim key=value fields) and its + /// revision, exactly as the firmware confirmed them. + pub acked_config_revision: Option, + pub acked_config: BTreeMap, + + /// Frozen camera bias set identifier (registry lives in the knowledge + /// base `setup/bias-sets.md`). + pub bias_set: Option, + /// Optical configuration / flux point labels from the run plan. + pub optical_configuration: Option, + pub flux_point: Option, + + /// Measured optical log-contrast for this run/point, when applicable. + pub measured_contrast: Option, + pub trigger_source: TriggerSource, + + /// Free-form notes (operator observations, deviations). + pub notes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct IntegrityRecord { + pub skipped_bytes: u64, + pub crc_failures: u64, + pub sequence_gaps: u64, + pub dropped_samples: u64, +} + +impl From for IntegrityRecord { + fn from(value: StreamIntegrity) -> Self { + Self { + skipped_bytes: value.skipped_bytes, + crc_failures: value.crc_failures, + sequence_gaps: value.sequence_gaps, + dropped_samples: value.dropped_samples, + } + } +} + +impl RunSidecar { + /// Builds a sidecar skeleton from a finished PDQ file. Protocol fields + /// and run metadata are filled by the owning plugin before writing. + pub fn from_pdq(run_id: &str, protocol: &str, pdq: &PdqSummary) -> Self { + Self { + run_id: run_id.to_owned(), + protocol: protocol.to_owned(), + created_utc: now_utc_iso8601(), + plugin_name: String::new(), + plugin_version: String::new(), + firmware_version: String::new(), + wire_protocol_version: crate::wire::PROTOCOL_VERSION, + pdq_path: pdq.path.clone(), + pdq_crc32: pdq.file_crc32, + pdq_frames: pdq.frames_written, + camera_raw_path: None, + adc_calibration: AdcCalibration::default(), + detector_load: DetectorLoad::FiftyOhm, + configured_sample_rate_hz: 0, + measured_sample_rate_hz: None, + integrity: pdq.integrity.into(), + valid: pdq.valid, + acked_config_revision: None, + acked_config: BTreeMap::new(), + bias_set: None, + optical_configuration: None, + flux_point: None, + measured_contrast: None, + trigger_source: TriggerSource::None, + notes: Vec::new(), + } + } + + pub fn write_json(&self, path: impl AsRef) -> std::io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_vec_pretty(self)?; + std::fs::write(path, json) + } + + pub fn read_json(path: impl AsRef) -> std::io::Result { + let bytes = std::fs::read(path)?; + serde_json::from_slice(&bytes).map_err(std::io::Error::other) + } +} + +fn now_utc_iso8601() -> String { + // Seconds-resolution UTC timestamp without pulling in chrono. + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = secs / 86_400; + let (year, month, day) = civil_from_days(days as i64); + let rem = secs % 86_400; + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rem / 3_600, + (rem % 3_600) / 60, + rem % 60 + ) +} + +/// Howard Hinnant's `civil_from_days` (public domain algorithm). +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sidecar_round_trips_through_json() { + let pdq = PdqSummary { + path: PathBuf::from("/data/A1-20260713-01.pdq"), + frames_written: 128, + sample_frames_written: 128, + samples_written: 32_768, + bytes_written: 65_536, + file_crc32: 0xDEAD_BEEF, + file_sha256: crate::Sha256Digest::from_bytes([0xAB; 32]), + sample_range: Some(crate::PdqSampleRange { + first_sample_index: 0, + end_sample_index_exclusive: 32_768, + sample_count: 32_768, + }), + sample_rate_hz: Some(20_000), + sample_segments: 1, + integrity: StreamIntegrity::default(), + valid: true, + }; + let mut sidecar = RunSidecar::from_pdq("A1-20260713-01", "A1", &pdq); + sidecar.plugin_name = "stage-a-a1".into(); + sidecar.acked_config_revision = Some(4); + sidecar.acked_config.insert("mode".into(), "A1".into()); + sidecar.trigger_source = TriggerSource::DrivePhase0; + + let json = serde_json::to_string(&sidecar).expect("serializes"); + let decoded: RunSidecar = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(decoded, sidecar); + assert!(decoded.created_utc.ends_with('Z')); + } + + #[test] + fn civil_from_days_matches_known_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(20_282), (2025, 7, 13)); + } +} diff --git a/stage-a-io/src/transport.rs b/stage-a-io/src/transport.rs new file mode 100644 index 0000000..e21832b --- /dev/null +++ b/stage-a-io/src/transport.rs @@ -0,0 +1,303 @@ +//! Byte transports: the real USB serial port and an in-memory mock. +//! +//! Exactly one armed plugin owns the port at a time; opening a busy device +//! is a visible error, never a silent second connection (the OS enforces +//! exclusivity via `serialport`'s exclusive open on POSIX). + +use std::io; +use std::sync::{Arc, Mutex}; +#[cfg(feature = "hardware")] +use std::time::Duration; + +pub trait Transport: Send { + /// Reads whatever is available into `buf`, blocking up to the + /// transport's timeout. `Ok(0)` means "nothing arrived this poll". + fn read(&mut self, buf: &mut [u8]) -> io::Result; + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()>; +} + +/// Real serial port. Construction fails visibly if the device is busy or +/// absent. +#[cfg(feature = "hardware")] +pub struct SerialTransport { + port: Box, +} + +#[cfg(feature = "hardware")] +impl SerialTransport { + pub fn open(path: &str, baud: u32, poll_timeout: Duration) -> io::Result { + let port = serialport::new(path, baud) + .timeout(poll_timeout) + // Windows opens a port with DTR deasserted; macOS and Linux assert + // it for us. A Teensy sketch that gates on `if (Serial)` would stay + // silent there, so assert it everywhere (ADR 032). + .dtr_on_open(true) + .open() + .map_err(|err| io::Error::other(format!("opening {path} failed: {err}")))?; + Ok(Self { port }) + } +} + +#[cfg(feature = "hardware")] +impl Transport for SerialTransport { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self.port.read(buf) { + Ok(n) => Ok(n), + Err(err) if err.kind() == io::ErrorKind::TimedOut => Ok(0), + Err(err) => Err(err), + } + } + + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + io::Write::write_all(&mut self.port, bytes) + } +} + +/// Shared in-memory duplex used by tests and the mock controller: the +/// "host" side reads what the "device" side wrote and vice versa. +#[derive(Default)] +struct DuplexState { + to_host: Vec, + to_device: Vec, +} + +#[derive(Clone, Default)] +pub struct MockLink { + state: Arc>, +} + +impl MockLink { + pub fn new() -> Self { + Self::default() + } + + pub fn host_end(&self) -> MockTransport { + MockTransport { + state: Arc::clone(&self.state), + is_host: true, + } + } + + pub fn device_end(&self) -> MockTransport { + MockTransport { + state: Arc::clone(&self.state), + is_host: false, + } + } +} + +pub struct MockTransport { + state: Arc>, + is_host: bool, +} + +impl Transport for MockTransport { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let source = if self.is_host { + &mut state.to_host + } else { + &mut state.to_device + }; + let n = source.len().min(buf.len()); + buf[..n].copy_from_slice(&source[..n]); + source.drain(..n); + Ok(n) + } + + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + let sink = if self.is_host { + &mut state.to_device + } else { + &mut state.to_host + }; + sink.extend_from_slice(bytes); + Ok(()) + } +} + +/// One serial port as the OS enumerated it. +/// +/// `label` is the human-readable USB manufacturer/product where the OS +/// reports one — e.g. `"Teensyduino Dual Serial"` — so port pickers can show +/// which entry is the Teensy. `is_usb` records whether the OS classified the +/// port as a USB device at all, which is the only device hint Windows gives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortInfo { + pub name: String, + pub label: Option, + pub is_usb: bool, +} + +impl PortInfo { + /// The port as a picker entry: the path, plus the USB label in parentheses + /// where there is one. Only the leading path is the value — see + /// `variant_path` in the Stage-A plugins. + pub fn variant(&self) -> String { + match &self.label { + Some(label) => format!("{} ({label})", self.name), + None => self.name.clone(), + } + } +} + +/// Every serial port visible to the OS (empty without the `hardware` feature). +#[cfg(feature = "hardware")] +pub fn available_ports() -> Vec { + serialport::available_ports() + .map(|ports| { + ports + .into_iter() + .map(|p| { + let (label, is_usb) = match p.port_type { + serialport::SerialPortType::UsbPort(info) => { + let label = 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, + }; + (label, true) + } + _ => (None, false), + }; + PortInfo { + name: p.port_name, + label, + is_usb, + } + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(not(feature = "hardware"))] +pub fn available_ports() -> Vec { + Vec::new() +} + +/// The enumerated ports worth probing for a Teensy. +/// +/// Unix names the device, so the name is the filter: macOS lists every device +/// twice (`tty.*` and `cu.*`) and only the callout node may be opened, and a +/// Teensy's CDC-ACM class node on Linux is `ttyACM*`. Windows names nothing — +/// every port is `COMn` — so USB-ness is the only signal there, and if the OS +/// classified no port at all the whole list is probed rather than none. What +/// actually identifies the Teensy is the probe (HELLO on the command port, +/// PDA1 sample frames on the stream port); this only keeps the probe from +/// stalling on unrelated ports such as Windows' phantom Bluetooth COM entries. +pub fn candidate_ports() -> Vec { + narrow_to_candidates(available_ports(), cfg!(windows)) +} + +fn narrow_to_candidates(ports: Vec, windows: bool) -> Vec { + if !windows { + return ports + .into_iter() + .filter(|port| port.name.contains("cu.usbmodem") || port.name.contains("ttyACM")) + .collect(); + } + if ports.iter().any(|port| port.is_usb) { + return ports.into_iter().filter(|port| port.is_usb).collect(); + } + ports +} + +/// Why there was nothing to probe, naming what the OS did enumerate — the +/// difference between "no device is attached" and "a device is attached but +/// this platform's filter dropped it" is the operator's next step. +pub fn no_candidate_ports_message() -> String { + let ports = available_ports(); + if ports.is_empty() { + return "no serial ports found — check the USB cable and that the Teensy is powered" + .to_owned(); + } + let seen = ports + .iter() + .map(PortInfo::variant) + .collect::>() + .join(", "); + format!("no serial port looked like a Teensy (the OS offered {seen})") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn port(name: &str, is_usb: bool) -> PortInfo { + PortInfo { + name: name.to_owned(), + label: None, + is_usb, + } + } + + fn names(ports: Vec) -> Vec { + ports.into_iter().map(|port| port.name).collect() + } + + #[test] + fn unix_keeps_the_callout_node_and_drops_its_tty_twin() { + let ports = vec![ + port("/dev/tty.usbmodem12345", true), + port("/dev/cu.usbmodem12345", true), + port("/dev/cu.Bluetooth-Incoming-Port", false), + ]; + assert_eq!( + names(narrow_to_candidates(ports, false)), + vec!["/dev/cu.usbmodem12345"] + ); + } + + #[test] + fn unix_keeps_the_linux_cdc_acm_node() { + let ports = vec![port("/dev/ttyACM0", true), port("/dev/ttyS0", false)]; + assert_eq!( + names(narrow_to_candidates(ports, false)), + vec!["/dev/ttyACM0"] + ); + } + + #[test] + fn windows_com_ports_survive_the_unix_name_filter() { + // The bug: COMn matches neither `cu.usbmodem` nor `ttyACM`, so the + // Teensy's two ports were filtered out before any probe could run. + let ports = vec![port("COM3", true), port("COM4", true)]; + assert_eq!( + names(narrow_to_candidates(ports, true)), + vec!["COM3", "COM4"] + ); + } + + #[test] + fn windows_drops_non_usb_ports_when_a_usb_port_exists() { + let ports = vec![port("COM1", false), port("COM7", true)]; + assert_eq!(names(narrow_to_candidates(ports, true)), vec!["COM7"]); + } + + #[test] + fn windows_probes_everything_when_the_os_classifies_nothing() { + let ports = vec![port("COM1", false), port("COM3", false)]; + assert_eq!( + names(narrow_to_candidates(ports, true)), + vec!["COM1", "COM3"] + ); + } + + #[test] + fn a_labelled_port_shows_its_usb_name_in_the_picker() { + let labelled = PortInfo { + name: "COM3".to_owned(), + label: Some("Teensyduino Dual Serial".to_owned()), + is_usb: true, + }; + assert_eq!(labelled.variant(), "COM3 (Teensyduino Dual Serial)"); + assert_eq!(port("COM4", true).variant(), "COM4"); + } +} diff --git a/stage-a-io/src/wire.rs b/stage-a-io/src/wire.rs new file mode 100644 index 0000000..dd7e827 --- /dev/null +++ b/stage-a-io/src/wire.rs @@ -0,0 +1,553 @@ +//! PDA1 binary wire format (Teensy → host). +//! +//! Mirrors `stage-a-controller/include/wire_protocol.h` exactly: a packed +//! 36-byte little-endian header followed by `payload_bytes` of payload, +//! integrity-protected by CRC32 (IEEE, reflected) over the zeroed-CRC header +//! plus payload. The host must tolerate arbitrary USB fragmentation and +//! resynchronise at the next valid magic + CRC. + +/// `"PDA1"` interpreted as a little-endian `u32`. +pub const MAGIC: u32 = 0x3141_4450; +pub const PROTOCOL_VERSION: u8 = 1; +pub const HEADER_BYTES: usize = 36; + +/// Maximum payload the parser will attempt to buffer. Larger claimed sizes +/// are treated as corruption and trigger resynchronisation. +pub const MAX_PAYLOAD_BYTES: usize = 1 << 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameType { + Control, + SamplesU16, + Summary, + Marker, + Unknown(u8), +} + +impl FrameType { + pub fn from_raw(raw: u8) -> Self { + match raw { + 1 => Self::Control, + 2 => Self::SamplesU16, + 3 => Self::Summary, + 4 => Self::Marker, + other => Self::Unknown(other), + } + } + + pub fn to_raw(self) -> u8 { + match self { + Self::Control => 1, + Self::SamplesU16 => 2, + Self::Summary => 3, + Self::Marker => 4, + Self::Unknown(other) => other, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameHeader { + pub version: u8, + pub frame_type: FrameType, + pub flags: u16, + pub sequence: u32, + pub payload_bytes: u32, + pub first_sample_index: u64, + pub sample_rate_hz: u32, + pub dropped_samples: u32, + pub crc32: u32, +} + +impl FrameHeader { + pub fn parse(bytes: &[u8; HEADER_BYTES]) -> Option { + let magic = u32::from_le_bytes(bytes[0..4].try_into().ok()?); + if magic != MAGIC { + return None; + } + // Unknown protocol versions are corruption, not future frames: the + // reference host parser resynchronises past them byte by byte. + if bytes[4] != PROTOCOL_VERSION { + return None; + } + Some(Self { + version: bytes[4], + frame_type: FrameType::from_raw(bytes[5]), + flags: u16::from_le_bytes(bytes[6..8].try_into().ok()?), + sequence: u32::from_le_bytes(bytes[8..12].try_into().ok()?), + payload_bytes: u32::from_le_bytes(bytes[12..16].try_into().ok()?), + first_sample_index: u64::from_le_bytes(bytes[16..24].try_into().ok()?), + sample_rate_hz: u32::from_le_bytes(bytes[24..28].try_into().ok()?), + dropped_samples: u32::from_le_bytes(bytes[28..32].try_into().ok()?), + crc32: u32::from_le_bytes(bytes[32..36].try_into().ok()?), + }) + } + + pub fn encode(&self) -> [u8; HEADER_BYTES] { + let mut out = [0_u8; HEADER_BYTES]; + out[0..4].copy_from_slice(&MAGIC.to_le_bytes()); + out[4] = self.version; + out[5] = self.frame_type.to_raw(); + out[6..8].copy_from_slice(&self.flags.to_le_bytes()); + out[8..12].copy_from_slice(&self.sequence.to_le_bytes()); + out[12..16].copy_from_slice(&self.payload_bytes.to_le_bytes()); + out[16..24].copy_from_slice(&self.first_sample_index.to_le_bytes()); + out[24..28].copy_from_slice(&self.sample_rate_hz.to_le_bytes()); + out[28..32].copy_from_slice(&self.dropped_samples.to_le_bytes()); + out[32..36].copy_from_slice(&self.crc32.to_le_bytes()); + out + } +} + +/// One complete, CRC-verified frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub header: FrameHeader, + pub payload: Vec, +} + +impl Frame { + /// Builds a frame with a freshly computed CRC (mock/firmware side). + pub fn build(mut header: FrameHeader, payload: Vec) -> Self { + header.payload_bytes = payload.len() as u32; + header.crc32 = frame_crc(&header, &payload); + Self { header, payload } + } + + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(HEADER_BYTES + self.payload.len()); + out.extend_from_slice(&self.header.encode()); + out.extend_from_slice(&self.payload); + out + } + + /// Decodes the payload of a `Summary` frame. + pub fn summary(&self) -> Option { + if self.header.frame_type != FrameType::Summary || self.payload.len() != 24 { + return None; + } + let p = &self.payload; + Some(SummaryPayload { + min_code: u16::from_le_bytes(p[0..2].try_into().ok()?), + max_code: u16::from_le_bytes(p[2..4].try_into().ok()?), + sample_count: u32::from_le_bytes(p[4..8].try_into().ok()?), + sum_codes: u64::from_le_bytes(p[8..16].try_into().ok()?), + first_tick_us: u32::from_le_bytes(p[16..20].try_into().ok()?), + last_tick_us: u32::from_le_bytes(p[20..24].try_into().ok()?), + }) + } + + /// Decodes the payload of a `SamplesU16` frame into ADC codes. + pub fn samples(&self) -> Option> { + if self.header.frame_type != FrameType::SamplesU16 || !self.payload.len().is_multiple_of(2) + { + return None; + } + Some( + self.payload + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(), + ) + } + + /// The ASCII payload of a `Control` frame. + pub fn control_text(&self) -> Option<&str> { + if self.header.frame_type != FrameType::Control { + return None; + } + std::str::from_utf8(&self.payload).ok() + } + + /// Decodes the payload of a `Marker` frame (phase-0 fiducial on the device + /// clock). + pub fn marker(&self) -> Option { + if self.header.frame_type != FrameType::Marker || self.payload.len() != 16 { + return None; + } + let p = &self.payload; + Some(MarkerPayload { + sample_index: u64::from_le_bytes(p[0..8].try_into().ok()?), + tick_us: u32::from_le_bytes(p[8..12].try_into().ok()?), + level: p[12], + source: p[13], + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SummaryPayload { + pub min_code: u16, + pub max_code: u16, + pub sample_count: u32, + pub sum_codes: u64, + pub first_tick_us: u32, + pub last_tick_us: u32, +} + +impl SummaryPayload { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(24); + out.extend_from_slice(&self.min_code.to_le_bytes()); + out.extend_from_slice(&self.max_code.to_le_bytes()); + out.extend_from_slice(&self.sample_count.to_le_bytes()); + out.extend_from_slice(&self.sum_codes.to_le_bytes()); + out.extend_from_slice(&self.first_tick_us.to_le_bytes()); + out.extend_from_slice(&self.last_tick_us.to_le_bytes()); + out + } + + pub fn mean_code(&self) -> f64 { + if self.sample_count == 0 { + return 0.0; + } + self.sum_codes as f64 / f64::from(self.sample_count) + } +} + +/// A `Marker` frame payload: a phase-0 fiducial stamped on the device clock +/// (matches the firmware `MarkerPayload`; `source = 1` is a modulation phase-0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MarkerPayload { + /// ADC sample index at the fiducial — aligns the marker with the stream. + pub sample_index: u64, + pub tick_us: u32, + pub level: u8, + pub source: u8, +} + +/// `source` value the firmware stamps on a modulation phase-0 marker. +pub const MARKER_SOURCE_PHASE0: u8 = 1; +/// `source` value the firmware stamps on an A2 optical comparator crossing. +pub const MARKER_SOURCE_COMPARATOR: u8 = 2; + +impl MarkerPayload { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(16); + out.extend_from_slice(&self.sample_index.to_le_bytes()); + out.extend_from_slice(&self.tick_us.to_le_bytes()); + out.push(self.level); + out.push(self.source); + out.extend_from_slice(&[0_u8, 0_u8]); // reserved[2] + out + } +} + +/// CRC32 (IEEE, reflected, init/final 0xFFFF_FFFF) — identical to the +/// firmware's `crc32Update` loop. +pub fn crc32(data: &[u8]) -> u32 { + crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF +} + +/// Streaming CRC32 with the same parameters as [`crc32`], for hashing data +/// that is not held in memory at once (e.g. the PDQ file writer). +#[derive(Debug, Clone, Copy)] +pub struct Crc32 { + state: u32, +} + +impl Default for Crc32 { + fn default() -> Self { + Self { state: 0xFFFF_FFFF } + } +} + +impl Crc32 { + pub fn update(&mut self, data: &[u8]) { + self.state = crc32_update(self.state, data); + } + + pub fn finalize(self) -> u32 { + self.state ^ 0xFFFF_FFFF + } +} + +fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { + for &byte in data { + crc ^= u32::from(byte); + for _ in 0..8 { + let mask = 0_u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + crc +} + +/// CRC over the zeroed-CRC header plus payload (firmware `frameCrc`). +pub fn frame_crc(header: &FrameHeader, payload: &[u8]) -> u32 { + let mut zeroed = *header; + zeroed.crc32 = 0; + let mut crc = 0xFFFF_FFFF_u32; + crc = crc32_update(crc, &zeroed.encode()); + crc = crc32_update(crc, payload); + crc ^ 0xFFFF_FFFF +} + +/// What the incremental parser reports for each recovered unit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseEvent { + Frame(Frame), + /// Bytes were skipped or a frame failed its CRC — the stream stays + /// usable, but the run must be flagged invalid. + Corruption { + skipped_bytes: usize, + crc_failures: usize, + }, +} + +/// Incremental PDA1 parser tolerating arbitrary fragmentation. +/// +/// Feed raw serial bytes with [`FrameParser::extend`], then drain complete +/// frames with [`FrameParser::next_event`]. On a bad magic the parser skips +/// forward one byte at a time; on a bad CRC it discards the candidate header +/// and rescans from the next byte, so a corrupted stream re-locks at the +/// next genuine frame boundary. +#[derive(Debug, Default)] +pub struct FrameParser { + buffer: Vec, + skipped_bytes: usize, + crc_failures: usize, +} + +impl FrameParser { + pub fn extend(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + /// Bytes retained while waiting for a complete header or payload. + /// Primarily useful at a finite-file EOF, where a nonzero value means + /// the PDQ ends with a truncated frame or garbage tail. + pub fn buffered_len(&self) -> usize { + self.buffer.len() + } + + /// Discards and returns the number of bytes still buffered. Live serial + /// readers normally never need this; finite-file readers use it once at + /// EOF to report a truncated tail without exposing parser internals. + pub fn discard_buffered(&mut self) -> usize { + let len = self.buffer.len(); + self.buffer.clear(); + len + } + + pub fn next_event(&mut self) -> Option { + loop { + // Scan to the next plausible magic. + let mut offset = 0; + while self.buffer.len() >= offset + 4 + && u32::from_le_bytes(self.buffer[offset..offset + 4].try_into().unwrap()) != MAGIC + { + offset += 1; + } + if offset > 0 { + self.buffer.drain(..offset); + self.skipped_bytes += offset; + } + + if self.buffer.len() < HEADER_BYTES { + return self.take_corruption(); + } + + let header_bytes: [u8; HEADER_BYTES] = self.buffer[..HEADER_BYTES].try_into().unwrap(); + let Some(header) = FrameHeader::parse(&header_bytes) else { + // Magic matched but parse failed (cannot happen today, but + // stay defensive): skip one byte and rescan. + self.buffer.drain(..1); + self.skipped_bytes += 1; + continue; + }; + + let payload_bytes = header.payload_bytes as usize; + if payload_bytes > MAX_PAYLOAD_BYTES { + self.buffer.drain(..1); + self.skipped_bytes += 1; + continue; + } + if self.buffer.len() < HEADER_BYTES + payload_bytes { + // Wait for more bytes; report any corruption noticed so far. + return self.take_corruption(); + } + + let payload = self.buffer[HEADER_BYTES..HEADER_BYTES + payload_bytes].to_vec(); + if frame_crc(&header, &payload) != header.crc32 { + self.crc_failures += 1; + self.buffer.drain(..1); + self.skipped_bytes += 1; + continue; + } + + self.buffer.drain(..HEADER_BYTES + payload_bytes); + if let Some(corruption) = self.take_corruption() { + // Deliver the corruption notice first; the verified frame is + // still buffered as raw bytes, so re-parse it next call. + let frame = Frame { header, payload }; + let mut bytes = frame.to_bytes(); + bytes.extend_from_slice(&self.buffer); + self.buffer = bytes; + return Some(corruption); + } + return Some(ParseEvent::Frame(Frame { header, payload })); + } + } + + fn take_corruption(&mut self) -> Option { + if self.skipped_bytes == 0 && self.crc_failures == 0 { + return None; + } + let event = ParseEvent::Corruption { + skipped_bytes: self.skipped_bytes, + crc_failures: self.crc_failures, + }; + self.skipped_bytes = 0; + self.crc_failures = 0; + Some(event) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn control_frame(sequence: u32, text: &str) -> Frame { + Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::Control, + flags: 0, + sequence, + payload_bytes: 0, + first_sample_index: 0, + sample_rate_hz: 0, + dropped_samples: 0, + crc32: 0, + }, + text.as_bytes().to_vec(), + ) + } + + #[test] + fn round_trips_a_frame_through_arbitrary_fragmentation() { + let frame = control_frame(7, "+7 OK state=SAFE_IDLE"); + let bytes = frame.to_bytes(); + + let mut parser = FrameParser::default(); + for chunk in bytes.chunks(3) { + parser.extend(chunk); + } + assert_eq!(parser.next_event(), Some(ParseEvent::Frame(frame))); + assert_eq!(parser.next_event(), None); + } + + #[test] + fn resynchronises_after_garbage_and_reports_corruption() { + let frame = control_frame(1, "+1 OK"); + let mut bytes = b"garbage!".to_vec(); + bytes.extend_from_slice(&frame.to_bytes()); + + let mut parser = FrameParser::default(); + parser.extend(&bytes); + assert_eq!( + parser.next_event(), + Some(ParseEvent::Corruption { + skipped_bytes: 8, + crc_failures: 0 + }) + ); + assert_eq!(parser.next_event(), Some(ParseEvent::Frame(frame))); + } + + #[test] + fn detects_crc_corruption_and_relocks_on_next_frame() { + let bad = control_frame(1, "+1 OK"); + let good = control_frame(2, "!STATUS state=RUNNING"); + let mut bytes = bad.to_bytes(); + let len = bytes.len(); + bytes[len - 1] ^= 0xFF; // corrupt payload -> CRC mismatch + bytes.extend_from_slice(&good.to_bytes()); + + let mut parser = FrameParser::default(); + parser.extend(&bytes); + let corruption = parser.next_event(); + match corruption { + Some(ParseEvent::Corruption { crc_failures, .. }) => assert!(crc_failures >= 1), + other => panic!("expected corruption, got {other:?}"), + } + assert_eq!(parser.next_event(), Some(ParseEvent::Frame(good))); + } + + #[test] + fn summary_payload_round_trips() { + let summary = SummaryPayload { + min_code: 12, + max_code: 3_900, + sample_count: 256, + sum_codes: 500_000, + first_tick_us: 1_000, + last_tick_us: 13_800, + }; + let frame = Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::Summary, + flags: 0, + sequence: 5, + payload_bytes: 0, + first_sample_index: 4_096, + sample_rate_hz: 20_000, + dropped_samples: 0, + crc32: 0, + }, + summary.encode(), + ); + assert_eq!(frame.summary(), Some(summary)); + assert!((summary.mean_code() - 1953.125).abs() < 1e-9); + } + + #[test] + fn marker_payload_round_trips() { + let marker = MarkerPayload { + sample_index: 1_234_567, + tick_us: 987_654, + level: 1, + source: MARKER_SOURCE_PHASE0, + }; + let frame = Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::Marker, + flags: 0, + sequence: 9, + payload_bytes: 0, + first_sample_index: 0, + sample_rate_hz: 20_000, + dropped_samples: 0, + crc32: 0, + }, + marker.encode(), + ); + assert_eq!(frame.marker(), Some(marker)); + // A samples decode must not accept a marker frame. + assert_eq!(frame.samples(), None); + } + + #[test] + fn samples_frame_decodes_codes() { + let codes = [1_u16, 2, 4_095]; + let payload: Vec = codes.iter().flat_map(|c| c.to_le_bytes()).collect(); + let frame = Frame::build( + FrameHeader { + version: PROTOCOL_VERSION, + frame_type: FrameType::SamplesU16, + flags: 0, + sequence: 9, + payload_bytes: 0, + first_sample_index: 0, + sample_rate_hz: 20_000, + dropped_samples: 0, + crc32: 0, + }, + payload, + ); + assert_eq!(frame.samples(), Some(codes.to_vec())); + } +} diff --git a/stage-a-io/src/worker.rs b/stage-a-io/src/worker.rs new file mode 100644 index 0000000..57ffaee --- /dev/null +++ b/stage-a-io/src/worker.rs @@ -0,0 +1,229 @@ +//! Bounded background I/O worker. +//! +//! The owning plugin's `process_frame()` must never block on serial: it only +//! drains this worker's bounded output queue and pushes bounded requests. +//! The worker thread owns the [`StageAClient`] (and thereby the serial +//! port), sends `PING` at 2 Hz while the controller is armed/running, and +//! requests `STOP` on shutdown. Firmware safety does not depend on that +//! STOP arriving — the on-device watchdog falls back to `SAFE_IDLE` — but a +//! clean stop is always attempted. + +use std::collections::BTreeMap; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crate::client::{ClientError, DeviceEvent, StageAClient, StreamIntegrity}; +use crate::protocol::Command; +use crate::transport::Transport; + +pub const COMMAND_QUEUE_DEPTH: usize = 16; +pub const OUTPUT_QUEUE_DEPTH: usize = 256; +const PING_INTERVAL: Duration = Duration::from_millis(500); +const IDLE_POLL: Duration = Duration::from_millis(5); + +/// Requests the plugin can queue for the worker. +#[derive(Debug, Clone)] +pub enum WorkerRequest { + /// Send a command and report its reply (or error) as a `Reply` output. + Send { tag: u64, command: Command }, + /// Enable/disable the 2 Hz watchdog ping (armed/running phases). + SetPinging(bool), + /// Stop the controller and shut the worker down. + Shutdown { reason: String }, +} + +/// Bounded outputs the plugin drains from `process_frame()`. +#[derive(Debug)] +pub enum WorkerOutput { + Reply { + tag: u64, + result: Result, String>, + }, + Event(DeviceEvent), + Integrity(StreamIntegrity), + /// The worker exited (clean shutdown or transport failure). + Stopped { + reason: String, + }, +} + +pub struct IoWorker { + requests: SyncSender, + outputs: Receiver, + join: Option>, +} + +impl IoWorker { + /// Spawns the worker over an already-open transport. Opening the + /// transport (and failing visibly if the device is busy) is the + /// caller's responsibility, in `LiveCapture` with effects allowed only. + pub fn spawn(client: StageAClient) -> Self { + let (request_tx, request_rx) = std::sync::mpsc::sync_channel(COMMAND_QUEUE_DEPTH); + let (output_tx, output_rx) = std::sync::mpsc::sync_channel(OUTPUT_QUEUE_DEPTH); + let join = std::thread::Builder::new() + .name("stage-a-io".into()) + .spawn(move || run_worker(client, request_rx, output_tx)) + .expect("spawning the stage-a I/O thread must succeed"); + Self { + requests: request_tx, + outputs: output_rx, + join: Some(join), + } + } + + /// Non-blocking enqueue; a full queue is a visible error, not a stall. + pub fn try_send(&self, request: WorkerRequest) -> Result<(), String> { + self.requests.try_send(request).map_err(|err| match err { + TrySendError::Full(_) => "stage-a I/O command queue is full".to_owned(), + TrySendError::Disconnected(_) => "stage-a I/O worker is gone".to_owned(), + }) + } + + /// Drains everything currently queued, without blocking. + pub fn drain_outputs(&self) -> Vec { + let mut out = Vec::new(); + while let Ok(output) = self.outputs.try_recv() { + out.push(output); + } + out + } + + /// Requests a controller STOP and joins the worker. + pub fn shutdown(mut self, reason: &str) { + let _ = self.requests.try_send(WorkerRequest::Shutdown { + reason: reason.to_owned(), + }); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +impl Drop for IoWorker { + fn drop(&mut self) { + let _ = self.requests.try_send(WorkerRequest::Shutdown { + reason: "worker dropped".to_owned(), + }); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +fn run_worker( + mut client: StageAClient, + requests: Receiver, + outputs: SyncSender, +) { + let mut pinging = false; + let mut last_ping = Instant::now(); + let mut last_integrity = client.integrity(); + + let stop_reason = loop { + match requests.recv_timeout(IDLE_POLL) { + Ok(WorkerRequest::Send { tag, command }) => { + let result = client + .request(&command) + .map_err(|err: ClientError| err.to_string()); + if outputs + .try_send(WorkerOutput::Reply { tag, result }) + .is_err() + { + break "output queue closed".to_owned(); + } + } + Ok(WorkerRequest::SetPinging(enabled)) => { + pinging = enabled; + last_ping = Instant::now(); + } + Ok(WorkerRequest::Shutdown { reason }) => break reason, + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break "request queue closed".to_owned(), + } + + match client.poll_events() { + Ok(events) => { + for event in events { + // Bounded best-effort delivery: a full output queue drops + // live telemetry, never blocks the serial loop. Exact + // data is preserved by the PDQ writer downstream of the + // worker owner, which uses Reply-driven flow instead. + let _ = outputs.try_send(WorkerOutput::Event(event)); + } + } + Err(err) => { + let _ = outputs.try_send(WorkerOutput::Reply { + tag: 0, + result: Err(err.to_string()), + }); + break "transport failure".to_owned(); + } + } + + let integrity = client.integrity(); + if integrity != last_integrity { + last_integrity = integrity; + let _ = outputs.try_send(WorkerOutput::Integrity(integrity)); + } + + if pinging && last_ping.elapsed() >= PING_INTERVAL { + last_ping = Instant::now(); + let _ = client.request(&Command::new("PING")); + } + }; + + // Best-effort clean stop; the firmware watchdog is the real guarantee. + let _ = client.request(&Command::new("STOP").field("reason", stop_reason.replace(' ', "_"))); + let _ = outputs.try_send(WorkerOutput::Stopped { + reason: stop_reason, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::MockController; + use crate::transport::MockLink; + + #[test] + fn worker_round_trips_commands_and_stops_cleanly() { + let link = MockLink::new(); + let mut controller = MockController::new(link.device_end()); + let client = + StageAClient::new(link.host_end()).with_reply_timeout(Duration::from_millis(100)); + let worker = IoWorker::spawn(client); + + // HELLO via the worker, served by the mock on this thread. + worker + .try_send(WorkerRequest::Send { + tag: 1, + command: Command::new("HELLO").field("protocol", 1), + }) + .expect("enqueue"); + controller.serve_n_commands(1); + + let deadline = Instant::now() + Duration::from_secs(1); + let mut reply_seen = false; + while Instant::now() < deadline && !reply_seen { + for output in worker.drain_outputs() { + if let WorkerOutput::Reply { tag: 1, result } = output { + let fields = result.expect("HELLO succeeds"); + assert_eq!(fields.get("protocol").map(String::as_str), Some("1")); + reply_seen = true; + } + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(reply_seen, "HELLO reply must reach the plugin queue"); + + // Shutdown must send STOP to the controller. + let handle = std::thread::spawn(move || { + controller.serve_n_commands(1); + controller + }); + worker.shutdown("test done"); + let controller = handle.join().expect("mock joins"); + assert_eq!(controller.state(), crate::mock::MockState::SafeIdle); + } +} diff --git a/stage-a-plugin-contract/Cargo.toml b/stage-a-plugin-contract/Cargo.toml new file mode 100644 index 0000000..9446f85 --- /dev/null +++ b/stage-a-plugin-contract/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stage-a-plugin-contract" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Mika Uthmann "] +description = "Serde-only inter-plugin control and status contract for the Stage-A device-owner plugins" + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" + +[lints.rust] +unsafe_code = "forbid" + diff --git a/stage-a-plugin-contract/README.md b/stage-a-plugin-contract/README.md new file mode 100644 index 0000000..735434c --- /dev/null +++ b/stage-a-plugin-contract/README.md @@ -0,0 +1,47 @@ +# Stage-A Plugin Contract + +This crate is the serde-only control-plane contract between the Stage-A workflow plugins and the +two plugins that permanently own the Teensy ports: + +- `stage-a-modulation` owns and controls the command port; +- `stage-a-photodiode` owns and reads the PDA1 stream port; +- experiment plugins such as `stage-a-a1` orchestrate those owners without opening either port. + +The crate deliberately has no Augur, serial, filesystem, or thread dependency. Its payloads can be +serialized through the host's persistent plugin context. Every context key and payload is +explicitly versioned. A request carries a unique request ID, the target owner instance, an +optional lease and run ID, and an optional requested semantic revision. Responses echo those +identities and report the ACKed revision. + +## Mailboxes + +| Direction | Context key | +|---|---| +| orchestrator → modulation owner | `stage_a.modulation_request.v1` | +| modulation owner → orchestrator | `stage_a.modulation_response.v1` | +| modulation owner snapshot | `stage_a.modulation_state.v1` | +| orchestrator → photodiode owner | `stage_a.photodiode_request.v1` | +| photodiode owner → orchestrator | `stage_a.photodiode_response.v1` | +| photodiode owner snapshot | `stage_a.photodiode_summary.v1` | + +Persistent context is a last-writer-wins mailbox, not a queue. An orchestrator must keep at most +one outstanding request per owner, retain it until its request ID is acknowledged, and never +reuse a request ID. Owners must make duplicate delivery idempotent by returning the original +result without repeating the effect. + +## Safety and data boundaries + +Control commands are semantic (`PrepareA1`, `SafeOff`, `BeginRecording`, and so on), not raw +firmware strings or remote setting changes. Automated mutations require an owner-issued lease; +leases expire unless renewed. Owner snapshots carry an instance ID and freshness deadline so an +orchestrator can detect reloads and stale state. + +Photodiode messages contain only bounded summaries and named PDQ receipts. Raw ADC arrays never +cross the JSON context. The finalized receipt names the PDQ/sidecar, SHA-256, byte and frame +counts, contiguous sample range, stream integrity, and validity. Analysis reads the finalized PDQ +through `stage-a-io`. + +`SynchronizationV1::Unsynced` is a first-class state. Missing firmware configuration revisions, +owner restarts, stream-epoch changes, stale snapshots, or run-ID mismatches must be reported as +UNSYNCED rather than inferred away. + diff --git a/stage-a-plugin-contract/src/csv.rs b/stage-a-plugin-contract/src/csv.rs new file mode 100644 index 0000000..471bdd0 --- /dev/null +++ b/stage-a-plugin-contract/src/csv.rs @@ -0,0 +1,62 @@ +//! Minimal CSV record splitting, shared by the protocol reader and the sensor +//! readout compactor. +//! +//! Deliberately not a CSV *library*: both callers read small files this plugin +//! or the host wrote, and both locate their columns by header name rather than +//! by position. What is actually needed is one correct field splitter — the +//! doubled-quote escaping the host emits, and a quoted `label` in a +//! hand-written protocol, are the only cases that are not `split(',')`. + +/// Splits one CSV record, honouring `"…"` quoting and `""` as an escaped quote. +pub fn split_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut chars = line.chars().peekable(); + while let Some(character) = chars.next() { + match character { + '"' if quoted => { + if chars.peek() == Some(&'"') { + current.push('"'); + chars.next(); + } else { + quoted = false; + } + } + '"' => quoted = true, + ',' if !quoted => fields.push(std::mem::take(&mut current)), + other => current.push(other), + } + } + fields.push(current); + fields +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_fields_split_on_commas() { + assert_eq!(split_line("a,b,c"), vec!["a", "b", "c"]); + assert_eq!(split_line("a,,c"), vec!["a", "", "c"]); + assert_eq!(split_line(""), vec![""]); + } + + #[test] + fn quoted_fields_keep_their_commas() { + assert_eq!(split_line("a,\"b,c\",d"), vec!["a", "b,c", "d"]); + } + + #[test] + fn doubled_quotes_are_one_literal_quote() { + assert_eq!(split_line("\"a\"\"b\",c"), vec!["a\"b", "c"]); + } + + #[test] + fn an_unterminated_quote_takes_the_rest_of_the_line() { + // Better than dropping the row: the caller validates the fields it + // needs, and a truncated line is reported there with its line number. + assert_eq!(split_line("a,\"b,c"), vec!["a", "b,c"]); + } +} diff --git a/stage-a-plugin-contract/src/lib.rs b/stage-a-plugin-contract/src/lib.rs new file mode 100644 index 0000000..03e766f --- /dev/null +++ b/stage-a-plugin-contract/src/lib.rs @@ -0,0 +1,1123 @@ +//! Versioned, serde-only messages shared by Stage-A experiment workflows and +//! the two persistent Teensy device-owner plugins, plus the small pure helpers +//! more than one Stage-A workflow needs ([`telemetry`], [`csv`]). +//! +//! This crate intentionally contains no Augur ABI types, serial transports, +//! filesystem access, raw ADC arrays, or experiment state machines. Every +//! experiment plugin exports `augur_plugin_vtable`, so shared code cannot live +//! in one of them and be linked by another — it lives here, in a plain library +//! that exports no vtable at all (ADR 031). + +#![forbid(unsafe_code)] + +pub mod csv; +pub mod telemetry; + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +pub const CONTRACT_VERSION_V1: u16 = 1; + +/// Firmware-qualified periodic-drive range. These values mirror +/// `stage-a-controller/include/board_config.h`; all Rust-side UI, service and +/// protocol validation uses this one definition rather than duplicating the +/// literals. The connected firmware remains authoritative and rejects outside +/// this range as well. +pub const DRIVE_FREQUENCY_MIN_MILLIHZ: u64 = 10; +pub const DRIVE_FREQUENCY_MAX_MILLIHZ: u64 = 2_000_000; +pub const DRIVE_DAC_UPDATE_RATE_HZ: u32 = 40_000; +/// Minimum sample density for an A1 photodiode waveform measurement. Nyquist +/// alone only proves non-aliasing; 16 samples/cycle is the project's minimum +/// shape-resolution acceptance threshold. +pub const A1_MIN_SAMPLES_PER_CYCLE: u32 = 16; + +pub fn drive_frequency_supported(frequency_millihz: u64) -> bool { + (DRIVE_FREQUENCY_MIN_MILLIHZ..=DRIVE_FREQUENCY_MAX_MILLIHZ).contains(&frequency_millihz) +} + +pub fn a1_measurement_frequency_limit_hz(sample_rate_hz: u32) -> f64 { + f64::from(sample_rate_hz) / f64::from(A1_MIN_SAMPLES_PER_CYCLE) +} + +pub const PLUGIN_ID_STAGE_A_MODULATION: &str = "stage-a.modulation"; +pub const PLUGIN_ID_STAGE_A_PHOTODIODE: &str = "stage-a.photodiode"; +pub const SERVICE_STAGE_A_MODULATION_CONTROL_V1: &str = "stage_a.modulation.control.v1"; +pub const SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1: &str = "stage_a.photodiode.control.v1"; + +pub const CTX_STAGE_A_MODULATION_REQUEST_V1: &str = "stage_a.modulation_request.v1"; +pub const CTX_STAGE_A_MODULATION_RESPONSE_V1: &str = "stage_a.modulation_response.v1"; +pub const CTX_STAGE_A_MODULATION_STATE_V1: &str = "stage_a.modulation_state.v1"; +pub const CTX_STAGE_A_PHOTODIODE_REQUEST_V1: &str = "stage_a.photodiode_request.v1"; +pub const CTX_STAGE_A_PHOTODIODE_RESPONSE_V1: &str = "stage_a.photodiode_response.v1"; +pub const CTX_STAGE_A_PHOTODIODE_SUMMARY_V1: &str = "stage_a.photodiode_summary.v1"; + +macro_rules! string_id { + ($name:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub String); + + impl $name { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +string_id!(ClientId); +string_id!(LeaseId); +string_id!(OwnerInstanceId); +string_id!(RunId); + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] +#[serde(transparent)] +pub struct RequestId(pub u64); + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] +#[serde(transparent)] +pub struct SemanticRevision(pub u64); + +/// Wall-clock freshness information transferable between dynamic plugins. +/// The consumer determines staleness against its current Unix time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FreshnessV1 { + pub observed_at_unix_ms: u64, + pub valid_for_ms: u64, +} + +impl FreshnessV1 { + pub fn is_stale_at(self, now_unix_ms: u64) -> bool { + now_unix_ms.saturating_sub(self.observed_at_unix_ms) > self.valid_for_ms + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ConnectionStateV1 { + Disconnected, + Connecting, + Connected { + port_label: String, + firmware_version: Option, + }, + Faulted { + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeaseSnapshotV1 { + pub lease_id: LeaseId, + pub holder: ClientId, + pub expires_at_unix_ms: u64, + pub run_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnsyncedReasonV1 { + NoOwnerSnapshot, + OwnerRestarted, + StaleSnapshot, + NoLease, + LeaseMismatch, + RunMismatch, + RequestedRevisionNotAcknowledged, + FirmwareRevisionUnavailable, + StreamEpochChanged, + DeviceFault, + Other(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SynchronizationV1 { + Synced { + run_id: RunId, + acknowledged_revision: SemanticRevision, + stream_epoch: Option, + }, + Unsynced { + reason: UnsyncedReasonV1, + detail: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceErrorCodeV1 { + ContractVersion, + WrongOwnerInstance, + StaleRequest, + DuplicateRequestConflict, + NotConnected, + LeaseRequired, + LeaseBusy, + LeaseMismatch, + LeaseExpired, + UnsafeExecutionContext, + InvalidCommand, + InvalidPath, + DeviceRejected, + Transport, + Io, + Integrity, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceErrorV1 { + pub code: ServiceErrorCodeV1, + pub message: String, + pub retryable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestOutcomeV1 { + InProgress, + Applied, + Rejected, +} + +/// Common request envelope. The command-specific aliases below are the +/// public mailbox payloads. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestEnvelopeV1 { + pub contract_version: u16, + pub request_id: RequestId, + pub requester: ClientId, + /// `None` is allowed only for discovery/connect or first lease acquire. + pub target_owner_instance: Option, + pub lease_id: Option, + pub run_id: Option, + pub requested_revision: Option, + pub issued_at_unix_ms: u64, + pub command: C, +} + +impl RequestEnvelopeV1 { + pub fn new(request_id: RequestId, requester: ClientId, command: C) -> Self { + Self { + contract_version: CONTRACT_VERSION_V1, + request_id, + requester, + target_owner_instance: None, + lease_id: None, + run_id: None, + requested_revision: None, + issued_at_unix_ms: 0, + command, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResponseCommonV1 { + pub contract_version: u16, + pub request_id: RequestId, + pub owner_instance: OwnerInstanceId, + pub run_id: Option, + pub requested_revision: Option, + pub acknowledged_revision: Option, + pub outcome: RequestOutcomeV1, + pub completed_at_unix_ms: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PeriodicWaveformV1 { + Sine, + Square, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WaveformV1 { + Off, + Constant { + level_dac: u16, + }, + Periodic { + waveform: PeriodicWaveformV1, + min_dac: u16, + max_dac: u16, + frequency_millihz: u64, + }, +} + +/// Complete semantic configuration for one A1 controller acquisition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct A1AcquisitionConfigV1 { + pub waveform: PeriodicWaveformV1, + pub frequency_millihz: u64, + pub center_dac: u16, + pub amplitude_dac: u16, + pub sample_rate_hz: u32, + pub block_samples: u32, + pub emit_raw_samples: bool, + pub emit_summary: bool, + pub optical_lut_id: Option, +} + +/// Complete, firmware-level configuration for one A2 step-latency point. +/// +/// The optical coordinates are calibrated lobe coordinates, not physical +/// photon flux. `min_half_us` is a precomputed safety floor from the qualified +/// A1/A5 timing bounds; the firmware enforces it and never guesses it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct A2AcquisitionConfigV1 { + pub mean_u_milli: u32, + pub depth_a_milli: u32, + pub frequency_millihz: u64, + pub min_half_us: u32, + pub v_null_dac: u16, + pub v_peak_dac: u16, + pub comparator_threshold_dac: u16, + pub comparator_hysteresis: u8, + pub comparator_invert: bool, + pub sample_rate_hz: u32, + pub block_samples: u32, + pub emit_raw_samples: bool, + pub emit_summary: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ModulationCommandV1 { + Connect, + Disconnect { + safe_off: bool, + reason: String, + }, + AcquireLease { + ttl_ms: u64, + }, + RenewLease { + ttl_ms: u64, + }, + ReleaseLease { + safe_off: bool, + reason: String, + }, + SetWaveform { + waveform: WaveformV1, + }, + /// Retarget the owner's *calibrated optical drive* to a new modulation + /// depth `a` (log contrast, in milli-units) without changing anything else + /// about the armed drive: waveform shape, frequency, requested normalized + /// cycle mean and calibration stay whatever the operator armed in the + /// modulation plugin. + /// This is the scoped amplitude-sweep path (A1 automation): the owner + /// rejects the command when its current drive cannot express `a` + /// (anything other than calibrated `OPTICAL_LOG_SINE` with an identified + /// transfer calibration) or the device link is closed. + SetOpticalDepth { + depth_a_milli: u32, + }, + /// Retarget the armed drive's *frequency*, leaving everything else — the + /// waveform shape, the depth, the operating point and the calibration — as + /// the operator armed it. The frequency counterpart of + /// [`ModulationCommandV1::SetOpticalDepth`], and the same scoping rules + /// apply: leased only, rejected when the link is closed or the armed drive + /// has no frequency to retarget (manual DAC method, constant mode). + /// + /// A1's frequency sweep drives this. The owner parks the operator's armed + /// frequency on the first one and restores it when the lease ends, so a + /// finished sweep does not leave the bench on its last point. + SetDriveFrequency { + frequency_millihz: u64, + }, + /// Retarget the armed drive's *operating point* — the normalized cycle-mean + /// lobe coordinate `ū`, in milli-units — leaving the waveform, depth, + /// frequency and calibration alone. The third axis alongside + /// [`ModulationCommandV1::SetOpticalDepth`] and + /// [`ModulationCommandV1::SetDriveFrequency`], and scoped the same way: + /// leased only, rejected when the link is closed or the armed drive has no + /// operating point to retarget (manual DAC method). + /// + /// This is what makes an `I_k` sweep possible. `ū` is a *normalized* lobe + /// coordinate, not physical flux — but it is the one knob that moves the + /// mean illumination without touching the depth, so a protocol that walks + /// it walks the bench's brightness axis. + /// + /// The owner parks the operator's armed `ū` on the first point and restores + /// it when the lease ends, so a finished sweep does not leave the bench on + /// its last one. + SetOperatingPoint { + mean_u_milli: u32, + }, + PrepareA1 { + configuration: A1AcquisitionConfigV1, + }, + /// Stop the current controller acquisition, enter firmware mode A2, + /// configure the optical log-square and its 50 % comparator, and require + /// the board to echo `trigger_source=comparator` with the comparator armed. + PrepareA2 { + configuration: A2AcquisitionConfigV1, + }, + StartAcquisition, + StopAcquisition { + reason: String, + }, + SafeOff { + reason: String, + }, +} + +pub type ModulationRequestV1 = RequestEnvelopeV1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControllerStateV1 { + Unknown, + SafeIdle, + Configured, + Running, + Faulted, +} + +/// The full desired or board-acknowledged command-port state at one semantic +/// revision. Owners never infer an ACK from the requested state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModulationTargetV1 { + pub revision: SemanticRevision, + pub waveform: Option, + pub a1_configuration: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub a2_configuration: Option, + pub acquisition_running: bool, + pub board_dac_code: Option, + pub firmware_configuration_revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModulationResponseV1 { + #[serde(flatten)] + pub common: ResponseCommonV1, + pub controller_state: ControllerStateV1, + pub acknowledged_target: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OpticalTargetV1 { + LogSine, + LinearSine, +} + +/// Exact optical-inversion parameters currently resolved by the modulation +/// owner. Additive in V1 so A1 sidecars can reproduce the requested drive +/// without misusing physical flux `I_k` for the normalized lobe coordinate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpticalDriveStateV1 { + pub target: OpticalTargetV1, + /// Requested normalized cycle-mean lobe coordinate `ū`. + pub requested_mean_u_milli: u32, + /// Mean reconstructed from the quantized internal wire coordinate. + pub resolved_mean_u_milli: u32, + /// Internal target pedestal/centre sent in the wire's legacy `u_k_milli` + /// field (`u_g` for log-sine, `u_c` for linear-sine). + pub internal_u_milli: u32, + pub depth_a_milli: u32, + /// DAC code at the excitation minimum of the lobe in use. + pub v_null_dac: u16, + /// DAC code at the excitation maximum of the same lobe. + /// + /// An absolute code, like `v_null_dac` — not the half-wave *span* between + /// them, which the earlier `v_pi_dac` field carried. One lobe is named by + /// two codes an operator can point at on the transfer curve, and mixing an + /// absolute code with a distance is exactly the confusion this pair exists + /// to prevent (ADR 016). The span is `v_peak_dac − v_null_dac`. + pub v_peak_dac: u16, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModulationStateV1 { + pub contract_version: u16, + pub owner_instance: OwnerInstanceId, + pub service_revision: u64, + pub connection: ConnectionStateV1, + pub capabilities: Vec, + pub lease: Option, + pub controller_state: ControllerStateV1, + pub active_run_id: Option, + pub requested: Option, + pub acknowledged: Option, + pub synchronization: SynchronizationV1, + pub last_response: Option, + pub freshness: FreshnessV1, + /// Identifier of the measured Pockels transfer calibration currently + /// applied to `V_null`/`Vπ`, so a consumer's sidecar can cite which + /// inversion produced a run's optical depth. `None` means the operator + /// entered the lobe parameters by hand. Additive in V1. + #[serde(default)] + pub calibration_id: Option, + /// Additive V1 optical-drive provenance. + #[serde(default)] + pub optical_drive: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct StreamIntegrityV1 { + pub skipped_bytes: u64, + pub crc_failures: u64, + pub sequence_gaps: u64, + pub dropped_samples: u64, + pub segment_restarts: u64, + pub truncated_bytes: u64, +} + +impl StreamIntegrityV1 { + pub fn is_clean(self) -> bool { + self == Self::default() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SampleRangeV1 { + pub first_sample_index: u64, + pub end_sample_index_exclusive: u64, + pub sample_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct Sha256V1(String); + +impl Sha256V1 { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("SHA-256 must be exactly 64 hexadecimal characters".into()); + } + Ok(Self(value.to_ascii_lowercase())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Sha256V1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Sha256V1 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// The exact file to open at a recording boundary. Metadata is deliberately +/// string-valued and bounded by the owner; scientific sidecars remain the +/// canonical rich metadata record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdqStartSpecV1 { + pub pdq_path: String, + pub sidecar_path: String, + pub expected_sample_rate_hz: Option, + pub expected_stream_epoch: Option, + pub metadata: BTreeMap, + /// Absolute directory the workflow client wants this recording written + /// below, so a coordinated run can put every file in one measurement + /// folder instead of the owner's own data directory. `None` keeps the + /// owner's configured data directory. `pdq_path`/`sidecar_path` stay + /// relative to whichever root applies, and the owner still refuses + /// traversal and symlinked path components below it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_dir: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdqStartedReceiptV1 { + pub run_id: RunId, + pub pdq_path: String, + pub sidecar_path: String, + pub opened_at_unix_ms: u64, + pub stream_epoch: u64, + pub first_sample_index: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PdqTerminationV1 { + Completed, + OperatorStopped, + LeaseExpired, + SafeOff, + DeviceFault, + IoFault, + Aborted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdqFinalizedReceiptV1 { + pub run_id: RunId, + pub pdq_path: String, + pub sidecar_path: String, + pub opened_at_unix_ms: u64, + pub finalized_at_unix_ms: u64, + pub file_size_bytes: u64, + pub sha256: Sha256V1, + pub frames_written: u64, + pub sample_frames_written: u64, + pub sample_range: Option, + pub sample_rate_hz: Option, + pub segment_count: u64, + pub integrity: StreamIntegrityV1, + pub termination: PdqTerminationV1, + pub valid: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PdqReceiptV1 { + Started(PdqStartedReceiptV1), + Finalized(PdqFinalizedReceiptV1), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PhotodiodeCommandV1 { + Connect, + Disconnect { + finalize_recording: bool, + reason: String, + }, + AcquireLease { + ttl_ms: u64, + }, + RenewLease { + ttl_ms: u64, + }, + ReleaseLease { + finalize_recording: bool, + reason: String, + }, + BeginRecording { + specification: PdqStartSpecV1, + }, + FinalizeRecording { + termination: PdqTerminationV1, + }, + AbortRecording { + reason: String, + }, +} + +pub type PhotodiodeRequestV1 = RequestEnvelopeV1; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeResponseV1 { + #[serde(flatten)] + pub common: ResponseCommonV1, + pub receipt: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeCalibrationV1 { + pub adc_calibration_id: String, + pub dark_id: String, + /// Full-extinction reference used only for rejected-port complement + /// geometry. Direct camera/emission-path measurements have no such anchor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor_id: Option, + pub dark_volts: f64, + /// Traceable direct-path dark reference. `None` for the historical + /// rejected-port geometry, where the same-detector complement cancels the + /// dark offset. Additive for older V1 readers and writers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dark_reference: Option, + /// Named full-extinction anchor after dark subtraction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_power_volts: Option, +} + +/// How a direct-path blocked-light reference entered the session state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PhotodiodeDarkSourceV1 { + /// Captured from the owner's settled raw detector window while the + /// operator had physically blocked the light. + MeasuredLampOff, + /// Entered through the numeric setting rather than measured by the owner. + Manual, +} + +/// Provenance for the direct camera/emission-path dark subtraction. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeDarkReferenceV1 { + pub dark_id: String, + pub source: PhotodiodeDarkSourceV1, + pub dark_volts: f64, + pub captured_at_unix_ms: u64, + /// Age when this enclosing summary or artifact was produced. + pub age_s: f64, +} + +/// Physical location of the one Stage-A photodiode. +/// +/// `RejectedPort` is the historical PBS-complement geometry and therefore the +/// default when an older owner did not publish this additive field. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PhotodiodePlacementV1 { + #[default] + RejectedPort, + /// Direct sample of the path sent towards the camera, before a microscope + /// emission chain has been established. + CameraPath, + /// Direct sample of fluorescence after the emission filter. + EmissionPath, +} + +/// Bounded optical result for one named run. It contains no raw or decimated +/// waveform samples; the finalized PDQ remains the source for replay. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeOpticalSummaryV1 { + pub run_id: RunId, + pub calibration: PhotodiodeCalibrationV1, + /// Detector geometry used to derive `measured_log_contrast`. + #[serde(default)] + pub placement: PhotodiodePlacementV1, + /// Fraction of the local beam sent to the photodiode, e.g. `0.5` for a + /// 50:50 splitter. It is provenance; a constant fraction cancels from log + /// contrast and is not used as a scale correction. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub splitter_fraction: Option, + pub measured_log_contrast: f64, + pub log_contrast_stddev: Option, + pub excitation_min_volts: f64, + pub excitation_max_volts: f64, + pub excitation_headroom_volts: f64, + pub low_clip_fraction: f64, + pub high_clip_fraction: f64, + pub measured_frequency_hz: Option, + pub fundamental_phase_rad: Option, + pub total_harmonic_distortion: Option, + /// Duration of the trailing window `measured_log_contrast` was estimated + /// over. A consumer that *commands* a depth and then reads this value back + /// has to wait at least this long, or it averages the previous depth in. + /// Additive in V1: absent from older owners, ignored by older consumers. + #[serde(default)] + pub window_seconds: Option, + /// Whole modulation cycles that window covered, from the phase-0 markers. + /// `a` is peak-to-peak, so below one cycle the owner withholds it entirely + /// rather than publish a phase-dependent under-estimate. `None` when there + /// is no marker period to measure against. + #[serde(default)] + pub covered_cycles: Option, +} + +/// Settled detector level over the owner's **measurement** window, in **raw +/// detector volts**: the ADC affine map only, before dark subtraction and before +/// any [`PhotodiodeOpticalSummaryV1`] geometry transform. Unlike the optical +/// summary this never refuses — it stays present while the window clips (see +/// `clipped`), because a consumer sweeping a static transfer curve needs a +/// level exactly where the detector is brightest. +/// +/// The window is fixed by the owner and **independent of any display setting**; +/// `sample_count` reports how long it actually was. Deriving it from the chart's +/// averaging preference instead let a display knob set the precision of the +/// Pockels transfer calibration downstream (ADR 019). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeLevelV1 { + pub mean_volts: f64, + /// Spread over the averaged window. A settled `CONST` point has a small + /// peak-to-peak; a drifting or still-slewing one does not. + pub peak_to_peak_volts: f64, + pub sample_count: u64, + /// Exclusive end of the averaged window on the device sample clock. The + /// window covers `[end_sample_index - sample_count, end_sample_index)`, so + /// a consumer can prove a level was measured *after* it commanded a + /// change without needing a shared wall clock. + pub end_sample_index: u64, + /// The window touches an ADC rail; `mean_volts` is a truncated estimate. + pub clipped: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeStreamV1 { + pub stream_epoch: u64, + pub sample_range: Option, + pub sample_rate_hz: Option, + pub latest_adc_code: Option, + pub integrity: StreamIntegrityV1, + /// Additive in V1: absent from older owners, and older consumers ignore it. + #[serde(default)] + pub level: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PhotodiodeSummaryV1 { + pub contract_version: u16, + pub owner_instance: OwnerInstanceId, + pub service_revision: u64, + pub connection: ConnectionStateV1, + pub lease: Option, + pub active_run_id: Option, + pub requested_revision: Option, + pub acknowledged_revision: Option, + pub stream: PhotodiodeStreamV1, + /// Directory the owner resolves relative PDQ/sidecar paths against. `None` + /// when it is unset, in which case every recording command is rejected — + /// automation clients check this before they start a coordinated run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_dir: Option, + pub active_recording: Option, + pub last_finalized_recording: Option, + pub optical_summary: Option, + /// Current detector placement, available even while no optical window has + /// passed the estimator gates. + #[serde(default)] + pub placement: PhotodiodePlacementV1, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub splitter_fraction: Option, + /// Current direct-path dark provenance, even while another optical gate + /// withholds `optical_summary`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dark_reference: Option, + /// Why `optical_summary` is absent, in the owner's own words. + /// + /// A withheld `a` is a fail-closed refusal, not missing data, and every + /// automation client that gates on `a` has to be able to tell the operator + /// which gate rejected the window — otherwise the only readout is "no `a`" + /// and the fix is a guess. Set exactly when `optical_summary` is `None` and + /// a window was available to judge. + /// + /// Additive in V1: absent from older owners, and older consumers ignore it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub optical_unavailable: Option, + pub synchronization: SynchronizationV1, + pub last_response: Option, + pub freshness: FreshnessV1, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn common(request_id: u64) -> ResponseCommonV1 { + ResponseCommonV1 { + contract_version: CONTRACT_VERSION_V1, + request_id: RequestId(request_id), + owner_instance: OwnerInstanceId::from("owner-7"), + run_id: Some(RunId::from("A1-20260721-003")), + requested_revision: Some(SemanticRevision(4)), + acknowledged_revision: Some(SemanticRevision(4)), + outcome: RequestOutcomeV1::Applied, + completed_at_unix_ms: Some(1_721_000_001_000), + error: None, + } + } + + #[test] + fn context_keys_are_stable_and_versioned() { + assert_eq!(PLUGIN_ID_STAGE_A_MODULATION, "stage-a.modulation"); + assert_eq!(PLUGIN_ID_STAGE_A_PHOTODIODE, "stage-a.photodiode"); + assert_eq!( + SERVICE_STAGE_A_MODULATION_CONTROL_V1, + "stage_a.modulation.control.v1" + ); + assert_eq!( + SERVICE_STAGE_A_PHOTODIODE_CONTROL_V1, + "stage_a.photodiode.control.v1" + ); + assert_eq!( + CTX_STAGE_A_MODULATION_REQUEST_V1, + "stage_a.modulation_request.v1" + ); + assert_eq!( + CTX_STAGE_A_MODULATION_RESPONSE_V1, + "stage_a.modulation_response.v1" + ); + assert_eq!( + CTX_STAGE_A_MODULATION_STATE_V1, + "stage_a.modulation_state.v1" + ); + assert_eq!( + CTX_STAGE_A_PHOTODIODE_REQUEST_V1, + "stage_a.photodiode_request.v1" + ); + assert_eq!( + CTX_STAGE_A_PHOTODIODE_RESPONSE_V1, + "stage_a.photodiode_response.v1" + ); + assert_eq!( + CTX_STAGE_A_PHOTODIODE_SUMMARY_V1, + "stage_a.photodiode_summary.v1" + ); + } + + #[test] + fn firmware_drive_bounds_and_a1_measurement_bounds_are_distinct() { + assert!(drive_frequency_supported(DRIVE_FREQUENCY_MIN_MILLIHZ)); + assert!(drive_frequency_supported(DRIVE_FREQUENCY_MAX_MILLIHZ)); + assert!(!drive_frequency_supported(DRIVE_FREQUENCY_MIN_MILLIHZ - 1)); + assert!(!drive_frequency_supported(DRIVE_FREQUENCY_MAX_MILLIHZ + 1)); + assert_eq!(a1_measurement_frequency_limit_hz(20_000), 1_250.0); + assert_eq!(a1_measurement_frequency_limit_hz(500_000), 31_250.0); + } + + #[test] + fn modulation_request_round_trips_with_semantic_discriminants() { + let mut request = ModulationRequestV1::new( + RequestId(12), + ClientId::from("stage-a-a1"), + ModulationCommandV1::PrepareA1 { + configuration: A1AcquisitionConfigV1 { + waveform: PeriodicWaveformV1::Sine, + frequency_millihz: 10_000, + center_dac: 2_048, + amplitude_dac: 512, + sample_rate_hz: 20_000, + block_samples: 256, + emit_raw_samples: true, + emit_summary: true, + optical_lut_id: Some("lut-2026-07".into()), + }, + }, + ); + request.target_owner_instance = Some(OwnerInstanceId::from("mod-owner-1")); + request.lease_id = Some(LeaseId::from("lease-a1")); + request.run_id = Some(RunId::from("run-3")); + request.requested_revision = Some(SemanticRevision(9)); + request.issued_at_unix_ms = 42; + + let json = serde_json::to_value(&request).expect("serializes"); + assert_eq!(json["command"]["kind"], "prepare_a1"); + assert_eq!( + json["command"]["configuration"]["frequency_millihz"], + 10_000 + ); + let decoded: ModulationRequestV1 = serde_json::from_value(json).expect("deserializes"); + assert_eq!(decoded, request); + } + + #[test] + fn set_optical_depth_round_trips_in_milli_units() { + let request = ModulationRequestV1::new( + RequestId(7), + ClientId::from("stage-a-a1"), + ModulationCommandV1::SetOpticalDepth { + depth_a_milli: 1_250, + }, + ); + let json = serde_json::to_value(&request).expect("serializes"); + assert_eq!(json["command"]["kind"], "set_optical_depth"); + assert_eq!(json["command"]["depth_a_milli"], 1_250); + let decoded: ModulationRequestV1 = serde_json::from_value(json).expect("deserializes"); + assert_eq!(decoded, request); + } + + #[test] + fn snapshots_keep_requested_and_acknowledged_revisions_distinct() { + let requested = ModulationTargetV1 { + revision: SemanticRevision(5), + waveform: Some(WaveformV1::Constant { level_dac: 900 }), + a1_configuration: None, + a2_configuration: None, + acquisition_running: false, + board_dac_code: None, + firmware_configuration_revision: None, + }; + let acknowledged = ModulationTargetV1 { + revision: SemanticRevision(4), + waveform: Some(WaveformV1::Constant { level_dac: 800 }), + board_dac_code: Some(800), + ..requested.clone() + }; + let snapshot = ModulationStateV1 { + contract_version: CONTRACT_VERSION_V1, + owner_instance: OwnerInstanceId::from("mod-owner-1"), + service_revision: 17, + connection: ConnectionStateV1::Connected { + port_label: "mock".into(), + firmware_version: Some("0.4.0".into()), + }, + capabilities: vec!["MOD".into(), "PDSTREAM".into()], + lease: None, + controller_state: ControllerStateV1::SafeIdle, + active_run_id: None, + requested: Some(requested), + acknowledged: Some(acknowledged), + synchronization: SynchronizationV1::Unsynced { + reason: UnsyncedReasonV1::RequestedRevisionNotAcknowledged, + detail: Some("requested 5, acknowledged 4".into()), + }, + last_response: None, + freshness: FreshnessV1 { + observed_at_unix_ms: 100, + valid_for_ms: 500, + }, + calibration_id: Some("pockels-20260724-120000".into()), + optical_drive: Some(OpticalDriveStateV1 { + target: OpticalTargetV1::LogSine, + requested_mean_u_milli: 400, + resolved_mean_u_milli: 399, + internal_u_milli: 355, + depth_a_milli: 1_000, + v_null_dac: 1_630, + v_peak_dac: 1_160, + }), + }; + let encoded = serde_json::to_vec(&snapshot).expect("serializes"); + let decoded: ModulationStateV1 = serde_json::from_slice(&encoded).expect("deserializes"); + assert_eq!(decoded.requested.unwrap().revision, SemanticRevision(5)); + assert_eq!(decoded.acknowledged.unwrap().revision, SemanticRevision(4)); + assert!(matches!( + decoded.synchronization, + SynchronizationV1::Unsynced { .. } + )); + assert_eq!( + decoded + .optical_drive + .as_ref() + .unwrap() + .requested_mean_u_milli, + 400 + ); + assert_eq!(decoded.optical_drive.unwrap().resolved_mean_u_milli, 399); + + let mut legacy = serde_json::to_value(&snapshot).expect("serializes"); + let object = legacy.as_object_mut().expect("state object"); + object.remove("calibration_id"); + object.remove("optical_drive"); + let decoded_legacy: ModulationStateV1 = + serde_json::from_value(legacy).expect("pre-provenance state decodes"); + assert!(decoded_legacy.calibration_id.is_none()); + assert!(decoded_legacy.optical_drive.is_none()); + } + + #[test] + fn finalized_pdq_receipt_round_trips_without_raw_samples() { + let receipt = PdqFinalizedReceiptV1 { + run_id: RunId::from("run-3"), + pdq_path: "/data/run-3_pd.pdq".into(), + sidecar_path: "/data/run-3.toml".into(), + opened_at_unix_ms: 1_000, + finalized_at_unix_ms: 2_000, + file_size_bytes: 8_192, + sha256: Sha256V1::parse("ab".repeat(32)).expect("digest"), + frames_written: 32, + sample_frames_written: 30, + sample_range: Some(SampleRangeV1 { + first_sample_index: 10_000, + end_sample_index_exclusive: 17_680, + sample_count: 7_680, + }), + sample_rate_hz: Some(20_000), + segment_count: 1, + integrity: StreamIntegrityV1::default(), + termination: PdqTerminationV1::Completed, + valid: true, + }; + let response = PhotodiodeResponseV1 { + common: common(22), + receipt: Some(PdqReceiptV1::Finalized(receipt.clone())), + }; + let json = serde_json::to_value(&response).expect("serializes"); + assert_eq!(json["receipt"]["kind"], "finalized"); + assert!(json.to_string().len() < 2_048, "receipt stays bounded"); + let decoded: PhotodiodeResponseV1 = serde_json::from_value(json).expect("deserializes"); + assert_eq!(decoded.receipt, Some(PdqReceiptV1::Finalized(receipt))); + } + + #[test] + fn sha256_and_freshness_validate_boundaries() { + assert!(Sha256V1::parse("0".repeat(64)).is_ok()); + assert!( + Sha256V1::parse("A".repeat(64)).is_ok_and(|digest| digest.as_str() == "a".repeat(64)) + ); + assert!(Sha256V1::parse("0".repeat(63)).is_err()); + assert!(Sha256V1::parse("z".repeat(64)).is_err()); + assert!(serde_json::from_str::(&format!("\"{}\"", "z".repeat(64))).is_err()); + + let freshness = FreshnessV1 { + observed_at_unix_ms: 1_000, + valid_for_ms: 500, + }; + assert!(!freshness.is_stale_at(1_500)); + assert!(freshness.is_stale_at(1_501)); + assert!(!freshness.is_stale_at(900), "clock rollback saturates"); + } + + #[test] + fn stream_integrity_is_fail_closed() { + assert!(StreamIntegrityV1::default().is_clean()); + assert!(!StreamIntegrityV1 { + segment_restarts: 1, + ..StreamIntegrityV1::default() + } + .is_clean()); + } + + #[test] + fn additive_v1_fields_decode_from_payloads_that_predate_them() { + // An older owner's stream block carries no `level`. + let stream: PhotodiodeStreamV1 = serde_json::from_value(json!({ + "stream_epoch": 3, + "sample_range": null, + "sample_rate_hz": 20_000, + "latest_adc_code": 1_024, + "integrity": StreamIntegrityV1::default(), + })) + .expect("stream without level decodes"); + assert!(stream.level.is_none()); + + let level = PhotodiodeLevelV1 { + mean_volts: 1.5, + peak_to_peak_volts: 0.01, + sample_count: 4_096, + end_sample_index: 1_000_000, + clipped: false, + }; + let round_tripped: PhotodiodeLevelV1 = + serde_json::from_value(serde_json::to_value(level).expect("serializes")) + .expect("deserializes"); + assert_eq!(round_tripped, level); + // The window is identified without a wall clock: it ends at + // `end_sample_index` and spans `sample_count` samples. + assert_eq!( + level.end_sample_index - level.sample_count, + 1_000_000 - 4_096 + ); + } +} diff --git a/stage-a-plugin-contract/src/telemetry.rs b/stage-a-plugin-contract/src/telemetry.rs new file mode 100644 index 0000000..5ca738d --- /dev/null +++ b/stage-a-plugin-contract/src/telemetry.rs @@ -0,0 +1,422 @@ +//! Compacts the host's sensor-telemetry CSV into the per-measurement readout +//! file that travels with a recording. +//! +//! The host polls the camera's monitoring block while recording and writes +//! `.sensor-monitoring.csv` next to the RAW. Two things are wrong +//! with keeping that file as it is: +//! +//! 1. **It stays behind.** A workflow gathers the camera RAW, its bias sidecar +//! and the description file into one measurement folder under one name; the +//! telemetry did not travel with them, so the bench conditions of a run were +//! separated from the run at the first `mv`. +//! +//! 2. **It is a wide table of mostly-empty cells.** The channels are polled on +//! different schedules — the die temperature drifts over minutes, the pixel +//! dead time is read far more often — so a row-per-poll layout with a column +//! per channel is padding by construction. The bias columns are pure +//! duplication on top of that: the same codes are already in the camera's +//! own bias sidecar, which travels with the RAW. +//! +//! So this rewrites it column-wise: one timestamp/value pair list per channel, +//! carrying only the samples where that channel was actually read. Nothing is +//! resampled, interpolated or aligned — a reading exists at the instant it was +//! taken or not at all. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// Schema tag for A1's readout files. +pub const SCHEMA_A1: &str = "stage-a.a1.sensor.v1"; + +/// Schema tag for A4's readout files. The layout is identical; the tag names +/// the workflow that produced the file so a folder of mixed measurements is +/// still self-describing. +pub const SCHEMA_A4: &str = "stage-a.a4.sensor.v1"; + +/// One channel's samples, in acquisition order. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Channel { + /// Microseconds since the recording's host clock anchor — the midpoint of + /// the poll, because a monitoring read is not instantaneous and the + /// midpoint is the least wrong single instant to attribute it to. + pub t_us: Vec, + pub value: Vec, +} + +impl Channel { + fn push(&mut self, t_us: i64, value: f64) { + self.t_us.push(t_us); + self.value.push(value); + } + + pub fn len(&self) -> usize { + self.t_us.len() + } + + pub fn is_empty(&self) -> bool { + self.t_us.is_empty() + } +} + +/// A poll that returned nothing usable, kept so a gap in a channel is +/// distinguishable from a channel that was never polled. +#[derive(Debug, Clone, PartialEq)] +pub struct PollFault { + pub t_us: i64, + pub status: String, + pub message: String, +} + +/// The compacted readout for one recording. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct SensorReadout { + /// Channel name → samples. Empty channels are dropped entirely. + pub channels: BTreeMap, + pub faults: Vec, + /// Polls read out of the source file, including the ones that failed. + pub polls: usize, +} + +impl SensorReadout { + pub fn is_empty(&self) -> bool { + self.channels.is_empty() && self.faults.is_empty() + } + + /// Renders the readout as JSON. + /// + /// Hand-written rather than via `serde_json` so the arrays stay on one line + /// each: these files are read by eye as often as by script, and a pretty + /// printer puts one number per line — thousands of lines for what is + /// conceptually one row. + pub fn to_json(&self, schema: &str, measurement_id: &str, recording_stem: &str) -> String { + let mut out = String::with_capacity(1_024 + self.polls * 24); + out.push_str("{\n"); + let _ = writeln!(out, " \"schema\": {},", json_string(schema)); + let _ = writeln!( + out, + " \"measurement_id\": {},", + json_string(measurement_id) + ); + let _ = writeln!(out, " \"recording\": {},", json_string(recording_stem)); + out.push_str( + " \"time_base\": \"t_us is the midpoint of each poll, in microseconds on the \ + host clock anchored at the start of this recording\",\n", + ); + out.push_str( + " \"note\": \"Channels are sampled independently and are not aligned; bias codes \ + are omitted because the camera's own bias sidecar already carries them.\",\n", + ); + let _ = writeln!(out, " \"polls\": {},", self.polls); + out.push_str(" \"channels\": {\n"); + let mut first = true; + for (name, channel) in &self.channels { + if !first { + out.push_str(",\n"); + } + first = false; + let _ = write!( + out, + " {}: {{ \"t_us\": [{}], \"value\": [{}] }}", + json_string(name), + join_i64(&channel.t_us), + join_f64(&channel.value), + ); + } + out.push_str("\n },\n"); + out.push_str(" \"faults\": ["); + for (index, fault) in self.faults.iter().enumerate() { + if index > 0 { + out.push(','); + } + let _ = write!( + out, + "\n {{ \"t_us\": {}, \"status\": {}, \"message\": {} }}", + fault.t_us, + json_string(&fault.status), + json_string(&fault.message), + ); + } + if self.faults.is_empty() { + out.push_str("]\n"); + } else { + out.push_str("\n ]\n"); + } + out.push_str("}\n"); + out + } +} + +/// Parses the host's `.sensor-monitoring.csv` into a compact readout. +/// +/// Unknown or reordered columns are handled by name, so a host that adds a +/// column does not shift every value by one. Rows that cannot be read are +/// skipped rather than failing the whole file: a truncated last line is normal +/// if the recording was cut short, and losing the other 4 000 samples over it +/// would be the wrong trade. +pub fn parse_csv(text: &str) -> SensorReadout { + let mut lines = text.lines(); + let Some(header) = lines.next() else { + return SensorReadout::default(); + }; + let columns: Vec<&str> = header.split(',').map(str::trim).collect(); + let index_of = |name: &str| columns.iter().position(|column| *column == name); + + let start = index_of("host_elapsed_start_us"); + let end = index_of("host_elapsed_end_us"); + let status = index_of("status"); + let error = index_of("error"); + // Bias columns are deliberately absent from this list. + let measured: Vec<(&str, usize)> = ["illumination_lux", "temperature_c", "pixel_dead_time_us"] + .into_iter() + .filter_map(|name| index_of(name).map(|index| (name, index))) + .collect(); + + let mut readout = SensorReadout::default(); + for line in lines { + if line.trim().is_empty() { + continue; + } + let fields = crate::csv::split_line(line); + let at = |index: Option| { + index + .and_then(|index| fields.get(index)) + .map(String::as_str) + }; + let midpoint = match ( + at(start).and_then(|value| value.parse::().ok()), + at(end).and_then(|value| value.parse::().ok()), + ) { + (Some(start), Some(end)) => start + (end - start) / 2, + (Some(start), None) => start, + _ => continue, + }; + readout.polls += 1; + + let mut any = false; + for (name, index) in &measured { + let Some(raw) = fields.get(*index) else { + continue; + }; + if raw.is_empty() { + continue; + } + let Ok(value) = raw.parse::() else { + continue; + }; + if !value.is_finite() { + continue; + } + readout + .channels + .entry((*name).to_owned()) + .or_default() + .push(midpoint, value); + any = true; + } + // A poll that produced no reading is only worth recording when the host + // said why; an ordinary "nothing due yet" row is not a fault. + let status_text = at(status).unwrap_or("").to_owned(); + let error_text = at(error).unwrap_or("").to_owned(); + if !any && (!error_text.is_empty() || (!status_text.is_empty() && status_text != "ok")) { + readout.faults.push(PollFault { + t_us: midpoint, + status: status_text, + message: error_text, + }); + } + } + readout +} + +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other if (other as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", other as u32); + } + other => out.push(other), + } + } + out.push('"'); + out +} + +fn join_i64(values: &[i64]) -> String { + let mut out = String::new(); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + let _ = write!(out, "{value}"); + } + out +} + +fn join_f64(values: &[f64]) -> String { + let mut out = String::new(); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + // Shortest round-trip form: these are f32 readings widened to f64, so + // the default Display is both exact and compact. + let _ = write!(out, "{value}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEADER: &str = "schema_version,sample_id,poll_kind,host_elapsed_start_us,\ +host_elapsed_end_us,raw_data_offset_before_bytes,raw_data_offset_after_bytes,illumination_lux,\ +temperature_c,pixel_dead_time_us,bias_diff_on_code,bias_diff_off_code,bias_fo_code,bias_hpf_code,\ +bias_refr_code,status,error"; + + fn csv(rows: &[&str]) -> String { + let mut text = String::from(HEADER); + for row in rows { + text.push('\n'); + text.push_str(row); + } + text.push('\n'); + text + } + + #[test] + fn channels_keep_only_the_polls_that_actually_read_them() { + // The whole point: the die temperature is polled far less often than + // the dead time, and a row-per-poll table pads the difference with + // empty cells. Each channel carries its own samples and nothing else. + let text = csv(&[ + "1,1,fast,1000,1200,0,0,,,12.5,,,,,,ok,", + "1,2,fast,2000,2200,0,0,,,12.6,,,,,,ok,", + "1,3,full,3000,3400,0,0,140.0,41.5,12.7,10,20,30,40,50,ok,", + "1,4,fast,4000,4200,0,0,,,12.8,,,,,,ok,", + ]); + let readout = parse_csv(&text); + + assert_eq!(readout.polls, 4); + assert_eq!(readout.channels["pixel_dead_time_us"].len(), 4); + assert_eq!(readout.channels["temperature_c"].len(), 1); + assert_eq!(readout.channels["illumination_lux"].len(), 1); + assert_eq!(readout.channels["temperature_c"].value, vec![41.5]); + } + + #[test] + fn bias_codes_are_dropped_because_the_bias_sidecar_already_has_them() { + let text = csv(&["1,1,full,1000,1200,0,0,140.0,41.5,12.7,10,20,30,40,50,ok,"]); + let readout = parse_csv(&text); + assert_eq!(readout.channels.len(), 3); + for name in readout.channels.keys() { + assert!(!name.starts_with("bias"), "bias channel survived: {name}"); + } + let parsed: serde_json::Value = + serde_json::from_str(&readout.to_json(SCHEMA_A1, "m", "s")).expect("valid JSON"); + let channels = parsed["channels"].as_object().expect("channels object"); + assert!( + channels.keys().all(|name| !name.starts_with("bias")), + "{channels:?}" + ); + } + + #[test] + fn a_sample_is_timestamped_at_the_midpoint_of_its_poll() { + // A monitoring read takes a few hundred microseconds; attributing it to + // its start would systematically date every reading early. + let text = csv(&["1,1,full,1000,1400,0,0,140.0,41.5,12.7,,,,,,ok,"]); + let readout = parse_csv(&text); + assert_eq!(readout.channels["temperature_c"].t_us, vec![1200]); + } + + #[test] + fn a_failed_poll_is_kept_as_a_fault_so_a_gap_is_explainable() { + let text = csv(&[ + "1,1,full,1000,1200,0,0,140.0,41.5,12.7,,,,,,ok,", + "1,2,full,2000,2200,0,0,,,,,,,,,error,\"i2c timeout, retrying\"", + ]); + let readout = parse_csv(&text); + assert_eq!(readout.polls, 2); + assert_eq!(readout.faults.len(), 1); + assert_eq!(readout.faults[0].t_us, 2100); + assert_eq!(readout.faults[0].status, "error"); + assert_eq!(readout.faults[0].message, "i2c timeout, retrying"); + } + + #[test] + fn an_ordinary_empty_poll_is_not_a_fault() { + let text = csv(&["1,1,fast,1000,1200,0,0,,,,,,,,,ok,"]); + let readout = parse_csv(&text); + assert_eq!(readout.polls, 1); + assert!(readout.faults.is_empty()); + assert!(readout.channels.is_empty()); + } + + #[test] + fn a_truncated_final_row_does_not_cost_the_rest_of_the_file() { + // Cutting a recording short leaves a partial last line. Losing 4 000 + // good samples over it would be the wrong trade. + let mut text = csv(&["1,1,full,1000,1200,0,0,140.0,41.5,12.7,,,,,,ok,"]); + text.push_str("1,2,full,20"); + let readout = parse_csv(&text); + assert_eq!(readout.channels["temperature_c"].len(), 1); + } + + #[test] + fn columns_are_found_by_name_not_by_position() { + // A host that inserts a column must not shift every reading by one. + let text = "host_elapsed_start_us,host_elapsed_end_us,new_column,temperature_c,status\n\ + 1000,1200,x,41.5,ok\n"; + let readout = parse_csv(text); + assert_eq!(readout.channels["temperature_c"].value, vec![41.5]); + } + + #[test] + fn an_empty_or_header_only_file_produces_an_empty_readout() { + assert!(parse_csv("").is_empty()); + assert!(parse_csv(HEADER).is_empty()); + } + + #[test] + fn the_json_is_one_line_per_channel_and_parses_back() { + let text = csv(&[ + "1,1,full,1000,1200,0,0,140.0,41.5,12.7,,,,,,ok,", + "1,2,fast,2000,2200,0,0,,,12.8,,,,,,ok,", + ]); + let json = parse_csv(&text).to_json(SCHEMA_A1, "meas-1", "meas-1_20260731T120000Z"); + + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert_eq!(parsed["schema"], SCHEMA_A1); + assert_eq!(parsed["measurement_id"], "meas-1"); + assert_eq!(parsed["polls"], 2); + assert_eq!(parsed["channels"]["pixel_dead_time_us"]["value"][1], 12.8); + assert_eq!(parsed["channels"]["temperature_c"]["t_us"][0], 1100); + + // Compactness is the point of hand-rendering it: a pretty printer would + // put one number per line. + for line in json.lines() { + assert!( + !line.trim_start().starts_with("12.8"), + "an array was expanded one value per line:\n{json}" + ); + } + } + + #[test] + fn quoted_error_text_with_commas_survives_the_round_trip() { + let text = csv(&["1,1,full,1000,1200,0,0,,,,,,,,,error,\"a, b, \"\"c\"\"\""]); + let readout = parse_csv(&text); + assert_eq!(readout.faults[0].message, "a, b, \"c\""); + let parsed: serde_json::Value = + serde_json::from_str(&readout.to_json(SCHEMA_A1, "m", "s")).expect("valid JSON"); + assert_eq!(parsed["faults"][0]["message"], "a, b, \"c\""); + } +}