From 33df1a3945659796dbf53d1398fc74f6336623c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:46:21 +0000 Subject: [PATCH 1/2] Add vhneuropixelsGLX_nidq; time digital on-edges at the first sample after Mirrors VH-Lab/NDI-matlab#1127. - ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json: reads the SpikeGLX NI-DAQ stream (*.nidq.bin/.meta) with the NDR neuropixelsGLX reader, so NI-DAQ digital lines (XDn = di(n+1)) can be named in epochprobemap.txt, e.g. an opto fiber on XD4 as vhneuropixelsGLX_nidq:dimp5. A blank vhlab session now has ten DAQ systems (test_vhprairieview's count updated). - ndi_daq_reader_mfdaq._read_derived_events: dep/den/dimp/dimn 'on' edges were timed one sample early (np.where without the + 1 that MATLAB's 1 + find(...), the 'off' edges and the ingested read use). The dimn 'off' bug fixed in MATLAB was already correct here. - Tests for both; bridge decision_log and CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011v7R5uyvdiE4Ex6Q7UkurQ --- CHANGELOG.md | 14 ++++ src/ndi/daq/mfdaq.py | 4 +- src/ndi/daq/ndi_matlab_python_bridge.yaml | 10 +++ .../vhlab/vhneuropixelsGLX_nidq.json | 16 +++++ tests/test_daq_derived_digital_events.py | 70 +++++++++++++++++++ tests/test_vhneuropixelsglx_nidq.py | 43 ++++++++++++ tests/test_vhprairieview.py | 7 +- 7 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 src/ndi/ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json create mode 100644 tests/test_daq_derived_digital_events.py create mode 100644 tests/test_vhneuropixelsglx_nidq.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d9cee8db..9912e212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **`vhneuropixelsGLX_nidq` vhlab DAQ system** (mirroring VH-Lab/NDI-matlab#1127). + Reads the SpikeGLX NI-DAQ stream (`*.nidq.bin` / `*.nidq.meta`) with the NDR + `neuropixelsGLX` reader, so probes on NI-DAQ digital lines can be described + in `epochprobemap.txt` -- e.g. an optogenetic fiber on XD4 as + `vhneuropixelsGLX_nidq:dimp5` (SpikeGLX XDn is channel di(n+1)). + `vhneuropixelsGLX` reads the IMEC `.ap.bin`, whose digital lines are the IMEC + sync bits. A blank vhlab session now has ten DAQ systems. + - **Update button: a package table with tick boxes** (mirroring NDI-matlab 19b8ab573 and 749b1a266). The navigator's Update button now lists NDI-python and each dependency clone in `~/.ndi/tools` in @@ -197,6 +205,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **Digital-edge events were one sample early.** `ndi_daq_reader_mfdaq`'s + `dep`, `den`, `dimp` and `dimn` events (the non-ingested read) timed each + "on" edge at the last sample before the transition; MATLAB, the "off" edges + and the ingested read all use the first sample after it. A line going high + at 1.000 s at 1 kHz gave 0.999 s. They now agree. + - **Reading samples through a DAQ system: raw and ingested epochs now agree.** `ndi_daq_system_mfdaq.readchannels_epochsamples` takes 0-based samples, as `epochtimes2samples` and `epochsamples2times` do, but passed them unchanged diff --git a/src/ndi/daq/mfdaq.py b/src/ndi/daq/mfdaq.py index 7058e6ec..d484b538 100644 --- a/src/ndi/daq/mfdaq.py +++ b/src/ndi/daq/mfdaq.py @@ -362,13 +362,13 @@ def _read_derived_events( # Find transitions if ct in ("dep", "dimp"): # positive transitions - on_samples = np.where((di_data[:-1] == 0) & (di_data[1:] == 1))[0] + on_samples = 1 + np.where((di_data[:-1] == 0) & (di_data[1:] == 1))[0] if ct == "dimp": off_samples = 1 + np.where((di_data[:-1] == 1) & (di_data[1:] == 0))[0] else: off_samples = np.array([], dtype=int) else: # negative transitions (den, dimn) - on_samples = np.where((di_data[:-1] == 1) & (di_data[1:] == 0))[0] + on_samples = 1 + np.where((di_data[:-1] == 1) & (di_data[1:] == 0))[0] if ct == "dimn": off_samples = 1 + np.where((di_data[:-1] == 0) & (di_data[1:] == 1))[0] else: diff --git a/src/ndi/daq/ndi_matlab_python_bridge.yaml b/src/ndi/daq/ndi_matlab_python_bridge.yaml index 97774777..7d8cf1c6 100644 --- a/src/ndi/daq/ndi_matlab_python_bridge.yaml +++ b/src/ndi/daq/ndi_matlab_python_bridge.yaml @@ -427,6 +427,16 @@ classes: epoch's segments over the workers. Ported, with the same plan fields and weights; ingest_epochfiles is now plan, each segment, attach, as in MATLAB. 5c5784c6a only marks a growing array for Code Analyzer. + + VH-Lab/NDI-matlab#1127 (d37bf16c7) fixes readevents_epochsamples and + readevents_epochsamples_ingested so dimn returns its off (0->1) + transitions; the dimn branch tested 'dimp'. Nothing to port for that: + this port's dimn branches were already right. Checking them found a + Python-only bug instead: the non-ingested read timed each dep/den/ + dimp/dimn "on" edge one sample early (np.where without the + 1 that + MATLAB's 1 + find(...) and the "off" edges have). Fixed here. + matlab_last_sync_hash stays at 5c5784c6a until #1127 is on + NDI-matlab main; move it to the merge commit then. python_path: "ndi/daq/mfdaq.py" python_class: "ndi_daq_reader_mfdaq" inherits: "ndi.daq.reader" diff --git a/src/ndi/ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json b/src/ndi/ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json new file mode 100644 index 00000000..098f01d5 --- /dev/null +++ b/src/ndi/ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json @@ -0,0 +1,16 @@ +{ + "Name": "vhneuropixelsGLX_nidq", + "DaqSystemClass": "ndi.daq.system.mfdaq", + "DaqReaderClass": "ndi.daq.reader.mfdaq.ndr", + "MetadataReaderClass": [], + "EpochProbeMapClass": "ndi.epoch.epochprobemap_daqsystem", + "FileParameters": [ + "#.nidq\\.bin\\>", + "#.nidq\\.meta\\>", + "epochprobemap.txt" + ], + "DaqReaderFileParameters": "neuropixelsGLX", + "MetadataReaderFileParameters": [], + "EpochProbeMapFileParameters": "epochprobemap.txt", + "HasEpochDirectories": true +} diff --git a/tests/test_daq_derived_digital_events.py b/tests/test_daq_derived_digital_events.py new file mode 100644 index 00000000..400cf916 --- /dev/null +++ b/tests/test_daq_derived_digital_events.py @@ -0,0 +1,70 @@ +"""Events derived from a digital channel: dep, den, dimp, dimn. + +Mirrors NDI-matlab tests/+ndi/+unittest/+daq/+reader/DigitalMarkEventsTest.m. +An event is timed at the first sample *after* the transition, as in +MATLAB's ``1 + find(d(1:end-1)==0 & d(2:end)==1)``: for a line that goes +high at t = 1.0 s, the rising edge is at 1.0 s, not one sample earlier. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ndi.daq.mfdaq import ChannelInfo, ndi_daq_reader_mfdaq + +SR = 1000.0 +DURATION = 10.0 + + +class _PulseReader(ndi_daq_reader_mfdaq): + """di1 is 1 for 1.0 <= t < 1.1 and 3.0 <= t < 3.1, else 0 (1 kHz, 0..10 s).""" + + def t0_t1(self, epochfiles): + return [(0.0, DURATION)] + + def getchannelsepoch(self, epochfiles): + return [ChannelInfo(name="di1", type="digital_in", time_channel=1)] + + def samplerate(self, epochfiles, channeltype, channel): + n = 1 if isinstance(channel, int) else len(channel) + return np.full(n, SR) + + def readchannels_epochsamples(self, channeltype, channel, epochfiles, s0, s1): + # s0/s1 come from epochtimes2samples, which counts from t0 = 0. + t = np.arange(s0, s1 + 1) / SR + ct = channeltype if isinstance(channeltype, str) else channeltype[0] + if ct in ("time", "t"): + return t.reshape(-1, 1) + d = (((t >= 1.0) & (t < 1.1)) | ((t >= 3.0) & (t < 3.1))).astype(float) + return d.reshape(-1, 1) + + +@pytest.fixture +def reader(): + return _PulseReader() + + +def test_dimp_on_off(reader): + t, d = reader.readevents_epochsamples("dimp", 1, ["mock"], 0.0, DURATION) + np.testing.assert_allclose(t, [1.0, 1.1, 3.0, 3.1], atol=1e-9) + np.testing.assert_array_equal(d, [1, -1, 1, -1]) + + +def test_dimn_on_off(reader): + # dimn: "on" is the 1->0 transition and "off" is the 0->1 transition. + t, d = reader.readevents_epochsamples("dimn", 1, ["mock"], 0.0, DURATION) + np.testing.assert_allclose(t, [1.0, 1.1, 3.0, 3.1], atol=1e-9) + np.testing.assert_array_equal(d, [-1, 1, -1, 1]) + + +def test_dep_rising_edges_only(reader): + t, d = reader.readevents_epochsamples("dep", 1, ["mock"], 0.0, DURATION) + np.testing.assert_allclose(t, [1.0, 3.0], atol=1e-9) + np.testing.assert_array_equal(d, [1, 1]) + + +def test_den_falling_edges_only(reader): + t, d = reader.readevents_epochsamples("den", 1, ["mock"], 0.0, DURATION) + np.testing.assert_allclose(t, [1.1, 3.1], atol=1e-9) + np.testing.assert_array_equal(d, [1, 1]) diff --git a/tests/test_vhneuropixelsglx_nidq.py b/tests/test_vhneuropixelsglx_nidq.py new file mode 100644 index 00000000..56bfd72c --- /dev/null +++ b/tests/test_vhneuropixelsglx_nidq.py @@ -0,0 +1,43 @@ +"""The vhlab vhneuropixelsGLX_nidq DAQ system (SpikeGLX NI-DAQ stream). + +Mirrors NDI-matlab's ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json +(VH-Lab/NDI-matlab#1127). vhneuropixelsGLX reads the IMEC .ap.bin, whose +digital lines are the IMEC SY bits; this system reads the .nidq.bin, so the +NI-DAQ lines are reachable: SpikeGLX XDn is channel di(n+1). +""" + +from __future__ import annotations + +import tempfile + +import pytest + +from ndi.daq.reader.mfdaq.ndr import ndi_daq_reader_mfdaq_ndr +from ndi.daq.system_mfdaq import ndi_daq_system_mfdaq +from ndi.file.navigator.epochdir import ndi_file_navigator_epochdir +from ndi.session.dir import ndi_session_dir +from ndi.setup.lab import lab as setup_lab + + +@pytest.fixture +def nidq_system(): + s = ndi_session_dir("vhtest", tempfile.mkdtemp()) + setup_lab(s, "vhlab") + return s.daqsystem_load(name="vhneuropixelsGLX_nidq") + + +def test_reads_the_nidq_stream_with_the_ndr_glx_reader(nidq_system): + assert isinstance(nidq_system, ndi_daq_system_mfdaq) + assert isinstance(nidq_system.daqreader, ndi_daq_reader_mfdaq_ndr) + assert nidq_system.daqreader.ndr_reader_string == "neuropixelsGLX" + + +def test_epochs_are_nidq_files_plus_epochprobemap(nidq_system): + fn = nidq_system.filenavigator + assert isinstance(fn, ndi_file_navigator_epochdir) + assert fn.fileparameters["filematch"] == [ + "#.nidq\\.bin\\>", + "#.nidq\\.meta\\>", + "epochprobemap.txt", + ] + assert fn.epochprobemap_fileparameters["filematch"] == ["epochprobemap.txt"] diff --git a/tests/test_vhprairieview.py b/tests/test_vhprairieview.py index a0fad1a3..d5ac6787 100644 --- a/tests/test_vhprairieview.py +++ b/tests/test_vhprairieview.py @@ -36,10 +36,13 @@ def vhlab_session(self): setup_lab(s, "vhlab") return s - def test_nine_daq_systems_including_vhprairieview(self, vhlab_session): + def test_ten_daq_systems_including_vhprairieview(self, vhlab_session): + # Nine until vhneuropixelsGLX_nidq (the SpikeGLX NI-DAQ stream) joined + # vhlab, as it did in NDI-matlab (VH-Lab/NDI-matlab#1127). summary = sessionSummary(vhlab_session) - assert len(summary["daqSystemNames"]) == 9 + assert len(summary["daqSystemNames"]) == 10 assert "vhprairieview" in summary["daqSystemNames"] + assert "vhneuropixelsGLX_nidq" in summary["daqSystemNames"] def test_vhprairieview_uses_the_image_classes(self, vhlab_session): """Not just present -- present with the right navigator and reader.""" From f47d665956da3ab6de587928754c50bb289103ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:57:24 +0000 Subject: [PATCH 2/2] Bridge: sync mfdaq_reader and setup to NDI-matlab dfba35572 CI pairs this branch with NDI-matlab's same-named branch (VH-Lab/NDI-matlab#1127), where both files have moved: mfdaq.m by the dimn fix (d37bf16c7; this port's dimn was already right) and +ndi/+setup/ by a comment in vhlab.m only. Notes in each decision_log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011v7R5uyvdiE4Ex6Q7UkurQ --- src/ndi/daq/ndi_matlab_python_bridge.yaml | 6 +++--- src/ndi/ndi_matlab_python_bridge.yaml | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ndi/daq/ndi_matlab_python_bridge.yaml b/src/ndi/daq/ndi_matlab_python_bridge.yaml index 7d8cf1c6..5a31042e 100644 --- a/src/ndi/daq/ndi_matlab_python_bridge.yaml +++ b/src/ndi/daq/ndi_matlab_python_bridge.yaml @@ -396,7 +396,7 @@ classes: - name: mfdaq_reader type: class matlab_path: "+ndi/+daq/+reader/mfdaq.m" - matlab_last_sync_hash: "5c5784c6a" + matlab_last_sync_hash: "dfba35572" decision_log: > NDI-matlab 770c1eb3 clamps readchannels_epochsamples_ingested's window to what the epoch has: a finite s0 below the first sample @@ -435,8 +435,8 @@ classes: Python-only bug instead: the non-ingested read timed each dep/den/ dimp/dimn "on" edge one sample early (np.where without the + 1 that MATLAB's 1 + find(...) and the "off" edges have). Fixed here. - matlab_last_sync_hash stays at 5c5784c6a until #1127 is on - NDI-matlab main; move it to the merge commit then. + Synced to dfba35572, the head of #1127's branch; the commits after + 5c5784c6a touching this file are d37bf16c7 alone. python_path: "ndi/daq/mfdaq.py" python_class: "ndi_daq_reader_mfdaq" inherits: "ndi.daq.reader" diff --git a/src/ndi/ndi_matlab_python_bridge.yaml b/src/ndi/ndi_matlab_python_bridge.yaml index 20001b3d..3fea3308 100644 --- a/src/ndi/ndi_matlab_python_bridge.yaml +++ b/src/ndi/ndi_matlab_python_bridge.yaml @@ -1320,7 +1320,7 @@ not_applicable: # ========================================================================= - name: setup matlab_path: "+ndi/+setup/" - matlab_last_sync_hash: "fd7ddbf06" + matlab_last_sync_hash: "dfba35572" python_path: "ndi/setup/" status: ported decision_log: > @@ -1362,6 +1362,13 @@ not_applicable: imports legacy lab data), so nothing here follows; lab, labNames and the per-lab wrappers are unchanged. + Re-examined against NDI-matlab dfba35572 (VH-Lab/NDI-matlab#1127). Its + only net change under +ndi/+setup/ is a comment in vhlab.m describing + the new vhneuropixelsGLX_nidq DAQ system (0b3c7d3dd also added it to + +daq/+system/export_vhlab_daq_systems.m, and dfba35572 took that back + out). No code moved, so nothing follows here; the DAQ system itself is + ndi_common/daq_systems/vhlab/vhneuropixelsGLX_nidq.json, copied as is. + # ========================================================================= # ndi.docs namespace — MATLAB documentation generation # =========================================================================