feat: wire imaging (behavior-clock synced) into the NWB export handler - #112
Open
tabedzki wants to merge 20 commits into
Open
feat: wire imaging (behavior-clock synced) into the NWB export handler#112tabedzki wants to merge 20 commits into
tabedzki wants to merge 20 commits into
Conversation
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>
13 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the imaging half of #111: an
imaging-rawexport now runs through the handler's validation and conversion flow and produces an NWB file whoseTwoPhotonSeriesis 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_utils—validate_imaging_data_existsreferencedimaging_element.Scanandimaging_element.FieldOfView, neither of which exists (imaging_elementiselement_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_numberslist now requires at least oneTiffSplitto 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_gbcomputed 0.05 GB/FOV, >10x short for raw stacks. It now derives bytes fromfov_pixel_resolution_xyand the frame counts inTiffSplit.Fileat 2 bytes/pixel for ScanImage int16.estimate_total_size's imaging branch no longer skips: the linkage its TODO was waiting on is the samerecording_ids_for_sessionhop the ephys branch above it already uses.nwb_export/conversion.py—resolve_imaging_pathsmaps a session or recording key throughTiffSplit/TiffSplit.Fileto 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_sessionruns 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_datagains an imaging branch, andrun_conversion_to_filepasses imaging timestamps through the converter's newaligned_timestampsrather than the sharedsync_timestampsarray.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 indocs/imaging_behavior_sync.mdderiving the clock convention.The clock convention
Worth reading before review, because it's easy to mistake for jitter.
trial.startisvr.timeElapsed(ExperimentLog.m:501), never corrected byblockStart— that variable is only ever read to computeblock.duration. The engine zeroestimeElapsedatfirstTic(virmenEngine.m:124), which runs aftervr.code.initialization(vr)(line 105) — and it's inside that initialization that theExperimentLogconstructor stampssession.startand thennewBlock()stampsblock.start. So the trial clock starts at block start, while NWB zeroes atsession.start.The gap between those two
clockcalls is the constructor tail — version bookkeeping,makeOrContinueLogfile I/O — plusrepmat(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 withtotalTrialsand 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
epochstamp instead lands 958 ms out. That rules outinitialTimestampas 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:
29 imaging tests pass (validation, sizing, path resolution, source-data wiring).
tests/nwb_export/test_nwb_export_handler.pyhas 10 failures and 9 errors that predate this branch — they fail at import ondj.config["custom"]with no DB configured.What is not verified
resolve_imaging_pathsand 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)
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