Skip to content

LAB-303 / Block R: ScanImage ZMQ frame-stream wrapper node, latency report, off-rig harness - #261

Open
mbreiser wants to merge 106 commits into
mainfrom
claude/scanimage-frames-wrapper
Open

mbreiser wants to merge 106 commits into
mainfrom
claude/scanimage-frames-wrapper

Conversation

@mbreiser

@mbreiser mbreiser commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

LAB-303 (ROS 2 graph) / Block R of the Bergamo lab day; the "successor idea" recorded on LAB-306. ScanImage 2026 Premium publishes every acquired frame on a ZeroMQ PUB socket (40-byte header of five doubles + int16 planes; bound only while an acquisition runs; PUB drops silently; late subscribers miss the first frames). This PR adds the ROS 2 wrapper for that stream and everything the lab day needs to measure it — built and tested OFF-RIG against a publisher stand-in, never yet against ScanImage.

All in ros2-poc/. Stacked on #254 (branched off claude/ros-studio-plan-b-c44942, one commit on top: 6f8d633): until #254 merges, the diff against main shows #254's changes too — review the last commit only. Nothing in arena_studio.html or the Studio JS changes.

  • nodes/scanimage_frames_node.py — ZMQ SUB (+ monitor socket for connect/disconnect) on a dedicated receive thread into a bounded queue; the ROS side publishes /scanimage/frame_meta (every frame: frameNumber, frameTimestamp_s, dims, nchan, rx_epoch_ms / rx_mono_ns via _common, pub_mono_ns, queue depth, gap_before, segment), /scanimage/frame_stats (1 Hz: frames, gaps by header frame number, dropped-by-queue, bad frames, acquisition segments with each one's first frame = the slow-joiner loss, connects/disconnects, queue depth, in-node hop p50/p99) and an optional bounded preview /scanimage/preview/ch<id> (sensor_msgs/CompressedImage PNG 128², ≤ 5 Hz per channel, --preview-hz 0 off) with the int16→8-bit mapping recorded per image on /scanimage/preview/meta. Reconnects when the publisher goes away and returns (a reconnect or a restarted frame counter opens a new segment). Invariant: received == published + dropped_by_queue + queue_depth.
  • nodes/bridge_node.py — the stream topics registered in the bag's topic map (STREAM_TOPICS) and recorded on arrival like /bias/frames; the converter derives nothing from them (streams, not behavior samples).
  • nodes/rig.py — --scanimage-frames / --scanimage-frames-args; pixi tasks scanimage-frames, test-frames, zmq-latency; pixi run test now runs the frames harness first, then the acceptance run.
  • tools/zmq_latency_report.py — from a bag or the node's --dump JSONL: per acquisition segment the stream delay relative to the fastest frame (p50/p99/max) and the clock drift, in-node hop, node→bridge hop (MCAP log_time), queue-depth histogram, gaps + slow-joiner loss, preview encode and frame→record latency; with the matching TIFF (stdlib page-description reader, classic + BigTIFF) the per-frame ZMQ-vs-TIFF timestamp difference and the ABSOLUTE acquisition→topic latency from the TIFF epoch; the procedure's pass bars as a table, --json, --strict.
  • tests/test_scanimage_frames.py + tests/zmq_frames_mock.py (the format and CLI of scanimage-2026-bergamo/tests/zmq-frames/mock_zmq_publisher.py, plus --sndhwm/--seed for the harness; --mock <path> runs the harness against the original).
  • README.md section.

Tier

none until the lab day — not in the 2026-10-20 candidate; its own candidate after Block Z+R has measured it on the Bergamo PC. Docs + ros2-poc/ only; no footer, no release notes.

Release notes

none (nothing user-visible in the web tools).

Test plan

Off-rig, done here (cd ros2-poc && pixi run test, macOS, mock publisher at 29.08 Hz × 512 × 512 × 2 ch):

  • pixi run test-frames: 39 / 39 — every frame counted and shaped right; in-node hop p50 0.09 ms / p99 0.3 ms; previews ≤ 5 Hz per channel, 128² PNG, mapping recorded; --drop-every 10 → 9 gaps in a new segment (a dropped LAST frame is invisible to any subscriber); publisher restart with no start delay → slow-joiner loss recorded (missed_before_first); node SIGSTOPped 1.5 s with the publisher's HWM at libzmq's default 1000 → nothing lost, publisher timing unaffected, backlog visible; HWM 20 → the publisher drops 25 frames and the node names them; clean SIGINT exit; tiny queue + stalled publisher thread → dropped_by_queue exact; the report on the dump and on a bag written with nodes/recorder.py, with a synthetic ScanImage-style TIFF (2 pages per frame, +5 ms offset recovered, absolute latency from epoch); bridge topic map + converter neutrality.
  • pixi run test (acceptance run) still green; py_compile clean over nodes/, tools/, tests/.

What the lab day (Block Z+R, scanimage-2026-bergamo/procedures/rig-session-zmq-ros2-wrapper.md) must measure — the stand-in cannot:

  • R1: the node follows ScanImage's bind on FOCUS (frame_stats.connected), dims/nchan agree with Hannah's checker and channelSave; first frame number seen by each subscriber.
  • R2 (60 s FOCUS): frame_meta count = checker count; zmq-latency: meta p99 < 34 ms, preview < 250 ms, queue depth p99 < 10 → latency_focus60.json.
  • R3 (GRAB 300, logging on): frame_meta count = TIFF pages / nchan; latency re-measured against the TIFF's frameTimestamps_sec + epoch (--tiff); whether the ZMQ header timestamp and the TIFF's share an origin (the report's zmq_minus_tiff_ts_ms).
  • R4: kill the node mid-FOCUS and restart (reconnect, new segment, gap recorded); SIGSTOP 10 s (MATLAB memory before/peak/after, drops counted); ZMQAddress = '' then reopen (node idles, no traceback) — acquisition timing (hMon, lostFrames) unchanged in all three.
  • R5: LOOP 10 min, two subscribers: 0 gaps, p99 drift < 10 %, node RSS flat, bag closes and reindexes.
  • Z6/Z7: resolution change 512→256 mid-session (dims_changes / new segment), stream off then on.

🤖 Generated with Claude Code

mbreiser and others added 30 commits October 2, 2026 14:57
The Program set confirm said ~8 min for a 20-panel arena; the batch on
rig5-mr (2026-09-30) took ~2 min: ~4.4 s program + reboot and ~1 s
verify + STOP per panel. Use 5 s + 1 s for the estimate and fix the
"~20 s" in the dialog and the Flash / Program set tooltips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ial arenas

getDegreesPerPixel() divided 360 deg by the INSTALLED pixel columns instead of the
full circumference. On G4 3x12of18 (the IAS talk arena) that gave 1.875 deg/px
instead of 1.25, so the px<->deg helper labels were wrong and a spatial period typed
in pixels generated the wrong grating (24 px came out as 45 deg, 36 frames). A step
size typed in degrees was off the same way. G6 3x12of18 (1.5 -> 1.0 deg/px) and
G6 2x8of10 (2.25 -> 1.8) had the same bug; full arenas were already right.

The generator already used the full circle (arenaCoordinates numCircle = num_cols).
The pitch now lives in one shared place, PatternGenerator.getDegreesPerPixel(), and
getArenaDimensions() reports circleCols / azimuthPixels, which the three spherical
generators also read, so the editor and generator cannot drift apart again.
Also fixes the Animate tab's frame-shift hint on partial arenas ("360 deg = 192 px"
-> "240 deg = 192 px"), and cache-busts generator.js because the editor's init now
calls the new helper.

tests/test-pattern-degrees-per-pixel.js runs the editor's own conversion functions
(extracted from pattern_editor.html) for every standard config, plus end-to-end
gratings typed in px and deg; it failed 15 checks before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…olumn

A partial arena's PAT stores only the installed columns, in columns_installed order
(block k = physical column columns_installed[k], the icon generator's convention).
ThreeViewer looked pixels up by the PHYSICAL column instead. Harmless when the
installed columns start at 0 (G4/G6 3x12of18), but on G6 2x8of10 (columns 1-8)
the whole pattern showed one panel to the side, pattern panel 1 never appeared, and
physical column 8 read block 0 of the next pixel row (the lookup wrapped at the full
circle, past the 160-px pattern width).

_getPatternColumnMap() maps physical column -> block from the CONFIGURED columns
(never the preview-visible set, so the CSHL 8/18 gap still doesn't shift the address
map); each LED ref carries patternCol; the lookup wraps inside the pattern width.
Shared by the Pattern Designer and the replay viewer, so the cache token moves to
1006-partial-cols in both (and the tests that pin it).

tests/test-three-viewer-partial-columns.js loads the viewer with Three.js stubbed and
checks every LED of every standard config reads its own block and row; against main's
viewer it fails only on G6_2x8of10 (column 1 showed block 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
renderCylindricalIconToCanvas drew math angles (y up) straight onto the canvas (y down),
so every icon was the 3D "Top Down" view mirrored: the gap (behind the fly) at the TOP and
the columns running counter-clockwise. TESTING.md always expected the gap at the bottom.
Angles are now negated at the drawing step (toCanvas), so icons match the 3D viewer and
MATLAB design_arena: south at the bottom, column 0 at the south boundary, columns clockwise
from above. The misleading "row 0 = top of arena" comment is corrected (row 0 is the
bottom; it maps to the inner ring, as in a perspective view from above).

Shared by the Studio, Console and Pattern Designer thumbnails. No export changes.
tests/test-icon-orientation.js records the canvas arcs and checks column 1 and 2 of every
standard config against the 3D viewer's placement; the old code failed 21 of 27 checks
(G6 2x10 column 1 drawn at -18 deg instead of -162 deg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…zimuth axis

Panel numbers: the 2D overlay labelled pattern blocks as if they were physical columns and
looped over every num_cols (drawing labels past the right edge on partial arenas). It now
labels each block with its physical panel through physicalPanelNumber() (block k =
columns_installed[k], G6 row-major / older generations column-major), the same numbers as
the 3D overlay: on G6 2x8of10 the first pattern panel reads 2/12, not 1/11.

Azimuth axis: a degree scale under the 2D grid (its own canvas, so edit-mode hit-testing
and captures are unchanged), in pattern coordinates: 0 deg = straight ahead at the middle,
negative to the fly's left; same values as the Mollweide view and the generator. Ticks
pick 15/30/45/60/90 deg to keep labels apart (G4 3x12of18: -120..+120 every 30 deg).

CLAUDE.md gains the three partial-arena rules (full-circle pitch, block k =
columns_installed[k], 0 deg = middle of the pattern) and points at LAB-296 for hardware.
tests/test-pattern-2d-view.js runs the extracted helpers against every standard config,
the 3D overlay's numbering and the Mollweide longitudes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…F color code

Color variants of one grating used to share a name (the filename carried only the layout
tag), and re-saves stacked tags (_4c_rir). Names now:
- record the ON/OFF choice used to GENERATE the frames, stamped at generate time:
  PanelColor.filenameColorCode -> '_4c-B' (ON blue), '_4c-B-G' (ON blue, OFF green),
  '_4c-VB', '_4c-F80A' (custom levels, one hex digit per channel); nothing extra for the
  default (ON all / OFF dark), so default names are unchanged apart from the words below;
- normalize the layout tag on save (PanelColor.stripColorTag): replaced, never stacked;
- use short words: grat/star/edge/sine/offon/anim, rot/exp/trans, Ndot, _img, comb,
  hcw/hccw/vup/vdown.

The controller keeps pattern names in a 64-byte buffer: <= 63 characters, plain ASCII,
and <= 58 so a same-name re-upload (stored as X001_<name>) keeps its .pat. Nothing in the
Designer checked this. js/pattern-set.js gains the shared rule (checkSdFilename,
sanitizeSdFilename, fitSdFilename; both tools load it), and the Designer fits every saved
and renamed name (fitPatternFilename: cleans characters, shortens only the descriptive
middle, keeps the arena prefix and the color tag; the rename prompt says what it changed).
The longest name Generate can produce is now 54 characters with the worst-case tag.

Helpers are typeof-guarded, so a stale cached panel-color.js / pattern-set.js only means
the old naming until a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ads, 0x85 label

- sdUploadOne runs PatternSet.checkSdFilename BEFORE sending the bytes: a name the
  controller can't store (non-ASCII, > 63 chars, no .pat) fails at once instead of after
  the whole upload (which left the data on the card as pattern.temp), and is not retried.
  59-63 characters log a warning (a re-upload would lose .pat).
- An upload never overwrites: the firmware stores a same-name re-upload as X001_<name>.
  sdLogicalName now strips that prefix, so the copy shows as a DUPLICATE of the original
  (onSdListing warns; protocols refuse to guess) instead of a separate pattern while the
  picker kept playing the old file. The upload also warns up front when the name is
  already on the card.
- The upload opcode is SET_PATTERN_FILE 0x85; the Studio's error text ("write rejected"),
  tooltips and comments said 0x8D, which the firmware doesn't have (also fixed in the
  Console comment and CLAUDE.md's quiesce list).

CLAUDE.md records the filename rule. tests/test-sd-filenames.js covers the shared rule,
the Designer's names (every arena prefix + worst-case tag <= 58), withColorTag /
fitPatternFilename, sdLogicalName and the upload order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l-G6 warning

From the adversarial review of candidate 2026-10-06 rc1 (Claude + Codex):
- The shared SD name rule (PatternSet.checkSdFilename) refused anything outside
  [A-Za-z0-9._-], so the Studio blocked uploads that worked before: spaces and
  parentheses are legal FAT long-name characters (e.g. a browser's "… (1).pat").
  It now errors only on what the card can't store (non-printable/non-ASCII, \ / : * ? " < > |,
  > 63 chars, no .pat, leading "."); the Designer still writes tidy names
  (SD_TIDY_CHARS) and says so when it changes one.
- Cache tokens (?v=1006-partial-cols) on every consumer of the changed shared modules:
  icon-generator.js (Designer, Studio, Console, v3 designer, icon tool), pattern-set.js
  (Designer, Studio, Console, v3 designer), panel-color.js (Designer) — so the fixes reach
  browsers holding an old copy. Pinned in tests.
- Partial-G6 warning (never blocks): current firmware ignores the panel mask and rejects
  any grid other than its own at load. The Designer says so once per session when a
  partial-G6 pattern is saved; the Studio upload warns when the session rig is a partial
  arena, when a file carries a partial panel mask, or when its grid differs from the rig's
  (PatternSet.g6HeaderGrid / g6PlaybackWarning; Studio.rigArena()).
- 2D panel numbers: a block past the installed list labels its own number, never NaN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he thumbnail download (LAB-298)

Bench 2026-10-07 (rig cshl_g6_2x10_ball, 24 patterns on the card): the first
TRIAL_PARAMS after Connect was acknowledged 57 s late. The SD listing auto-picks
pattern 1 and the picker thumbnail downloaded the whole file (0x84). With
Chrome's default 255-byte Web Serial read buffer the body arrived 901 bytes
short, and the one-at-a-time link waited out the read's 60 s timeout with the
GET_PATTERN_INFO sweep and the user's command queued behind it. Firmware ruled
out from Python (listing + 24x 0x88 in 48 ms, 81 KB download in 6 ms).

- arena-link.js: open the port with a 1 MiB bufferSize; a background lane
  (`{ background: true }`) that waits while any foreground send is queued; a
  bulk body fails after idleTimeoutMs (5 s) without a byte instead of waiting
  out timeoutMs.
- Studio: the GET_PATTERN_INFO sweep (cardPatternInfo, also caches file size)
  and the picker thumbnail fetch use the background lane; a card-only
  thumbnail downloads only when PatPreview.liveFetchVerdict allows it (size
  known, <= 1 MiB, no run). Frame counts for the closed-loop resolver (#201)
  are still filled for every pattern. Tooltip explains a thumbnail refused
  while the display runs (status 10).
- Tests: link buffer/stall/lanes, liveFetchVerdict, Studio call sites.

Verified on hardware: first TRIAL_PARAMS ack 2 ms (was 57,013 ms); 81 KB
thumbnail download 7 ms; 24 frame counts done 197 ms after Connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ipherals (LAB-214, Studio v0.96)

- js/arena-wire-g6.js: GET_I2C_SCAN 0xB0 / I2C_TRANSFER 0xB1 encoders + decoders
  (status text, "No Qwiic jack" detection) with golden tests.
- js/qwiic-drivers.js (new): host register maps ported from the firmware repo's
  tests/qwiic_sensors.py + qwiic_peripherals.py — SHT4x (two-leg read, CRC),
  LSM6DSOX (tilt, latched bump: LIR + INT_CLR_ON_READ + INT1 routing), TSL2591,
  VEML7700, AS7343 (releases the bus while integrating), PCA9685 (ALLCALL off,
  servo pulse), PCA9548; an async bus mutex; all-call shadow decided from MODE1;
  the KINDS registry is the one place to add or drop a part.
- js/studio-qwiic.js (new): rig `peripherals:` parser (tolerant, warnings), the
  scan-vs-YAML check in both directions (declared-but-missing = error,
  attached-but-undeclared = warning), setup, chip, "Copy as rig YAML".
- arena_studio.html: one Console "Qwiic" window (Scan → device dropdown → one
  device's readout in display units; bump check; servo go-to-position), a
  connect-time setup scan and a top-bar chip. Every I2C command is gated on the
  0xC2 qwiic_i2c feature bit: controllers without the jack are never sent any.
  Test only — nothing is written to run logs (that is PR 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nection columns, no sticky "live" status (LAB-214)

Bench feedback (all six boards working on the 12-18):
- The scan table is the picker: one radio row per identified device (click the
  row or its button); the dropdown is gone.
- Each row names its category (Servo / PWM, Temp / humidity, Accel / tilt / bump,
  Light (broadband / lux / spectral), I2C mux) — KINDS.category, one place.
- "where" is now "connection": direct (on the controller's own chain) or
  "mux 0x70 · ch N" (behind a PCA9548 channel).
- Stopping Live restores the scan summary; a tick in flight at Stop no longer
  re-writes "live · N Hz".
- Table text left-aligned (it inherited the analog table's right alignment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-214)

- js/arena-configs.js regenerated (scripts/generate-arena-configs.js) from the
  maDisplayTools branch feat/arenas-1x12of18-3x10of14: G6 arena IDs 7
  (G6_1x12of18) and 8 (G6_3x10of14). angle_offset_deg centers the gap at the back
  so the installed panels sit centered at the front in the 3D view and icons
  (Mercator/Mollweide center the installed arc by construction).
- configs/rigs/g6_1x12of18_bench.yaml: the arena_12-18 bench, one row, with the
  six-board Qwiic chain declared under peripherals:; g6_3x10of14.yaml with
  peripherals: [] until its chain is known. Both added to configs/rigs/index.json.
- tests: geometry, IDs, centering, index entries, clean peripherals parse.

The maDisplayTools change must merge first: the sync-arena-configs workflow
regenerates arena-configs.js from maDisplayTools main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s, color-bank figure; Qwiic read-on-select (LAB-214)

- Qwiic: clicking a device reads it immediately; Live keeps running across device
  changes (each tick reads the selected device); the servo follows its slider while
  dragging.
- Console sliders act while dragging — Test display level/duty re-send the last
  figure, LED power sets the LED — through liveSender(): single-flight, latest
  value wins, nothing sent while a run owns the link, one log line on release.
- Test display streams at the CONTROLLER's frame geometry (0xCB rows×cols; fw
  panel_count_per_frame) instead of refusing every non-20-panel rig: the rig figure
  is placed in the firmware frame (installed columns from the left, rig rows
  repeated up), so the 1×12 bench drives a 4×12 build. Wire.streamFrameBytes +
  encodeStreamFrame(payload, {panels}) with golden tests; .bin/.pat file streaming
  stays 20-panel.
- Test display → color A–D: light one LED bank (T0–T3) at a time via the shared
  PanelColor.bankAt (100 of 400 LEDs per panel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…commit bumps; notes live in the PR body

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n), live gates, deferred Qwiic setup (LAB-214)

From the adversarial review of candidate 2026-10-06 rc4 (PR #247 comment):
- BLOCKING: test figures are drawn on the rig's FULL arena grid again
  (activeArenaGeom = num_rows × num_cols); grid column c → firmware column c and
  only the installed columns must fit the firmware frame. rc4 collapsed partial
  arenas to installed width and left-aligned them, which shifted every figure one
  panel on G6_2x8of10 (columns 1–8) with 0xCB firmware and refused it outright on
  pre-0xCB firmware; it also mis-numbered the ISP progress map and changed the
  paste dimensions. Mapping now lives in js/studio-stream-geometry.js with Node
  tests (2×10 / 2×8of10 identity on both firmware kinds, 1×12of18 → 4×12).
- Live slider re-streams and Qwiic reads pause during an ISP batch and a
  post-mortem (a stream re-activates the display; 0xC8/0xC9 need it stopped).
- Connect-time Qwiic setup starts after the controller reads and is not awaited,
  so its sensor configure waits never delay the SD listing.
- Orientation glyphs sit inside the installed span (1×12of18's "R").
- Best-effort servo release (PCA9685 all off) on an intentional disconnect.
- Docs: drop the stale "v0.96" (the release commit owns versions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apping, unmirrored icons, short SD-safe filenames, upload name checks (LAB-295)
…ve Console sliders, firmware-sized test display + color banks (LAB-214)
…1.5, Pattern Designer v0.13, Arena Studio v0.95

Candidate for Production. PRs: #245, #246, #248, #250.

Release-Manifest: releases/2026-10-06.json
…ters, bridge node reusing bridge.py

Separate pixi workspace (RoboStack Jazzy + conda-forge; osx-arm64/win-64/linux-64) under
ros2-poc/; the root pixi.toml and fictrac-bridge/ are untouched so bridge 3.4 stays side by side.

- Zenoh router + rmw_zenoh_cpp with loopback-only configs (stock files, endpoint lines only)
- fictrac_udp_adapter: FicTrac UDP → /fictrac/raw (std_msgs/String JSON), FicTrac untouched
- kinefly_udp_adapter + kinefly_sim: KF/1 (mbreiser/Kinefly-MacOS) → /kinefly/raw
- bias_node + bias_sim + bias_cmd: BIAS HTTP command port ⇄ /bias/cmd,/bias/reply,/bias/status;
  timeout = unknown outcome + reconcile; video file reported as an artifact
- bridge_node: bridge.py's Hub/LogWriter/Pipeline/make_dispatcher on ROS input, same WebSocket
  schema on ws://localhost:8765, same behavior_v2 log; adds connector_status latency lines,
  source_lost/resumed watchdog, ["kf",…] rows, BIAS connector events, /arena/frame mirror
- rig.py: one `pixi run rig` command supervising router + nodes
- tests: studio_emulator (the Studio runner's exact bridge conversation), check_runlog_readers
  (JS readers accept the log), test_ros2_poc (29 checks incl. adapter SIGKILL mid-run,
  closed-loop report rejection/idx checks, convert round trip): 29/29 on Linux

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHyggowbCA5ucBg71bU26V
…outer restart, launcher pre-flight)

- bridge_node: RosLogWriter hooks (on_open/on_close) replace the message re-parsing wrapper;
  source watchdog scoped per log file; ordered ROS shutdown (join the spin thread before
  destroy_node — removes "terminate called without an active exception" on SIGTERM)
- bias_node: worker exception guard (a malformed BIAS body no longer kills the node),
  get-status polls coalesced to one pending
- rig.py: port pre-flight with a one-line culprit per port (SO_REUSEADDR probe), router death
  stops the rig, Zenoh config paths set explicitly (Windows shells leave $PIXI_PROJECT_ROOT literal)
- nodes/_common.py: shared envelope, QoS, spin/shutdown boilerplate, wait_port
- test harness: adapter kill anchored on the runner's fictracApply line in the log; export guard
- README: soak (12 runs, flat RSS, p99 ≈ 3.5 ms), zero loss at 200 Hz + 500 Hz, router
  kill/restart with no data gap, student-mistake messages

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHyggowbCA5ucBg71bU26V
… TerminateProcess)

Python cannot send SIGINT to another process on Windows (send_signal raises ValueError), so
Ctrl-C on the rig PC would have looked like a launcher crash. popen_group/signal_group in
nodes/_common.py give every child its own process group on both platforms and map
SIGINT → CTRL_BREAK_EVENT, SIGTERM/SIGKILL → TerminateProcess on Windows; rig.py and the
test harness use them (and catch ValueError). Linux path unchanged (setsid + killpg).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHyggowbCA5ucBg71bU26V
…s a watch item)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHyggowbCA5ucBg71bU26V
Mac run of the items the Linux container could not cover: pixi install 25 s
fresh, talker/listener, 29/29 acceptance, the unmodified Pages Studio against
`pixi run rig -- --sim` with a G6 controller (two Mode-3 constant-bias runs
auto-committed to the course repo, byte-identical to the bridge node's file,
idx mismatch 0.00 %, runlog-check OK), the real BIAS Mac port and the real
Kinefly fork (9600/9600 datagrams, 0 rejected, kf rows in the committed log).

bias_node: the real Mac port resolves a relative `filename` against its own
cwd and start-capture then succeeds without capturing; the reply now carries
a warning for relative names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WINDOWS-TEST.md scripts the one open acceptance item (install timing, the
$PIXI_PROJECT_ROOT expansion check, talker/listener, the 29-check harness,
rig + unmodified Studio + controller, real BIAS over HTTP with an absolute
video path and the unknown-outcome path, Ctrl-C cleanup, student mistakes)
and lists what to send back.

Windows' signal module has no SIGKILL, so rig.py's stop escalation, the
harness's mid-run adapter kill and _common.signal_group would have raised
AttributeError there; they now share _common.SIGKILL (getattr fallback 9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ectors)

The web runner executed only the `log` and FicTrac plugins; every other plugin
command was a visible skip, so a protocol's `camera` plugin (BiasPlugin) never
recorded in a web run. Now:

- js/arena-runner-g6.js: a `connectorCommand` op for plugins named in
  `connectorPluginNames` (instance name → connector id, 'bias' for BiasPlugin).
  The step waits for the bridge's reply; ok → command event with the reply
  (outcome, value, artifact, rtt); error/unknown → step error, run continues
  (proceed-and-skip). A bridge that advertises no connectors (every ≤ 3.4
  bridge) yields a skip that names the bridge version. listSkippedPlugins
  takes an optional driveable set so the Edit-view note counts real skips.
- js/fictrac-bridge-client.js: connectorCommand() → {type:'connector_command'}
  and the id-matched connector_reply; never rejects (not connected → error,
  no reply in time → 'unknown', disconnect in flight → 'unknown'). hello_ack's
  `connectors` list → supportsConnector(). js/arena-session.js passes the map.
- arena_studio.html: connectorPluginNames(exp) from BiasPlugin instances,
  passed to runSequence; Run log lines show outcome · rtt · artifact; footer
  v0.95; release notes.
- ros2-poc bridge_node.py: hello_ack gains `connectors: ['bias']`;
  connector_command → /bias/cmd, the matching /bias/reply → connector_reply to
  the asking websocket; both halves logged (`via: ws` on Studio-path replies).
  bias_node.py: --video-dir resolves a relative filename (MATLAB's
  experiment-folder behavior). Emulator drives connect/startRecording/
  getTimestamp/stopRecording/bogus through the bridge; harness 32/32.

Tests: runner 512/512, bridge client 173/173, root suite 60/60 files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…95 connector path)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erter, ROS Studio tier (LAB-303)

Frank's Plan B built as a bounded spike on the existing ROS 2 PoC graph, so the LAB-303
architecture decision can be made on measured data (ros2-poc/README.md → "Plan B spike").
Bridge 3.4 stays installed side by side; the browser protocol is unchanged.

* nodes/recorder.py + bridge_node.py: rosbag2_py records every topic per run into an MCAP
  bag — device topics as received, /arena/frame with the closed-loop node's own sample time,
  /arena/browser (every browser message, verbatim), /arena/event, /arena/session — each
  bridge-side record with the writer's emission sequence number. Profiles: crashsafe
  (default; no chunking, no cache) and rosbag2's default (one unflushed 768 KiB chunk).
* nodes/mcap_convert.py (`pixi run convert`): bag → behavior_v2 / behavior_v1 / full NDJSON,
  pure Python (mcap + a schema-driven CDR decoder, no ROS process), `--recover` for a torn
  bag, `--order seq|time`. log_export returns the converted log; the live NDJSON stays as a
  shadow during the spike and `converted == live` is checked at every export.
* interfaces/arena_interfaces (FicTracFrame, ArenaFrame, ConnectorReply) built by
  `pixi install` through pixi-build-ros; `pixi run build-interfaces` = the colcon alternative
  (install rpath fix for macOS). The adapter publishes /fictrac/frame typed; the bridge
  consumes it and publishes/records /arena/frame typed when the package imports.
* tests/test_ros2_poc.py: judged against the CONVERTED log; + bag per topic, fidelity vs the
  shadow (sequenced lines byte-identical; derived lines ±2 ms), levels, ordering, typed
  schemas, and a SIGKILL crash phase for both profiles. 46 / 46 on the Mac.
* ROS Studio Pages path: scripts/tiers/stamp.py --ros REF → /ros/ (channel "ros", magenta
  banner), deploy-pages.yml input `ros` (off by default), `pixi run ros-preview`,
  js/build-channel.js channel "ros" (+ badge, window-name suffix, tests). Production and
  Next untouched.
* protocols/bias_camera_closed_loop_poc.yaml (+ index entry): the bench protocol.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mbreiser and others added 25 commits October 16, 2026 11:44
…allowlisted set, grab, waitIdle, abort) → bridge 'scanimage' connector; the run file (and MCAP bag) closes at run end via log_control off; footer + release notes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…color, 6 duty levels × 3 s at 20 fps, controller settings + ScanImage setup in the header

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…server (same condition table and TIFF layout; stdlib only, --dry-run/--ping)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tterns.js --gs16; no refresh-rate confound vs the 22 Sept GS16 runs), protocol points at them; SCANIMAGE-DAY.md rewritten for the JSON-server connector, the built/tested state, the rig test plan (new-arena trigger check, 20 panels, Inventory after power cycles) and what still needs the rig

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… setup, Block Z deferred, fallback trigger at 10 min)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n week, item 1)

A bag whose writer was killed (no metadata.yaml; a torn tail) no longer needs
`--recover` by hand. mcap_index.recover_bag rewrites the .mcap whole + indexed
(torn tail dropped, original kept as .torn.bak), appends one /arena/session
{"event":"recovered"} record so the bag itself says so, and asks rosbag2's own
Reindexer for metadata.yaml (verified: it reads the crashsafe file in file
order). The bridge node runs it when a log_export meets such a bag; a freshly
started node adopts the newest unclosed bag in --log-dir for its first export,
so the Studio's export after a bridge restart gets the salvaged run. The
log_export_result carries `recovered: true` + the recovery report (and the
conversion report); the converter emits the marker as a session line; a bag
with nothing salvageable (rosbag2 defaults leave a 0-byte file) falls back to
the shadow NDJSON. `pixi run export -- --json`, `pixi run reindex -- BAG --recover`.

Harness crash phase: a restarted node's log_export recovers the bag on its own
(frames == the --recover CLI's, marker present) and leaves a whole bag
(metadata.yaml, chunk index, converts without --recover). 54 / 54.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-303 adoption week, item 2)

Decision note docs/typed-distribution-decision.md: per-PC MSVC build (35 s + v143 +
detached-environments + an unmerged upstream fix on every PC) vs a CI-built conda
channel (5–9 s, no compiler, one sha256-pinned build so rmw_zenoh type hashes agree).
Recommendation: the channel; host = the in-repo channel/ read over raw.githubusercontent.com
at an arena-interfaces-v* TAG (T6 measured), moving to a prefix.dev lab channel when the
lab owns one. The per-PC build stays the .msg author's path.

Repo side: .github/workflows/arena-interfaces.yml builds win-64 / osx-arm64 / linux-64
(windows-2022 → v143; detached-environments C:\px for MAX_PATH; pixi v0.80.0 pinned
because the backend is not in the lock), indexes the three with rattler-index and
publishes the `arena-interfaces-channel` artifact; on a tag it also uploads to prefix.dev
when ARENA_INTERFACES_CHANNEL / PREFIX_API_KEY are set. Build-only on PRs touching the
package. The release recipe (version bump, download, tag, pixi.toml edits) is in the note.
pixi.toml is unchanged: the default String-only environment is untouched. Verified
locally on the Mac: pixi build 17 s, rattler-index fs → repodata; the Windows runner is
verified by the PR run this push triggers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ad of GitHub (LAB-303 S1/S2, item 3)

S1 (ww18 5.7: the bridge was hard-killed mid closed loop, the Studio showed only
"bridge disconnected", the run carried on and BIAS kept recording):
- fictrac-bridge-client emits 'disconnect' {involuntary, url}; involuntary = not our
  disconnect(). Emitted before pending connector waiters are rejected, so the loss
  is recorded as the reason, not a generic send failure.
- ArenaRunner.bridgeInUse: the active run depends on the bridge when a closed loop
  is armed (startClosedLoop), connector plugins are declared, or the bridge is
  recording the run. ArenaSession faults such a run with 'bridge_lost' on an
  involuntary close; a user Disconnect or an unused bridge is not a fault.
- run-log deriveOutcome → BRIDGE_LOST (the adapter now defers to it for faulted
  aborts); transcript line carries the note. Studio: banner + log line, STOP via
  the runner's finally, NO controller post-mortem (the controller is fine). The
  bridge's own record is recovered by its first export after a restart (item 1).

S2 (ww18 5: a tab opened at ?p=… showed no protocol until refreshed): initFromUrl
awaited the course repo's shared-pattern listing (a GitHub API call with no
timeout) BEFORE the ?p= load; on a slow or walled network that held the whole
init and a refresh merely got a warm connection. Now the listing starts first
and gets ≤ 1.5 s before the load goes ahead, every pre-step is guarded, and an
unknown protocol key banners instead of leaving an empty Run view.

Footer v0.97 + ET stamp, release notes. Tests: deriveOutcome, adapter outcome,
session bridge-lost hook (4 cases), runner bridgeInUse (unit + closed-loop flow).
Root suite 69/69. Prettier on the changed JS only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g before the first acquisition (ScanImage 2026.0.0); ping/status pass on the Bergamo PC
…ws even after grabs on ScanImage 2026.0.0); laser-clocked vDAQ noted; single connector grab passes
…aded server stalled the Studio's module imports in Chrome; localhost cost ~2 s/connection on Windows)
…z on the Bergamo, not ~29); dry-run results logged
…gamo card's slots (violet 7, blue 3, green 5, yellow 9)
… colors 2-4, 640 frames cut the end of duty 255); run 1 logged
…ottling (Studio window hidden/occluded); workaround logged
…uty steps visible in both channels, per color)
… x14) + frame-continuity check of runs 1-2 (0 gaps, 29.12 Hz)
…3 S1)

- bridgeRequired only for a recorded run the bridge is actually logging
  (bridge.logging), not merely connected for the scope.
- A log_control_ack slower than the 800 ms wait still names the run's bridge
  file: the run subscribes to 'loglevel' once and takes `file` for THIS run.
- disconnect() and a connect() over a still-closing socket settle every
  pending request (export, level ack) themselves; the superseded socket's
  late onclose is ignored, so it must not be the only place they settle.
- The bridge-lost alert no longer hardcodes `pixi run bridge` (wrong on the
  ROS 2 graph): "restart the bridge program on the rig PC".

Tests: client 187 (two new reconnect cases), studio-shell 81 (wiring checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… this branch's own S1/S2

#257's bridge-loss model replaces 484b187's: the client emits 'lost' only for an
OPEN socket closing unasked (failed connects and superseded sockets are not
losses), fails every pending request first, and the RUNNER decides via
onBridgeLost (apply on / bridge-routed steps / bridgeRequired). The runner's
bridgeInUse getter and the 'disconnect'-involuntary path are removed; so is the
fault('bridge_lost') outcome path (BRIDGE_LOST now comes from summary.bridgeLost).

Connector delta on top of #257 (its PR body's "item 3"):
- _failPending settles connector_command waiters (outcome 'unknown');
- sequenceUsesBridge counts connector plugin instances (BiasPlugin, ScanImagePlugin)
  as bridge-routed steps, so a loss in an open-loop ITI before a camera step stops the run;
- the bridge-lost alert says devices driven through the bridge were not told to stop.

Also: the run-end bag close in onRunFinished referenced an undeclared `bridge`
(a ReferenceError swallowed by its catch — the fix was a no-op); it now uses
Studio.session.bridge, closes before the upload, and only for this run's log.
initFromUrl takes #257's fire-and-forget listings over the grace-period race;
patByteSource keeps rc5's background-lane version with the layered preview map.

Tests: root suite 70/70 files (runner 544, client 199, session 106, adapter 34,
run-log 39, studio-shell 82, preview-sources 23).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…viz port

Studio / runner / client
- connectorCommand waits timeout_s + CONNECTOR_TIMEOUT_MARGIN_MS (default 15 s): the
  browser outlasts the device node's own deadline, so a reply at the deadline is an
  outcome, not an 'unknown'. 2P protocol's waitIdle already declares timeout_s.
- plugin-registry: ScanImagePlugin `set.value` is text (the allowlist holds numbers,
  booleans and paths); the node coerces "640" / "true" to the JSON scalar.
- protocol-yaml-v3: `variables:` written as a list of anchored scalars (`- &name v`,
  the 2P protocols) kept their identity wrong — `.anchor` was read off a primitive
  (String.prototype.anchor), hence the 2026-10-16 "Variable "function anchor() …"
  is defined more than once" warning. extractVariables/_findVariablePair handle
  both shapes; v3 round-trip suite 39 (823/823).
- runner test: connector plugin steps make a run bridge-dependent (loss in the ITI
  before a camera step → BRIDGE_LOST; without the map → continues). 547/547.
- footer stamp; release notes v0.97 extended; CLAUDE.md: ROS-era rules.

ros2-poc
- mcap_index.reindex replaces the bag atomically (os.replace), never unlink-then-rename.
- recorder: ONE constant storage sidecar per log dir (not one hidden file per run);
  optional max_bag_bytes → bridge_node --max-bag-mb (off by default: bags are archived
  on a share, not pushed to GitHub).
- scanimage_node: coerce_scalar for `set` values.
- kf_row lives once in nodes/_rows.py (no ROS imports), used by the node and the
  converter — `converted == live` depends on one encoder.
- ported from #252: nodes/viz_node.py, foxglove/*.json, tests/test_viz_node.py,
  sizing/; rig.py --foxglove also starts viz_node (+ --viz-args); pixi tasks viz,
  test-viz, fox-json (nodes/foxglove_json.py salvaged from the data branch).
- repo hygiene: colcon build logs (interfaces/log) and the one-off win-64 channel
  payload removed from the tree (+ .gitignore); the typed-prebuilt env still reads
  the pinned commit URL.
- stamp.py: one stamping path again (stamp_tree takes a skip set); tiers 109/109.

Harness: ros2-poc `pixi run test` all PASS (default env), test-viz PASS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p) + a G6 .pat reader

tools/tiff_run_to_mcap.py: ScanImage TIFFs (frames, channel ids and frame rate from the SI
header tag, per-frame timestamps and the DO1 aux-trigger stamps) + the run's .pat files →
one MCAP with Foxglove well-known JSON schemas: /scanimage/preview/ch<id> (observed, fixed
int16→8-bit window per channel), /scanimage/frame_ref, /scanimage/qc (whole-field mean
minus the pre-stimulus baseline), /arena/stimulus (commanded from a run log, else
RECONSTRUCTED from the first DO1 pulse + nominal step lengths), /arena/display and
/arena/scene (RECONSTRUCTED from .pat × duty × LED color), /run/events and a
/run/provenance message naming the source of every topic. Also writes a Foxglove layout.
tools/patfile.py: minimal G6PT V1/V2 reader mirroring js/pat-parser.js. `pixi run tiff2mcap`.

First use: the 2026-10-16 Bergamo color sweep, run 2 (four 800-frame TIFFs, 29.12 Hz, DO1
spans 24.0 / 24.0 / 24.0 / 23.0 s as the day's analysis found).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…harness (LAB-303 Block R)

Built off-rig against a stand-in of ScanImage 2026's ZeroMQ frame PUB (40-byte header of five
doubles + int16 planes), so the Bergamo lab day (Block Z+R) only measures.

- nodes/scanimage_frames_node.py: SUB + monitor socket on a receive thread into a bounded queue;
  /scanimage/frame_meta (every frame), /scanimage/frame_stats (1 Hz: gaps by header frame number,
  dropped-by-queue, acquisition segments with the slow-joiner loss, connects, hop p50/p99), optional
  bounded preview /scanimage/preview/ch<id> (CompressedImage PNG 128², <= 5 Hz) with the display
  mapping on /scanimage/preview/meta. received == published + dropped_by_queue + queue_depth.
- bridge_node.py: STREAM_TOPICS recorded on arrival into every bag; the converter derives nothing.
- rig.py --scanimage-frames [--scanimage-frames-args]; pixi tasks scanimage-frames, test-frames,
  zmq-latency; `pixi run test` runs the frames harness, then the acceptance run.
- tools/zmq_latency_report.py: per segment stream delay (relative), clock drift, in-node and
  node->bridge hops, queue-depth histogram, gaps, preview latency; with the TIFF (stdlib reader)
  the ZMQ-vs-TIFF timestamp difference and the absolute latency from `epoch`; the procedure's bars.
- tests/test_scanimage_frames.py (39 checks) + tests/zmq_frames_mock.py (the scanimage repo's
  mock_zmq_publisher.py format/CLI + --sndhwm/--seed; --mock runs the original: 37/37).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
hanmarsan and others added 4 commits October 21, 2026 11:46
…ean exit)

rig.py and the harnesses stop children with CTRL_BREAK_EVENT; its default
action ended the frames node (and every spin() node) at once, rc
0xC000013A (Bergamo 2026-10-20, test-frames E 33/34). SIGBREAK now raises
KeyboardInterrupt like Ctrl-C, as bridge_node.py already does.
test-frames E 'node exits cleanly on SIGINT' passes on the rig PC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…frame-counter)

hSI.hDisplay.lastFrameNumber throws 'Index exceeds array bounds' on
ScanImage 2026.0.0 while idle and even during FOCUS, so every 1 Hz status
poll carried an error; on a simulated vDAQ (2026-10-21) the failing get
took ~59 ms against ~2 ms for AcquisitionMex(hSI.hScan2D.hAcq,
'getAcqStatus'). That error path may be the 1 Hz MATLAB stall of
2026-10-20 (Q3). New --frame-counter acqstatus (default) | display (the
old get, kept to compare on the rig) | none; status and waitIdle gain
acq{frameNumberAcq, totalAcquiredFrames, acqNumber, numDroppedFrames*,
fifoOverflows, dcOvervoltage}. The mock answers the new fixed eval;
acceptance 55/55 (one new check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…+ epoch (P2)

Every acquisition numbers frames from 1, so matching by frameNumber alone
took the first segment (Bergamo 2026-10-20: a FOCUS instead of the GRAB).
A candidate now holds every TIFF frame, has exactly as many frames, and
starts 0..--epoch-window-s (5) after the TIFF epoch; none or several stop
with exit 2 and a listing, --segment overrides. A bar checks the ZMQ and
TIFF timestamps agree. The epoch difference is reported as epoch_offset_ms
(it holds ScanImage's arming -> first-line gap), with no latency bar.
test-frames 38/38 (four new F checks incl. three segments, TIFF = last).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epoch + warns on missed_before_first (LAB-303)

ScanImage 2026's simulated vDAQ writes epoch = [1601 1 1 0 0 0]. On Windows the local-zone path
(no --utc-offset-h) crashed in naive.astimezone() (OSError before 1970; Bergamo PC 2026-10-22), and
with an offset the 1601 epoch made every segment miss the epoch window, so --segment was needed.

- epoch_wall_ms returns None for a year before 2000 or a date the platform can't convert; the epoch
  is then ignored with a warning: no epoch offset, the TIFF's segment chosen by frame count alone.
- A segment whose first frame is > 1 (a late join, or a reconnect mid-acquisition) now gets a
  warning in the markdown, the JSON (`warnings`) and on stderr. Still not a pass bar.
- Harness (tests/test_scanimage_frames.py): 3 new F checks; 46/46 pass on the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants