Skip to content

fix(firmware): don't set illumination_is_on from the strobe ISR (next-channel bleed) — v1.5 - #619

Open
Alpaca233 wants to merge 7 commits into
Cephla-Lab:masterfrom
Alpaca233:fix/firmware-strobe-isr-host-illumination-flag
Open

fix(firmware): don't set illumination_is_on from the strobe ISR (next-channel bleed) — v1.5#619
Alpaca233 wants to merge 7 commits into
Cephla-Lab:masterfrom
Alpaca233:fix/firmware-strobe-isr-host-illumination-flag

Conversation

@Alpaca233

@Alpaca233 Alpaca233 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Problem

In hardware-triggered multi-channel acquisitions, a frame can carry a faint copy of the next channel's signal: a gradient that is strongest in the rows read out last (top of the image on a vertically flipped camera), sometimes uniform, intermittent. Reported on a Squid+ with a Kinetix in level-trigger mode (same exposure on every channel), where it was reproducible enough to look like "channel mixing".

Root cause

ISR_strobeTimer() set illumination_is_on = true at strobe start. The host's acquisition loop (MultiPointWorker.acquire_camera_image) sleeps exactly exposure + strobe_delay after writing the trigger and then immediately sends SET_ILLUMINATION for the next channel. The strobe ends exposure + strobe_delay after the trigger arrives, so the margin between the two is USB/loop jitter, on the order of a millisecond, in either direction. When SET_ILLUMINATION lands while the strobe is still on, set_illumination() sees the flag and calls turn_on_illumination() → the next channel's port is energized immediately. The v1.3 strobe end (#549) then turns off only the latched port and, seeing the source has changed, leaves the flag true — so the next channel's laser stays on through the current frame's readout, the inter-frame gap and the next frame's reset sweep, until its own strobe end.

Before v1.3 the same race had the opposite outcome (the strobe end turned off the new source and stranded the old one); v1.3 fixed the stranding but preserved the accidental re-light.

Fix

The ISR no longer sets the flag. It still clears it at strobe end when the strobe extinguished the host's own source (unchanged from v1.3/v1.4), so:

  • Acquisition (HW trigger): the flag is never true (the worker never sends TURN_ON in HW mode) → SET_ILLUMINATION during a strobe is DAC/source-only, no re-light; the strobe end turns off the latched port. Bleed gone.
  • fix(firmware): close strobe ISR race that leaves old channel HIGH on switch #549 live channel switch (TURN_OFF(old) → strobe start → SET_ILLUMINATION(new)TURN_ON(new) → strobe end): identical to v1.4 — the latched old port is extinguished, the host-commanded new port stays on until its own strobe end.
  • HW live intensity changes: identical to v1.4 — the first strobe end clears the host flag, later SET_ILLUMINATIONs are DAC-only. (A "never touch the flag" variant was considered and rejected: it would leave the flag true after a live channel switch, and every intensity-slider tick would then re-light the laser continuously until the next strobe end.)
  • Software-trigger, continuous, multi-port and the <= 30 ms delayMicroseconds branch: unchanged.

Behavioural change is two deleted illumination_is_on = true; writes (both strobe-start branches). A follow-up commit folds the byte-identical strobe begin/end sequences of the two ISR branches into strobe_begin()/strobe_end() helpers so the invariant (begin never writes the host flag; end owns the guarded clear) is structural rather than comment-enforced — behavior-identical. Plus the version bump 1.4 → 1.5.

Host side (same fix, second half)

LiveController.set_microscope_mode()'s live-restart path unconditionally toggled illumination around a channel switch — a leftover from 2020 software-triggered live view, and the one remaining path that holds illumination_is_on true across a switch in hardware-trigger mode. It now skips the toggle when the strobe owns an MCU-gated light (_strobe_owns_light()), and keeps it for software trigger, for a host-held manual toggle, and for sources the strobe can't gate (software shutter / SciMicroscopy LED array, where this call is the only light switch). Covered by tests/control/test_live_controller_hw_illumination.py.

Per Copilot's review, the skip is firmware-gated: _strobe_owns_light() requires the MCU to report firmware >= (1, 3) (on an unlatched <= 1.2 ISR, dropping the legacy TURN_OFF(old) would widen that firmware's stuck-port race), so older controllers keep today's behaviour byte-for-byte. Additionally, on firmware < (1, 5) set_microscope_mode() waits out the last hardware trigger's strobe window (strobe delay + exposure + STROBE_GUARD_MARGIN_MS) before changing the illumination source — SET_ILLUMINATION can then never land mid-strobe, which removes the trigger condition of both old-firmware races in live view and acquisitions (trigger times recorded via note_hardware_trigger_sent() at every HW trigger site). v1.5+ controllers skip the wait entirely; in acquisitions it is normally absorbed by the existing wait-for-frame. An adversarial review of the guard hardened it further: the live timer is stopped before the wait, trigger sends and the wait share _hw_trigger_send_lock (flushing an in-flight timer-thread send) and the wait loops until the window has actually passed; the wait is gated on firmware only, not the current trigger mode (a strobe can still be in flight right after leaving HARDWARE mode); and note_hardware_trigger_sent() also covers Microscope.acquire_image and the contrast-AF worker. On pre-1.5 firmware a live channel switch may block the GUI thread for up to one frame time — accepted as the cost of correctness there. The intensity-only update_illumination() path is deliberately unguarded (it never changes the source; a same-source re-light mid-strobe is harmless).

Testing

  • pio run -e teensy41 — builds clean (warnings pre-existing in TMC4361A.cpp / Teensy core), firmware.hex produced
  • pio test -e native — 80/80
  • pytest tests/control/test_firmware_protocol.py tests/control/test_firmware_sim_serial.py tests/control/test_microcontroller.py — 47 passed, 1 pre-existing skip
  • pytest tests/control/test_live_controller_hw_illumination.py tests/control/test_intensity_cap.py tests/control/test_channel_sequence.py tests/control/test_LiveControlWidget_offset.py — 62 passed; black --check clean
  • Hardware verification required (the ISR isn't covered by the native tests, as with fix(firmware): close strobe ISR race that leaves old channel HIGH on switch #549): (1) the fix(firmware): close strobe ISR race that leaves old channel HIGH on switch #549 repro — HW-triggered live, switch 640 → 405, confirm 640 nm goes dark; (2) a 2-channel HW-triggered acquisition at ~100 ms exposure over ~100 FOVs, confirm no next-channel gradient in either channel; (3) HW live, drag the intensity slider, confirm the laser is only on during strobes.

Not in this PR (follow-ups)

  • FirmwareSimSerial still reports 1.3 (was not bumped for 1.4 either); nothing in Python is gated above 1.0.
  • Pre-existing, unrelated to this change: SET_STROBE_DELAY landing mid-strobe moves the in-flight strobe end; TURN_ON mid-strobe with a changed source lights two ports until strobe end; an edge-mode retrigger while the strobe is HIGH postpones its end.

🤖 Generated with Claude Code

Alpaca233 and others added 4 commits August 22, 2026 22:49
…-channel bleed) — v1.5

In hardware-triggered multi-channel acquisitions the next channel's laser
could come on during the current frame's readout, so each image carried a
faint copy of the following channel (gradient in the last-read rows,
intermittent). Root cause: ISR_strobeTimer set illumination_is_on = true
at strobe start. The host sleeps exactly exposure + strobe_delay after the
trigger and then sends SET_ILLUMINATION for the next channel; if that
lands while the strobe is still on, set_illumination() sees the flag and
calls turn_on_illumination(), energizing the new port immediately. The
v1.3 strobe end then turns off only the latched port and, because the
source changed, leaves the flag true, so the new port stays on through
readout until its own strobe end.

Fix: the ISR no longer sets the flag. It still clears it at strobe end
when the strobe extinguished the host's own source (v1.3/v1.4 behaviour),
so HW-triggered live view stays strobe-only after the first frame and
intensity changes in live remain DAC-only. The flag can now only become
true through TURN_ON_ILLUMINATION, which the acquisition loop never sends
in hardware-trigger mode, so a strobe can no longer be the reason a second
port is lit. The Cephla-Lab#549 latch guarantee (the ISR always extinguishes the
port it lit) is unchanged.

Bump firmware 1.4 -> 1.5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…omments

The strobe begin and end sequences were byte-identical across the <=30 ms
and >30 ms ISR branches, and the v1.5 invariant ("begin never writes the
host-owned illumination_is_on; end owns the guarded clear") was stated in
three comment blocks instead of in code. Extract static inline
strobe_begin()/strobe_end() so the rule has one home, and cut the
duplicated prose (ISR preamble, per-branch clear comments, globals.h and
constants.h entries) down to one statement next to the code and one next
to the declaration. Behavior-identical; builds clean, native tests 80/80.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e owns the light

set_microscope_mode()'s live-restart path unconditionally did
turn_off(old) / turn_on(new) around a channel switch — a leftover from
2020 software-triggered live view. In hardware-trigger mode with an
MCU-gated source (TTL laser shutter, MCU LED matrix) the strobe owns the
light, and this TURN_ON is the one remaining path that holds firmware
illumination_is_on true across a switch: the light stays continuously on
until the first strobe end, and SET_ILLUMINATION can re-light a port
mid-strobe (the host half of the next-channel-bleed fix; firmware v1.5
closes the ISR half).

Keep the toggle when the trigger mode is not HARDWARE, when the host is
deliberately holding illumination on (manual toggle — captured before
turn_off clears it), or when the strobe can't gate the light (software
shutter, e.g. LDI PC mode; external SciMicroscopy LED array), where this
call is the only thing that turns the lamp on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/simplify pass: the set_microscope_mode comment restated the helper
docstring (keep only the capture-ordering fact + pointer); two per-test
docstrings restated the module docstring; the five tests shared one
assert pair, now _assert_toggled(). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes hardware-triggered channel bleed by separating host illumination state from ISR strobe state.

Changes:

  • Refactors firmware strobe begin/end handling and bumps firmware to v1.5.
  • Avoids unnecessary host illumination toggles in hardware-trigger mode.
  • Adds host-side illumination-switching tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
software/control/core/live_controller.py Adjusts live channel-switch illumination behavior.
software/tests/control/test_live_controller_hw_illumination.py Tests illumination toggling scenarios.
firmware/controller/src/functions.cpp Refactors strobe handling and removes ISR flag writes.
firmware/controller/src/globals.h Clarifies illumination flag ownership.
firmware/controller/src/constants.h Bumps firmware version to 1.5.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +106 to +107
if self.trigger_mode != TriggerMode.HARDWARE:
return False

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code] Fixed in 5f7ae36 - _strobe_owns_light() now requires firmware >= (1, 3) (older controllers keep the legacy toggle unchanged), and on firmware < (1, 5) set_microscope_mode() additionally waits out the last hardware trigger's strobe window before sending SET_ILLUMINATION, which removes the trigger condition of the <= 1.2 stuck-port race in both live view and acquisitions. Covered by new tests (firmware (1, 2) keeps the toggle; the wait engages on (1, 4) and not on (1, 5)).

Alpaca233 and others added 3 commits August 23, 2026 16:50
…he strobe window on old firmware

Copilot review: skipping the legacy TURN_OFF(old)/TURN_ON(new) on
firmware <= 1.2 widens that firmware's stuck-port race (unlatched ISR:
SET_ILLUMINATION mid-strobe makes the strobe end turn off the NEW port
and strand the old one). Two changes:

- _strobe_owns_light() now also requires firmware >= (1, 3): on older
  controllers set_microscope_mode() keeps today's toggle, byte-for-byte.
- On firmware < (1, 5), set_microscope_mode() waits out the last
  hardware trigger's strobe window (strobe delay + exposure +
  STROBE_GUARD_MARGIN_MS) before changing the illumination source, so
  SET_ILLUMINATION can never land mid-strobe: that removes the trigger
  condition of both the <= 1.2 stuck-port race and the 1.3/1.4
  next-channel re-light, in live view and acquisitions alike. Trigger
  times are recorded via note_hardware_trigger_sent() at every HW
  trigger site (live timer, snap, worker single/RGB captures). v1.5+
  controllers skip the wait entirely; in acquisitions the wait is
  normally absorbed by the existing wait-for-frame anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y HW-trigger sites

Adversarial review of the guard commit found the wait ran BEFORE the live
timer was stopped and read _strobe_clear_at once, so a timer-thread
trigger could still start a strobe behind its back; and it was gated on
the CURRENT trigger mode, missing a strobe still in flight right after
leaving HARDWARE mode.

- set_microscope_mode now stops the timer first, then waits via
  _wait_for_strobe_clear(): gated on firmware (< 1.5) only, taking a new
  _hw_trigger_send_lock (also held around the trigger sends in
  trigger_acquisition and snap) to flush an in-flight send, and looping
  until the recorded window has actually passed.
- note_hardware_trigger_sent() added at the two remaining HW-trigger
  sites: Microscope.acquire_image and the contrast-AF worker.
- update_illumination() (intensity slider) stays unguarded on purpose:
  it never changes the source, and a same-source re-light mid-strobe is
  harmless on every firmware; documented on _wait_for_strobe_clear().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, comment dedup

/simplify pass over the strobe-guard commits:

- Microcontroller gains strobe_isr_latches_source() (>= 1.3) and
  illumination_change_safe_during_strobe() (>= 1.5) next to
  supports_multi_port(), replacing raw version tuples in LiveController.
- note_hardware_trigger_sent() self-guards (HARDWARE mode + firmware),
  making it a no-op on v1.5+ so the per-trigger camera round trip
  (2 live DCAM reads on Hamamatsu) disappears on current firmware.
- New LiveController.send_camera_trigger() is the one seam that locks,
  sends, and records; trigger_acquisition, snap and the worker's two
  sites use it, deleting four duplicated trigger+note blocks and their
  mode conditionals. (Reviewed alternative — recording inside
  Microcontroller.send_hardware_trigger — rejected: SimulatedCamera
  never reaches hw_trigger_fn, so the guard would go dead in
  simulation, and 2 of 6 sites bypass the camera funnel.)
- Firmware rationale now lives once on STROBE_GUARD_MARGIN_MS (moved
  out of the middle of the watchdog constants); other comments point
  at it. update_illumination() documents its intensity-only exemption;
  the worker's NL5 branch documents why it records no window.
- Tests: module-level time import, dead fake-camera stub dropped,
  new coverage for the v1.5 no-op and the send_camera_trigger seam.

No behavior change on any firmware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants