diff --git a/.agents/skills/g6-orientation/SKILL.md b/.agents/skills/g6-orientation/SKILL.md index cebdf46b..9a4b6637 100644 --- a/.agents/skills/g6-orientation/SKILL.md +++ b/.agents/skills/g6-orientation/SKILL.md @@ -42,7 +42,7 @@ interface. |---|---| | **reiserlab/maDisplayTools** | **MATLAB** tools (G4+). Pattern math reference — **web output must match MATLAB exactly** (CI validates, tol 1e-4). Its protocol **runner is G4.1-only and frozen** (decision 2026-10-26); protocol authority = `webDisplayTools/docs/development/v3-spec.md` + conformance fixtures. (Its `docs/development/G4G6_ROADMAP.md` is largely historical now — use the umbrella docs for current G6.) | | **reiserlab/LED-Display_pyDisplayTools** (pyDisplayTools) | **Python** tools for the displays (G4 pattern + experiment-folder tools). **Accepted destination** for the Python arena library and the **headless runner** (`arena-run`), a peer of the web runner (decision 2026-10-26). | -| **reiserlab/rig-nodes** | **Accepted destination, not yet created:** shared ROS 2 infrastructure for every lab rig (`rig_interfaces`, device nodes for FicTrac / Kinefly / BIAS / ScanImage / SiPM, MCAP recording and supervision primitives). Today that code lives in `webDisplayTools/ros2-poc/` on the 2026-10-20 release candidate. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. | +| **reiserlab/rig-nodes** | **Accepted destination, not yet created:** shared ROS 2 infrastructure for every lab rig (`rig_interfaces`, device nodes for FicTrac / Kinefly / BIAS / ScanImage / SiPM, MCAP recording and supervision primitives). Today that code lives in `webDisplayTools/rig/` on the 2026-10-20 release candidate. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. | ### G6 firmware (what the web connects to over USB) | repo | role | @@ -124,7 +124,7 @@ interface. (WebSocket `ws://localhost:8765` ← UDP `:60000`) → browser. `pixi run bridge` + `pixi run sim` (start the bridge first). The web runner executes only the `fictrac` and `log` plugins; `backlight`/`camera`/`temperature` are declared for MATLAB and **skipped on the web**. -- **ROS 2 graph (LAB-303, candidate 2026-10-20)** — `ros2-poc/` replaces the bridge with a graph +- **ROS 2 graph (LAB-303, candidate 2026-10-20)** — `rig/` replaces the bridge with a graph (rmw_zenoh, MCAP record, BIAS + ScanImage connectors) behind the SAME WebSocket; the Studio is unchanged. Where the pieces will live from here: `rig-software-repos-and-runners.md` (above). diff --git a/.agents/skills/protocol-yaml/SKILL.md b/.agents/skills/protocol-yaml/SKILL.md index 04670c18..8324f96b 100644 --- a/.agents/skills/protocol-yaml/SKILL.md +++ b/.agents/skills/protocol-yaml/SKILL.md @@ -314,6 +314,39 @@ changes may tune a trial, never restructure it. Web-only; MATLAB ignores `runtim to run it** until the web runner implements that capability; MATLAB may run it. Leave it out otherwise. +### `controller:` — sticky controller settings the run must assert (Studio v0.93) + +The controller keeps a few **sticky** settings across runs (panel display mode, refresh rate, +Digital IO roles…). Wiring belongs in the rig YAML; what the *experiment* needs goes in a +top-level `controller:` block, and the Studio asserts it before every run (GET → SET → GET, +refuse on mismatch) and writes the before/after snapshot into the run header. Design: +`docs/development/controller-settings-strategy.md`; user doc: `docs/development/controller-settings.md`. + +```yaml +rig: "./configs/rigs/bergamo_g6_2x10_2p.yaml" +requires: [controller_block] # a runner without this feature REFUSES the file instead of ignoring the block +controller: + panel_mode: triggered # 0 oneshot | 1 persistent | 2 triggered | 3 gated (names or numbers) + refresh_policy: line_sync_safe # cap the re-stream rate at the rig's limits.max_refresh_hz — OR refresh_hz: + panel_firmware: "2p" # SD footer version (or prefix) the panel image must match; "2p" = the line-sync build +``` + +- **Most protocols do not need the block.** A course protocol inherits the rig's + `defaults.panel_mode` (persistent); the Studio asserts that too. Only declare a mode when the + experiment depends on it (imaging: `triggered`). +- **A protocol with a `controller:` block is strict**: rig mismatch (its `rig:` vs the session + rig), an unreadable setting, or the wrong panel firmware **blocks the run** (green and blue + buttons). On rigs with `strict: true` in the rig YAML this holds for every protocol. +- `refresh_hz` and `refresh_policy` are mutually exclusive. Prefer the policy: the refresh a + pattern needs follows its grayscale (GS2 streams at 1000 Hz by default, GS16 at 300), so the + Studio derives it from the referenced patterns and the rig cap; a bare number is for refresh + experiments only and must not exceed the rig's `limits.max_refresh_hz`. +- Panel firmware is checked against the SD footer (what image was uploaded) **and** the + session's fleet verify (which panels actually run it). Unverified panels only warn; a failed + verify blocks. Settings → Controller → *Verify panels* runs the fleet check (~1 s per panel). +- The validator prints the block and cross-checks it against the rig file when that is in the repo. +- MATLAB does not implement the block yet; the `requires` token is what makes it refuse. + ## Plugins `plugins:` normally comes from the rig (the Studio pre-fills New protocols from the rig diff --git a/.agents/skills/protocol-yaml/scripts/validate-protocol.mjs b/.agents/skills/protocol-yaml/scripts/validate-protocol.mjs index 7a9ec9f6..effc7bd8 100644 --- a/.agents/skills/protocol-yaml/scripts/validate-protocol.mjs +++ b/.agents/skills/protocol-yaml/scripts/validate-protocol.mjs @@ -71,6 +71,46 @@ if (Array.isArray(exp.requires) && exp.requires.length) { console.log('ℹ requires: [' + exp.requires.join(', ') + ']' + (missing.length ? ' — the WEB runner refuses to run this (missing: ' + missing.join(', ') + ')' : '')); } +// ── controller: block (sticky controller settings asserted before the run) ────── +// docs/development/controller-settings-strategy.md. Malformed values are already in +// `blocking` (collectBlockingErrors); here: what it declares, the capability token, and a +// cross-check against the rig file when it is readable from the repo root. +const cb = exp.controller; +if (cb && cb.declared) { + const parts = []; + if (cb.panel_mode != null) parts.push('panel_mode ' + cb.panel_mode + ' (' + ['oneshot', 'persistent', 'triggered', 'gated'][cb.panel_mode] + ')'); + if (cb.refresh_hz != null) parts.push('refresh_hz ' + cb.refresh_hz); + if (cb.refresh_policy) parts.push('refresh_policy ' + cb.refresh_policy); + if (cb.panel_firmware) parts.push('panel_firmware "' + cb.panel_firmware + '"'); + console.log('ℹ controller: ' + (parts.join(', ') || '(empty block)') + ' — asserted by the Studio before every run, recorded in the run header'); + const rigPath = String(exp.rig_path || ''); + const rigFile = rigPath.startsWith('./') || rigPath.startsWith('configs/') ? resolve(rigPath) : null; + if (rigFile && existsSync(rigFile)) { + try { + const { createRequire } = await import('node:module'); + const Registry = createRequire(import.meta.url)(resolve('js/plugin-registry.js')); + const rig = Registry.parseRigIo(v3.parseRigYAMLText(readFileSync(rigFile, 'utf8'))); + const notes = []; + if (rig.defaults.panel_mode != null && cb.panel_mode != null && rig.defaults.panel_mode !== cb.panel_mode) { + notes.push('protocol panel_mode ' + cb.panel_mode + ' overrides the rig default ' + rig.defaults.panel_mode + ' (allowed; the run asserts the protocol\'s value)'); + } + if (cb.refresh_hz != null && rig.limits.max_refresh_hz != null && cb.refresh_hz > rig.limits.max_refresh_hz) { + console.error('✗ BLOCKING: controller.refresh_hz ' + cb.refresh_hz + ' exceeds the rig limit max_refresh_hz ' + rig.limits.max_refresh_hz + ' (' + rigPath + ')'); + process.exitCode = 1; + } + if (cb.refresh_policy === 'line_sync_safe' && rig.limits.max_refresh_hz == null) { + console.warn('⚠ controller: refresh_policy line_sync_safe but the rig ' + rigPath + ' declares no limits.max_refresh_hz — nothing to cap at'); + } + if (rig.requires.panel_firmware && cb.panel_firmware && rig.requires.panel_firmware !== cb.panel_firmware) { + notes.push('protocol requires panel firmware "' + cb.panel_firmware + '", rig requires "' + rig.requires.panel_firmware + '" — both are checked'); + } + for (const w of rig.warnings || []) console.warn('⚠ rig ' + rigPath + ': ' + w); + for (const n of notes) console.log('ℹ ' + n); + console.log('ℹ rig ' + rigPath + ': defaults.panel_mode ' + rig.defaults.panel_mode + ', limits.max_refresh_hz ' + rig.limits.max_refresh_hz + + ', requires.panel_firmware ' + JSON.stringify(rig.requires.panel_firmware) + ', strict ' + rig.strict); + } catch (e) { console.warn('⚠ rig ' + rigPath + ' could not be cross-checked: ' + (e && e.message ? e.message : e)); } + } +} const rcNames = Object.keys(exp.runtime_controls || {}); if (rcNames.length) { const { createRequire } = await import('node:module'); diff --git a/.github/workflows/arena-interfaces.yml b/.github/workflows/rig-interfaces.yml similarity index 76% rename from .github/workflows/arena-interfaces.yml rename to .github/workflows/rig-interfaces.yml index 2242cf32..215eccf9 100644 --- a/.github/workflows/arena-interfaces.yml +++ b/.github/workflows/rig-interfaces.yml @@ -1,30 +1,30 @@ -# Build ros2-poc/interfaces/arena_interfaces (the typed ROS 2 messages of the arena bridge graph, +# Build rig/interfaces/rig_interfaces (the typed ROS 2 messages of the arena bridge graph, # LAB-303) ONCE per platform in CI and publish the result as a conda channel, so a rig PC installs # typed messages as a download — no MSVC, no build backend, no MAX_PATH workaround. Decision and -# the consumer-side manifest edits: ros2-poc/docs/typed-distribution-decision.md; the measurements -# behind it: ros2-poc/docs/typed-windows.md. +# the consumer-side manifest edits: rig/docs/typed-distribution-decision.md; the measurements +# behind it: rig/docs/typed-windows.md. # # Runs: build-only on a PR that touches the package or this file (proves the three platforms still -# build); the `arena-interfaces-channel` artifact on every run; the hosted-channel upload only on an -# `arena-interfaces-v*` tag AND only when the repository variable ARENA_INTERFACES_CHANNEL names a +# build); the `rig-interfaces-channel` artifact on every run; the hosted-channel upload only on an +# `rig-interfaces-v*` tag AND only when the repository variable RIG_INTERFACES_CHANNEL names a # prefix.dev channel (with the PREFIX_API_KEY secret). Until the lab owns one, the channel artifact -# is committed into ros2-poc/channel/ by hand (the decision note has the recipe). -name: arena-interfaces +# is committed into rig/channel/ by hand (the decision note has the recipe). +name: rig-interfaces on: pull_request: paths: - - 'ros2-poc/interfaces/**' - - '.github/workflows/arena-interfaces.yml' + - 'rig/interfaces/**' + - '.github/workflows/rig-interfaces.yml' push: - tags: ['arena-interfaces-v*'] + tags: ['rig-interfaces-v*'] workflow_dispatch: permissions: contents: read concurrency: - group: arena-interfaces-${{ github.event.pull_request.number || github.ref }} + group: rig-interfaces-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: @@ -48,7 +48,7 @@ jobs: defaults: run: shell: bash - working-directory: ros2-poc + working-directory: rig steps: - uses: actions/checkout@v7 with: @@ -57,16 +57,16 @@ jobs: with: pixi-version: ${{ env.PIXI_VERSION }} run-install: false - # MAX_PATH: the runner's checkout (D:\a\webDisplayTools\webDisplayTools\ros2-poc, 45 + # MAX_PATH: the runner's checkout (D:\a\webDisplayTools\webDisplayTools\rig, 40 # chars) plus the ~211 chars the rosidl build adds crosses 260. detached-environments # moves .pixi (build workspace included) to a 4-char root — the ww18 recipe. - name: Windows build root if: runner.os == 'Windows' run: pixi config set --local detached-environments 'C:\px' - - name: pixi build arena_interfaces + - name: pixi build rig_interfaces run: | rm -rf ../dist - pixi build --path interfaces/arena_interfaces/pixi.toml --output-dir ../dist + pixi build --path interfaces/rig_interfaces/pixi.toml --output-dir ../dist mkdir -p ../dist/${{ matrix.subdir }} mv ../dist/*.conda ../dist/${{ matrix.subdir }}/ ls -la ../dist/${{ matrix.subdir }} @@ -84,7 +84,7 @@ jobs: EOF - uses: actions/upload-artifact@v5 with: - name: arena_interfaces-${{ matrix.subdir }} + name: rig_interfaces-${{ matrix.subdir }} path: dist/${{ matrix.subdir }}/*.conda if-no-files-found: error retention-days: 30 @@ -102,13 +102,13 @@ jobs: # dist/, so the subdir is restored here from the artifact name). - uses: actions/download-artifact@v5 with: - pattern: arena_interfaces-* + pattern: rig_interfaces-* path: artifacts - name: assemble + index the channel run: | mkdir -p channel - for d in artifacts/arena_interfaces-*; do - subdir=${d#artifacts/arena_interfaces-} + for d in artifacts/rig_interfaces-*; do + subdir=${d#artifacts/rig_interfaces-} mkdir -p "channel/$subdir" cp "$d"/*.conda "channel/$subdir/" done @@ -121,20 +121,20 @@ jobs: for name, rec in pk.items(): print(rd.parent.name, name, rec["sha256"], [d for d in rec["depends"] if d.startswith("ros2-distro-mutex")]) EOF - # The deliverable: a complete three-platform channel. Commit it into ros2-poc/channel/ + # The deliverable: a complete three-platform channel. Commit it into rig/channel/ # (decision note §"Releasing a new build") or let the step below upload it. - uses: actions/upload-artifact@v5 with: - name: arena-interfaces-channel + name: rig-interfaces-channel path: channel if-no-files-found: error retention-days: 90 # Hosted channel (prefix.dev), only when the lab has one: set the repository variable - # ARENA_INTERFACES_CHANNEL to its name and the secret PREFIX_API_KEY. Tags only, so a + # RIG_INTERFACES_CHANNEL to its name and the secret PREFIX_API_KEY. Tags only, so a # PR build can never publish. - name: upload to the hosted channel - if: startsWith(github.ref, 'refs/tags/arena-interfaces-v') && vars.ARENA_INTERFACES_CHANNEL != '' + if: startsWith(github.ref, 'refs/tags/rig-interfaces-v') && vars.RIG_INTERFACES_CHANNEL != '' env: PREFIX_API_KEY: ${{ secrets.PREFIX_API_KEY }} - ARENA_INTERFACES_CHANNEL: ${{ vars.ARENA_INTERFACES_CHANNEL }} - run: pixi exec rattler-build upload prefix --channel "$ARENA_INTERFACES_CHANNEL" channel/*/*.conda + RIG_INTERFACES_CHANNEL: ${{ vars.RIG_INTERFACES_CHANNEL }} + run: pixi exec rattler-build upload prefix --channel "$RIG_INTERFACES_CHANNEL" channel/*/*.conda diff --git a/CLAUDE.md b/CLAUDE.md index 3f0824dc..afbcb785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ When editing web tools, audit all buttons and controls in the modified code sect - Web outputs must match MATLAB outputs exactly **Repository boundaries (decision 2026-10-26):** `docs/development/rig-software-repos-and-runners.md` -is the record. The browser tools and their rig-side gateway (`ros2-poc/nodes/bridge_node.py`) stay +is the record. The browser tools and their rig-side gateway (`rig/nodes/bridge_node.py`) stay here; the Python arena library + headless runner go to `LED-Display_pyDisplayTools`; shared ROS 2 infrastructure (interfaces, device nodes, recording, supervision) goes to `rig-nodes`, which does not exist yet. Until the extraction session, every file is where the record's inventory says it is today. @@ -369,17 +369,17 @@ fix flows to every page automatically; two hand-written HTML pages never will. colocated < uploaded — every write names its layer, arrival order never decides). The awaits before the load are allowlisted in `tests/test-studio-preview-sources.js`. - **ROS 2 bridge = the bridge going forward (LAB-303, decided 2026-10-16/17).** Rig PCs run - `cd ros2-poc && pixi run rig` (its own pixi workspace: Zenoh router + FicTrac adapter + bridge node + `cd rig && pixi run rig` (its own pixi workspace: Zenoh router + FicTrac adapter + bridge node + optional `--bias` / `--scanimage` / `--foxglove`). Bridge 3.4 (`pixi run bridge` at the root) is the legacy path for course rigs, BUT `fictrac-bridge/bridge.py` is STILL IMPORTED by - `ros2-poc/nodes/bridge_node.py` and `mcap_convert.py` (Hub, LogWriter, Pipeline, the pure + `rig/nodes/bridge_node.py` and `mcap_convert.py` (Hub, LogWriter, Pipeline, the pure encoders): a change there changes ROS acquisition and the conversion of every historical bag. **MCAP bag = the record**; the `behavior_v2` JSONL is DERIVED (converter), transitional and FicTrac-only — new plugin streams go into the bag, never into a new `behavior_vN`. Bags are archived on the group share (later a bucket), never pushed to GitHub, so `--max-bag-mb` (rosbag2 splitting) is an optional knob, off by default. `mcap_index.reindex` replaces the bag ATOMICALLY (`os.replace`); never reintroduce an unlink-then-rename on the record of truth. Row encoders shared - by the live writer and the converter live in `ros2-poc/nodes/_rows.py` (NO ROS imports — the + by the live writer and the converter live in `rig/nodes/_rows.py` (NO ROS imports — the converter must run without a ROS process; `converted == live` depends on ONE encoder). - **Connector plugins (LAB-303):** a protocol plugin whose class is `BiasPlugin` → connector `bias`, `ScanImagePlugin` → `scanimage` (`connectorPluginNames(exp)` in the Studio). The runner turns their diff --git a/arena_studio.html b/arena_studio.html index 8f0e4351..85e4b25f 100644 --- a/arena_studio.html +++ b/arena_studio.html @@ -3426,7 +3426,7 @@

Import error