Skip to content

feat: wire imaging (behavior-clock synced) into the NWB export handler - #112

Open
tabedzki wants to merge 20 commits into
feat/nwb-export-handler-completionfrom
claude/imaging-behavior-sync-coord-2d4000
Open

feat: wire imaging (behavior-clock synced) into the NWB export handler#112
tabedzki wants to merge 20 commits into
feat/nwb-export-handler-completionfrom
claude/imaging-behavior-sync-coord-2d4000

Conversation

@tabedzki

Copy link
Copy Markdown
Contributor

Closes the imaging half of #111: an imaging-raw export now runs through the handler's validation and conversion flow and produces an NWB file whose TwoPhotonSeries is on the ViRMEn behavior clock, instead of raising "imaging export not yet wired".

Targets feat/nwb-export-handler-completion. This branch already merges that backend, so the diff here is the imaging work on top of it.

Depends on tabedzki/tank-lab-to-nwb#1, which adds the per-interface timestamp support this calls into. That should land first.

What changed

nwb_production_utilsvalidate_imaging_data_exists referenced imaging_element.Scan and imaging_element.FieldOfView, neither of which exists (imaging_element is element_calcium_imaging.imaging_preprocess). It now walks the real chain, ImagingPipelineSession → AcquiredTiff → TiffSplit → TiffSplit.File, and takes a recording key so callers do the session → recording hop the way the ephys validator already does.

An empty fov_numbers list now requires at least one TiffSplit to exist rather than passing vacuously — an empty DataJoint restriction matches every row, which is what made the old code report success for sessions with no imaging at all.

estimate_imaging_size_gb computed 0.05 GB/FOV, >10x short for raw stacks. It now derives bytes from fov_pixel_resolution_xy and the frame counts in TiffSplit.File at 2 bytes/pixel for ScanImage int16. estimate_total_size's imaging branch no longer skips: the linkage its TODO was waiting on is the same recording_ids_for_session hop the ephys branch above it already uses.

nwb_export/conversion.pyresolve_imaging_paths maps a session or recording key through TiffSplit/TiffSplit.File to absolute paths, in acquisition order (the ScanImage interface treats a multi-file list as one continuous recording, so unordered paths would give unordered frames). imaging_timestamps_for_session runs the I2C sync, applies the clock offset below, and strides the per-frame array down to the interface's sample count; a frame count that isn't a whole multiple of that raises instead of silently misaligning. build_source_data gains an imaging branch, and run_conversion_to_file passes imaging timestamps through the converter's new aligned_timestamps rather than the shared sync_timestamps array.

nwb_export_handler — the imaging branch takes the same session → recording hop as ephys and calls the validator.

Docs — new docs/nwb_export.md (stage flow, the recipe for wiring a modality, the dependency story) and a new section 6 in docs/imaging_behavior_sync.md deriving the clock convention.

The clock convention

Worth reading before review, because it's easy to mistake for jitter.

trial.start is vr.timeElapsed (ExperimentLog.m:501), never corrected by blockStart — that variable is only ever read to compute block.duration. The engine zeroes timeElapsed at firstTic (virmenEngine.m:124), which runs after vr.code.initialization(vr) (line 105) — and it's inside that initialization that the ExperimentLog constructor stamps session.start and then newBlock() stamps block.start. So the trial clock starts at block start, while NWB zeroes at session.start.

The gap between those two clock calls is the constructor tail — version bookkeeping, makeOrContinueLog file I/O — plus repmat(obj.trialInfo, 1, totalTrials) allocating the whole trial array. That's allocation and I/O cost, not drift and not a physical delay, so it scales with totalTrials and varies per session. The code reads it from each log; it is never hardcoded.

Skip it and trial 1's first imaging frame lands 4.7 ms before trial 1 starts instead of 22.3 ms after — wrong by a frame period, and small enough to pass for ordinary jitter.

Reconstructing the zero through the imaging PC's absolute epoch stamp instead lands 958 ms out. That rules out initialTimestamp as the zero (it misses by 6.35 s), but the residual is inter-machine wall-clock skew — which is exactly why the sync is content-based through the I2C [block, trial, iteration] packets. Wall clocks should never be used to align these streams.

Verification

End-to-end on the sample session, through the shipped code path:

source_data: ['ScanImageImaging', 'VirmenData']
diagnostics: slope 1.000027891, residual 10.4 ms, epoch_offset 27.0 ms,
             2000 frames -> 400 volumes
nwb:         TwoPhotonSeries + 179 trials, 0.420 GB
frame 644 (trial 1's first imaging frame) = +22.3 ms after trial 1 start

29 imaging tests pass (validation, sizing, path resolution, source-data wiring). tests/nwb_export/test_nwb_export_handler.py has 10 failures and 9 errors that predate this branch — they fail at import on dj.config["custom"] with no DB configured.

What is not verified

resolve_imaging_paths and the handler's imaging branch have mocked coverage only. No DataJoint instance was reachable, so the session → recording → TiffSplit hop is untested against a real database, and the end-to-end run above supplied TIFF paths directly to bypass it. That hop is the most likely place for this to break in production and deserves a run against a real session before this is relied on.

Still open (tracked on #111)

  • Mixed ephys+imaging clock rule. Ephys aligns behavior onto the ephys clock; imaging-only sessions use the ViRMEn clock. A session with both has no rule yet. This hardens at the first DANDI upload, so it wants sign-off before then.
  • Pinning tank-lab-to-nwb. Still path-installed and unpinned, so the two repos move in lockstep by hand.

🤖 Generated with Claude Code

https://claude.ai/code/session_017dzDXz6d8TiLLvgsA38P1V

Alvalunasan and others added 17 commits July 13, 2026 14:16
Point out where the "Locked Tables Alert" Slack message originates
(main_locked_tables_alert's raw `SHOW OPEN TABLES` query) and which
cron-driven processes hold locks on acquisition.SessionVideo and
pupillometry.PupillometrySessionModelData: the pupillometry queue/check
handlers, which run on their own schedules without a flock guard.

Assisted-by: ClaudeCode:claude-sonnet-5
`SHOW OPEN TABLES` (used by main_locked_tables_alert) has no
connection/process id, so it only ever showed which table was locked,
not who was holding it. Cross-reference `SHOW FULL PROCESSLIST` by
database name and attach each match's process id, user, host, command,
elapsed time, and current query to the Slack alert so on-call can go
straight to `KILL <id>` or the offending cron job instead of guessing.

Assisted-by: ClaudeCode:claude-sonnet-5
…n list

`add_locking_process_info()` matched `SHOW OPEN TABLES.Database` against
`SHOW PROCESSLIST.db`, but `db` is only a connection's current default
database (set by the last `USE`), not the database of a table it has
locked. In practice this match almost never succeeds, so the Slack alert
was reporting `Locking_process=unknown` for every real lock (reproduced
locally against a MariaDB container with a cross-database `LOCK TABLES`).

There is no processlist field that reliably maps a locked table back to
the connection holding it; that requires `performance_schema.metadata_locks`
joined to `processlist`, which needs a server-side config change
(performance_schema on, plus the mdl instrument enabled) that's out of
scope here. Instead, list all active (non-Sleep) connections alongside the
locked tables so on-call can cross-reference them manually.

Assisted-by: ClaudeCode:claude-sonnet-5
Checks free space on /, /mnt/cup/braininit, and /mnt/cup/u19_dj and sends
a Slack notification to dev_notifications when free space drops below
whichever is smaller: 1% of total capacity or 5 TB. Wired into the
existing nightly (3am) cron job in cronjob_alert.py.

Assisted-by: ClaudeCode:claude-sonnet-5
…ck the rest

Previously old_log_deletion, live_session_stats_deletion, noDB_backup, and
the new disk_space_alert ran as unguarded sequential calls: an exception in
an earlier job would abort the script and skip every job after it. Adds
run_cronjob_alert_job(), which runs each job in its own try/except, reports
the failure to the dev_notifications Slack channel, and lets the remaining
jobs continue to run.

Assisted-by: ClaudeCode:claude-sonnet-5
shutil.disk_usage() (and the raw f_bfree statvfs field) includes blocks
reserved for root, so it can overstate what a normal user can actually
write. Switch to os.statvfs()'s f_bavail, matching what `df` reports as
"Avail" and "Capacity", and compute the percentage against the same
denominator df uses (blocks in use + blocks available to users).

Assisted-by: ClaudeCode:claude-sonnet-5
…off plan

Ports getSyncInfo.cpp + SyncImagingBehavior.m to Python (tifffile-based),
verified against a real ScanImage session. Adds the sync mechanism write-up,
the NWB imaging-export feasibility plan (issue #111), and the coordinator
handoff document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spike passes: TowersNWBConverter, ScanImageImagingInterface and our
behavior-clock timestamps coexist, and a real conversion of the sample
session puts trial 1's first imaging frame 22.3 ms after trial 1 starts.

Getting there surfaced defects in tank-lab-to-nwb (one sync array applied to
every interface, a hard MATLAB requirement, and a tz-aware/naive subtraction)
plus the block-vs-session clock zero, which costs 27 ms if missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fset

trial.start rides ViRMEn's vr.timeElapsed, which the engine zeroes at firstTic
- after vr.code.initialization has already stamped both session.start and
block.start. NWB zeroes at session.start, so every timeElapsed-based quantity,
imaging timestamps included, needs the block-vs-session shift.

The shift is struct allocation plus log-file I/O between two clock calls, so it
varies per session and has to be read from each log rather than hardcoded.

Also records why wall clocks can't do this job: reconstructing the zero through
the imaging PC's epoch stamp lands 958 ms out, which is the inter-machine skew
that motivates content-based I2C sync in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validate_imaging_data_exists referenced imaging_element.Scan and
imaging_element.FieldOfView, neither of which exists -- imaging_element is
element_calcium_imaging.imaging_preprocess. It now walks the real chain,
ImagingPipelineSession -> AcquiredTiff -> TiffSplit -> TiffSplit.File, and
takes a recording key so callers resolve session -> recording_ids the same way
the ephys validator does.

An empty fov_numbers list now requires at least one TiffSplit to exist rather
than passing vacuously: an empty DataJoint restriction matches every row, which
is what made the old code report success for sessions with no imaging at all.

estimate_imaging_size_gb computed 0.05 GB/FOV, more than 10x short for raw
stacks. It now derives bytes from fov_pixel_resolution_xy and the frame counts
in TiffSplit.File, at 2 bytes/pixel for ScanImage int16, and falls back to
0.5 GB/FOV only when the lookup fails. estimate_total_size's imaging branch no
longer skips: the Scan-to-session linkage its TODO was waiting on is the same
recording_ids_for_session hop the ephys branch above it already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the stale imaging validator tests, which mocked the nonexistent
imaging_element.Scan/FieldOfView tables, with tests against the real TiffSplit
chain. Pins the vacuous-pass bug from both sides so an empty fov_numbers list
cannot silently report success again.

The conversion-side tests describe path resolution and the imaging source_data
entry, which are not implemented yet, so two of them fail by design until that
lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the handler's stage flow, the six-step recipe for adding a modality, the
imaging specifics (deferring to imaging_behavior_sync.md for the sync and clock
details), and the tank-lab-to-nwb dependency, which is path-installed and
unpinned and so has to move in lockstep with this repo.

Records the clock convention as policy: imaging-only sessions use the ViRMEn
clock, and the block-vs-session offset is computed per session rather than
hardcoded. Mixed ephys+imaging sessions stay an open question, flagged for
sign-off before the first DANDI upload, since ephys currently aligns behavior
onto the ephys clock instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolve_imaging_paths maps a session or recording key through TiffSplit and
TiffSplit.File to absolute TIFF paths, in acquisition order -- the ScanImage
interface treats a multi-file list as one continuous recording, so unordered
paths would produce unordered frames. The root join is done inline rather than
via element_interface.find_full_path, since element-interface is in the
optional "pipeline" extra and this helper should import without it.

imaging_timestamps_for_session runs the I2C sync and applies the
block-vs-session offset, read per session rather than assumed, then strides the
per-frame array down to whatever sample count the interface reports -- a
volumetric fastZ stack exposes volumes, not pages. A frame count that is not a
whole multiple of the sample count now raises instead of silently misaligning.

build_source_data gains an imaging branch, and run_conversion_to_file passes
imaging timestamps through the converter's new per-interface aligned_timestamps
rather than the shared sync_timestamps array.

The handler's imaging branch no longer raises "not yet wired": it takes the
same session -> recording hop as ephys and calls validate_imaging_data_exists.

The path-resolution test's mock gains __mul__: tiff_split_directory is on the
TiffSplit master and tiff_split_filename on the Part, so resolving a path needs
the join and not just a restriction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also records what is not verified: the session -> recording -> TiffSplit hop
has only mocked coverage, since no DataJoint instance was reachable, and the
end-to-end run supplied TIFF paths directly to bypass it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tabedzki and others added 3 commits August 27, 2026 15:40
A mesoscope session has one TiffSplit per field of view, and fields of view are
separate regions of tissue rather than continuations of one another. The
previous code flattened every split's files into a single ScanImageImaging
entry, which presented unrelated regions as one continuous recording and
misaligned every field of view after the first.

resolve_imaging_paths_by_fov groups the same resolution by tiff_split, and
build_source_data emits ScanImageImagingFOV<n> per field of view, each with its
own timestamps computed from its own files. An explicit tiff_paths override
still names exact files and stays a single interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two modalities are not acquired in the same session, so the conflict
between the ephys clock and the ViRMEn clock cannot arise. Records the
condition under which the question would come back rather than dropping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The I2C packet's third field is the per-trial iteration counter, not ViRMEn's
global one: logTick assigns obj.currentIt, writes trial.time at that index, and
returns the same number for broadcast, all in one call. Indexing trial.time
with the global counter instead would be badly wrong, so the reasoning and its
confirmation on the sample session are worth keeping.

Also notes what the packets do not give: most frames carry none, and the packet
marks when ViRMEn computed the iteration rather than when it was displayed.

Co-Authored-By: Claude Opus 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