Skip to content

feat: acquisition tracks (sequential vs. simultaneous channels) - #165

Merged
tlambert03 merged 1 commit into
mainfrom
feat/acquisition-tracks
Oct 26, 2026
Merged

tlambert03 merged 1 commit into
mainfrom
feat/acquisition-tracks

Conversation

@tlambert03

@tlambert03 tlambert03 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Last piece of the #160 split (after #161, #162, #163 and #164). This adds acquisition tracks: which channels are acquired at the same time, as on a point scanner.

sim = ms.Simulation(
    ...,
    channels=[LASER_405, LASER_488, LASER_561, LASER_640],
    tracks=[[0, 2], [1, 3]],  # or by name: [["405", "561"], ["488", "640"]]
    # tracks="simultaneous",  # everything at once
)

Until now every channel was implicitly acquired on its own (excited only by its own light sources), and that's still the default (tracks="sequential", which gives identical results to main). But a lot of real bleedthrough comes from having several lasers on at once, so in a track all light sources of all its channels excite every fluorophore, and every fluorophore can show up in every channel of the track. Saturation is computed from the total excitation, since the fluorophore sees all of the lasers at once. examples/multi_channel_confocal.py is a decent demo of this (EGFP/EYFP with 488 + 515... with both lasers on, EYFP actually outshines EGFP in the 488 channel).

Some rules that fall out of this:

  • a laser listed in several channels of a track (e.g. two detection windows behind one 488 line) is one laser, counted once, so those channels must agree on its power and excitation filters. Lights and filters are compared by their spectra, so laser(488) and laser(488.0) are the same laser. Sharing only some light sources between channels is an error.
  • on a point scanner, the channels of a track must share one pixel dwell time
  • new Confocal.shared_pinhole (default True): channels in a track go through one pinhole, with pinhole_au in Airy units at the average of their emission band centers. shared_pinhole=False gives each channel its own pinhole, matched to its band, as before. (Averaging is a convention I picked... open to other ideas)
  • ChannelFluorescence gets an optional track (None means "on its own"), so a custom modality can tell which channels were acquired together

Unrelated-ish fix along the way: a Simulation field assignment that fails validation used to leave the new (invalid) value set. It's now undone.

A few things I'm leaving for later:

  • each channel still applies its own dichroic to its own detection. On a real scanner a track shares one (multiband) dichroic, so you need to give the channels matching dichroics if that matters (mentioned in the docs)
  • mutating a channel in place (sim.channels[0].exposure_ms = ...) skips the track checks
  • changing channels and tracks together needs tracks="sequential" first (the error message says so)
  • the illumination row of plot_summary still only shows each channel's own lights, while the absorption row shows the whole track

Closes #160

- New `Simulation.tracks`: "sequential" (default, each channel excited only
  by its own light sources, as before), "simultaneous", or a list of `Track`s
  (bare lists of channel indices or names are cast, e.g. [[0, 2], [1, 3]]).
  All light sources of a track excite every fluorophore, so fluorophores can
  bleed into every channel of their track; saturation uses the total.
- A light source used by several channels of a track is counted once; those
  channels must agree on its power and excitation filters (ValueError
  otherwise, or if they share only some light sources).  Lights and filters
  are identified by their spectra (so e.g. `laser(488)` and `laser(488.0)`
  match, in any order); channels without lights (a lamp) by their excitation
  filters.
- Point scanners: channels in a track must share one pixel dwell time.
- New `Confocal.shared_pinhole` (default True): channels in a track share one
  pinhole, with `pinhole_au` at the average of their emission band centers.
- `ChannelFluorescence` gets an optional `track` (None: acquired on its own,
  e.g. with its own pinhole), and `create(..., absorption_rate=, track=)`.
- A rejected `Simulation` field assignment no longer leaves the new value set.
- `Track` is exported from `microsim.schema`.

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

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.08257% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 89.99%. Comparing base (8400fd8) to head (f2f8f52).

Files with missing lines Patch % Lines
src/microsim/schema/modality/_simple_psf.py 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #165      +/-   ##
==========================================
+ Coverage   89.06%   89.99%   +0.93%     
==========================================
  Files          51       51              
  Lines        3483     3579      +96     
==========================================
+ Hits         3102     3221     +119     
+ Misses        381      358      -23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tlambert03
tlambert03 merged commit 7023cb0 into main Oct 26, 2026
34 of 35 checks passed
@tlambert03
tlambert03 deleted the feat/acquisition-tracks branch October 26, 2026 09:12
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.

1 participant