From 32264bef9e0125051f3e45d5e81b1a5a8ae223dc Mon Sep 17 00:00:00 2001 From: hanmarsan Date: Wed, 28 Oct 2026 18:27:38 -0400 Subject: [PATCH 1/2] =?UTF-8?q?rig=20+=20docs:=20the=202P=20imaging=20work?= =?UTF-8?q?=20moves=20to=20the=20private=20rig=20repo=20(decision=20record?= =?UTF-8?q?=20=C2=A73.3=20amendment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Michael's decision, 2026-10-28: everything specific to the 2P imaging experiments lives in reiserlab/scanimage-2026-bergamo `2p-rig-nodes/` (a pixi workspace in the rig-nodes layout on the public rig_nodes package; PROVENANCE.md there lists the source commit of every file). Copied with provenance, not rewritten in history. Removed here: - tiff2mcap (#273): rig/tools/tiff_run_to_mcap.py, check_tiff2mcap.py, rig/tests/test_tiff2mcap_runlog.py and the tiff2mcap / test-tiff2mcap / check-tiff2mcap tasks (patfile.py stays: arena code bound for pyDisplayTools) - tools/led_sweep_si.py, tools/led_sweep_quicklook.py - records: rig/SCANIMAGE-DAY.md, rig/scanimage-day-2026-10-16.md, docs/development/2p-scanimage-signal-logging.md, 2p-line-sync-rig-checklist.md - the arena composition's ScanImage node: rig.py --scanimage / --si-mock / --scanimage-args, the scanimage-node / scanimage-cmd / si-mock tasks, and the harness's ScanImage stand-in + its 5 checks (rig-nodes 0.4.0 drops the node, rig-nodes #4; re-pinning rig/ to 0.4.0 must not precede this change) Kept public (the boundary): the gateway's connector dispatch and its scanimage CONNECTOR_TOPICS entry, mcap_convert's connector derivation, the Studio's ScanImagePlugin and the emulator's --scanimage-cmds path; the 2P protocols and the Bergamo arena rig profile stay for now (comments repointed). Also: the dated §3.3 amendment in rig-software-repos-and-runners.md, the orientation skill's rig-nodes row, CLAUDE.md's rig line, and tests/test-flasher-catalog.js (its exception for the moved checklist), the expected harness counts in rig/WINDOWS-TEST.md. Tests on the Bergamo PC: root `pixi run test` 72/72 files; rig/ harness `pixi run test` 54/54 and `-e typed` 57/57 (main: 59 / 62, minus the 5 ScanImage checks; rig/WINDOWS-TEST.md updated), run on rig_nodes 0.2.1 with rig/pixi.lock unchanged (pixi 0.82 needed an unlocked install, the known #280 lock-format issue the next candidate re-locks). Co-Authored-By: Claude Opus 5.5 --- .agents/skills/g6-orientation/SKILL.md | 4 +- CLAUDE.md | 3 +- configs/rigs/bergamo_g6_2x10_2p.yaml | 2 +- .../development/2p-line-sync-rig-checklist.md | 138 ---- .../2p-scanimage-signal-logging.md | 133 ---- .../rig-software-repos-and-runners.md | 37 ++ protocols/g6_2p_color_led_sweep.yaml | 2 +- protocols/g6_2x10_2p_diagnostics.yaml | 2 +- protocols/g6_2x10_2p_duty_sweep.yaml | 2 +- rig/SCANIMAGE-DAY.md | 188 ------ rig/WINDOWS-TEST.md | 2 +- rig/nodes/rig.py | 10 - rig/pixi.toml | 10 - rig/scanimage-day-2026-10-16.md | 91 --- rig/tests/test_ros2_poc.py | 25 +- rig/tests/test_tiff2mcap_runlog.py | 114 ---- rig/tools/check_tiff2mcap.py | 104 --- rig/tools/tiff_run_to_mcap.py | 627 ------------------ tests/test-flasher-catalog.js | 14 +- tools/led_sweep_quicklook.py | 141 ---- tools/led_sweep_si.py | 234 ------- 21 files changed, 52 insertions(+), 1831 deletions(-) delete mode 100644 docs/development/2p-line-sync-rig-checklist.md delete mode 100644 docs/development/2p-scanimage-signal-logging.md delete mode 100644 rig/SCANIMAGE-DAY.md delete mode 100644 rig/scanimage-day-2026-10-16.md delete mode 100644 rig/tests/test_tiff2mcap_runlog.py delete mode 100644 rig/tools/check_tiff2mcap.py delete mode 100644 rig/tools/tiff_run_to_mcap.py delete mode 100644 tools/led_sweep_quicklook.py delete mode 100644 tools/led_sweep_si.py diff --git a/.agents/skills/g6-orientation/SKILL.md b/.agents/skills/g6-orientation/SKILL.md index ffd8f56d..2fe16e5a 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** | **Current location (since 2026-10-26)** of the shared ROS 2 infrastructure for every lab rig: `rig_interfaces` (FicTracFrame, ArenaFrame, ConnectorReply), the device adapters + simulators (FicTrac, Kinefly, BIAS, ScanImage; SiPM and the ScanImage frames node follow when their PRs land), the MCAP recorder/indexer/CDR decoder/Foxglove rewriter, the supervision primitives and the Zenoh configs, as the `rig_nodes` + `rig_interfaces` packages (one package-set version, conda channel at a release tag). `CONTRACT.md` there is the graph contract (topics, QoS, envelope, invariants). webDisplayTools' `rig/` installs the set and keeps only the arena gateway, composition and visualization. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. | +| **reiserlab/rig-nodes** | **Current location (since 2026-10-26)** of the shared ROS 2 infrastructure for every lab rig: `rig_interfaces` (FicTracFrame, ArenaFrame, ConnectorReply), the device adapters + simulators (FicTrac, Kinefly, BIAS; the 2P imaging code — ScanImage JSON connector, ZMQ frames node, SiPM node, tiff2mcap — is private since 2026-10-28, in reiserlab/scanimage-2026-bergamo `2p-rig-nodes/`), the MCAP recorder/indexer/CDR decoder/Foxglove rewriter, the supervision primitives and the Zenoh configs, as the `rig_nodes` + `rig_interfaces` packages (one package-set version, conda channel at a release tag). `CONTRACT.md` there is the graph contract (topics, QoS, envelope, invariants). webDisplayTools' `rig/` installs the set and keeps only the arena gateway, composition and visualization. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. | ### G6 firmware (what the web connects to over USB) | repo | role | @@ -125,7 +125,7 @@ interface. 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, released 2026-10-20/26)** — `rig/` replaces the bridge with a graph - (rmw_zenoh, MCAP record, BIAS + ScanImage connectors) behind the SAME WebSocket; the Studio is + (rmw_zenoh, MCAP record, the BIAS connector; the 2P rig adds its private ScanImage/SiPM nodes) behind the SAME WebSocket; the Studio is unchanged. Since 2026-10-26 the device nodes, simulators and recording primitives come from the installed `rig_nodes` package (reiserlab/rig-nodes); `rig/` keeps the gateway (`bridge_node.py`), the composition (`rig.py`) and the arena visualization. Record: `rig-software-repos-and-runners.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 24ab1b0d..a86b93ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -377,7 +377,8 @@ fix flows to every page automatically; two hand-written HTML pages never will. 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 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 + + optional `--bias` / `--foxglove`; the 2P rig's ScanImage and SiPM nodes are private, in + reiserlab/scanimage-2026-bergamo `2p-rig-nodes/`). 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 `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. diff --git a/configs/rigs/bergamo_g6_2x10_2p.yaml b/configs/rigs/bergamo_g6_2x10_2p.yaml index ccd9e242..040437c6 100644 --- a/configs/rigs/bergamo_g6_2x10_2p.yaml +++ b/configs/rigs/bergamo_g6_2x10_2p.yaml @@ -5,7 +5,7 @@ # asserted by Arena Studio before every run and written into the run header # (docs/development/controller-settings-strategy.md). # -# Cabling (rig checklist docs/development/2p-line-sync-rig-checklist.md): +# Cabling (rig checklist, now in reiserlab/scanimage-2026-bergamo procedures/webdisplaytools-2p/2p-line-sync-rig-checklist.md): # Digital IO 1 (J3) → vDAQ free digital input (D2.1), ScanImage "Aux trigger 1": # one 5 V pulse per 300 Hz frame transfer, stamped into every TIFF header. # Digital IO 2 (J4) ← ScanImage line clock (HIGH while pixels are recorded, LOW in the diff --git a/docs/development/2p-line-sync-rig-checklist.md b/docs/development/2p-line-sync-rig-checklist.md deleted file mode 100644 index 24342d33..00000000 --- a/docs/development/2p-line-sync-rig-checklist.md +++ /dev/null @@ -1,138 +0,0 @@ -# Rig checklist — BETA 2P line-sync panel firmware on the Bergamo (2026-09-23) - -> **Superseded (2026-09-29).** The 2P line-sync behaviour is part of the single production panel build from -> `panel-fw-v1.3.0` on (Triggered: falling edge, free-running, 1 µs BCM base; every other mode unchanged). The -> BETA image and its catalog/flasher entries this checklist refers to were removed from webDisplayTools; the -> panel-firmware release notes and `g6_01` spec carry the current procedure. Kept for the rig-run history below. - - -For whoever runs the test at the rig PC. **Nothing needs to be cloned, pulled, or downloaded**: the -firmware and the protocol are both in the published Arena Studio's own catalogs (merged 2026-09-23). -No bench test was run on this firmware; the rig's own line clock is the test. Roll-back is one ISP push of -an image in the same catalog (§ 6). - -## 0. What you need open - -| What | Where | -|---|---| -| Arena Studio (published) | https://reiserlab.github.io/webDisplayTools/arena_studio.html | -| This checklist | https://github.com/reiserlab/webDisplayTools/blob/main/docs/development/2p-line-sync-rig-checklist.md | -| ScanImage logging guide (full version; § 3 below is the minimal one) | https://github.com/reiserlab/webDisplayTools/blob/main/docs/development/2p-scanimage-signal-logging.md | -| Background (why / what changed) | https://github.com/reiserlab/LED-Display_G6_Firmware_Panel/blob/main/panel/bench/2p-line-sync-2026-09-22.md | - -The firmware image is the Studio catalog entry **"v0.3.1 — BETA 2P line-sync: active-low EINT + 1 µs BCM -base + free-running Triggered (9014b5b+ …)"**; the Studio checks its sha256 after fetching. The protocol -is the library entry **"2P line-sync duty sweep — G6 2×10 on the Bergamo"**. Fallback if the site's -catalog is unreachable: download -https://raw.githubusercontent.com/reiserlab/webDisplayTools/main/flasher/firmware/g6-panel-v0.3.1-isp-BETA-eintlow-2p-9014b5b.bin -(136,352 bytes) and -https://raw.githubusercontent.com/reiserlab/webDisplayTools/main/protocols/g6_2x10_2p_duty_sweep.yaml -and use "Load local .bin…" / "Open protocol → local .yaml" instead. - -What the firmware changes, in one line each: BCM base time 3 → 1 µs (a full-duty row is ~15 µs and -fits the ~18 µs line-clock gap; brightness at a given duty is ⅓ of before); panel display mode 2 -(Triggered) free-runs one row per line-clock edge instead of stopping after 20. SPI ingest and the ISP -path are unchanged. - -## 1. Flash all 18 panels (Arena Studio) - -1. Connect to the controller (USB). The connect log should show firmware `781efe2b · 2×10`. -2. Panel firmware panel → **Choose…** → under **Official ISP images** pick the **BETA 2P line-sync** entry - (not the default) → **Use**. The Studio fetches it, verifies its sha256, reads the footer - (`2p-9014b5bb-d`, CRC `38282271`) and uploads it to the controller SD. It refuses to continue if the - SD write CRC does not match — just re-upload. -3. Tick **verify each**, then flash **all 18 panels** (1–7, 9–17, 19–20). Do not leave a mixed arena. -4. The verify sweep must read `MATCH 0x38282271` for every panel. If one says MISMATCH, flash that - panel once more. Flashing needs the display stopped (the Studio does this itself). - -## 2. Two-minute functional check before imaging (this replaces the bench test) - -1. Controller ▾ → panel display mode **1** (Persistent). Play any pattern. All panels show it, about - ⅓ as bright as you are used to. → the build boots, ingests SPI, scans. -2. Panel display mode **2** (Triggered). Line-clock BNC on **J4**, J30 shunt installed. Panels go - **dark** (no clock yet). Start ScanImage **Focus**: panels light and stay steady. Stop Focus: dark. - → the free-running Triggered path works off the real line clock. -3. If only the top row or two of each panel light in step 2, the controller is in mode 3 (Gated). - Set mode 2. - -## 3. Setup for the protocol run - -| Item | Setting | Wire (Debug ▾ → unlock → Send raw hex) | -|---|---|---| -| **session rig** (top bar) | **Bergamo G6 — 2×10 two-photon line-sync (strict)** | — the protocols' `rig:` must match it or the run is refused | -| panel display mode | 2 (Triggered) — **asserted automatically** by the rig default at connect and by the protocols' `controller:` block before every run; the log shows `panel mode: persistent → triggered (verified …)` | manual fallback `02 1B 02` | -| Digital IO 1 (BNC **J3**) | out_debug_framescan — HIGH during every SPI frame transfer (applied from the rig at connect) | read back: `01 AD` | -| Digital IO 2 (BNC **J4**) | in_trigger — the line-clock input (applied from the rig at connect) | read back: `01 AD` | -| refresh rate | ≤ 300 Hz — **asserted automatically** (rig `limits.max_refresh_hz` + protocol `refresh_policy: line_sync_safe`) | manual fallback `03 16 2C 01` | -| panel firmware | footer `2p-…` **checked automatically**; run Settings → Controller → **Verify panels** once per session (~20 s) so the run header carries a fleet verify | — | -| telemetry ring | on (default) | — | -| pattern | `frame2_h_ccw_200f`, SD index 4 (built-in g6_2x10 set) — or any **Gray_16** pattern on the card | edit the two anchors at the top of the YAML | - -Record in ScanImage (vDAQ) alongside Ch2 / Ch3 — **minimal version, everything in the TIFF**: - -- **Arena J3 → a FREE vDAQ `D2.x` input on its own cable** (Bergamo: `D2.1`; `D2.0` is the resonant - sync), and on the imaging system's **Triggers** tab set **Aux trigger 1** to that port. **Use a FREE digital input.** The resonant scanner sync already occupies one `D2.x` port (on the Bergamo it is `D2.0`) and the acquisition triggers may occupy others — check the Resource Configuration. Never T the arena signal into an existing cable: on 2026-09-23 J3 spliced into the sync cable clamped the sync during every frame transfer, ScanImage lost period lock, and no frames were formed while the display was on. - That puts the 300 Hz frame-transfer times into every frame header. The protocol's 2 s `allOff` between - steps stops those pulses, so each duty epoch shows up as a block of timestamps with gaps between. -- **Optional:** photodiode on one panel → rear SMB **AI3 = Channel 4** (keep it under 2 Vpp), Channels - window → Save. Only needed to separate real leak light from SiPM recovery at low duty. -- Nothing triggers ScanImage: start the Grab by hand, then press Run. - -The full version (Data Recorder `.h5`, line/frame clock copies, AO marker) and the verification steps are -in [`2p-scanimage-signal-logging.md`](2p-scanimage-signal-logging.md). Ch3 is the leak you are measuring, -not a sync signal. - -## 4. Run - -The Bergamo PC has **no bridge process** (the `pixi run bridge` logger the course rigs use), and none is -needed: use the **blue ▶ Test experiment** button, not the green ▶ Run experiment. The green button is -gated on the bridge and will refuse with "Bridge not connected". Test experiment runs the whole sequence -on the arena exactly the same way; the only differences are that nothing is auto-committed to the course -repo and the controller telemetry ring is not drained (not needed — the TIFF aux-trigger record is the -timing source for this test). - -1. Studio → **Open protocol** → **Open from Library…** → **"2P line-sync duty sweep — G6 2×10 on the - Bergamo"** (~62 s: blank · duty 25 · 64 · 128 · 191 · 255 (rotating) · duty 128 static · blank, with a - 2 s allOff between steps). -2. Start the ScanImage **Grab** first, then **▶ Test experiment**. -3. When it finishes, click **⬇ Save** in the log strip — it downloads the browser run log (`.json` + - `.txt`: every condition and command with host timestamps and controller replies). Keep it next to the - TIFF; it is the record of which duty ran when, and of the panel-mode / DIO-role commands. -4. If time allows, roll back (§ 6) and run the same protocol again on the old firmware. - -### 4b. The diagnostic runs of the week of 23 Sept (before the Thorlabs email) - -Run order, all unidirectional, fill 0.9, Ch2 + Ch3, one Grab per run (plan and predictions: -`Bergamo_testing/092226_SNR/analysis/PLAN_rig_tests_before_thorlabs_email.md`): - -1. **Objective capped**, the duty sweep above, unchanged. Decides optical vs electrical. -2. **SiPM bias series**: the sweep at the current gain setting, then at two lower gain settings on both - modules. Write down what the control is (voltage or gain number) and its value for each file. -3. **Brightness diagnostics**: protocol **"2P line-sync diagnostics — full-field brightness ramp/steps, - 1 Hz flash, 10 % sparse, 6 s all-off"** (~52 s, duty 128 throughout except the last flash at 255). - It needs four new patterns on the SD card, once: **⛭ Console → Patterns → Add ▾ → From course repo…** - → the protocol's folder `g6_2x10_2p_diagnostics_patterns` (four small GS16 files; keep the four - existing patterns). The run view's pattern preflight must show all four resolved before you press - ▶ Test experiment. -4. **Arena covered** (cloth over the LEDs, or emitters unplugged) with the panels running, objective - uncapped: the duty sweep again. Panel electrical pickup on its own. - -Duty 255 stays in these diagnostic protocols on purpose (it is the anomaly under study); for experiments -keep duty ≤ ~190 in every scan mode. - -## 5. What to expect / what would mean trouble - -- Expected: no pedestal in the imaged part of the line at any duty; no 300 Hz line in Ch3. Edge - columns may keep a small stationary offset that is identical on every line (detector recovery). -- Pedestal only at duty 255: the ~15 µs row is over the gap margin. Usable range is everything below. -- Pedestal at all duties, scaling with duty: real light during the imaged line — stop, save everything, - send the Ch3 folded waveform against the looped-back line clock. -- Panels lit with no line clock in mode 2: wrong mode, or wrong firmware on some panels — re-run the - verify sweep; Panel firmware → Info (0xE3) shows the SD footer `2p-9014b5bb-d`. -- **Stop display does not blank on this firmware; All off does.** The protocol ends with allOff. - -## 6. Roll-back - -Studio → Choose… → official images → **"v0.3.1 — Active-low EINT trigger + ISP indicator (9014b5b)"** -→ flash all 18 with verify → `MATCH 0x9871E334` everywhere. That is exactly the firmware the arena had -on 22 Sept 2026. diff --git a/docs/development/2p-scanimage-signal-logging.md b/docs/development/2p-scanimage-signal-logging.md deleted file mode 100644 index 6f74b3a8..00000000 --- a/docs/development/2p-scanimage-signal-logging.md +++ /dev/null @@ -1,133 +0,0 @@ -# Logging arena sync signals in ScanImage (vDAQ) — where they go and how to check they got there - -Companion to `2p-line-sync-rig-checklist.md`. Sources: ScanImage docs and the vDAQ datasheet, linked -inline; verified against the Rev. A4 datasheet tables (April 2021). - -## 0. Minimal version (recommended for the first run) — everything inside the TIFF - -Nothing here triggers ScanImage; you start the Grab by hand. Aux triggers only stamp times into frames -you are already acquiring. - -1. **One cable:** arena **J3** (Digital IO 1 = `out_debug_framescan`, a ~0.7 ms pulse per 300 Hz frame - transfer) → a **FREE** vDAQ breakout digital input (Bergamo: **`D2.1`**), on its own cable. **Use a FREE digital input.** The resonant scanner sync already occupies one `D2.x` port (on the Bergamo it is `D2.0`) and the acquisition triggers may occupy others — check the Resource Configuration. Never T the arena signal into an existing cable: on 2026-09-23 J3 spliced into the sync cable clamped the sync during every frame transfer, ScanImage lost period lock, and no frames were formed while the display was on. -2. **One dropdown:** Resource Configuration → imaging system → **Triggers** tab → **Aux trigger 1** = that port - (`D2.1`). GUI "Aux trigger 1" lands in header key `auxTrigger0`. -3. **Protocol:** `g6_2x10_2p_duty_sweep.yaml` already has a 2 s `allOff` between steps. `allOff` disarms - the controller's refresh timer, so the J3 pulses stop, and each duty epoch is a block of ~5.5 - timestamps per frame in `Aux Trigger 1` separated by a ~2 s gap. Epoch order = protocol order. -4. Ch2 / Ch3 as before. That is all you need to test "pedestal and 300 Hz line vs duty" and to compare - the two firmwares. -5. **Optional, only if a photodiode is at the rig:** photodiode → rear SMB **AI3 = Channel 4** (< 2 Vpp), - Channels window → Save. It is the only way to tell a residual at low duty apart from SiPM recovery. - -Not needed for this test: recording the line clock (it is generated by the same FPGA as the pixel clock, -so image columns already are the line-clock phase, and its shape is known from the scope), the frame -clock, the AO marker, or the Data Recorder. §§ 1–4 below describe those for the full version. - -## 1. Three places an external signal can end up - -| Route | Hardware input | Sample timing | Where the data lands | Good for | -|---|---|---|---|---| -| **Auxiliary trigger** | vDAQ breakout, an **input-capable digital line (D2.x)**, 5 V TTL | edge timestamps, 8 ns resolution, FPGA-timed | **inside the TIFF**: per-frame header keys `auxTrigger0…3` (shown as `Aux Trigger n = [t1 t2 …]`, seconds from acquisition start) | sparse digital events: the arena's 300 Hz frame-transfer pulse, condition-change markers, a frame clock copy | -| **Data Recorder** | breakout **precision analog inputs AI0–AI11**, ±10 V, 16-bit, 5 GΩ | continuous, 500 Hz – 500 kHz | **separate HDF5 file** (`_.h5`), one dataset per signal, no timestamp dataset | anything continuous or analog: line clock, AO condition marker, photodiode, frame clock copy | -| **Image channel** | PCIe card rear **SMB high-speed AI 0–3**, 0.5–2 Vpp, 50 Ω, 14-bit, 62.5–125 MS/s | pixel clock, **only during the imaged part of each line** | **inside the TIFF** as an extra channel (Ch1–Ch4 ↔ AI0–AI3) | signals you want mapped onto image pixels: a photodiode showing where in the field light arrives (what Ch3 = AI2 did on 22 Sept) | - -Datasheet: [vDAQ Datasheet Rev. A4](https://vidriotechnologies.com/wp-content/uploads/2022/01/vDAQ-Datasheet.pdf) -— Table 1 (high-speed AI), Table 2 (precision AI), Tables 4–5 (DIO). Digital groups 0 and 1 have -per-line direction, groups 2 and 3 are group-directed; in ScanImage practice **D2.x are the inputs -(external triggers), D3.x the outputs (exported clocks)** ([vDAQ Configuration](https://docs.scanimage.org/Configuration/DAQ/vDAQ+Config.html), -[Stage Scanning](https://docs.scanimage.org/Premium+Features/Stage+Scanning.html) uses "any of the D2.X ports as a digital input"). -Input high ≥ 2.0–2.3 V, so the arena's 5 V outputs and a 3.3 V line clock both register. - -## 2. Step by step - -### 2a. Export the line clock and frame clock (so they exist on BNCs) - -1. ScanImage → **Resource Configuration** → your imaging system (the vDAQ scan system) → **Triggers** tab. -2. **Line clock out** → pick an output line, e.g. `D3.0`. **Frame clock out** → e.g. `D3.1`. - ([Imaging Systems](https://docs.scanimage.org/Configuration/Imaging+Systems.html), [Exported Clocks](https://docs.scanimage.org/Concepts/Triggers/Exported+Clocks.html)) -3. The line clock is HIGH while a line is imaged and LOW during the turnaround (measured 22 Sept: - 44.8 µs HIGH / 18.4 µs LOW at 15.825 kHz). The frame clock is a 5 V pulse at the start of each raster. -4. Line clock BNC → **T** → arena **J4** (Digital IO 2, `in_trigger`) **and** a Data Recorder AI (see 2c). - -### 2b. Aux triggers → TIFF header - -1. Same **Triggers** tab → **Aux trigger 1** → pick a FREE input line (Bergamo: `D2.1`; `D2.0` carries the - resonant sync); **Aux trigger 2** → another free line (`D2.2`). - ([Auxiliary Trigger](https://docs.scanimage.org/Concepts/Triggers/Auxiliary+Trigger.html)) -2. Wire **arena J3** (Digital IO 1 = `out_debug_framescan`, one ~0.7 ms HIGH pulse per SPI frame transfer, - 300 Hz) → `D2.1` on its own cable. Optionally the **frame clock** (D3.1 → T → `D2.2`) so every TIFF frame also carries its - own clock edge, which lets you align the HDF5 recording to the TIFF (§ 3). -3. Constraints from the docs page: resonant scanning only (the Bergamo is); **mutually exclusive with - I2C recording and photon counting**; hard limit 1000 timestamps per frame, **keep it to ~10** — the - J3 pulse gives ~5.5 per 18.2 ms frame, fine; the **line clock (288 per frame) must never go on an aux - trigger** — the per-frame header is a fixed ~2001-byte buffer and later keys are silently dropped when - it overflows ([scanimage-octo-reader README](https://github.com/horsto/scanimage-octo-reader)). - Rising edges are timestamped; a debounce (`DEBOUNCE_TIME_AUX_TRIGGERS` in `Acquisition.m`) rejects very - short pulses — 0.7 ms is far above it. -4. Where it lands: each TIFF page's `ImageDescription` gains - `Aux Trigger 1 = [0.3138 0.3316 …];` — seconds on the same FPGA clock as `frameTimestamps_sec`. - -### 2c. Data Recorder → HDF5 - -1. **Resource Configuration** → **+** → add **Data Recorder** (vDAQ) → open its configuration page. - ([vDAQ Analog Data Recorder](https://docs.scanimage.org/Basic+Features/Data+Recorder.html)) -2. **+** → add signals: `AI0` line clock copy, `AI1` arena **J27 AO** condition marker (0/0.5/…/3.0 V), - `AI2` **J3** frame-transfer envelope copy, `AI3` frame-clock copy, `AI4` photodiode (if you have one). - Give each a dataset name (no `/`), optional units. -3. **Sample Rate** = **500000** (500 kHz, the maximum → 2 µs; ~31 samples per line, ~9 in the 18 µs gap). -4. Tick **Auto Start** (starts/stops with the Grab), **Duration = Inf**, leave **Use Trigger** off. -5. **File Directory** = the same folder as the TIFF, **File Basename** = the TIFF basename; the file is - `_.h5`. Optional **Compression** (gzip 5). -6. Where it lands: **not in the TIFF**. One `.h5` per acquisition, one dataset per signal, plain sample - arrays at the sample rate. There is **no timestamp dataset**, hence the frame-clock copy in step 2: - the first frame-clock edge in the `.h5` is TIFF frame 1. - -### 2d. (Optional) photodiode as an image channel - -1. Photodiode amplifier → PCIe **rear SMB AI3** (this is Channel 4). Keep the signal **within 0.5–2 Vpp** - (attenuate a 5 V amplifier output; overvoltage protection is ±10 V but the ADC clips). -2. **Channels** window → Channel 4 → **Save** and **Display**. -3. Result: an image whose pixel values are the light hitting the diode *during the imaged part of each - line* — the most direct picture of "is any LED light present while pixels are being formed". The gap - flash itself is invisible here (not sampled), which is what the Data Recorder copy is for. - -## 3. Verifying it worked (do a 10 s Grab before the real run) - -1. **Aux triggers** — read a frame header: - - MATLAB: `[header, ~, imgInfo] = scanimage.util.opentif('file_00001.tif');` and look at the per-frame - fields, or open the TIFF's `ImageDescription` with `imfinfo` and search for `Aux Trigger`. - - Python: `from ScanImageTiffReader import ScanImageTiffReader; r = ScanImageTiffReader(p); print(r.description(100))` - ([ScanImage Tiff Reader](https://vidriotech.gitlab.io/scanimage-tiff-reader/)). - - Expect `Aux Trigger 1 = [ … 5–6 values … ]` on every frame while the arena is running, `[]` while it - is stopped. `[]` on every frame = wrong port or the arena's DIO 1 is not in `out_debug_framescan` - (`01 AD` from the Studio prints the roles). - - Also check `frameTimestamps_sec` is present on every frame and that the key list is complete - (`auxTrigger0…3, I2CData` all present) — a missing tail means the header buffer overflowed. -2. **Data Recorder** — open the `.h5` (HDFView, `h5py`, or MATLAB `h5read`): one dataset per signal, - length ≈ sample rate × grab duration, line-clock dataset toggling at ~15.8 kHz, AO dataset stepping - with the protocol, frame-clock dataset with one pulse per 18.18 ms. -3. **Image channel** — Channel 4 shows the photodiode; with the display off it is flat. -4. Cross-check: the number of frame-clock edges in the `.h5` equals the number of TIFF pages per channel. - -## 4. Recommended hook-up for the duty-sweep run (summary) - -| Signal | Source | vDAQ input | Route | -|---|---|---|---| -| Line clock | D3.0 (Line clock out) | → arena J4, and AI0 | Data Recorder | -| Frame clock | D3.1 (Frame clock out) | D2.2 and AI3 | aux trigger 2 + Data Recorder | -| Frame-transfer envelope | arena J3 (DIO 1) | D2.1 and AI2 | aux trigger 1 + Data Recorder | -| Condition marker | arena J27 (AO) | AI1 | Data Recorder | -| Photodiode | amplifier | AI4, and SMB AI3 (Ch4) | Data Recorder + image channel | -| Leak (what you measure) | SiPM | Ch2 (gated), Ch3 (ungated) | TIFF | - -Everything digital is 5 V TTL into 5 V-tolerant vDAQ inputs; the arena's BNC outputs are level-translated -5 V. Use BNC T-pieces on the two clock lines, not daisy chains through the arena. - -## 5. What was missing on 22 Sept and why it mattered - -The handover notes "Header aux-trigger fields 0–3 and I2C are empty. No line clock / frame clock / arena -sync recorded." With §2b–2c in place, the 300 Hz phase is measured directly from `Aux Trigger 1` rather -than fitted at 300.03 Hz, the condition boundaries come from the AO trace rather than frame-number -guesses, and the photodiode separates LED light during the imaged line from detector recovery after an -in-gap flash — the open question the firmware change cannot answer by itself. diff --git a/docs/development/rig-software-repos-and-runners.md b/docs/development/rig-software-repos-and-runners.md index c0e9724e..37ec574d 100644 --- a/docs/development/rig-software-repos-and-runners.md +++ b/docs/development/rig-software-repos-and-runners.md @@ -113,6 +113,43 @@ stay as history. | **stays** (arena visualization) | `nodes/viz_node.py`, `tests/test_viz_node.py`, the arena derivation in `nodes/foxglove_json.py`, `foxglove/arena-fictrac.json`, `foxglove/arena-bias.json`, `docs/foxglove-arena-layout.json`, `tools/tiff_run_to_mcap.py`; from okey-cokey: `nodes/stimulus_node.py`, `nodes/stimulus_core.py`, `nodes/dashboard_replay.py`, `tests/test_stimulus.py`, `tests/test_stimulus_live.py`, `foxglove/arena-okey-cokey.json`, `tests/fixtures/protocols-2026-10-14/*` | | **stays** (rig-specific config and records) | `README.md`, `WINDOWS-TEST.md`, `SCANIMAGE-DAY.md`, `scanimage-day-2026-10-16.md`, `windows-results-ww18-2026-10-15.md`, `docs/plan-b-explainer*.html`, `docs/build-standalone-explainer.py`, `docs/img/*`; from okey-cokey: `config/bias/okey-cokey-bfs-u3-51s5m.json`, `tools/okey-cokey-start.ps1`, `tools/bias-ufmf.ps1`, the `okey-cokey-*.md` run sheets, `HANDOVER-okey-cokey-2026-10-21.md`, `docs/okey-cokey-foxglove-dashboard-plan.md` | +#### Amendment 2026-10-28: the imaging work is private + +Michael's decision, 2026-10-28: everything specific to the 2P imaging experiments leaves the public +repositories and lives in the private rig repository `reiserlab/scanimage-2026-bergamo`, under +`2p-rig-nodes/` (a pixi workspace in the rig-nodes layout; package `twop_rig_nodes`). It depends on +the public `rig_nodes` package, so dependencies still point downward. This overrides the table above +where they disagree: + +- **Moved to `2p-rig-nodes/`:** + - the ScanImage JSON connector (`scanimage_node`, `scanimage_cmd`, `si_json_mock`), which was in + rig-nodes 0.2.1 / 0.3.0 and is removed for 0.4.0 (rig-nodes #4); + - from #261 / #266: the ScanImage ZMQ frames node, its mock and latency report, the read-only SiPM + driver / node / command tool and their harnesses; + - `tiff_run_to_mcap.py` (#273) with `check_tiff2mcap.py` and its test. `patfile.py`, the G6 + pattern reader it uses, is arena code and stays here (bound for pyDisplayTools, the row above); + the private repository carries a copy; + - `tools/led_sweep_si.py`, `tools/led_sweep_quicklook.py`. + A `PROVENANCE.md` there gives the source commit of every file. Copied, not rewritten in history: + earlier versions stay readable here and in rig-nodes. +- **Records moved** to that repository's `procedures/webdisplaytools-2p/`: `rig/SCANIMAGE-DAY.md`, + `rig/scanimage-day-2026-10-16.md`, `2p-scanimage-signal-logging.md` and the superseded + `2p-line-sync-rig-checklist.md`. The Bergamo rows of rig-nodes `SETUP.md` went to + `2p-rig-nodes/SETUP.md`. +- **Stays public, the boundary:** + - the generic connector mechanism: the gateway's connector dispatch and `CONNECTOR_TOPICS` + (including the `scanimage` entry and, once the gateway half of #261 / #266 lands, the + `/sipm/*` and `/scanimage/frame_*` registrations), `mcap_convert`'s connector derivation, the + `//cmd|reply|status` contract, `ConnectorReply`, the runner's `connector_command`; + - the Studio's `ScanImagePlugin`, which must skip cleanly when no node answers; + - the studio emulator's `--scanimage-cmds` path. +- **Stays public for now:** the 2P protocols (`protocols/g6_2p_*`, `g6_2x10_2p_*`) with their + patterns, and `configs/rigs/bergamo_g6_2x10_2p.yaml`. Most protocols are expected to move to + private repositories later. +- **The arena composition** drops `rig.py --scanimage` / `--si-mock`. On the 2P rig the private + workspace runs `pixi run rig -- --no-router …` next to this one. The PR that re-pins `rig/` to + rig-nodes 0.4.0 must be the same PR, or a later one, as the one that drops those flags. + ### 3.4 The robot station `meca_driver`, `meca_description`, `meca_sequencer`, keypoints and routes stay in Isabel's repository; diff --git a/protocols/g6_2p_color_led_sweep.yaml b/protocols/g6_2p_color_led_sweep.yaml index 449d02f5..c07f4410 100644 --- a/protocols/g6_2p_color_led_sweep.yaml +++ b/protocols/g6_2p_color_led_sweep.yaml @@ -72,7 +72,7 @@ version: 3 # to `frames_per_grab` in pre_blank and the file stem per color; nothing else. # ScanImage must record: DO1 (frame-transfer envelope) and the line clock # loop-back on aux inputs or spare AI channels — see -# docs/development/2p-scanimage-signal-logging.md §0. +# reiserlab/scanimage-2026-bergamo procedures/webdisplaytools-2p/2p-scanimage-signal-logging.md §0. # # Notes # 1. duty: 0 in trialParams means "pattern's stored duty", NOT off. Off is allOff. diff --git a/protocols/g6_2x10_2p_diagnostics.yaml b/protocols/g6_2x10_2p_diagnostics.yaml index 5e4c4aaa..0809e7f4 100644 --- a/protocols/g6_2x10_2p_diagnostics.yaml +++ b/protocols/g6_2x10_2p_diagnostics.yaml @@ -49,7 +49,7 @@ version: 3 # flash kinetics — it is the cleanest mark and needs no extra wiring. # 3. The setAnalogOut markers below (0.5–2.5 V per condition, arena J27). Only # recorded if the vDAQ Data Recorder logs an AI (docs/development/ -# 2p-scanimage-signal-logging.md § 2c); host-timed like (1), so they add a +# 2p-scanimage-signal-logging.md § 2c, in the private 2P rig repo); host-timed like (1), so they add a # cross-check, not precision. A frame-locked AO ("frame_number" role) is not # validated on this controller yet — do not rely on it. # ============================================================================ diff --git a/protocols/g6_2x10_2p_duty_sweep.yaml b/protocols/g6_2x10_2p_duty_sweep.yaml index 2431a227..a67adeac 100644 --- a/protocols/g6_2x10_2p_duty_sweep.yaml +++ b/protocols/g6_2x10_2p_duty_sweep.yaml @@ -62,7 +62,7 @@ version: 3 # Not needed for this test: line clock (same FPGA as the pixel clock → image # columns already ARE its phase; shape known from the scope), # frame clock, AO marker, Data Recorder .h5. Full version: -# docs/development/2p-scanimage-signal-logging.md. The +# reiserlab/scanimage-2026-bergamo procedures/webdisplaytools-2p/2p-scanimage-signal-logging.md. The # setAnalogOut steps below are harmless if unrecorded. # Ch3 is NOT a display sync; it is the leak signal you are measuring. # diff --git a/rig/SCANIMAGE-DAY.md b/rig/SCANIMAGE-DAY.md deleted file mode 100644 index 09cf5bf0..00000000 --- a/rig/SCANIMAGE-DAY.md +++ /dev/null @@ -1,188 +0,0 @@ -# ScanImage day (2026-10-16): a real 2P experiment through the ROS 2 graph - -**Goal for today:** one simple, real experiment on the Bergamo rig — the G6 four-color arena shows -one-color top-row gratings at six panel duty levels while ScanImage grabs an ex vivo fly prep for -SNR / SiPM characterization (lasers ON, data run) — with ScanImage driven from the protocol through -the same connector path that drives BIAS, and the run log + bag holding the command/reply pairs so -the TIFFs can be aligned to the trials. If the connector path slips, the fallback (§6) still gets -the data today. - -Everything here is on branch `claude/ros-studio-plan-b-c44942` (draft PR #254): the ROS 2 graph, -Studio v0.96 with the `scanimage` plugin, the Windows results from ww18 (2026-10-15). **The -off-rig half is built and tested** (Mac, 2026-10-16 morning, commits 93fc80d…); the rig session -runs §1–§5. **Decided with Michael 2026-10-16:** ROS 2 graph adopted (LAB-303), MCAP primary, -**ScanImage's built-in JSON server, not si_zmp** (LAB-305: event-driven, no frame-timing -disturbance; si_zmp's polling timer disturbed frames), one grab per color, no analog marker today -(alignment = Digital IO 1 frame-transfer pulse into ScanImage), GS16 patterns (no refresh-rate -confound vs the 22 Sept runs), unidirectional scan, both channels, server local to the Bergamo PC. - -## The two-hour version (2026-10-16: 2 h of rig time in total — run THIS, not the long plan) - -Block Z is **not today** (Hannah's ZeroMQ frame-stream test, 60 min dark, conflicts with our -acquisitions; it runs whenever the rig is next free). Steps below are the long plan's §1–§5 with -the optional parts removed and the install overlapped with the hardware setup. - -| When | Who | What | Pass | -|---|---|---|---| -| 0:00 | Claude on the PC | `git switch` + `pixi install --locked` in `rig` (cold install can take minutes: start it FIRST). Skip `pixi run test` unless the install finished early | env installs | -| 0:00 | Michael | Arena on the microscope; Studio from the checkout (`python .claude/nocache-server.py 8092`), Connect | Inventory: 20 present | -| 0:10 | Michael | Controller ▾: mode 2, Digital IO 1 = framescan, refresh reads 300. Upload `protocols/g6_2p_color_led_sweep_patterns/` (4 files), note the slots | slots noted; IDs in the YAML fixed if not 5–8 | -| 0:15 | Michael | MATLAB (R2025b, ScanImage idle): `hJ = most.network.json.Server(5555);` logging on, `logFilePath` on F:, unidirectional, channels 1+2 | — | -| 0:20 | Claude | `pixi run rig -- --scanimage` then `pixi run scanimage-cmd -- ping` and `-- status --json` | ping ok, status shows logging on + F: path | -| 0:25 | both, dark | New-arena trigger check: FOCUS → Test full-field ≤ 128 lights every panel; stop FOCUS → all dark; DO1 pulses + line clock on ScanImage's inputs | all three seen | -| 0:35 | Claude, dark | One grab through the connector: `set framesPerSlice 60`, `setFileStem connector_check`, `grab`, `waitIdle` | one TIFF on F:, counter +1, idle again | -| 0:40 | both, dark | Dry run of the whole protocol from the Studio (≈ 2.5 min + overhead) | 4 TIFF series, DO1 in each, bag closed at run end, log converts with 4 grab replies | -| 0:55 | Michael | Prep in, lasers on, SiPMs recording | — | -| 1:05 → 1:50 | both | The experiment: the protocol once per repeat (≈ 3 min each incl. the operator check between runs); 3 to 5 repeats | per run: TIFFs, bag, converted log | -| 1:50 | both | `clear hJ`, arena `allOff`, copy TIFFs + logs; results log filled in | — | - -If the connector ping (0:20) or the single grab (0:35) fails and is not fixed in ten minutes: -switch to §6 (fallback script) at once and keep the schedule. - -## Where things are - -| What | Where | -|---|---| -| **ScanImage remote control = the built-in JSON server** (`most.network.json.Server`): TCP 5555, `_sweep` → grab → 1 s → six conditions (duty 7/15/31/63/127/255, 3 s each, 20 fps) → allOff → waitIdle → post_blank. ≈ 2.5 min. Everything tunable in `variables:` | `protocols/g6_2p_color_led_sweep.yaml` (+ `protocols/index.json`); header = controller settings, ScanImage setup, new-arena check | -| **Patterns (GS16)**: the four one-color top-row gratings re-encoded as Gray_16 (lit = 15), names `G6_2x10_toprow_grat20__gs16`, **to be uploaded onto the card** (Console → Patterns → Add ▾ → folder); expected slots 5–8 after the GS2 set in 1–4 — verify in the SD listing (the Studio resolves by NAME; IDs are the fallback) | `protocols/g6_2p_color_led_sweep_patterns/` (`node scripts/make-color-optomotor-patterns.js --gs16`) | -| **Fallback driver** (no ROS, stdlib Python): same condition table and TIFF layout | `tools/led_sweep_si.py` (`--dry-run`, `--ping`) | -| **Panel firmware facts (side session 2026-10-16, note `panel-firmware-lineage-2026-10-16.md`)**: all 20 panels carry the 2P timing (falling-edge free-running Triggered, 1 µs base in Triggered, EINT pull-down); bottom row v1.3.1 (`0xB8796BAF`), top row bank-window diag builds (`0x362644DD` / `0x1A74DD1D` / `0x9E03D589`); **20 panels, not 18**; clean bank-window builds hung at boot before → run Inventory after every power cycle; never mode 3 (Gated) on the Bergamo; the 5 A supply resets on all-on at duty 255 → test displays ≤ 128; Triggered gives ⅓ of mode-1 light and the top row's bank windows cut ~5× more | `docs/development/2p-line-sync-rig-checklist.md` (§1 flash is NOT needed today; §2 check applies), `2p-scanimage-signal-logging.md` | -| Hannah's Block Z (ZeroMQ frame stream, ~60 min, runs DARK, changes channelSave/framesPerSlice/acqsPerLoop/logging and restores them; no acquisitions from another block while its subscriber runs) | `reiserlab/scanimage-2026-bergamo` `procedures/rig-session-2026-10-16.md` + `-zmq-frames.md`. **Not today** (2 h of rig time); it runs whenever the rig is next free. Ports would not collide (Z: ZMQ PUB 65179; ours: JSON 5555) | -| Rig-PC environment notes (git not on PATH → GitHub Desktop's git; MATLAB `matlab` is R2026a, ScanImage needs **R2025b**; Python from the operator's terminal; F: for data; `logFilePath` was empty) | same repo, `procedures/rig-session-2026-10-16.md` → "Environment notes" | - -## Settled decisions (2026-10-16, Michael) - -1. **PC:** the Bergamo PC `reiserlab-bergamo1` runs everything: ScanImage + JSON server, the ROS graph, - the Studio (served from this checkout), the arena controller. Client on `127.0.0.1` (sidesteps B1 - and the server's all-interfaces bind). -2. **Panels:** no flashing today. Verify with Inventory (20 present) and the §3 behavior check. -3. **Stimulus:** 4 colors × 6 duties (7/15/31/63/127/255) × 3 s at 20 fps on the GS16 gratings, - one grab per color (startGrab costs 1.2–2.4 s, so per-condition grabs would waste more than they - image). Repeats = run the protocol again (new TIFF counters). -4. **ScanImage:** unidirectional, both channels, logging on, TIFFs under `F:\ImagingData\2026-10-16\…`, - framesPerSlice set by the protocol (640 ≈ 22 s at 29 Hz). Lasers on. -5. **Alignment:** Digital IO 1 (BNC J3) frame-transfer pulse + the line clock loop-back recorded by - ScanImage (aux triggers → TIFF header, or spare AI channels). No AO marker today. - -## Plan (in order; stop and report after each) - -### 1. Bergamo PC setup (30 min, no hardware) - -``` -# a terminal the operator owns (git via GitHub Desktop if `git` is not on PATH) -git clone https://github.com/reiserlab/webDisplayTools.git # or fetch an existing clone -cd webDisplayTools && git switch claude/ros-studio-plan-b-c44942 && git pull -cd rig && pixi install --locked # default env (String topics); 25 s warm, minutes cold, ~1.5 GB -pixi run test # harness: expect 52 / 52 (≈ 60 s; ports 7447/8765/60000 must be free) -``` -Then ScanImage (R2025b MATLAB), idle: -```matlab -hJ = most.network.json.Server(5555); % the JSON server; keep this handle; clear hJ to stop it -hSI.hChannels.loggingEnable = true; hSI.hScan2D.logFilePath = 'F:\ImagingData\2026-10-16\led_sweep'; -``` -And the connector against the real server: -``` -pixi run router # terminal A (or skip: `pixi run rig` starts it) -pixi run scanimage-node # terminal B: 127.0.0.1:5555, status 1 Hz -pixi run scanimage-cmd -- ping # terminal C → "ping → ok (… ms) value={'acqState': 'idle'}" -pixi run scanimage-cmd -- status --json # acqState, lastFrameNumber, logFileCounter, stem, path, loggingEnable -``` -**Pass:** ping ok with a few-ms RTT; status shows logging on and the F: path. Record the RTT. - -### 2. Connector bench against the real ScanImage, dark (15 min) - -``` -pixi run scanimage-cmd -- set --property hStackManager.framesPerSlice --value 60 -pixi run scanimage-cmd -- setFileStem --stem connector_check -pixi run scanimage-cmd -- grab # ≈ 1–3 s; reply carries artifact {stem, counter, path} -pixi run scanimage-cmd -- waitIdle --timeout-s 20 -pixi run scanimage-cmd -- grab && pixi run scanimage-cmd -- abort -``` -**Pass:** one TIFF `connector_check_00001.tif` on F:, logFileCounter incremented, acqState back to -idle, Hannah's `hMon` shows no lostFrames, MATLAB never froze. **Record:** grab start RTT (the -synchronous startGrab), TIFF frame count. - -### 3. The arena on the microscope (new arena: 20 min) - -Studio from this checkout (not Pages): `python .claude/nocache-server.py 8092` at the repo root → -`http://localhost:8092/arena_studio.html?advanced=1` (soft password per the branch notes). Connect. -1. **Console → Panel firmware → Inventory:** 20 present; 1–10 `fw_match 0xB8796BAF`; 11–20 - `fw_differs` with `0x362644DD` / `0x1A74DD1D` / `0x9E03D589`. Repeat after any power cycle. -2. **Controller ▾:** panel display mode **2** (Triggered). **Digital IO 1 = out_debug_framescan.** - Digital IO 2 stays `in_trigger` (line clock → EINT). **Refresh rate reads 300 Hz** (GS16 default). -3. **Trigger + edges (the new-arena check):** ScanImage FOCUS (dark) so the line clock runs → - Console → Test → full-field at level **≤ 128**: every panel lit at ~⅓ of mode-1 brightness. - Stop FOCUS → every panel dark. On the scope / ScanImage AI: DO1 shows one pulse per controller - refresh tick (~0.7 ms wide), the line clock is on its loop-back channel. `stopDisplay` does not - blank; `allOff` does. -4. **Patterns:** Console → Patterns → Add ▾ → `protocols/g6_2p_color_led_sweep_patterns/` (4 GS16 - files). SD listing shows `…_gs16` names; note their slots. If they are not 5–8, fix the four IDs - in the protocol's `variables:` (the Studio resolves by name; the IDs are the fallback). -5. **Graph up:** `pixi run rig -- --scanimage` (router + fictrac adapter + scanimage node + bridge - node). Studio Console → FicTrac/bridge: Connect → hello_ack lists connectors `bias, scanimage`. - -### 4. Dry run without the prep, dark (20 min) — the go/no-go - -Lasers off. Studio → Open `protocols/g6_2p_color_led_sweep.yaml` → Run (experiment). **Pass:** -- 24 stimulus conditions + 2 blanks run; four TIFF series `violet_sweep_…`, `blue_sweep_…`, - `green_sweep_…`, `yellow_sweep_…` on F:, each ≈ 640 frames; -- DO1 pulses visible in each TIFF's aux record / AI channel; six brightness steps visible on the - arena by eye (the duty steps); -- the run ends, the bridge's run file AND the bag close (`[record] closed …bag` in the rig terminal); - `pixi run convert -- ` (or the Studio export) gives a `.jsonl` with 4 `grab` replies carrying - `artifact.stem` + `counter`, 4 `waitIdle` ok, `setFileStem` ×4, `set` ×1; `runlog-check` OK. -- `hMon`: no lostFrames attributable to the connector (compare with Block Z's idle baseline). - -### 5. The experiment (with Michael) - -Prep in, lasers on, SiPM channels recording. Run the protocol once per repeat. After each run: -the converted log, the bag, the TIFF list, the DO1 trace. **Alignment check:** TIFF frame times vs -the `grab` reply `t_ms` and the first DO1 pulse of each color block; duty steps vs the trialParams -`["a",…]` echoes in the log. - -### 6. Fallback (keeps today's data if the connector path slips) - -`python tools/led_sweep_si.py --dry-run` → `--ping` → `python tools/led_sweep_si.py` on the rig -PC (stdlib Python, talks to the JSON server directly). It sets the stem and starts the grab per -color and cues the operator; the Studio runs the matching color block by hand (**Test experiment** -or Run on a copy of the protocol with the `scanimage` commands removed). Same one-TIFF-per-color -layout and DO1 alignment, so the analysis does not change. - -## What still needs the rig (cannot be tested off-rig) - -- The JSON server's real reply shape for `set` (`actual`) and `eval` on ScanImage 2026.0.0 — the - mock follows Hannah's mock; a difference shows up in step 1/2 as an `error` reply, and the - node's `steps` list says which request. -- startGrab latency on the Bergamo PC (1.2–2.4 s in LAB-305) → whether `grab_settle: 1` is enough - (the grab reply already waits for `acqState == grab`, so the settle covers scanner start only). -- The arena's trigger path on the microscope (step 3.3), the DO1 record in ScanImage, the SD slots - after the upload, and the bank-window boot behavior after power cycles. -- Windows specifics of the node: the TCP client is stdlib; `raise_timer_resolution()` applies as - for the other nodes. `pixi run test` is the check (52/52 expected; ww18 was 47/47 before today). - -## Rules -- Nothing goes to Production or Next; all code on branch `claude/ros-studio-plan-b-c44942`, commits - name the step. Never change ScanImage's vendor files, `MicroscopeMDF.m`, `.cfg`/`.usr`, or the - power plan (the scanimage repo's rules apply). Clear the JSON server (`clear hJ`) at the end. -- Dark until Michael says the prep is ready (steps 1–4). Confirm before anything that starts FOCUS, - GRAB or LOOP while someone is at the microscope. -- Keep a results log from the first command (`rig/scanimage-day-2026-10-16.md`): one row per - step, timings, every error verbatim. Add our block to the scanimage repo's session index - (`procedures/rig-session-2026-10-16.md`, block letter S, this file as the plan) — that repo - commits straight to `main`. - -## Paste into Claude Code on the rig PC - -``` -ScanImage day on the Bergamo rig (LAB-303 / LAB-306). Fetch reiserlab/webDisplayTools, switch to -claude/ros-studio-plan-b-c44942 (git pull) and read rig/SCANIMAGE-DAY.md end to end: the -off-rig half is built and tested; today is steps 1–5 in order, dark through step 4, lasers on -only when Michael says so. Also read procedures/rig-session-2026-10-16.md in -reiserlab/scanimage-2026-bergamo (Hannah's Block Z runs today on this rig, dark, before us; its -environment notes apply). Stop and report after each numbered step, keep the results log the -brief asks for, and use the fallback (§6) rather than debugging on site if the connector path -fails. Small fixes go on the branch; never touch Production, Next, or ScanImage's own files. -``` diff --git a/rig/WINDOWS-TEST.md b/rig/WINDOWS-TEST.md index a0701fa3..b783a7c2 100644 --- a/rig/WINDOWS-TEST.md +++ b/rig/WINDOWS-TEST.md @@ -161,7 +161,7 @@ whether the router keeps running after Ctrl-C (`tasklist | findstr rmw_zenohd`). pixi run test -- --keep ``` -Expected: `59 / 59 checks passed` (62 / 62 with `-e typed`) in about a minute and a line `artifacts kept in …`. Keep that +Expected: `54 / 54 checks passed` (57 / 57 with `-e typed`; 59 / 62 before the ScanImage connector left on 2026-10-28) in about a minute and a line `artifacts kept in …`. Keep that directory (`summary.md`, `crash-results.json`, the converted export, `runlogs\*.bag`, `proc-logs\`). Windows-specific things it exercises for the first time: child processes in their own group, `CTRL_BREAK_EVENT` as the stop signal, `TerminateProcess` for the mid-run kills (the FicTrac adapter diff --git a/rig/nodes/rig.py b/rig/nodes/rig.py index 1c046ac8..45360bee 100644 --- a/rig/nodes/rig.py +++ b/rig/nodes/rig.py @@ -5,7 +5,6 @@ pixi run rig -- --kinefly --bias # + Kinefly adapter + BIAS node pixi run rig -- --sim # + the FicTrac simulator (demo without a camera) pixi run rig -- --bias --bias-sim # + a simulated BIAS program (no camera software) - pixi run rig -- --scanimage # + the ScanImage node (JSON server on this PC); --si-mock for a stand-in pixi run rig -- --foxglove # + foxglove_bridge (:8766) from the `viz` environment This file is the arena rig's COMPOSITION (rig-software-repos-and-runners.md §3.2): which nodes an @@ -58,8 +57,6 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--kinefly", action="store_true", help="also run the Kinefly KF/1 adapter") p.add_argument("--bias", action="store_true", help="also run the BIAS camera node") p.add_argument("--bias-sim", action="store_true", help="also run the BIAS simulator (a fake BIAS program)") - p.add_argument("--scanimage", action="store_true", help="also run the ScanImage node (talks to ScanImage's JSON server, default 127.0.0.1:5555)") - p.add_argument("--si-mock", action="store_true", help="also run the ScanImage JSON-server stand-in (port 5555)") p.add_argument("--sim", action="store_true", help="also run the FicTrac simulator (random walk, 50 Hz)") p.add_argument("--viz-args", default="", help="extra args for viz_node.py (--deg-per-frame, --frame-dir, --feature-az0, --frames)") p.add_argument("--foxglove", action="store_true", @@ -69,7 +66,6 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--fictrac-args", default="", help="extra args for the FicTrac adapter") p.add_argument("--kinefly-args", default="", help="extra args for the Kinefly adapter") p.add_argument("--bias-args", default="", help="extra args for the BIAS node") - p.add_argument("--scanimage-args", default="", help="extra args for the ScanImage node (e.g. --port 5555 --status-every 1)") p.add_argument("--sim-args", default="--proto udp --rate 50", help="args for the FicTrac simulator") args = p.parse_args(argv) @@ -98,8 +94,6 @@ def main(argv: list[str] | None = None) -> int: ports.append(("udp", 60001, "another Kinefly adapter")) if args.bias_sim: ports.append(("tcp", 5010, "a BIAS program or the BIAS simulator")) - if args.si_mock: - ports.append(("tcp", 5555, "ScanImage's JSON server or its stand-in")) if foxglove: ports.append(("tcp", FOXGLOVE_PORT, "another foxglove_bridge (`pixi run -e viz foxglove-bridge` or another rig --foxglove)")) taken = preflight(ports) @@ -124,10 +118,6 @@ def main(argv: list[str] | None = None) -> int: nodes.append(Child("bias-sim", node_argv("rig_nodes.sims.bias_sim"))) if args.bias: nodes.append(Child("bias", node_argv("rig_nodes.bias_node", *split_args(args.bias_args)))) - if args.si_mock: - nodes.append(Child("si-mock", node_argv("rig_nodes.sims.si_json_mock"))) - if args.scanimage: - nodes.append(Child("scanimage", node_argv("rig_nodes.scanimage_node", *split_args(args.scanimage_args)))) bridge = Child("bridge", [py, str(NODES / "bridge_node.py"), *split_args(args.bridge_args)]) nodes.append(bridge) if foxglove: diff --git a/rig/pixi.toml b/rig/pixi.toml index 8f058117..74e56db2 100644 --- a/rig/pixi.toml +++ b/rig/pixi.toml @@ -148,17 +148,12 @@ fictrac-adapter = "rig-fictrac-adapter" kinefly-adapter = "rig-kinefly-adapter" bias-node = "rig-bias-node" bias-cmd = "rig-bias-cmd" -# ScanImage (built-in JSON server on the ScanImage PC, LAB-303/LAB-306): node + one-shot command. -scanimage-node = "rig-scanimage-node" -scanimage-cmd = "rig-scanimage-cmd" # ── the bridge node (this rig's gateway): bridge.py's hub + log writer + dispatcher on ROS input ── bridge-node = "python nodes/bridge_node.py" # ── simulators (rig_nodes.sims) ──────────────────────────────────────────── sim = "rig-fictrac-sim" kinefly-sim = "rig-kinefly-sim" bias-sim = "rig-bias-sim" -# A stand-in for ScanImage's JSON server (stock behaviors incl. bug B2) for off-rig tests. -si-mock = "rig-si-mock" # ── the one command a rig runs ────────────────────────────────────────────── rig = "python nodes/rig.py" # ── acceptance test (LAB-303 PoC criteria): whole graph + Studio emulator + model fly ── @@ -186,11 +181,6 @@ graph = "rig-graph" viz = "python nodes/viz_node.py" test-viz = "python tests/test_viz_node.py" fox-json = "python nodes/foxglove_json.py" -# after-the-fact Foxglove MCAP of a 2P color-sweep run from its ScanImage TIFFs + the arena .pat files (tools/tiff_run_to_mcap.py) -tiff2mcap = "python tools/tiff_run_to_mcap.py" -# its run-log side on a synthetic log (no TIFFs), and a check of a --runlog MCAP against that run log -test-tiff2mcap = "python tests/test_tiff2mcap_runlog.py" -check-tiff2mcap = "python tools/check_tiff2mcap.py" # `viz` environment only (`pixi run -e viz …`): the interactive views. rqt-graph = "rqt_graph" rqt-topic = "rqt_topic" diff --git a/rig/scanimage-day-2026-10-16.md b/rig/scanimage-day-2026-10-16.md deleted file mode 100644 index 1241c0cd..00000000 --- a/rig/scanimage-day-2026-10-16.md +++ /dev/null @@ -1,91 +0,0 @@ -# ScanImage day 2026-10-16: results log - -*Paths in this record predate the 2026-10-26 rename `ros2-poc/` → `rig/`, `arena_interfaces` → `rig_interfaces`.* - -Plan: [SCANIMAGE-DAY.md](SCANIMAGE-DAY.md) (the two-hour version). PC: `REISERLAB-BERGA` (reiserlab-bergamo1). -Branch `claude/ros-studio-plan-b-c44942` @ `cff5835`. Times are ET. - -| Time | Step | What | Result | Notes | -|---|---|---|---|---| -| 12:5x | 0:00 | Fresh clone of webDisplayTools to `Documents\GitHub\webDisplayTools`, `git switch claude/ros-studio-plan-b-c44942` | ok | git via GitHub Desktop (`app-3.5.8\resources\app\git\cmd\git.exe`); not on PATH | -| 12:5x | 0:00 | pixi was not installed; installed pixi 0.82.0 with the official installer (`~\.pixi\bin`, user PATH) | ok | winget not available on this PC | -| 12:55 | 0:00 | `pixi install --locked` in `ros2-poc` (default env) | ok, 27 s | cold install | -| 12:56 | 0:00 | `pixi run test` (harness) | **53/53 PASS**, 51 s | plan expected 52 (the bag-recovery check from cff5835 adds one). Skipped: JS reader check (no root pixi env). String topics (no MSVC), as expected. Frame lag p50 0.65 / p99 1.04 ms | -| 13:0x | prep (arena not up yet) | Pattern folder vs protocol `variables:` | ok | 4 GS16 `.pat` (81338 B each), names match the four anchors | -| 13:0x | §6 prep | `tools/led_sweep_si.py --dry-run` | **crashed, fixed** | `UnicodeEncodeError` printing `→` on a cp1252 console (plain `python`, no `PYTHONUTF8`; the operator's terminal Python 3.14 would hit it). Fix: reconfigure stdout/stderr to UTF-8 in `main()`. Re-run: plan printed, nothing sent. The `pixi run` CLIs are unaffected (`PYTHONUTF8=1` in pixi.toml) | -| ~13:00 | 0:15 | Michael: MATLAB R2025b, ScanImage idle, `hJ = most.network.json.Server(5555)`, logging on, `logFilePath = F:\ImagingData\2026-10-16\led_sweep`, `bidirectional = false`, `channelSave = [1 2]` | ok | no errors | -| 13:02 | 0:20 | `pixi run rig -- --scanimage` | ok | router 7447, fictrac adapter, scanimage node → 127.0.0.1:5555 (status 1 Hz), bridge-node 3.4 on ws://127.0.0.1:8765; 1 ms system timer granted. Zenoh config deprecation WARNs (harmless) | -| 13:02 | 0:20 | `scanimage-cmd -- ping` | **ok, 0.41 ms** | `acqState: idle`. CLI also prints a zenohc "Error closing session: close operation timed out" on exit (harmless, ~10 s exit delay) | -| 13:03 | 0:20 | `scanimage-cmd -- status --json` | **failed → fixed** | ScanImage 2026.0.0: `hSI.hDisplay.lastFrameNumber` throws `Index exceeds array bounds.` (Display/get.lastStripeData line 434) until the first acquisition after ScanImage starts. The snapshot raised on it → status `ok:false`, every 1 Hz status line `reachable:false`, and an UNKNOWN reconcile on the first grab of the day would have failed too. Fix (`scanimage_node.py _snapshot`): acqState must read; other props that error → `null` + `errors{}`. Harness 53/53 after the fix | -| 13:05 | 0:20 | re-run after restart: ping, status | **ok** | ping 1.1 ms; status: idle, logFileCounter 1, stem `file`, path `F:\ImagingData\2026-10-16\led_sweep`, loggingEnable true, lastFrameNumber null (no acquisition yet); 6 gets 8.2 ms total | -| ~13:24 | 0:35 | connector `set framesPerSlice 60`, `setFileStem connector_check` | ok | set 153 ms, setFileStem 89 ms | -| 13:24 | 0:35 | connector `grab` | ok (started) | startGrab eval **3897 ms** (LAB-305: 1.2–2.4 s); acqState `grab` on the first poll; artifact {stem connector_check, counter 1} | -| 13:24 | 0:35 | connector `waitIdle --timeout-s 20` | **timeout** | still `grab` after 20.1 s; GUI showed **0 of 60 frames**; no TIFF on F:. `extTrigEnable` 0, `trigAcqInTerm` empty, `scannerFrequency` 7916.5 | -| ~13:30 | 0:35 | connector `abort` | **ok, 524 ms** | back to idle; logFileCounter 1 → 2 (no file written) | -| ~13:32 | 0:35 | GUI GRAB (Michael) | **0 of 60 too** | → not the connector | -| ~13:38 | 0:35 | cause | **found** | sample clock is the laser: `hScan2D.LaserTriggerPort = /vDAQ0/CLK_IN` ("ImagingScanner: Laser Trigger"), `externalSampleClock = 1`, `laserTriggerSampleMaskEnable = 1`. Lasers were off for "dark" → no clock → no frames. **On this rig "dark" = laser on + shutter closed** (the scanimage repo's "lasers off *or* shutter closed" — it must be the shutter). SCANIMAGE-DAY's "Lasers off" for steps 2–4 needs correcting | -| ~13:40 | 0:35 | 920 on, shutter closed: GUI GRAB still 0 of 60 at first; then Michael fixed it by hand (details: _TBD — what changed_) → GUI GRAB works | ok | `connector_check_00008.tif` 63.2 MB. `hJ`, logging, path survived (no ScanImage restart needed) | -| 13:42 | 0:35 | connector `grab` + `waitIdle` | grab ok, **waitIdle error** | startGrab 2629 ms; `connector_check_00009.tif` 63.2 MB; counter 9 → 10; idle after ~3 s — but waitIdle's reply read `lastFrameNumber`, which throws **even after acquisitions** on this rig → outcome error. Fix (`scanimage_node.py` waitIdle): lastFrameNumber failure → `null` + a failed step, not an error. Harness 53/53 | -| 13:44 | 0:35 | connector `grab` + `waitIdle` after the fix | **PASS** | startGrab 3112 ms; waitIdle ok, 3.08 s; `connector_check_00010.tif` 63.2 MB; counter 10 → 11. startGrab here 2.6–3.9 s (LAB-305: 1.2–2.4 s); the grab reply waits for acqState `grab`, so `grab_settle: 1` covers scanner start only — watch the first duty step in the dry run | -| ~13:47 | 0:00 | Studio served from the checkout: `python .claude\nocache-server.py 8092` (Claude, background) | ok | `arena_studio.html` 200, footer Arena Studio v0.96 | -| 13:49 | 0:00 | Arena plugged in; Studio Connect; init | ok | refresh 300 Hz · SPI 25 MHz · panel mode persist · AO 2500 mV · SD fw `panel-fw-v1.3.1`. An ALL_ON 0xFF went out at 13:49:16 (no supply reset) | -| 13:49 | 0:00 | Console → Panel firmware → Inventory | **PASS, 20/20** | 1–10 `0xB8796BAF`; `0x362644DD` ×6 (11, 12, 13, 17, 19, 20), `0x1A74DD1D` ×3 (14, 15, 18), `0x9E03D589` ×1 (16) — exactly the lineage note; 11–20 ≠ SD panel.bin as expected | -| ~14:00 | 0:10 | Studio: "no rig selected", Bench-rig dropdown empty + its lock dead, I/O roles greyed, pattern play blocked | **cause found, fixed** | The page's module block never ran: the server log shows the import graph stalling mid-`yaml` (`stringifyCollection.js` the last request; no `configs/metadata/*` or `rigs/index.json` ever fetched). `.claude/nocache-server.py` was a single-threaded `HTTPServer` — Chrome's idle pre-connected sockets block it. Also `localhost` cost **~2 s per connection** (IPv6 `::1` tried first, nothing listening) vs 23 ms on 127.0.0.1. Fix: `ThreadingHTTPServer` + a second listener on `::1` → localhost 9 ms. (Classic substrate — Connect, Console, Inventory — worked throughout, as designed.) | -| 14:16 | 0:10 | Reload + Connect on the fixed server | ok | module block runs; rig auto-derived `cshl_g6_2x10_ball` (first 2×10 in the index); 34 pattern frame counts read; patterns play (Arena Trial mode 2, pats 3/4/7/9) | -| 14:22 | 0:10 | Inventory after reconnect | **PASS, 20/20** | same firmware map as 13:49 | -| 14:23 | 0:10 | Panel mode 2; Bench rig → `cshl_g6_2x10` (explicit); DIO roles | **ok** | SET_PANEL_DISPLAY_MODE 2 ok; GET_DIO_ROLE: Digital IO 1 `out_debug_framescan` LOW, Digital IO 2 `in_trigger` LOW; AO 5 V | -| ~14:25 | 0:10 | GS16 pattern upload | ok | four `…_gs16` files at SD slots **3, 5, 7, 9** (1-based; protocol expects 5–8 — color↔slot mapping TBD; the Studio resolves by name, IDs are the fallback) | -| ~14:26 | 3.5 | FicTrac/bridge Connect (ws://localhost:8765) | ok | bridge-node: `client connected (total 1)` | -| ~14:30 | 0:25 | New-arena trigger check (shutter closed, laser on) | **PASS** | FOCUS → full-field 128 lights every panel; stop FOCUS → all dark. **Note (Michael):** color panels a tad flickery with this build — follow up later | -| ~14:35 | 0:40 | Open `g6_2p_color_led_sweep` → Run | warning (non-blocking) | "Runtime variables need attention: Variable "function anchor() { [native code] }" is defined more than once · Variable "undefined" is defined more than once ×17 … The run will use the YAML values as written." — Studio bug, not the protocol: the runtime-controls check reads `.anchor` on plain anchored scalars in `variables:` (→ `String.prototype.anchor`). Follow up later | -| 14:30–14:33 | 0:40 | **Dry run** (shutter closed, laser on) | **commands PASS; 2 findings** | run file + bag opened 14:30:20. Connector: `set` framesPerSlice ok 159 ms; 4 × setFileStem ok; 4 × grab ok (startGrab 2.4–2.8 s); 4 × waitIdle ok (24.1–24.8 s). TIFFs `violet_/blue_/green_/yellow_sweep_00001.tif`, 642.9 MB each, 46 s apart. Bridge: 3 × "arena_command kept verbatim: head '0d 08 02 07 00 14 …'" (trialParams mode 2, pattern ID **7**, 20 fps for violet). Michael: blue/green/yellow showed 6 duty steps; violet looked like ~3 (likely the eye's low violet sensitivity at duty 7/15/31 in mode 2 — detectors / log duties will tell) | -| 14:35 | 0:40 | finding: acquisition outlasts the stimulus by ~25 s per color | **fixed in protocol** | ScanImage `scanFrameRate` = **15.0 Hz** (512×512, unidirectional), protocol was sized for ~29 Hz → 640 frames ≈ 43 s vs 19 s stimulus. `frames_per_grab` 640 → **330** (22 s) → run ≈ 110 s instead of 191 s | -| 14:37 | 0:40 | Studio run log (Save) | **COMPLETED, 26/26 steps, 0 errors, 190.7 s** | violet ran all 6 duty conditions × 3 s (as did the others) → "~3 visible" was perception. Run file + bag did NOT close at run end: no GitHub sign-in → the end-of-run commit step never reached `setLogging(false)` (Studio bug, follow up). Closed by toggling FicTrac ▸ log (opened an empty next file — harmless) | -| 14:38 | 0:40 | bag → `pixi run convert` | **PASS** | closed, not torn, 1451 records, 3411 lines, 0 skipped; 13 connector commands = set + 4 setFileStem + 4 grab + 4 waitIdle, all `ok`; grab artifacts violet/blue/green/yellow_sweep #1 = the TIFFs on F: | -| 14:39 | 0:40 | pattern IDs from the 24 trialParams | **PASS** | resolved by name: violet 7, blue 3, green 5, yellow 9 (6 each) = Michael's slots; protocol fallback IDs updated 5–8 → 7/3/5/9 | -| 14:40 | 0:40 | **GO/NO-GO** | **GO** | — | -| ~14:45 | 5 | Michael: ScanImage 256×256 → `scanFrameRate` **29.1 Hz** (read via connector); prep in focus, shutter open | — | `frames_per_grab` 330 → **640** (19 s × 29.1 = 553 + margin); run ≈ 110 s | -| 14:51–14:53 | 5 | **Experiment run 1** (prep, shutter open) | **completed; end of duty 255 cut for 3 colors** | TIFFs `violet/blue/green/yellow_sweep_00002.tif`, 162.9 MB each (640 × 256²). All 13 connector commands ok. Bag closed after the FicTrac ▸ log toggle (940 records). Timing vs grab request (`runlogs/si_timing.py`): acquisition ≈ reply (+3.4 s) → +25.4 s. Violet steps 3.0 s, stim +4.55 → +22.64, idle by +26.8 (**+4.2 s margin**). Blue/green/yellow steps **~3.6 s** (host-timed drift), stim ends +26.0 / +27.8 / +26.9 but ScanImage idle by +25.0–26.4 → **last ~0.5–1.5 s of duty 255 not imaged** for blue, green, yellow | -| 14:58 | 5 | fix | protocol | `frames_per_grab` 640 → **800** (27.5 s); run ≈ 130 s. Follow up: why host-timed steps run 3.6 s for colors 2–4 | -| 15:02–15:04 | 5 | **Experiment run 2** (800 frames) | **PASS** | TIFFs `*_sweep_00003.tif`, 203.7 MB each. 13/13 connector commands ok; bag closed (753 records), converts clean. Margins idle − stimulus end: violet +4.11, blue +3.97, green +3.24, yellow +4.38 s; 'grab' status seen after every stimulus end; stimulus starts ≥ 1.1 s after the grab reply. Steps ~3.6–3.8 s for all four colors this time (stimulus ≈ 23 s) | -| 15:10 | 5 | step timing (`runlogs/step_timing.py`, trialParams intervals) | **cause found** | dry run: every step 3.01–3.02 s. Run 1: violet 3.0, then blue/green/yellow mostly **4.00**; run 2: all **4.00** (last 3.01). Exactly +1.00 s, never in between → **Chrome timer throttling**: `ArenaRunner._sleep` is a plain `setTimeout`, which Chrome aligns to 1 s while the tab is hidden or the window occluded (operator working in ScanImage). Dry run: Studio visible. Workaround: keep the Studio window visible during runs. Code fix later: a throttle-proof timer for host-timed steps (Worker-based timer, or keep-alive), plus a warning when `document.hidden` during a run | -| 15:20 | 5 | Michael: **no more repeats — run 2 is the dataset** (run 1 kept, end of duty 255 cut for blue/green/yellow) | — | — | - -## Quick look at run 2 (`tools/led_sweep_quicklook.py`, 15:25) - -`python tools/led_sweep_quicklook.py ros2-poc/runlogs/arena-log-20261016-150214-328.jsonl F:\ImagingData\2026-10-16\led_sweep 3` -— stdlib+numpy TIFF reader; frame time = header `epoch` (PC local, EDT) + `frameTimestamps_sec`, aligned to -the trialParams times in the run log (same PC clock); whole-field mean per page; step window = step start -+ 0.3 s → next step − 0.1 s; baseline = frames before the first step. **Whole field, no ROI, no motion -correction — a sanity check, not the analysis.** - -Each TIFF: 1600 pages = 800 frames × 2 channels; frames start 1.8–2.4 s before the first step and end -1.0–2.2 s after the last (4.0 s steps — the throttled timer). Mean increase over baseline (a.u.), d7 → d255: - -| color | ch1 baseline | ch1: d7 / d15 / d31 / d63 / d127 / d255 | ch2 baseline | ch2: d7 / d15 / d31 / d63 / d127 / d255 | -|---|---|---|---|---| -| violet | 113.3 | +15 / +23 / +58 / +132 / +265 / **+374** | 29.8 | +3.5 / +8.8 / +23 / +61 / +127 / **+185** | -| blue | 92.4 | −0.1 / −0.3 / +7.7 / +6.5 / +2.6 / +20 | 34.5 | −1.5 / −1.4 / +0.1 / +1.0 / +2.7 / +5.4 | -| green | 116.8 | +19 / +28 / +45 / +72 / +115 / **+165** | 44.2 | +0.5 / +1.2 / +1.7 / +5.8 / +13 / +19 | -| yellow | 48.0 | +1.7 / +1.3 / −0.5 / +6.2 / +16 / +17 | 32.3 | +3.7 / +7.0 / +12 / +21 / +40 / **+62** | - -- The duty steps are visible in the images and line up with the logged step times — the connector → - TIFF → run-log alignment works end to end. -- Panel light reaches the detectors for every color; it rises with duty, near-monotonically for violet, - green (ch1) and yellow (ch2). Violet is by far the largest in both channels; green mostly ch1, yellow - mostly ch2; blue the smallest (only d255 clearly above noise). -- The per-frame SD within a step grows with duty (violet ch1: 5 → 135), i.e. the stimulus light - fluctuates frame to frame — possibly LED drive vs frame-rate beating; compare with the flicker Michael - saw at 0:25. -- Yellow d127 has 76 frames, not ~105: its step was 3.0 s (the one unthrottled step). -- **MATLAB messages during the session (no timestamps, reported by Michael after the runs):** - "Client disconnected because of error / Client connected" ×4 — most likely the scanimage node being killed - at each graph stop/restart (13:04, 13:43, end), not a protocol failure (every connector reply in both runs - was `ok`). "**PLL got unlocked** … Check that the laser clock is consistent" ×14 — the vDAQ's sample-clock - PLL on the laser sync (`CLK_IN`); most likely while the laser was being switched on / settling - (13:30–13:40, the 0-frame grabs). Frame check (`runlogs/frame_check.py`): runs 1 and 2, all 8 TIFFs — - frame numbers consecutive, **0 gaps**, frame interval 34.339–34.341 ms (29.12 Hz). Rules out dropped - frames, not a pixel-clock glitch within a frame. Next time: "Apply Clock Settings" must not be light red - before a grab. -- Next (later session): per-pixel / ROI maps, the 22 Sept method (`Bergamo_testing/092226_SNR/analysis/`, - not on this PC), the DO1 aux-trigger record per frame, run 1 for comparison. diff --git a/rig/tests/test_ros2_poc.py b/rig/tests/test_ros2_poc.py index c0d27743..ed4b19f3 100644 --- a/rig/tests/test_ros2_poc.py +++ b/rig/tests/test_ros2_poc.py @@ -398,11 +398,6 @@ def start(name: str, argv: list[str]) -> Proc: bias_port = free_tcp_port() start("bias-sim", [PY, "-m", "rig_nodes.sims.bias_sim", "--port", str(bias_port), "--video-dir", str(videos)]) start("bias-node", [PY, "-m", "rig_nodes.bias_node", "--port", str(bias_port), "--status-every", "1", "--video-dir", str(videos)]) - # ScanImage stand-in on a free port (a rig PC may have the real JSON server on 5555): short - # grabs, a short start delay, and one allowlisted property it never answers (the UNKNOWN path). - si_port = free_tcp_port() - start("si-mock", [PY, "-m", "rig_nodes.sims.si_json_mock", "--port", str(si_port), "--grab-s", "1.0", "--start-delay-s", "0.2", "--stall-on", "hSI.hRoiManager.scanZoomFactor"]) - start("scanimage-node", [PY, "-m", "rig_nodes.scanimage_node", "--port", str(si_port), "--status-every", "1", "--timeout-s", "1.5"]) bridge_argv = [PY, str(NODES / "bridge_node.py"), "--log-dir", str(runlogs), "--source-timeout", "1.5", "--bag-profile", a.bag_profile] bridge = start("bridge-node", bridge_argv) check("bridge node serving ws://127.0.0.1:8765", wait_port("127.0.0.1", 8765, 20.0)) @@ -480,7 +475,7 @@ def side_actor() -> None: em = run_capture( [PY, str(HERE / "studio_emulator.py"), "--epochs", str(a.epochs), "--epoch-s", str(a.epoch_s), "--iti-s", "1", "--bias-amplitude", "90", "--min-frames", "40", "--out", str(export), "--report", str(report), - "--connector-cmds", "--connector-video", "poc_studio.avi", "--scanimage-cmds"], + "--connector-cmds", "--connector-video", "poc_studio.avi"], timeout=75 + a.epochs * (a.epoch_s + 2), ) (logdir / "emulator.log").write_text(em.stderr, encoding="utf-8") @@ -496,10 +491,9 @@ def side_actor() -> None: check("MCAP bag recorded for the run (metadata.yaml + .mcap)", main_bag is not None and (main_bag / "metadata.yaml").exists() and any(main_bag.glob("*.mcap")), str([b.name for b in bags])) bagsum = bag_summary(main_bag) if main_bag else {} print(f"[test] bag: {bagsum.get('records')} records, {bagsum.get('bytes')} B, topics {bagsum.get('topics')}, schemas {bagsum.get('schemas')}, frame lag {bagsum.get('frame_lag_ms')}", file=sys.stderr) - want_topics = ["/arena/session", "/arena/browser", "/arena/event", "/arena/frame", "/kinefly/raw", "/bias/cmd", "/bias/reply", "/bias/status", - "/scanimage/cmd", "/scanimage/reply", "/scanimage/status"] + want_topics = ["/arena/session", "/arena/browser", "/arena/event", "/arena/frame", "/kinefly/raw", "/bias/cmd", "/bias/reply", "/bias/status"] missing = [t for t in want_topics if not bagsum.get("topics", {}).get(t)] - check("every stream recorded in the bag (session, browser, event, frame, kinefly, bias + scanimage cmd/reply/status)", + check("every stream recorded in the bag (session, browser, event, frame, kinefly, bias cmd/reply/status)", not missing, f"missing {missing}") raw_ft = {t: n for t, n in bagsum.get("topics", {}).items() if t.startswith("/fictrac/")} check("raw FicTrac topics NOT recorded by default (2026-10-18: the merged /arena/frame record is the FicTrac record; --record-fictrac-raw opts in)", @@ -585,19 +579,6 @@ def side_actor() -> None: bool(logs) and bagsum.get("bias_frames_t") == sum(s.get("frames") or 0 for s in logs), f"bag {bagsum.get('bias_frames_t')} vs stamp logs {[s.get('frames') for s in logs]}") - # The ScanImage connector (JSON server stand-in): the Studio-path sequence the 2P protocol uses. - si_ev = [e for e in events if e.get("type") == "connector" and e.get("src") == "scanimage"] - si_rep = [e for e in si_ev if e.get("kind") == "reply"] - si_st = [e for e in si_ev if e.get("kind") == "status"] - si_cmd = [e.get("command") for e in si_ev if e.get("kind") == "command"] - check("ScanImage command/reply pairs in the run log (via=ws, plugin scanimage)", len(si_rep) >= 8 and all(e.get("via") == "ws" and e.get("plugin") == "scanimage" for e in si_rep), f"{len(si_rep)} replies: {[r.get('command') for r in si_rep]}") - check("ScanImage status lines in the run log carry acqState", len(si_st) >= 2 and all("acqState" in e for e in si_st if e.get("reachable")), str(len(si_st))) - check("Studio-path ScanImage commands logged in order", si_cmd == ["ping", "setFileStem", "set", "set", "grab", "waitIdle", "abort", "get", "ping", "bogusCommand"], str(si_cmd)) - grab = [e for e in si_rep if e.get("command") == "grab"] - check("grab reply in the run log carries the TIFF artifact (stem, counter, path)", bool(grab) and grab[0].get("ok") and isinstance(grab[0].get("artifact"), dict) - and grab[0]["artifact"].get("kind") == "tiff" and grab[0]["artifact"].get("stem") == "emu_violet_d7" and grab[0]["artifact"].get("counter") is not None, json.dumps([g.get("artifact") for g in grab])) - unk = [e for e in si_rep if e.get("command") == "get"] - check("ScanImage UNKNOWN outcome logged with the reconciled status", bool(unk) and unk[0].get("outcome") == "unknown" and isinstance(unk[0].get("reconciled"), dict), json.dumps(unk)[:300]) cs = [e for e in events if e.get("type") == "connector_status" and e.get("src") == "fictrac"] p99 = cs[-1]["lat_ms"]["p99"] if cs else None check("connector_status: adapter→bridge latency p99 < 50 ms", p99 is not None and p99 < 50.0, str(cs[-1] if cs else None)) diff --git a/rig/tests/test_tiff2mcap_runlog.py b/rig/tests/test_tiff2mcap_runlog.py deleted file mode 100644 index 9dbc2677..00000000 --- a/rig/tests/test_tiff2mcap_runlog.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""test_tiff2mcap_runlog.py — the run-log side of tools/tiff_run_to_mcap.py on a synthetic log (no TIFFs). - - pixi run python tests/test_tiff2mcap_runlog.py - -load_runlog (trialParams paired with their send times, allOff from the compact `a` rows, connector records), -controller_clock (controller t_us → PC ms from the executed trialParams) and runlog_timeline (the first -displayed frame per step, the allOff after the last step), plus the fallbacks when the log can't support them. -""" -from __future__ import annotations - -import json -import sys -import tempfile -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) -from tiff_run_to_mcap import controller_clock, load_runlog, runlog_timeline # noqa: E402 - -failures = 0 - - -def check(name: str, ok: bool, detail: str = "") -> None: - global failures - failures += not ok - print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" ({detail})" if detail and not ok else "")) - - -T0 = 1_792_000_000_000.0 # logging start, PC ms -RATE = 1.00005 # controller clock runs 50 ppm fast -OFF_US = 7_000_000.0 # controller t_us at PC T0 -LAT_MS = 5.0 # trialParams → first displayed frame - - -def ctrl_us(pc_ms: float) -> float: - return OFF_US + (pc_ms - T0) * 1e3 * RATE - - -def make_log(sends_ok: bool = True, with_cc: bool = True) -> list: - rows = [{"type": "session", "event": "logging_started", "ms": T0}] - rows.append(["a", 3, 7, "0100", 0, 11]) # pre_blank allOff - trials = [("violet", 7, 7, 1000.0), ("violet", 255, 7, 5000.0), ("blue", 7, 3, 20000.0), ("blue", 15, 3, 24000.0)] - for cond_color, duty, pid, t in trials: - send = T0 + t - rows.append({"type": "log", "event": "runner", "phase": "trial-running", "condition": f"{cond_color}_d{duty}", - "params": {"mode": 2, "patternId": pid, "frameRate": 20, "duty": duty}, "rx_ms": send + 7}) - if sends_ok: - rows.append({"type": "log", "event": "arena_command", "t": send, "head": "0d 08 02 …"}) - if with_cc: - rows.append(["cc", send + 4, ctrl_us(send), 1, 8, 0, ""]) - for k in range(3): # three displayed frames, 50 ms apart - rows.append(["cf", send + 4, ctrl_us(send + LAT_MS + 50 * k), 2, k, pid, 600, 776, 3000, 0, 3]) - rows.append(["a", 9000, 6, "0100", 0, 9006]) # violet allOff (t_off from T0) - rows.append(["a", 28000, 6, "0100", 0, 28006]) # blue allOff - for cond in ("pre_blank", "violet_d255", "blue_d15"): - rows.append({"type": "log", "event": "runner", "phase": "command", "op": "allOff", "condition": cond, "rx_ms": T0}) - rows.append({"type": "connector", "kind": "command", "ms": 11, "command": "set", "params": {"property": "x", "value": 1}}) - rows.append({"type": "connector", "kind": "reply", "rx_epoch_ms": T0 + 300, "command": "set", "outcome": "ok", "rtt_ms": 289, "value": 1}) - rows.append({"type": "connector", "kind": "status", "ms": 500}) # status polls are not events - return rows - - -def write(rows: list) -> Path: - f = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False, encoding="utf-8") - for r in rows: - f.write(json.dumps(r) + "\n") - f.close() - return Path(f.name) - - -# 1. a complete log -log = load_runlog(write(make_log())) -check("4 trials, colors and duties from the condition names", [(t["color"], t["duty"]) for t in log["trials"]] - == [("violet", 7), ("violet", 255), ("blue", 7), ("blue", 15)]) -check("trialParams paired with arena_command send times", log["sends_paired"] and log["trials"][0]["t_cmd_ms"] == T0 + 1000) -check("allOff from the `a` rows (send time = logging start + t_off)", log["all_off"] == [T0 + 3, T0 + 9000, T0 + 28000] - and log["all_off_source"].startswith("a rows"), str(log["all_off"])) -check("connector: command at start + ms, reply at rx_epoch_ms, status ignored", - [(c["kind"], c["t_ms"]) for c in log["connector"]] == [("command", T0 + 11), ("reply", T0 + 300)]) - -to_pc, info = controller_clock(log) -check("controller clock fit recovers the rate", to_pc is not None and abs(info["ppm"] - (1 / RATE - 1) * 1e6) < 0.5, json.dumps(info)) -check("controller clock maps back to PC within 1 µs", abs(to_pc(ctrl_us(T0 + 12345.0)) - (T0 + 12345.0)) < 1e-3) - -tl = runlog_timeline(log, "violet", to_pc) -check("violet: 2 steps, first displayed frame = send + latency", len(tl["steps"]) == 2 - and abs(tl["steps"][0]["t_disp_ms"] - (T0 + 1000 + LAT_MS)) < 1e-3, str([s["t_disp_ms"] - T0 for s in tl["steps"]])) -check("violet: displayed frames carry their own times and indices", [f[1] for f in tl["steps"][1]["frames"]] == [0, 1, 2] - and abs(tl["steps"][1]["frames"][2][0] - (T0 + 5000 + LAT_MS + 100)) < 1e-3) -check("violet: allOff = the first allOff after the last step", tl["t_off_ms"] == T0 + 9000) -check("violet: display latency is the median send→frame time", abs(tl["display_latency_ms"] - LAT_MS) < 1e-3) -check("blue frames don't leak into violet steps", all(f[2] == 7 for s in tl["steps"] for f in s["frames"])) -check("a color with no trials → None", runlog_timeline(log, "green", to_pc) is None) - -# 2. a duty that disagrees with the condition name is flagged -rows = make_log() -next(r for r in rows if isinstance(r, dict) and r.get("phase") == "trial-running")["params"]["duty"] = 9 -check("duty ≠ condition name is flagged", not load_runlog(write(rows))["trials"][0]["duty_matches_condition"]) - -# 3. no arena_command lines → send times fall back to the runner's receive time, no clock map -log2 = load_runlog(write(make_log(sends_ok=False))) -fn, why = controller_clock(log2) -check("unpaired sends: t_cmd = runner rx_ms", not log2["sends_paired"] and log2["trials"][0]["t_cmd_ms"] == T0 + 1007) -check("unpaired sends: no controller clock map, with a reason", fn is None and "paired False" in why, why) -tl2 = runlog_timeline(log2, "violet", fn) -check("no clock map: t_disp = send time, labelled so", tl2["steps"][0]["t_disp_ms"] == T0 + 1007 - and tl2["steps"][0]["t_disp_source"] == "trialParams send time" and not tl2["steps"][0]["frames"]) - -# 4. no controller telemetry -log3 = load_runlog(write(make_log(with_cc=False))) -check("no cc rows: no controller clock map", controller_clock(log3)[0] is None) - -print(f"\n{'ALL PASS' if not failures else f'{failures} FAILED'}") -sys.exit(1 if failures else 0) diff --git a/rig/tools/check_tiff2mcap.py b/rig/tools/check_tiff2mcap.py deleted file mode 100644 index 4259fc70..00000000 --- a/rig/tools/check_tiff2mcap.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -"""check_tiff2mcap.py — check a tiff2mcap MCAP built with --runlog against that run log. - - pixi run python tools/check_tiff2mcap.py - -PASS/FAIL per check, exit 1 on any failure: - 1. every /arena/stimulus step marker sits at its logged trialParams send time (±1 ms) and says "commanded" - 2. /run/events carries every ScanImage connector command and reply from the log - 3. /arena/display has one message per displayed-frame record of the controller, in time order - 4. per color: the TIFF anchor's end check (last DO1 vs allOff + latency) is within ±5 ms - 5. per color, at the d255 step: the qc Δmean jump (the channel that moves most) falls within one imaging - frame of the observed display_start (SKIP when the step is below 5× the spread of the pre-step level, e.g. blue) -""" -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import numpy as np - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from tiff_run_to_mcap import COLORS, load_runlog # noqa: E402 - -failures = 0 - - -def check(name: str, ok: bool, detail: str = "") -> None: - global failures - failures += not ok - print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" ({detail})" if detail else "")) - - -def main(mcap_path: str, runlog_path: str) -> int: - from mcap.reader import make_reader - - log = load_runlog(Path(runlog_path)) - topics = {"/arena/stimulus": [], "/run/events": [], "/arena/display": [], "/scanimage/qc": [], "/run/provenance": []} - with open(mcap_path, "rb") as f: - for _, ch, m in make_reader(f).iter_messages(topics=list(topics)): - topics[ch.topic].append((m.log_time / 1e6, json.loads(m.data) if ch.topic != "/arena/display" else None)) - prov = topics["/run/provenance"][0][1] - - on = sorted((t, o) for t, o in topics["/arena/stimulus"] if o["on"]) - sends = np.array([t["t_cmd_ms"] for t in log["trials"]]) - d = np.array([t for t, _ in on]) - sends if len(on) == len(sends) else np.array([np.inf]) - check("stimulus markers at logged trialParams send times (±1 ms)", len(on) == len(sends) and np.abs(d).max() <= 1.0, - f"{len(on)} markers vs {len(sends)} logged; max |Δ| {np.abs(d).max():.3f} ms") - check("stimulus markers labelled commanded", all(o["source"].startswith("commanded") for _, o in on), - ", ".join(sorted({o["source"] for _, o in on}))) - - ev = [o for _, o in topics["/run/events"]] - n_cmd = sum(c["kind"] == "command" for c in log["connector"]) - n_rep = sum(c["kind"] == "reply" for c in log["connector"]) - got_cmd = sum(o["event"] == "scanimage_command" for o in ev) - got_rep = sum(o["event"] == "scanimage_reply" for o in ev) - check("ScanImage commands + replies in /run/events", (got_cmd, got_rep) == (n_cmd, n_rep), - f"{got_cmd}/{n_cmd} commands, {got_rep}/{n_rep} replies") - - disp_t = np.array([t for t, _ in topics["/arena/display"]]) - n_off = sum(o["event"] == "allOff" for o in ev) - check("one /arena/display message per controller frame record (+ one dark frame per allOff)", - len(disp_t) == len(log["cf"]) + n_off, f"{len(disp_t)} messages, {len(log['cf'])} cf rows + {n_off} allOff") - - for c, an in prov.get("anchors", {}).items(): - ec = an["end_check_ms"] - check(f"{c}: anchor end check within ±5 ms", ec is not None and abs(ec) <= 5.0, - f"{ec:+.2f} ms; epoch correction {an['epoch_correction_ms']:+.1f} ms") - - starts = {(o["color"], o["detail"].split(":")[0]): t for t, o in topics["/run/events"] if o["event"] == "display_start"} - for c in COLORS: - t_disp = starts.get((c, "duty 255")) - q = [(t, o) for t, o in topics["/scanimage/qc"] if o["color"] == c] - if t_disp is None or not q: - check(f"{c}: qc jump at d255 within one imaging frame of display_start", False, "no display_start or qc") - continue - tq = np.array([t for t, _ in q]) - frame_ms = float(np.median(np.diff(tq))) - keys = [k for k in q[0][1] if k.startswith("dmean_ch")] - best = None - for k in keys: - y = np.array([o[k] for _, o in q]) - pre = y[(tq > t_disp - 1000) & (tq < t_disp - 2 * frame_ms)].mean() - post = y[(tq > t_disp + 300) & (tq < t_disp + 1300)].mean() - if best is None or abs(post - pre) > abs(best[1] - best[0]): - best = (pre, post, y, k) - pre, post, y, k = best - noise = float(np.std(y[(tq > t_disp - 1000) & (tq < t_disp - 2 * frame_ms)])) # includes slow baseline drift - if abs(post - pre) < 5 * noise: - print(f"SKIP {c}: qc jump at d255 — step {post - pre:+.0f} ADU is below 5× the pre-step spread ({noise:.1f} ADU)") - continue - idx = np.where((tq > t_disp - 500) & (np.sign(post - pre) * (y - (pre + 0.5 * (post - pre))) > 0))[0] - dt = tq[idx[0]] - t_disp if len(idx) else np.inf - check(f"{c}: qc jump at d255 within one imaging frame of display_start", -frame_ms <= dt <= frame_ms, - f"{k} half-way at {dt:+.1f} ms (frame {frame_ms:.1f} ms, step {post - pre:+.0f} ADU)") - - print(f"\n{'ALL PASS' if not failures else f'{failures} FAILED'}") - return 1 if failures else 0 - - -if __name__ == "__main__": - if len(sys.argv) != 3: - raise SystemExit(__doc__) - raise SystemExit(main(sys.argv[1], sys.argv[2])) diff --git a/rig/tools/tiff_run_to_mcap.py b/rig/tools/tiff_run_to_mcap.py deleted file mode 100644 index 9bf6ce3a..00000000 --- a/rig/tools/tiff_run_to_mcap.py +++ /dev/null @@ -1,627 +0,0 @@ -#!/usr/bin/env python3 -"""tiff_run_to_mcap.py — an after-the-fact Foxglove MCAP for a 2P color-sweep run (ScanImage TIFFs + arena patterns). - - pixi run tiff2mcap -- --tiff-dir --counter 3 --pat-dir ../protocols/g6_2p_color_led_sweep_patterns \\ - --out oct16_color_sweep.mcap --layout-out foxglove-oct16.json [--runlog arena-log-….jsonl] - -Pure Python (numpy + the `mcap` package; no ROS process). What goes in, and what each topic IS: - - /scanimage/preview/ch foxglove.CompressedImage (PNG) OBSERVED each frame downsampled 2×2, one fixed - int16→8-bit window per channel (recorded) - /scanimage/frame_ref JSON OBSERVED frame number, channel ids, TIFF path + pages, - frameTimestamps_sec, epoch, DO1 pulses in the frame - /scanimage/qc JSON DERIVED whole-field mean per channel, minus the pre-stimulus - baseline of that file - /arena/stimulus JSON COMMANDED (--runlog: each trialParams at its logged send - time) or RECONSTRUCTED (first DO1 pulse + nominal step - lengths): color, duty, pattern, fps, step - /arena/display foxglove.CompressedImage (PNG) OBSERVED (--runlog with controller telemetry: every frame - the controller reports displaying, at its own time) or - RECONSTRUCTED (.pat × duty × LED color at the protocol's - fps, frame 0 at each step) - /arena/scene foxglove.SceneUpdate DERIVED from /arena/display: 20 panel cubes on the arena - cylinder, colored by LED color × lit fraction × duty - /run/events JSON acquisition start/end per color (TIFF), step edges, allOff, - and with --runlog every ScanImage connector command + reply - /run/provenance JSON (one message) how every topic was made, the clock used, the gaps - -Clock without a run log: TIFF `epoch` (PC local time) + `frameTimestamps_sec`, converted with ---utc-offset-h (EDT = -4). The stimulus timeline is placed on that clock through the DO1 pulses ScanImage -stamped into the frames (aux trigger 0): the first pulse is the first trialParams, and the step edges are -the protocol's nominal lengths (labelled RECONSTRUCTED). - -Clock with --runlog: the run log's PC clock (Unix epoch). The ScanImage `epoch` field is NOT used for -placement: on 2026-10-16 it ran 0.53–0.67 s ahead of the first frame (different per grab; on the epoch -clock the first DO1 pulse lands before the trialParams that caused it). Intervals inside a TIFF are exact -(DO1 span = the logged trialParams→allOff time within 1–3 ms), so each TIFF is anchored by one point: its -first DO1 pulse = the controller's first displayed frame after that color's first trialParams (controller -time mapped to the PC clock by a linear fit of the trialParams it executed against their logged send -times; without controller telemetry, the trialParams send time). The per-file correction to the epoch -clock is in /run/provenance. The pixels are what ScanImage recorded; nothing here proves the light that -reached the detectors — the qc trace does. -""" - -from __future__ import annotations - -import argparse -import base64 -import datetime as dt -import json -import re -import struct -import sys -import zlib -from pathlib import Path - -import numpy as np - -HERE = Path(__file__).resolve().parent -sys.path.insert(0, str(HERE)) -sys.path.insert(0, str(HERE.parents[1] / "tools")) # led_sweep_quicklook.read_tiff_pages (stdlib TIFF reader) -from patfile import read_pat # noqa: E402 - -try: - from led_sweep_quicklook import read_tiff_pages # noqa: E402 -except ImportError: # the quicklook lives at the repo root tools/ - read_tiff_pages = None - -COLORS = ("violet", "blue", "green", "yellow") -LED_RGB = {"violet": (150, 0, 255), "blue": (0, 90, 255), "green": (0, 255, 70), "yellow": (255, 170, 0)} -LED_NM = {"violet": 405, "blue": 470, "green": 525, "yellow": 590} -DUTIES = (7, 15, 31, 63, 127, 255) -PANEL = 20 - - -# ── TIFF ───────────────────────────────────────────────────────────────────── -def tiff_si_header(path: Path) -> str: - """ScanImage's `SI.*` header = TIFF tag 305 (Software) of the first IFD.""" - with open(path, "rb") as f: - data = f.read(2_000_000) - bo = "<" if data[:2] == b"II" else ">" - big = struct.unpack_from(bo + "H", data, 2)[0] == 43 - if big: - off = struct.unpack_from(bo + "Q", data, 8)[0] - n = struct.unpack_from(bo + "Q", data, off)[0] - pos, ent, fmt = off + 8, 20, "HHQQ" - else: - off = struct.unpack_from(bo + "I", data, 4)[0] - n = struct.unpack_from(bo + "H", data, off)[0] - pos, ent, fmt = off + 2, 12, "HHII" - for i in range(n): - tag, typ, count, val = struct.unpack_from(bo + fmt, data, pos + i * ent) - if tag == 305: - return data[val:val + count].decode("latin1", "replace") - return "" - - -def si_value(header: str, key: str): - m = re.search(re.escape(key) + r"\s*=\s*([^\n]*)", header) - if not m: - return None - v = m.group(1).strip() - if v.startswith("[") and v.endswith("]"): - return [float(x) for x in v[1:-1].replace(";", " ").split()] - try: - return float(v) - except ValueError: - return v.strip("'") - - -def frame_fields(desc: str) -> dict: - out = {} - for key in ("frameNumbers", "frameTimestamps_sec", "acqTriggerTimestamps_sec", "endOfAcquisition"): - m = re.search(re.escape(key) + r"\s*=\s*([-\d.]+)", desc) - out[key] = float(m.group(1)) if m else None - m = re.search(r"epoch\s*=\s*\[([^\]]*)\]", desc) - out["epoch"] = [float(x) for x in re.split(r"[\s,;]+", m.group(1).strip()) if x] if m else None - m = re.search(r"auxTrigger0\s*=\s*\[([^\]]*)\]", desc) - out["aux0"] = [float(x) for x in re.split(r"[\s,;]+", m.group(1).strip()) if x] if m and m.group(1).strip() else [] - return out - - -def epoch_ns(epoch: list, utc_offset_h: float) -> int: - y, mo, d, h, mi, s = epoch - sec = int(s) - frac = s - sec - t = dt.datetime(int(y), int(mo), int(d), int(h), int(mi), sec, tzinfo=dt.timezone(dt.timedelta(hours=utc_offset_h))) - return int(t.timestamp() * 1e9) + int(round(frac * 1e9)) - - -# ── PNG (pure Python) ──────────────────────────────────────────────────────── -def png_bytes(img: np.ndarray) -> bytes: - """8-bit grayscale (H,W) or RGB (H,W,3) → PNG.""" - if img.ndim == 2: - color_type, row = 0, img - else: - color_type, row = 2, img - h, w = img.shape[:2] - raw = b"".join(b"\x00" + np.ascontiguousarray(row[i]).astype(np.uint8).tobytes() for i in range(h)) - - def chunk(tag: bytes, body: bytes) -> bytes: - return struct.pack(">I", len(body)) + tag + body + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF) - - return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, color_type, 0, 0, 0)) - + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b"")) - - -# ── schemas (Foxglove well-known types, JSON encoding) ─────────────────────── -def _ts(): - return {"type": "object", "properties": {"sec": {"type": "integer"}, "nsec": {"type": "integer"}}} - - -def _vec3(): - return {"type": "object", "properties": {k: {"type": "number"} for k in "xyz"}} - - -def _color(): - return {"type": "object", "properties": {k: {"type": "number"} for k in "rgba"}} - - -def _pose(): - return {"type": "object", "properties": {"position": _vec3(), "orientation": {"type": "object", "properties": {k: {"type": "number"} for k in "xyzw"}}}} - - -SCHEMA_IMAGE = {"title": "foxglove.CompressedImage", "type": "object", "properties": { - "timestamp": _ts(), "frame_id": {"type": "string"}, - "data": {"type": "string", "contentEncoding": "base64"}, "format": {"type": "string"}}} -SCHEMA_SCENE = {"title": "foxglove.SceneUpdate", "type": "object", "properties": { - "deletions": {"type": "array", "items": {"type": "object", "properties": {"timestamp": _ts(), "type": {"type": "integer"}, "id": {"type": "string"}}}}, - "entities": {"type": "array", "items": {"type": "object", "properties": { - "timestamp": _ts(), "frame_id": {"type": "string"}, "id": {"type": "string"}, "lifetime": _ts(), - "frame_locked": {"type": "boolean"}, - "metadata": {"type": "array", "items": {"type": "object", "properties": {"key": {"type": "string"}, "value": {"type": "string"}}}}, - "cubes": {"type": "array", "items": {"type": "object", "properties": {"pose": _pose(), "size": _vec3(), "color": _color()}}}, - "spheres": {"type": "array", "items": {"type": "object", "properties": {"pose": _pose(), "size": _vec3(), "color": _color()}}}, - "cylinders": {"type": "array", "items": {"type": "object", "properties": {"pose": _pose(), "size": _vec3(), "bottom_scale": {"type": "number"}, "top_scale": {"type": "number"}, "color": _color()}}}, - "texts": {"type": "array", "items": {"type": "object", "properties": {"pose": _pose(), "billboard": {"type": "boolean"}, "font_size": {"type": "number"}, "scale_invariant": {"type": "boolean"}, "color": _color(), "text": {"type": "string"}}}}}}}}} - - -def json_schema(name: str, props: dict) -> dict: - return {"title": name, "type": "object", "properties": props} - - -def ts(ns: int) -> dict: - return {"sec": ns // 1_000_000_000, "nsec": ns % 1_000_000_000} - - -# ── the run ────────────────────────────────────────────────────────────────── -def load_color(tiff_dir: Path, color: str, counter: int, utc_offset_h: float, preview_size: int) -> dict: - path = tiff_dir / f"{color}_sweep_{counter:05d}.tif" - if not path.exists(): - raise SystemExit(f"missing {path}") - header = tiff_si_header(path) - chans = [int(c) for c in (si_value(header, "SI.hChannels.channelSave") or [])] or None - frame_rate = si_value(header, "SI.hRoiManager.scanFrameRate") - avg = si_value(header, "SI.hScan2D.logAverageFactor") - pages = list(read_tiff_pages(path)) - n_ch = len(chans) if chans else (2 if len(pages) % 2 == 0 else 1) - chans = chans or list(range(1, n_ch + 1)) - frames = [] - for i in range(0, len(pages), n_ch): - desc, _ = pages[i] - ff = frame_fields(desc) - imgs = {chans[k]: pages[i + k][1] for k in range(n_ch)} - frames.append({"fields": ff, "imgs": imgs, "pages": list(range(i, i + n_ch))}) - ep = frames[0]["fields"]["epoch"] - t0_ns = epoch_ns(ep, utc_offset_h) - for fr in frames: - fr["t_ns"] = t0_ns + int(round(fr["fields"]["frameTimestamps_sec"] * 1e9)) - pulses = sorted(p for fr in frames for p in fr["fields"]["aux0"]) - return {"color": color, "path": path, "header": header, "chans": chans, "frame_rate": frame_rate, "avg": avg, - "frames": frames, "t0_ns": t0_ns, "epoch": ep, "aux0": pulses, - "channels_source": "SI.hChannels.channelSave (TIFF tag 305)" if si_value(header, "SI.hChannels.channelSave") else "ASSUMED page order"} - - -def step_edges(run: dict, durations: list[float]) -> list[dict]: - """RECONSTRUCTED: the first DO1 pulse is the first trialParams; steps follow at the given lengths.""" - if not run["aux0"]: - return [] - t = run["aux0"][0] - edges = [] - for k, (duty, dur) in enumerate(zip(DUTIES, durations)): - edges.append({"step": k, "duty": duty, "t_start_s": t, "t_end_s": t + dur}) - t += dur - return edges - - -# ── run log (Studio → bridge .jsonl, behavior_v2) ──────────────────────────── -COND_RE = re.compile(r"^(" + "|".join(COLORS) + r")_d(\d+)$") - - -def load_runlog(path: Path) -> dict: - """The parts of a run log this tool uses. Times are PC epoch ms. - - trials: one per trialParams, in order: condition, color, duty, pattern_id, fps, t_cmd_ms (the - arena_command send time; the runner's trial-running receive time if those don't pair up) - all_off: allOff send times (compact `a` rows "01 00", t_off from logging start) - connector: ScanImage connector commands (logging start + ms) and replies (rx_epoch_ms) - cc8: controller-clock t_us of each trialParams the controller executed (`cc` rows, cmd 8) - cf: the controller's displayed-frame records (t_us, frame idx, pattern id) - """ - rows = [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()] - objs = [o for o in rows if isinstance(o, dict)] - t0 = next((o["ms"] for o in objs if o.get("event") == "logging_started"), None) - running = [o for o in objs if o.get("event") == "runner" and o.get("phase") == "trial-running"] - sends = [o["t"] for o in objs if o.get("event") == "arena_command" and str(o.get("head", "")).startswith("0d 08")] - paired = len(sends) == len(running) - trials = [] - for i, o in enumerate(running): - m = COND_RE.match(str(o.get("condition", ""))) - p = o.get("params", {}) - trials.append({"condition": o.get("condition"), "color": m.group(1) if m else None, - "duty": int(p.get("duty", m.group(2) if m else 0)), "pattern_id": p.get("patternId"), - "fps": p.get("frameRate"), "t_cmd_ms": float(sends[i] if paired else o["rx_ms"]), - "duty_matches_condition": bool(m) and int(m.group(2)) == int(p.get("duty", -1))}) - a_off = [float(t0 + a[1]) for a in rows if isinstance(a, list) and a[0] == "a" and a[3] == "0100"] if t0 is not None else [] - runner_off = [float(o["rx_ms"]) for o in objs if o.get("event") == "runner" and o.get("op") == "allOff"] - all_off = a_off if len(a_off) == len(runner_off) else runner_off - connector = [] - for o in objs: - if o.get("type") != "connector" or o.get("kind") not in ("command", "reply"): - continue - if o["kind"] == "command" and t0 is not None: - connector.append({"t_ms": float(t0 + o["ms"]), "kind": "command", "command": o.get("command"), - "detail": json.dumps(o.get("params", {}), separators=(",", ":"))}) - elif o["kind"] == "reply": - v = o.get("value") - connector.append({"t_ms": float(o.get("rx_epoch_ms") or o.get("t_ms")), "kind": "reply", "command": o.get("command"), - "detail": f"{o.get('outcome')} rtt {o.get('rtt_ms', 0):.0f} ms " - + json.dumps(v, separators=(",", ":"))[:200]}) - cc8 = [float(a[2]) for a in rows if isinstance(a, list) and a[0] == "cc" and a[4] == 8] - cf = [(float(a[2]), int(a[4]), int(a[5])) for a in rows if isinstance(a, list) and a[0] == "cf"] - return {"path": str(path), "t0_ms": t0, "trials": trials, "sends_paired": paired, "all_off": sorted(all_off), - "all_off_source": "a rows (send)" if all_off is a_off else "runner rx_ms", "connector": sorted(connector, key=lambda c: c["t_ms"]), - "cc8": cc8, "cf": cf} - - -def controller_clock(log: dict): - """Linear map controller t_us → PC ms from the trialParams the controller executed vs their send times. - Returns (fn, info) or (None, reason) when the telemetry can't support it.""" - cc8, sends = log["cc8"], [t["t_cmd_ms"] for t in log["trials"]] - if not log["sends_paired"] or len(cc8) != len(sends) or len(cc8) < 2: - return None, f"no controller clock map (cc trialParams {len(cc8)}, logged sends {len(sends)}, paired {log['sends_paired']})" - x = np.array(cc8) / 1e3 - y = np.array(sends) - k, b = np.polyfit(x - x[0], y, 1) - res = y - (k * (x - x[0]) + b) - fn = lambda t_us: k * (t_us / 1e3 - x[0]) + b # noqa: E731 - return fn, {"rate": float(k), "ppm": float((k - 1) * 1e6), "residual_sd_ms": float(res.std()), "max_abs_residual_ms": float(abs(res).max()), - "note": "fit of send time (PC) vs controller execution time; residual includes USB/host latency jitter"} - - -def runlog_timeline(log: dict, color: str, to_pc) -> dict | None: - """One color's logged steps. t_disp_ms = the controller's first displayed frame after the trialParams - (PC clock) when the telemetry allows, else the send time. t_off_ms = the first allOff after the last step.""" - steps = [t for t in log["trials"] if t["color"] == color] - if not steps: - return None - cf = log["cf"] - cc_by_trial = dict(zip(range(len(log["trials"])), log["cc8"])) if to_pc else {} - out = [] - for s in steps: - i = log["trials"].index(s) - t_disp, frames = None, [] - if to_pc and i in cc_by_trial: - t_exec = cc_by_trial[i] - t_next = cc_by_trial.get(i + 1, float("inf")) - frames = [(to_pc(t_us), idx, pid) for t_us, idx, pid in cf if t_exec <= t_us < t_next and pid == s["pattern_id"]] - if frames: - t_disp = frames[0][0] - out.append({**s, "step": len(out), "t_disp_ms": t_disp if t_disp is not None else s["t_cmd_ms"], - "t_disp_source": "controller first displayed frame" if t_disp is not None else "trialParams send time", - "frames": frames}) - last = out[-1]["t_cmd_ms"] - t_off = next((t for t in log["all_off"] if t > last), None) - lat = [s["t_disp_ms"] - s["t_cmd_ms"] for s in out] - return {"steps": out, "t_off_ms": t_off, "display_latency_ms": float(np.median(lat))} - - -def main(argv=None) -> int: - ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--tiff-dir", required=True) - ap.add_argument("--counter", type=int, required=True, help="TIFF series counter (…_00003.tif → 3)") - ap.add_argument("--pat-dir", required=True, help="folder with G6_2x10_toprow_grat20__gs16.pat") - ap.add_argument("--out", required=True) - ap.add_argument("--layout-out", default=None) - ap.add_argument("--runlog", default=None, help="the run's .jsonl (optional): logged step times, the PC clock " - "(each TIFF anchored by its first DO1 pulse), displayed frames from controller telemetry, ScanImage commands") - ap.add_argument("--utc-offset-h", type=float, default=-4.0, help="TIFF epoch is PC local time (EDT = -4)") - ap.add_argument("--fps", type=float, default=20.0, help="pattern frame rate in the protocol") - ap.add_argument("--step-s", type=float, default=4.0, help="nominal step length when no run log (the day's host-timed steps)") - ap.add_argument("--step-durations", default='{"yellow": [4, 4, 4, 4, 3, 4]}', - help="JSON per-color override of the six step lengths (default: the day's one unthrottled yellow d127 step)") - ap.add_argument("--preview-size", type=int, default=128) - ap.add_argument("--display-scale", type=int, default=4, help="nearest-neighbour upscale of the 200×40 display image") - ap.add_argument("--scene-hz", type=float, default=5.0) - a = ap.parse_args(argv) - if read_tiff_pages is None: - raise SystemExit("needs tools/led_sweep_quicklook.py (repo root) for read_tiff_pages") - - from mcap.writer import Writer, CompressionType - - tiff_dir, pat_dir, out = Path(a.tiff_dir), Path(a.pat_dir), Path(a.out) - overrides = json.loads(a.step_durations) - runs = [load_color(tiff_dir, c, a.counter, a.utc_offset_h, a.preview_size) for c in COLORS] - - # with a run log: put every TIFF on the run log's PC clock, anchored by its first DO1 pulse - log, to_pc, clock_info, timelines = None, None, None, {} - if a.runlog: - log = load_runlog(Path(a.runlog)) - to_pc, clock_info = controller_clock(log) - for run in runs: - tl = runlog_timeline(log, run["color"], to_pc) - if not tl or not run["aux0"]: - continue - anchor_ms = tl["steps"][0]["t_disp_ms"] - epoch_ns_first_do1 = run["t0_ns"] + int(round(run["aux0"][0] * 1e9)) - shift_ns = int(round(anchor_ms * 1e6)) - epoch_ns_first_do1 - run["t0_ns"] += shift_ns - for fr in run["frames"]: - fr["t_ns"] += shift_ns - # end check: last DO1 pulse (+ one 300 Hz period) vs the logged allOff + display latency - end_ms = anchor_ms + (run["aux0"][-1] - run["aux0"][0] + 1 / 300) * 1e3 - tl.update(anchor_ms=anchor_ms, epoch_correction_ms=shift_ns / 1e6, - end_check_ms=(end_ms - (tl["t_off_ms"] + tl["display_latency_ms"])) if tl["t_off_ms"] else None) - timelines[run["color"]] = tl - runs.sort(key=lambda r: r["t0_ns"]) - pats = {c: read_pat(pat_dir / f"G6_2x10_toprow_grat20_{c}_gs16.pat") for c in COLORS} - - # fixed display mapping per channel from the FIRST file (so colors are comparable) - window = {} - for ch in runs[0]["chans"]: - stack = np.stack([fr["imgs"][ch] for fr in runs[0]["frames"][::8]]) - lo, hi = np.percentile(stack, 0.5), np.percentile(stack, 99.8) - window[ch] = (float(lo), float(max(hi, lo + 1))) - - def to8(img, ch): - lo, hi = window[ch] - x = (img.astype(np.float32) - lo) / (hi - lo) - return (np.clip(x, 0, 1) * 255).astype(np.uint8) - - def downsample(img): - h, w = img.shape - f = max(1, h // a.preview_size) - return img[: h - h % f, : w - w % f].reshape(h // f, f, w // f, f).mean(axis=(1, 3)) - - def stimulus_source(color): - if color in timelines: - return "commanded (run log trialParams, logged send time)" - return "reconstructed (first DO1 pulse + nominal step lengths)" - - gaps = [] - with open(out, "wb") as fh: - w = Writer(fh, compression=CompressionType.ZSTD) - w.start(profile="", library="tiff_run_to_mcap.py (webDisplayTools rig)") - sch_img = w.register_schema("foxglove.CompressedImage", "jsonschema", json.dumps(SCHEMA_IMAGE).encode()) - sch_scene = w.register_schema("foxglove.SceneUpdate", "jsonschema", json.dumps(SCHEMA_SCENE).encode()) - sch_ref = w.register_schema("scanimage.FrameRef", "jsonschema", json.dumps(json_schema("scanimage.FrameRef", { - "color": {"type": "string"}, "frame": {"type": "integer"}, "t_acq_s": {"type": "number"}, "tiff": {"type": "string"}, - "pages": {"type": "array", "items": {"type": "integer"}}, "channels": {"type": "array", "items": {"type": "integer"}}, - "do1_pulses_in_frame": {"type": "integer"}, "source": {"type": "string"}})).encode()) - sch_qc = w.register_schema("scanimage.QC", "jsonschema", json.dumps(json_schema("scanimage.QC", { - "color": {"type": "string"}, "frame": {"type": "integer"}, "duty": {"type": "integer"}, "step": {"type": "integer"}, - **{f"mean_ch{ch}": {"type": "number"} for ch in runs[0]["chans"]}, - **{f"dmean_ch{ch}": {"type": "number"} for ch in runs[0]["chans"]}, "source": {"type": "string"}})).encode()) - sch_stim = w.register_schema("arena.Stimulus", "jsonschema", json.dumps(json_schema("arena.Stimulus", { - "color": {"type": "string"}, "wavelength_nm": {"type": "integer"}, "duty": {"type": "integer"}, "step": {"type": "integer"}, - "on": {"type": "boolean"}, "pattern": {"type": "string"}, "fps": {"type": "number"}, "t_rel_s": {"type": "number"}, - "source": {"type": "string"}})).encode()) - sch_ev = w.register_schema("run.Event", "jsonschema", json.dumps(json_schema("run.Event", { - "event": {"type": "string"}, "color": {"type": "string"}, "detail": {"type": "string"}, "source": {"type": "string"}})).encode()) - sch_prov = w.register_schema("run.Provenance", "jsonschema", b'{"type":"object"}') - ch_prev = {ch: w.register_channel(f"/scanimage/preview/ch{ch}", "json", sch_img) for ch in runs[0]["chans"]} - ch_ref = w.register_channel("/scanimage/frame_ref", "json", sch_ref) - ch_qc = w.register_channel("/scanimage/qc", "json", sch_qc) - ch_stim = w.register_channel("/arena/stimulus", "json", sch_stim) - ch_disp = w.register_channel("/arena/display", "json", sch_img) - ch_scene = w.register_channel("/arena/scene", "json", sch_scene) - ch_ev = w.register_channel("/run/events", "json", sch_ev) - ch_prov = w.register_channel("/run/provenance", "json", sch_prov) - - def put(chan, t_ns, obj): - w.add_message(chan, log_time=t_ns, publish_time=t_ns, data=json.dumps(obj, separators=(",", ":")).encode()) - - def img_msg(t_ns, png, frame_id): - return {"timestamp": ts(t_ns), "frame_id": frame_id, "format": "png", "data": base64.b64encode(png).decode()} - - def scene(t_ns, color, frame, duty, on): - rgb = LED_RGB[color] - cubes = [] - pat = pats[color] - n_cols, n_rows = pat["cols"], pat["rows"] - R = 1.0 - for r in range(n_rows): - for c in range(n_cols): - lit = float(frame[r * PANEL:(r + 1) * PANEL, c * PANEL:(c + 1) * PANEL].mean()) / 15.0 if (on and frame is not None) else 0.0 - level = lit * (duty / 255.0) if on else 0.0 - az = 2 * np.pi * (c + 0.5) / n_cols - cubes.append({"pose": {"position": {"x": R * np.cos(az), "y": R * np.sin(az), "z": 0.3 + 0.62 * r}, - "orientation": {"x": 0, "y": 0, "z": float(np.sin((az + np.pi / 2) / 2)), "w": float(np.cos((az + np.pi / 2) / 2))}}, - "size": {"x": 0.58, "y": 0.06, "z": 0.58}, - "color": {"r": 0.12 + rgb[0] / 255 * level * 0.88, "g": 0.12 + rgb[1] / 255 * level * 0.88, "b": 0.12 + rgb[2] / 255 * level * 0.88, "a": 0.95}}) - return {"deletions": [], "entities": [{ - "timestamp": ts(t_ns), "frame_id": "arena", "id": "panels", "lifetime": ts(0), "frame_locked": False, - "metadata": [{"key": "source", "value": "reconstructed"}, {"key": "color", "value": color}, {"key": "duty", "value": str(duty)}], - "cubes": cubes, - "spheres": [{"pose": {"position": {"x": 0, "y": 0, "z": 0.6}, "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}}, - "size": {"x": 0.12, "y": 0.12, "z": 0.12}, "color": {"r": 1, "g": 1, "b": 1, "a": 1}}], - "cylinders": [], "texts": [{"pose": {"position": {"x": 0, "y": 0, "z": 1.35}, "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}}, - "billboard": True, "font_size": 0.12, "scale_invariant": False, "color": {"r": 0.9, "g": 0.93, "b": 0.95, "a": 1}, - "text": f"{color} {LED_NM[color]} nm · duty {duty}" if on else "arena dark"}]}]} - - def display_png(color, frame, duty): - rgb = np.array(LED_RGB[color], dtype=np.float32) - img = (frame[::-1, :].astype(np.float32) / 15.0)[:, :, None] * (duty / 255.0) * rgb # row 0 (bottom) → image bottom - img = np.clip(img, 0, 255).astype(np.uint8) - s = a.display_scale - return png_bytes(np.repeat(np.repeat(img, s, axis=0), s, axis=1)) - - recon_display = f"reconstructed: .pat frame × duty/255 × LED color, fps {a.fps}, frame index restarts at 0 at each step" - prov = {"source_files": {}, - "clock": ("run log PC clock (Unix epoch); each TIFF anchored by its first DO1 pulse = the controller's first " - "displayed frame after the color's first trialParams; the ScanImage epoch field is not used for placement") - if timelines else f"TIFF epoch (PC local, UTC{a.utc_offset_h:+.0f}) + frameTimestamps_sec", - "stimulus_source": {c: stimulus_source(c) for c in COLORS}, - "display_source": {c: ("observed: the controller's displayed-frame records (cf rows), controller clock → PC" - if c in timelines and timelines[c]["steps"][0]["frames"] else recon_display) for c in COLORS}, - "scene_source": "derived from /arena/display: panel cubes colored by LED color × lit fraction × duty", - "preview_mapping": {f"ch{ch}": {"lo": window[ch][0], "hi": window[ch][1], "downsample": "2x2 mean"} for ch in window}, - "step_lengths_s": {}, "gaps": []} - if log is not None: - prov["runlog"] = {"path": log["path"], "trials": len(log["trials"]), "trialParams_send_times_paired": log["sends_paired"], - "all_off_source": log["all_off_source"], "connector_records": len(log["connector"]), - "controller_frames": len(log["cf"]), "controller_clock": clock_info, - "duty_mismatches": [t["condition"] for t in log["trials"] if not t["duty_matches_condition"]]} - prov["anchors"] = {c: {"first_do1_pc_ms": tl["anchor_ms"], "epoch_correction_ms": tl["epoch_correction_ms"], - "display_latency_ms": tl["display_latency_ms"], "end_check_ms": tl["end_check_ms"], - "t_disp_source": tl["steps"][0]["t_disp_source"]} for c, tl in timelines.items()} - for c in COLORS: - if c not in timelines: - prov["gaps"].append({"color": c, "where": "run log given but no trialParams or DO1 for this color — reconstructed"}) - counts = {} - for run in runs: - color = run["color"] - tl = timelines.get(color) - if tl: - # logged steps in TIFF-relative seconds: the light changes at the displayed time (qc windows); - # the stimulus marker sits at the commanded send time - f0, anchor = run["aux0"][0], tl["anchor_ms"] - rel = lambda t_ms: f0 + (t_ms - anchor) / 1e3 # noqa: E731 - st = tl["steps"] - t_last = (tl["t_off_ms"] + tl["display_latency_ms"]) if tl["t_off_ms"] else st[-1]["t_disp_ms"] + a.step_s * 1e3 - bounds = [s["t_disp_ms"] for s in st] + [t_last] - edges = [{"step": k, "duty": s["duty"], "pattern_id": s["pattern_id"], "fps": s["fps"] or a.fps, - "t_start_s": rel(bounds[k]), "t_end_s": rel(bounds[k + 1]), "t_cmd_s": rel(s["t_cmd_ms"]), "frames": s["frames"]} - for k, s in enumerate(st)] - dur = [round((bounds[k + 1] - bounds[k]) / 1e3, 4) for k in range(len(st))] - else: - dur = overrides.get(color) or [a.step_s] * len(DUTIES) - edges = step_edges(run, dur) - prov["step_lengths_s"][color] = dur - prov["source_files"][color] = {"tiff": str(run["path"]), "frames": len(run["frames"]), "channels": run["chans"], - "channels_source": run["channels_source"], "scanFrameRate": run["frame_rate"], - "logAverageFactor": run["avg"], "epoch_local": run["epoch"], - "do1_pulses": len(run["aux0"]), "first_do1_s": run["aux0"][0] if run["aux0"] else None, - "last_do1_s": run["aux0"][-1] if run["aux0"] else None} - t0 = run["t0_ns"] - put(ch_ev, t0, {"event": "acquisition_start", "color": color, "detail": f"{run['path'].name}, {len(run['frames'])} frames @ {run['frame_rate']:.2f} Hz", - "source": "observed (TIFF, anchored to the run log by DO1)" if tl else "observed (TIFF epoch)"}) - # baseline = frames before the first DO1 pulse - first_do1 = run["aux0"][0] if run["aux0"] else None - base = {ch: float(np.mean([fr["imgs"][ch].mean() for fr in run["frames"] if first_do1 is None or fr["fields"]["frameTimestamps_sec"] < first_do1] or [0.0])) for ch in run["chans"]} - # frame-number continuity (observed) - nums = [int(fr["fields"]["frameNumbers"]) for fr in run["frames"]] - if any(b - a_ != 1 for a_, b in zip(nums, nums[1:])): - prov["gaps"].append({"color": color, "where": "TIFF frameNumbers not consecutive"}) - # observed frames - for fr in run["frames"]: - t_ns = fr["t_ns"] - t_rel = fr["fields"]["frameTimestamps_sec"] - step = next((e for e in edges if e["t_start_s"] <= t_rel < e["t_end_s"]), None) - for ch in run["chans"]: - put(ch_prev[ch], t_ns, img_msg(t_ns, png_bytes(downsample(to8(fr["imgs"][ch], ch)).astype(np.uint8)), f"scanimage_ch{ch}")) - put(ch_ref, t_ns, {"color": color, "frame": int(fr["fields"]["frameNumbers"]), "t_acq_s": t_rel, "tiff": run["path"].name, - "pages": fr["pages"], "channels": run["chans"], "do1_pulses_in_frame": len(fr["fields"]["aux0"]), "source": "observed"}) - qc = {"color": color, "frame": int(fr["fields"]["frameNumbers"]), "duty": step["duty"] if step else 0, "step": step["step"] if step else -1, "source": "derived from observed pixels"} - for ch in run["chans"]: - m = float(fr["imgs"][ch].mean()) - qc[f"mean_ch{ch}"] = m - qc[f"dmean_ch{ch}"] = m - base[ch] - put(ch_qc, t_ns, qc) - counts[color] = len(run["frames"]) - # stimulus (commanded or reconstructed) + display (observed or reconstructed) + scene - pat = pats[color] - src = stimulus_source(color) - if not edges: - prov["gaps"].append({"color": color, "where": "no DO1 pulses in the TIFF — no stimulus timeline"}) - scene_every = max(1, int(round(a.fps / a.scene_hz))) if a.scene_hz > 0 else 0 - for e in edges: - ts_ns = t0 + int(e["t_start_s"] * 1e9) - tc_ns = t0 + int(e.get("t_cmd_s", e["t_start_s"]) * 1e9) - put(ch_stim, tc_ns, {"color": color, "wavelength_nm": LED_NM[color], "duty": e["duty"], "step": e["step"], "on": True, - "pattern": f"G6_2x10_toprow_grat20_{color}_gs16", "fps": e.get("fps", a.fps), - "t_rel_s": e.get("t_cmd_s", e["t_start_s"]), "source": src}) - put(ch_ev, tc_ns, {"event": "step_start", "color": color, "detail": f"duty {e['duty']} (step {e['step']})", "source": src}) - if e.get("frames"): - # OBSERVED: each frame the controller reports displaying, at its own (PC-mapped) time - put(ch_ev, ts_ns, {"event": "display_start", "color": color, - "detail": f"duty {e['duty']}: first displayed frame {(ts_ns - tc_ns) / 1e6:.1f} ms after the command", - "source": "observed (controller frame records)"}) - for k, (t_ms, idx, _pid) in enumerate(e["frames"]): - t_ns = int(round(t_ms * 1e6)) - frame = pat["frames"][idx % pat["num_frames"]] - put(ch_disp, t_ns, img_msg(t_ns, display_png(color, frame, e["duty"]), "arena_display")) - if scene_every and k % scene_every == 0: - put(ch_scene, t_ns, scene(t_ns, color, frame, e["duty"], True)) - else: - n_disp = int((e["t_end_s"] - e["t_start_s"]) * a.fps) - for k in range(n_disp): - t_ns = ts_ns + int(k / a.fps * 1e9) - frame = pat["frames"][k % pat["num_frames"]] - put(ch_disp, t_ns, img_msg(t_ns, display_png(color, frame, e["duty"]), "arena_display")) - if scene_every and k % scene_every == 0: - put(ch_scene, t_ns, scene(t_ns, color, frame, e["duty"], True)) - if edges: - if tl and tl["t_off_ms"]: - t_off = int(round(tl["t_off_ms"] * 1e6)) - off_src = "commanded (run log allOff send time)" - off_detail = f"last DO1 pulse at {run['aux0'][-1]:.3f} s; DO1 end − (allOff + latency) = {tl['end_check_ms']:+.1f} ms" - else: - t_off = t0 + int(edges[-1]["t_end_s"] * 1e9) - off_src = "observed (DO1) / " + src - off_detail = f"last DO1 pulse at {run['aux0'][-1]:.3f} s" - put(ch_stim, t_off, {"color": color, "wavelength_nm": LED_NM[color], "duty": 0, "step": -1, "on": False, - "pattern": "", "fps": a.fps, "t_rel_s": (t_off - t0) / 1e9, "source": off_src}) - put(ch_ev, t_off, {"event": "allOff", "color": color, "detail": off_detail, "source": off_src}) - put(ch_scene, t_off, scene(t_off, color, None, 0, False)) - put(ch_disp, t_off, img_msg(t_off, display_png(color, np.zeros_like(pat["frames"][0]), 0), "arena_display")) - t_end = run["frames"][-1]["t_ns"] - put(ch_ev, t_end, {"event": "acquisition_end", "color": color, "detail": f"{len(run['frames'])} frames", "source": "observed (TIFF)"}) - if log is not None and timelines: - for c in log["connector"]: - put(ch_ev, int(round(c["t_ms"] * 1e6)), {"event": f"scanimage_{c['kind']}", "color": "", "detail": f"{c['command']} {c['detail']}", - "source": "commanded (run log)" if c["kind"] == "command" else "observed (run log reply)"}) - prov["frames_per_color"] = counts - put(ch_prov, runs[0]["t0_ns"], prov) - w.add_metadata("provenance", {k: json.dumps(v) for k, v in prov.items()}) - w.finish() - - print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)") - print(json.dumps({"frames_per_color": counts, "preview_mapping": prov["preview_mapping"], "step_lengths_s": prov["step_lengths_s"], - "do1": {c: (prov["source_files"][c]["first_do1_s"], prov["source_files"][c]["last_do1_s"], prov["source_files"][c]["do1_pulses"]) for c in prov["source_files"]}, - "anchors": prov.get("anchors"), "controller_clock": clock_info, "gaps": prov["gaps"]}, indent=1)) - - if a.layout_out: - chans = runs[0]["chans"] - cfg = { - f"Image!ch{ch}": {"imageMode": {"imageTopic": f"/scanimage/preview/ch{ch}"}, "foxglovePanelTitle": f"ScanImage ch{ch} (observed, {a.preview_size}px preview)"} for ch in chans} - cfg.update({ - "Image!display": {"imageMode": {"imageTopic": "/arena/display"}, - "foxglovePanelTitle": "Arena display (controller-reported frames × .pat × duty)" if any( - t["steps"][0]["frames"] for t in timelines.values()) else "Arena display (RECONSTRUCTED from .pat × duty)"}, - "3D!arena": {"cameraState": {"perspective": True, "distance": 4.2, "phi": 55, "thetaOffset": 40, "targetOffset": [0, 0, 0.6], "target": [0, 0, 0], "targetOrientation": [0, 0, 0, 1], "fovy": 45, "near": 0.01, "far": 100}, - "followTf": "arena", "scene": {"enableStats": False, "backgroundColor": "#0f1419"}, "topics": {"/arena/scene": {"visible": True}}, - "foxglovePanelTitle": "Arena (RECONSTRUCTED panel states)"}, - "Plot!qc": {"title": "Whole-field mean minus pre-stimulus baseline (observed) + commanded duty", "showLegend": True, "legendDisplay": "floating", "xAxisVal": "timestamp", - "paths": [{"value": f"/scanimage/qc.dmean_ch{ch}", "enabled": True, "timestampMethod": "receiveTime", "label": f"ch{ch} Δmean (ADU)"} for ch in chans] - + [{"value": "/arena/stimulus.duty", "enabled": True, "timestampMethod": "receiveTime", "label": "duty (0–255)"}]}, - "StateTransitions!color": {"paths": [{"value": "/arena/stimulus.color", "timestampMethod": "receiveTime", "label": "LED color"}], "isSynced": True}, - "RawMessages!events": {"topicPath": "/run/events", "diffEnabled": False, "diffMethod": "custom", "diffTopicPath": "", "showFullMessageForDiff": False, "expansion": "all"}, - "RawMessages!prov": {"topicPath": "/run/provenance", "expansion": "all", "diffEnabled": False, "diffMethod": "custom", "diffTopicPath": "", "showFullMessageForDiff": False}, - }) - img_ids = [f"Image!ch{ch}" for ch in chans] - layout = {"configById": cfg, "globalVariables": {}, "userNodes": {}, "playbackConfig": {"speed": 1.0}, - "layout": {"direction": "row", "splitPercentage": 58, - "first": {"direction": "column", "splitPercentage": 55, - "first": {"direction": "row", "splitPercentage": 50, "first": img_ids[0], "second": img_ids[1] if len(img_ids) > 1 else "Image!display"}, - "second": {"direction": "row", "splitPercentage": 50, "first": "Image!display", "second": "3D!arena"}}, - "second": {"direction": "column", "splitPercentage": 45, "first": "Plot!qc", - "second": {"direction": "column", "splitPercentage": 25, "first": "StateTransitions!color", - "second": {"direction": "row", "splitPercentage": 55, "first": "RawMessages!events", "second": "RawMessages!prov"}}}}} - Path(a.layout_out).write_text(json.dumps(layout, indent=2), encoding="utf-8") - print(f"wrote layout {a.layout_out}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test-flasher-catalog.js b/tests/test-flasher-catalog.js index 12981349..f82ec76e 100644 --- a/tests/test-flasher-catalog.js +++ b/tests/test-flasher-catalog.js @@ -208,19 +208,11 @@ for (const f of textFiles) { if (!served.has(m[1])) referenced.set(m[1], (referenced.get(m[1]) || []).concat(f)); } } +// The superseded 2P line-sync checklist, the one document allowed to name removed images, moved to the +// private 2P rig repository on 2026-10-28 (rig-software-repos-and-runners.md §3.3 amendment). for (const [file, where] of referenced) { - // The superseded 2P checklist keeps its history (banner at the top says the files are gone). - const only2p = where.every((w) => w.endsWith('2p-line-sync-rig-checklist.md')); - checkBool( - `no live reference to a removed image: ${file}`, - only2p, - 'referenced from ' + [...new Set(where)].join(', ') - ); + checkBool(`no live reference to a removed image: ${file}`, false, 'referenced from ' + [...new Set(where)].join(', ')); } -checkBool( - '2P rig checklist marked superseded', - /Superseded \(2026-09-29\)/.test(read('docs/development/2p-line-sync-rig-checklist.md')) -); console.log('\n=== Summary ==='); console.log(`${total - failures} / ${total} checks passed`); diff --git a/tools/led_sweep_quicklook.py b/tools/led_sweep_quicklook.py deleted file mode 100644 index 6a526dda..00000000 --- a/tools/led_sweep_quicklook.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Quick look at a g6_2p_color_led_sweep run: per duty step, the mean image brightness per channel. - -Reads ScanImage TIFFs with a minimal stdlib+numpy reader (uncompressed classic or BigTIFF, as -ScanImage writes them; no tifffile needed), takes each frame's absolute time from the frame header -(`epoch` = acquisition start, PC local time, + `frameTimestamps_sec`), and lines the frames up with -the duty steps in the converted run log (`arena_command` 0x08 trialParams times, same PC clock). - - python tools/led_sweep_quicklook.py [--utc-offset-h -4] - -Output per color: baseline (frames before the first step), then per duty step the mean of each -channel minus baseline, and the per-frame noise (std over the step's frames) → a crude SNR. -A quick look only: whole-field means, no ROI, no motion correction. -""" -from __future__ import annotations - -import argparse -import datetime as dt -import json -import re -import struct -import sys -from pathlib import Path - -import numpy as np - -COLORS = ("violet", "blue", "green", "yellow") -DUTIES = (7, 15, 31, 63, 127, 255) - - -def read_tiff_pages(path: Path): - """Yield (description, ndarray) per page. Uncompressed, single-sample, strips.""" - with open(path, "rb") as f: - data = memoryview(f.read()) - bo = {b"II": "<", b"MM": ">"}[bytes(data[:2])] - magic = struct.unpack_from(bo + "H", data, 2)[0] - big = magic == 43 - if big: - off = struct.unpack_from(bo + "Q", data, 8)[0] - ent_fmt, cnt_fmt, ent_size, next_fmt = "HHQQ", "Q", 20, "Q" - else: - off = struct.unpack_from(bo + "I", data, 4)[0] - ent_fmt, cnt_fmt, ent_size, next_fmt = "HHII", "H", 12, "I" - tsz = {1: 1, 2: 1, 3: 2, 4: 4, 16: 8} - while off: - n = struct.unpack_from(bo + cnt_fmt, data, off)[0] - p = off + struct.calcsize(cnt_fmt) - tags = {} - for i in range(n): - tag, typ, count, val = struct.unpack_from(bo + ent_fmt, data, p + i * ent_size) - size = tsz.get(typ, 1) * count - inline = size <= (8 if big else 4) - # value field follows tag (2) + type (2) + count (4 classic / 8 BigTIFF) - vo = p + i * ent_size + 4 + (8 if big else 4) if inline else val - if typ == 2: - tags[tag] = bytes(data[vo:vo + count]).rstrip(b"\0").decode("latin-1") - elif typ in (3, 4, 16): - f = {3: "H", 4: "I", 16: "Q"}[typ] - tags[tag] = struct.unpack_from(bo + f * count, data, vo) - w, h = tags[256][0], tags[257][0] - bits = tags.get(258, (16,))[0] - fmt = tags.get(339, (1,))[0] # 1 uint, 2 int - dtype = np.dtype(bo + {(16, 2): "i2", (16, 1): "u2", (8, 1): "u1", (32, 2): "i4"}[(bits, fmt)]) - offs, cnts = tags[273], tags[279] - buf = b"".join(bytes(data[o:o + c]) for o, c in zip(offs, cnts)) - yield tags.get(270, ""), np.frombuffer(buf, dtype=dtype, count=w * h).reshape(h, w) - off = struct.unpack_from(bo + next_fmt, data, p + n * ent_size)[0] - - -def frame_series(path: Path, utc_offset_h: float): - """Per page: (abs_epoch_ms, channel_index_within_frame, mean).""" - out = [] - per_frame = {} - for desc, img in read_tiff_pages(path): - ts = re.search(r"frameTimestamps_sec\s*=\s*([-\d.eE+]+)", desc) - ep = re.search(r"epoch\s*=\s*\[([^\]]+)\]", desc) - fn = re.search(r"frameNumbers\s*=\s*(\d+)", desc) - if not (ts and ep): - continue - y, mo, d, hh, mi, ss = [float(x) for x in ep.group(1).split()] - t0 = dt.datetime(int(y), int(mo), int(d), int(hh), int(mi), tzinfo=dt.timezone(dt.timedelta(hours=utc_offset_h))) - abs_ms = (t0.timestamp() + ss + float(ts.group(1))) * 1000.0 - k = int(fn.group(1)) if fn else len(out) - ch = per_frame.get(k, 0) - per_frame[k] = ch + 1 - out.append((abs_ms, ch, float(img.mean()))) - return out - - -def main(argv=None) -> int: - for s in (sys.stdout, sys.stderr): - try: - s.reconfigure(encoding="utf-8", errors="replace") - except (AttributeError, ValueError): - pass - a = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - a.add_argument("runlog") - a.add_argument("tiff_dir") - a.add_argument("counter", type=int) - a.add_argument("--utc-offset-h", type=float, default=-4.0, help="PC local time vs UTC (EDT = -4)") - a.add_argument("--settle-s", type=float, default=0.3, help="skip this long after each step change") - args = a.parse_args(argv) - - rows = [json.loads(l) for l in open(args.runlog, encoding="utf-8") if l.strip().startswith("{")] - tp = [r["t"] for r in rows if r.get("event") == "arena_command" and str(r.get("head", "")).split(" ")[1:2] == ["08"]] - offs = [r["t"] for r in rows if r.get("event") == "arena_command" and str(r.get("head", "")).split(" ")[1:2] == ["00"]] - if len(tp) != 24: - print(f"expected 24 trialParams, found {len(tp)}") - for c, color in enumerate(COLORS): - tif = Path(args.tiff_dir) / f"{color}_sweep_{args.counter:05d}.tif" - s = frame_series(tif, args.utc_offset_h) - if not s: - print(f"{color}: no frame headers parsed in {tif}") - continue - t = np.array([x[0] for x in s]); ch = np.array([x[1] for x in s]); m = np.array([x[2] for x in s]) - steps = tp[c * 6:(c + 1) * 6] - step_len = float(np.median(np.diff(steps))) if len(steps) > 1 else 3000.0 - end = steps[-1] + step_len - nch = int(ch.max()) + 1 - print(f"\n{color}: {tif.name} {len(s)} pages, {nch} channel(s), frames {(t.min() - steps[0]) / 1000:+.2f} → " - f"{(t.max() - end) / 1000:+.2f} s vs stimulus start/end (step {step_len / 1000:.2f} s)") - for k in range(nch): - sel = ch == k - base_sel = sel & (t < steps[0] - 100) - base = m[base_sel].mean() if base_sel.any() else float("nan") - line = [f" ch{k + 1}: baseline {base:8.2f} (n={int(base_sel.sum())})"] - for j, t_on in enumerate(steps): - t_off = steps[j + 1] if j + 1 < len(steps) else end - w = sel & (t >= t_on + args.settle_s * 1000) & (t < t_off - 100) - if not w.any(): - line.append(f"d{DUTIES[j]}: —") - continue - d = m[w] - base - line.append(f"d{DUTIES[j]}: {d.mean():+7.2f} (sd {m[w].std():.2f}, n={int(w.sum())})") - print(" ".join(line[:1])) - for x in line[1:]: - print(" " + x) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/led_sweep_si.py b/tools/led_sweep_si.py deleted file mode 100644 index cdfb973e..00000000 --- a/tools/led_sweep_si.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""led_sweep_si.py — FALLBACK for the 2P color LED-intensity sweep (2026-10-16): drive ScanImage's -grabs from a plain script on the microscope PC while the Arena Studio runs the matching protocol by -hand (Console → Test experiment), so the day's data is still collected if the connector path -(bridge node + scanimage_node.py) fails on site. - -Same condition table as protocols/g6_2p_color_led_sweep.yaml, same TIFF layout (one grab per -color, stem "_sweep"), so the analysis does not change. Alignment is the controller's -Digital IO 1 frame-transfer pulse recorded by ScanImage, exactly as in the connector path. - -Talks to ScanImage's built-in JSON server (`hJ = most.network.json.Server(5555)` in MATLAB) over -a persistent TCP connection, the way scanimage_node.py does: length-prefixed JSON, one client, -scalar properties only, two fixed evals (startGrab, abort). No ROS, no pixi: the Python that ships -with the rig PC is enough (stdlib only). - - python tools/led_sweep_si.py --dry-run # print the plan, touch nothing - python tools/led_sweep_si.py --ping # one get of acqState, then exit - python tools/led_sweep_si.py --colors violet blue # a subset - python tools/led_sweep_si.py # the whole sweep, prompting before each grab - -Sequence per color: set logFileStem → startGrab → wait `--settle` s → (operator starts the Studio -condition for this color — the script prints the cue and, unless --no-prompt, waits for Enter) → -poll acqState until idle (the grab length is framesPerSlice, set once at the start) → next color. -""" - -from __future__ import annotations - -import argparse -import json -import socket -import struct -import sys -import time - -COLORS = ("violet", "blue", "green", "yellow") # bank 0..3: 405, 470, 525, 590 nm -DUTIES = (7, 15, 31, 63, 127, 255) -STEP_S = 3.0 -SETTLE_S = 1.0 -FRAMES_PER_GRAB = 640 # ≥ (settle + 6 × step) × scan frame rate (29 Hz → 19 s → 551), with margin - - -class SiTimeout(TimeoutError): - pass - - -class SiError(RuntimeError): - pass - - -class JsonClient: - """ScanImage JSON server client (ported from scanimage_node.py / si_remote.py).""" - - def __init__(self, host: str, port: int, timeout_s: float) -> None: - self.host, self.port, self.timeout_s = host, port, timeout_s - self._sock: socket.socket | None = None - - def drop(self) -> None: - if self._sock is not None: - try: - self._sock.close() - except OSError: - pass - self._sock = None - - def _recv_exact(self, n: int) -> bytes: - assert self._sock is not None - buf = b"" - while len(buf) < n: - chunk = self._sock.recv(n - len(buf)) - if not chunk: - raise ConnectionError("server closed the connection") - buf += chunk - return buf - - def request(self, msg: dict, timeout_s: float | None = None) -> dict: - data = json.dumps(msg, separators=(",", ":")).encode("utf-8") - to = timeout_s if timeout_s is not None else self.timeout_s - try: - if self._sock is None: - self._sock = socket.create_connection((self.host, self.port), timeout=to) - self._sock.settimeout(to) - self._sock.sendall(struct.pack(" None: - self.request({"command": "eval", "function": expr, "num_outputs": 0}, timeout_s) - - -def wait_state(cli: JsonClient, want: str, timeout_s: float, poll_s: float = 0.5) -> str: - end = time.monotonic() + timeout_s - st = cli.get("hSI.acqState") - while st != want and time.monotonic() < end: - time.sleep(poll_s) - st = cli.get("hSI.acqState") - return str(st) - - -def log(results: list, **row) -> None: - row["t"] = time.strftime("%H:%M:%S") - results.append(row) - print(" " + json.dumps(row), flush=True) - - -def main(argv: list[str] | None = None) -> int: - # A Windows console is cp1252 by default: the plan/cue lines use → and ×, which would crash print(). - for s in (sys.stdout, sys.stderr): - try: - s.reconfigure(encoding="utf-8", errors="replace") - except (AttributeError, ValueError): - pass - p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--host", default="127.0.0.1") - p.add_argument("--port", type=int, default=5555) - p.add_argument("--colors", nargs="+", default=list(COLORS), choices=COLORS) - p.add_argument("--duties", nargs="+", type=int, default=list(DUTIES), help="panel duty levels (for the plan + cue only; the Studio protocol sets them)") - p.add_argument("--step", type=float, default=STEP_S, help="s per duty level in the Studio protocol (plan + wait budget)") - p.add_argument("--settle", type=float, default=SETTLE_S, help="s after the grab starts before cueing the stimulus") - p.add_argument("--frames", type=int, default=FRAMES_PER_GRAB, help="framesPerSlice to set once (0 = leave ScanImage's value)") - p.add_argument("--stem-suffix", default="_sweep", help='TIFF stem = ""') - p.add_argument("--timeout", type=float, default=2.0, help="per-request timeout (s)") - p.add_argument("--grab-timeout", type=float, default=15.0, help="startGrab is synchronous in ScanImage (1.2–2.4 s on the Bergamo PC)") - p.add_argument("--no-prompt", action="store_true", help="do not wait for Enter before each grab (time the Studio by the printed cue)") - p.add_argument("--dry-run", action="store_true", help="print the plan and exit; nothing is sent") - p.add_argument("--ping", action="store_true", help="read acqState and the file settings, then exit") - p.add_argument("--results", default=None, help="write the per-step JSON lines here too") - a = p.parse_args(argv) - - per_color = a.settle + len(a.duties) * a.step - print(f"[plan] {len(a.colors)} colors × {len(a.duties)} duties × {a.step:g} s (+{a.settle:g} s settle) = {per_color:g} s of grab per color; " - f"framesPerSlice {a.frames or 'unchanged'}; stems {[c + a.stem_suffix for c in a.colors]}; duties {a.duties}") - if a.dry_run: - for c in a.colors: - print(f" {c}: set stem {c + a.stem_suffix!r} → startGrab → {a.settle:g} s → Studio runs {c} duties {a.duties} ({a.step:g} s each) → allOff → wait idle") - print("[plan] dry run: nothing sent") - return 0 - - cli = JsonClient(a.host, a.port, a.timeout) - results: list = [] - try: - st = cli.get("hSI.acqState") - fps = cli.get("hSI.hRoiManager.scanFrameRate") - info = {"acqState": st, "scanFrameRate": fps, "logFilePath": cli.get("hSI.hScan2D.logFilePath"), - "logFileStem": cli.get("hSI.hScan2D.logFileStem"), "logFileCounter": cli.get("hSI.hScan2D.logFileCounter"), - "loggingEnable": cli.get("hSI.hChannels.loggingEnable"), "framesPerSlice": cli.get("hSI.hStackManager.framesPerSlice"), - "channelSave": cli.get("hSI.hChannels.channelSave"), "bidirectional": cli.get("hSI.hScan2D.bidirectional")} - print(f"[si] {json.dumps(info)}") - if a.ping: - return 0 - if st != "idle": - print(f"[si] acqState is {st!r}; abort it in ScanImage first", file=sys.stderr) - return 1 - if not info["loggingEnable"]: - print("[si] WARNING: logging is OFF in ScanImage — grabs will not write TIFFs. Enable logging, then rerun.", file=sys.stderr) - return 1 - if not info["logFilePath"]: - print("[si] WARNING: logFilePath is empty — set it to the F: data folder in ScanImage, then rerun.", file=sys.stderr) - return 1 - need = per_color * float(fps or 29.0) - if a.frames: - actual = cli.set("hSI.hStackManager.framesPerSlice", int(a.frames)) - log(results, step="set", property="framesPerSlice", value=a.frames, actual=actual) - if a.frames < need: - print(f"[si] WARNING: framesPerSlice {a.frames} < {need:.0f} needed for {per_color:g} s at {fps} Hz", file=sys.stderr) - for color in a.colors: - stem = color + a.stem_suffix - if not a.no_prompt: - input(f"\n[{color}] Studio: select the {color} block (duties {a.duties}), hand on Run. Press Enter to START THE GRAB…") - actual = cli.set("hSI.hScan2D.logFileStem", stem) - counter = cli.get("hSI.hScan2D.logFileCounter") - log(results, step="setFileStem", color=color, stem=actual, counter=counter) - t0 = time.perf_counter() - try: - cli.eval("hSI.startGrab()", a.grab_timeout) - except SiTimeout as exc: - log(results, step="grab", color=color, outcome="unknown", error=str(exc)) - st = wait_state(cli, "grab", 5.0) - if st != "grab": - print(f"[{color}] grab outcome unknown and acqState is {st!r}; stopping", file=sys.stderr) - return 3 - st = wait_state(cli, "grab", 5.0) - log(results, step="grab", color=color, acqState=st, start_ms=round((time.perf_counter() - t0) * 1000)) - if st != "grab": - print(f"[{color}] startGrab returned but acqState is {st!r}; stopping", file=sys.stderr) - return 1 - time.sleep(a.settle) - print(f"\a[{color}] >>> RUN THE {color.upper()} BLOCK IN THE STUDIO NOW ({len(a.duties)} × {a.step:g} s) <<<", flush=True) - budget = per_color + 20.0 - st = wait_state(cli, "idle", budget) - frames = cli.get("hSI.hDisplay.lastFrameNumber") - counter2 = cli.get("hSI.hScan2D.logFileCounter") - log(results, step="waitIdle", color=color, acqState=st, lastFrameNumber=frames, counter_after=counter2) - if st != "idle": - print(f"[{color}] still {st!r} after {budget:g} s — aborting the grab", file=sys.stderr) - cli.eval("hSI.abort()", a.grab_timeout) - log(results, step="abort", color=color, acqState=wait_state(cli, "idle", 5.0)) - print("\n[done] all colors grabbed; arena should be dark (allOff in the Studio).") - return 0 - except (SiError, OSError) as exc: - print(f"[si] error: {exc}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - print("\n[si] interrupted — aborting any running grab", file=sys.stderr) - try: - cli.eval("hSI.abort()", a.grab_timeout) - except Exception: - pass - return 130 - finally: - if a.results and results: - with open(a.results, "a", encoding="utf-8", newline="\n") as fh: - for r in results: - fh.write(json.dumps(r) + "\n") - cli.drop() - - -if __name__ == "__main__": - raise SystemExit(main()) From e25914ebbc5f11c2b61ccd04712a18a58f411ebb Mon Sep 17 00:00:00 2001 From: hanmarsan Date: Wed, 28 Oct 2026 18:33:01 -0400 Subject: [PATCH 2/2] rig: the gateway records the 2P rig's SiPM and frame-stream topics (gateway half of #261 / #266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2P rig's nodes are private since 2026-10-28 (reiserlab/scanimage-2026-bergamo `2p-rig-nodes/`; decision record §3.3 amendment). What stays public is the gateway's side, ported from #266's head (a4442ad, which includes #261) onto main: - bridge_node: the `sipm` connector (/sipm/cmd|reply|status in CONNECTOR_TOPICS, every bag's topic map and hello_ack), and the ScanImage frame-stream STREAM_TOPICS (/scanimage/frame_meta, frame_stats, preview/meta, preview/ch1-4 as sensor_msgs/CompressedImage) recorded on arrival, never derived into the run log - mcap_convert: /sipm/reply|status → {"type":"connector","src":"sipm",…} events - tests/test_gateway_registration.py (`pixi run test-gateway`, 5 checks): the gateway checks of #266's test_sipm (D1-D3) and #261's test_scanimage_frames (§G), which the private harnesses no longer carry Not ported: the nodes, their harnesses and tasks, the rig.py flags (all private now), and #261's rig_nodes._common SIGBREAK fix (_common lives in rig-nodes; the private package carries it as a shim). Tests on the Bergamo PC: test-gateway 5/5; harness `pixi run test` 59/59 (same as main); rig/pixi.lock unchanged (installed unlocked for the test, #280). Co-Authored-By: Claude Opus 5.5 --- rig/nodes/bridge_node.py | 34 ++++++++++++- rig/nodes/mcap_convert.py | 6 ++- rig/pixi.toml | 2 + rig/tests/test_gateway_registration.py | 68 ++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 rig/tests/test_gateway_registration.py diff --git a/rig/nodes/bridge_node.py b/rig/nodes/bridge_node.py index 3628eada..d47dd377 100644 --- a/rig/nodes/bridge_node.py +++ b/rig/nodes/bridge_node.py @@ -38,7 +38,9 @@ PLAN B SPIKE (LAB-303, 2026-10-14) — MCAP as the record of truth: * While a run's log is open, EVERYTHING is recorded into `/.bag/` with rosbag2_py + the MCAP storage plugin (rig_nodes.recorder, reiserlab/rig-nodes): the device topics as received - (`/fictrac/raw` or the typed `/fictrac/frame`, `/kinefly/raw`, `/bias/cmd|reply|status`), + (`/fictrac/raw` or the typed `/fictrac/frame`, `/kinefly/raw`, `/bias/cmd|reply|status`, `/sipm/cmd|reply|status`, + the ScanImage frame-stream topics `/scanimage/frame_meta|frame_stats|preview/*` — streams, bag only; their + nodes are the 2P rig's, private in reiserlab/scanimage-2026-bergamo `2p-rig-nodes/`), `/arena/frame` (the closed-loop evaluation with ITS OWN sample time `t`/`ms`), and two bridge-side streams — `/arena/browser` (every browser message, verbatim, with the receive stamp) and `/arena/event` (bias_config, heading_tare, connector_status, source_lost, warn) @@ -83,6 +85,7 @@ from rclpy.node import Node from rclpy.qos import QoSDurabilityPolicy, QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy from rclpy.serialization import deserialize_message, serialize_message +from sensor_msgs.msg import CompressedImage from std_msgs.msg import String HERE = Path(__file__).resolve().parent @@ -160,12 +163,31 @@ def package_versions() -> dict: SCANIMAGE_CMD_TOPIC = "/scanimage/cmd" SCANIMAGE_REPLY_TOPIC = "/scanimage/reply" SCANIMAGE_STATUS_TOPIC = "/scanimage/status" +# The SiPM controller (the 2P rig's read-only node, private: reiserlab/scanimage-2026-bergamo 2p-rig-nodes/): its 1 Hz status carries bias/enable/offsets/ +# temperature, the detector settings ScanImage's TIFF header does not have. +SIPM_CMD_TOPIC = "/sipm/cmd" +SIPM_REPLY_TOPIC = "/sipm/reply" +SIPM_STATUS_TOPIC = "/sipm/status" +# ScanImage's ZeroMQ frame stream as topics (the 2P rig's frames node, private, 2p-rig-nodes/; LAB-303 Block R). STREAMS: +# recorded into the bag as received, like /bias/frames, and never derived into a run-log line (the +# telemetry rule: a string-tagged stream is not a behavior sample). The preview images are bounded at +# the source (--preview-hz, 0 = off); ch1..ch4 cover ScanImage's four channels. +SCANIMAGE_FRAME_META_TOPIC = "/scanimage/frame_meta" +SCANIMAGE_FRAME_STATS_TOPIC = "/scanimage/frame_stats" +SCANIMAGE_PREVIEW_META_TOPIC = "/scanimage/preview/meta" +SCANIMAGE_PREVIEW_TOPICS = tuple(f"/scanimage/preview/ch{i}" for i in (1, 2, 3, 4)) +COMPRESSED_IMAGE_TYPE = "sensor_msgs/msg/CompressedImage" +STREAM_TOPICS = { + SCANIMAGE_FRAME_META_TOPIC: STRING_TYPE, SCANIMAGE_FRAME_STATS_TOPIC: STRING_TYPE, SCANIMAGE_PREVIEW_META_TOPIC: STRING_TYPE, + **{t: COMPRESSED_IMAGE_TYPE for t in SCANIMAGE_PREVIEW_TOPICS}, +} # Device connectors the Studio may drive (LAB-303): connector_command → the node's cmd topic; its # reply/status topics become {"type":"connector","src":,…} run-log events (derived by the # converter). Adding a connector = one entry here, a flag in rig.py, a topic pair in mcap_convert.py. CONNECTOR_TOPICS = { "bias": {"cmd": BIAS_CMD_TOPIC, "reply": BIAS_REPLY_TOPIC, "status": BIAS_STATUS_TOPIC}, "scanimage": {"cmd": SCANIMAGE_CMD_TOPIC, "reply": SCANIMAGE_REPLY_TOPIC, "status": SCANIMAGE_STATUS_TOPIC}, + "sipm": {"cmd": SIPM_CMD_TOPIC, "reply": SIPM_REPLY_TOPIC, "status": SIPM_STATUS_TOPIC}, } FRAME_TOPIC = "/arena/frame" RAW_QOS = QoSProfile( @@ -322,6 +344,8 @@ def topics(self) -> dict[str, str]: SESSION_TOPIC: STRING_TYPE, BROWSER_TOPIC: STRING_TYPE, EVENT_TOPIC: STRING_TYPE, KINEFLY_TOPIC: STRING_TYPE, BIAS_CMD_TOPIC: STRING_TYPE, BIAS_REPLY_TOPIC: STRING_TYPE, BIAS_STATUS_TOPIC: STRING_TYPE, BIAS_FRAMES_TOPIC: STRING_TYPE, SCANIMAGE_CMD_TOPIC: STRING_TYPE, SCANIMAGE_REPLY_TOPIC: STRING_TYPE, SCANIMAGE_STATUS_TOPIC: STRING_TYPE, + SIPM_CMD_TOPIC: STRING_TYPE, SIPM_REPLY_TOPIC: STRING_TYPE, SIPM_STATUS_TOPIC: STRING_TYPE, + **STREAM_TOPICS, } t[FRAME_TOPIC] = ARENA_FRAME_TYPE if self._typed else STRING_TYPE if self.record_fictrac_raw: @@ -665,6 +689,11 @@ def __init__(self, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue, on_kin # Recorded on arrival, like /fictrac/raw: it never becomes a run-log line (the stopRecording reply # carries its summary), so there is no shadow line to stay in step with. self.create_subscription(String, BIAS_FRAMES_TOPIC, lambda b: self._record(BIAS_FRAMES_TOPIC, b), RAW_QOS, raw=True) + # The ScanImage frame stream (meta, stats, preview meta, preview images): the same arrival-time + # record, raw bytes, no run-log line — the bag is the only place they go. + for topic, typ in STREAM_TOPICS.items(): + self.create_subscription(CompressedImage if typ == COMPRESSED_IMAGE_TYPE else String, topic, + lambda b, topic=topic: self._record(topic, b), RAW_QOS, raw=True) def _record(self, topic: str, data: bytes) -> None: rec = self._log.rec @@ -908,7 +937,8 @@ def on_log_open() -> None: "record": {"bag": log.bag_name, "profile": log._profile, "typed": typed, "fictrac_raw": log.record_fictrac_raw}, "topics": {"fictrac": FICTRAC_FRAME_TOPIC if typed else FICTRAC_TOPIC, "kinefly": KINEFLY_TOPIC, "bias": [BIAS_CMD_TOPIC, BIAS_REPLY_TOPIC, BIAS_STATUS_TOPIC, BIAS_FRAMES_TOPIC], - "scanimage": [SCANIMAGE_CMD_TOPIC, SCANIMAGE_REPLY_TOPIC, SCANIMAGE_STATUS_TOPIC], "frame": FRAME_TOPIC, + "scanimage": [SCANIMAGE_CMD_TOPIC, SCANIMAGE_REPLY_TOPIC, SCANIMAGE_STATUS_TOPIC], + "sipm": [SIPM_CMD_TOPIC, SIPM_REPLY_TOPIC, SIPM_STATUS_TOPIC], "frame": FRAME_TOPIC, "browser": BROWSER_TOPIC, "event": EVENT_TOPIC, "session": SESSION_TOPIC}, } ) diff --git a/rig/nodes/mcap_convert.py b/rig/nodes/mcap_convert.py index 3c2840ea..3f8c3b22 100644 --- a/rig/nodes/mcap_convert.py +++ b/rig/nodes/mcap_convert.py @@ -29,6 +29,7 @@ /kinefly/raw the adapter envelope (DERIVED stream) → ["kf", ms, seq, t_src, L, R, head, abd] /bias/reply, /bias/status the BIAS node's JSON (DERIVED) → {"type":"connector","src":"bias",…} /scanimage/reply, /scanimage/status the ScanImage node's JSON (DERIVED) → {"type":"connector","src":"scanimage",…} + /sipm/reply, /sipm/status the SiPM node's JSON (DERIVED) → {"type":"connector","src":"sipm",…} /fictrac/raw | /fictrac/frame the adapter's FicTrac records (only when the bridge node ran with --record-fictrac-raw; bags before 2026-10-18 always have them) → used only for level `full` @@ -81,11 +82,14 @@ TOPIC_BIAS_STATUS = "/bias/status" TOPIC_SCANIMAGE_REPLY = "/scanimage/reply" TOPIC_SCANIMAGE_STATUS = "/scanimage/status" +TOPIC_SIPM_REPLY = "/sipm/reply" +TOPIC_SIPM_STATUS = "/sipm/status" # Connector device topics → (connector id, event kind): their JSON becomes a DERIVED # {"type":"connector","src":,"kind":…} line (mirror of bridge_node.CONNECTOR_TOPICS). CONNECTOR_TOPIC_SRC = { TOPIC_BIAS_REPLY: ("bias", "reply"), TOPIC_BIAS_STATUS: ("bias", "status"), TOPIC_SCANIMAGE_REPLY: ("scanimage", "reply"), TOPIC_SCANIMAGE_STATUS: ("scanimage", "status"), + TOPIC_SIPM_REPLY: ("sipm", "reply"), TOPIC_SIPM_STATUS: ("sipm", "status"), } @@ -227,7 +231,7 @@ def fictrac_fields(rec: dict) -> tuple[int, list[float]] | None: def convert(records: list[dict], level: str | None = None, order: str = "seq", connectors=None) -> tuple[list, dict]: """→ (lines as JSON values, report). `level` None = the level the run was opened with.""" - stats = {"frames": 0, "browser_lines": 0, "rows": 0, "events": 0, "kf": 0, "bias": 0, "scanimage": 0, + stats = {"frames": 0, "browser_lines": 0, "rows": 0, "events": 0, "kf": 0, "bias": 0, "scanimage": 0, "sipm": 0, "connector_cmds": 0, "skipped": 0, "unordered_inserts": 0} opens = [r for r in records if r["topic"] == TOPIC_SESSION] session_open = None diff --git a/rig/pixi.toml b/rig/pixi.toml index 74e56db2..dc218499 100644 --- a/rig/pixi.toml +++ b/rig/pixi.toml @@ -159,6 +159,8 @@ rig = "python nodes/rig.py" # ── acceptance test (LAB-303 PoC criteria): whole graph + Studio emulator + model fly ── # This is also the cross-repository smoke for the installed rig-nodes package set (record §7.8). test = "python tests/test_ros2_poc.py" +# the gateway records + derives the 2P rig's topics (its SiPM / frame-stream nodes are private: scanimage-2026-bergamo 2p-rig-nodes/) +test-gateway = "python tests/test_gateway_registration.py" studio-emulator = "python tests/studio_emulator.py" # ── MCAP record + converter ────────────────────────────────────────────────── # bag → behavior_v2 NDJSON (also `-- --level behavior_v1|full`, `-- --order time`); diff --git a/rig/tests/test_gateway_registration.py b/rig/tests/test_gateway_registration.py new file mode 100644 index 00000000..4b1c69aa --- /dev/null +++ b/rig/tests/test_gateway_registration.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""test_gateway_registration.py — the gateway records and derives the 2P rig's topics (static, no graph). + + pixi run test-gateway # from rig/ (≈ 2 s) + +The 2P rig's SiPM and ScanImage frame-stream nodes are private (reiserlab/scanimage-2026-bergamo +`2p-rig-nodes/`); this gateway only knows their topic NAMES. These checks are the gateway half of +those nodes' harnesses (#266's test_sipm D1–D3 and #261's test_scanimage_frames §G), kept here +with the code they test: + + SiPM bridge_node records /sipm/cmd|reply|status in every bag and subscribes it as a connector; + mcap_convert derives /sipm/reply|status into {"type":"connector","src":"sipm",…} events + frames bridge_node records the frame-stream topics (meta, stats, preview meta, preview ch1–4, + the images as sensor_msgs/CompressedImage) in every bag; mcap_convert derives nothing from + them (a stream is not a behavior sample) + +Exit 0 when every check passes. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "nodes")) +import bridge_node # noqa: E402 +import mcap_convert # noqa: E402 + +results: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, info: str = "") -> bool: + results.append((name, bool(ok), info)) + print(f" {'PASS' if ok else 'FAIL'} {name}{'' if ok else ' — ' + str(info)[:400]}", file=sys.stderr) + return bool(ok) + + +def main() -> int: + lw = bridge_node.RecordingLogWriter(None, "behavior_v2", None, profile="crashsafe", typed=False, export="converted", connectors=[]) + tmap = lw.topics() + + # ── SiPM (was test_sipm D1–D3) ── + check("sipm: bridge_node records /sipm/cmd|reply|status in every bag", + all(tmap.get(t) == "std_msgs/msg/String" for t in ("/sipm/cmd", "/sipm/reply", "/sipm/status")), str(sorted(tmap))) + check("sipm: bridge_node subscribes the SiPM node as a connector", + bridge_node.CONNECTOR_TOPICS.get("sipm", {}).get("status") == "/sipm/status", str(bridge_node.CONNECTOR_TOPICS.get("sipm"))) + check("sipm: the converter derives /sipm/reply|status into connector events", + mcap_convert.CONNECTOR_TOPIC_SRC.get("/sipm/status") == ("sipm", "status") + and mcap_convert.CONNECTOR_TOPIC_SRC.get("/sipm/reply") == ("sipm", "reply"), "") + + # ── the ScanImage frame stream (was test_scanimage_frames §G) ── + stream = ("/scanimage/frame_meta", "/scanimage/frame_stats", "/scanimage/preview/meta") + previews = tuple(f"/scanimage/preview/ch{c}" for c in (1, 2, 3, 4)) + check("frames: bridge_node records the stream topics (meta, stats, preview meta, preview ch1–4) in every bag", + all(t in tmap for t in stream + previews) and all(tmap[t] == "sensor_msgs/msg/CompressedImage" for t in previews) + and all(tmap[t] == "std_msgs/msg/String" for t in stream), str(sorted(tmap))) + check("frames: the converter derives nothing from the stream topics (streams are not behavior samples)", + not any(t in mcap_convert.CONNECTOR_TOPIC_SRC for t in bridge_node.STREAM_TOPICS) + and "frame_meta" not in Path(mcap_convert.__file__).read_text(encoding="utf-8"), "") + + n_ok = sum(1 for _, ok, _ in results if ok) + print(f"\n{n_ok} / {len(results)} checks passed", file=sys.stderr) + return 0 if n_ok == len(results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main())