diff --git a/src/ndi/setup/daq/reader/mfdaq/stimulus/ndi_matlab_python_bridge.yaml b/src/ndi/setup/daq/reader/mfdaq/stimulus/ndi_matlab_python_bridge.yaml new file mode 100644 index 00000000..fb14fc47 --- /dev/null +++ b/src/ndi/setup/daq/reader/mfdaq/stimulus/ndi_matlab_python_bridge.yaml @@ -0,0 +1,156 @@ +# ndi_matlab_python_bridge.yaml — src/ndi/setup/daq/reader/mfdaq/stimulus/ +# The Primary Contract for the ndi.setup.daq.reader.mfdaq.stimulus namespace. +# +# Only vhlabvisspike2 is recorded here so far. The other readers in this +# package (nielsenvisintan, nielsenvisneuropixelsglx, rayolab_intanseries, +# vhaudreybpod) have never had bridge entries; they are left for their own +# change rather than recorded unexamined. + +project_metadata: + bridge_version: "1.1" + naming_policy: "Strict MATLAB Mirror" + indexing_policy: "Semantic Parity (1-based for user concepts, 0-based for internal data)" + +# ========================================================================= +# Classes +# ========================================================================= +classes: + + # ========================================================================= + # ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2 + # ========================================================================= + - name: vhlabvisspike2 + type: class + matlab_path: "+ndi/+setup/+daq/+reader/+mfdaq/+stimulus/vhlabvisspike2.m" + matlab_last_sync_hash: "fe64a9f53" + python_path: "ndi/setup/daq/reader/mfdaq/stimulus/vhlabvisspike2.py" + python_class: "ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2" + inherits: "ndi.daq.reader.mfdaq.cedspike2" + decision_log: > + Examined against NDI-matlab fe64a9f53 (the file's last-touching commit + on main) on 2026-11-02. UNTIL NOW THE PORT HAD NO STIMULUS DECODING: it + was a bare subclass of the CED Spike2 reader, so mk1-mk3 and e1-e3 -- + the channels the vhvis_spike2 DAQ system exists to provide -- were + looked up as CED channel numbers in spike2data.smr, which is not where + MATLAB reads them from. Now ported: epochclock (dev_local_time), + getchannelsepoch (mk1 stim on/off, mk2 stimid, mk3 setup/clear, e1 + frame, e2 vertical refresh, e3 pretime), samplerate (NaN) and + readevents_epochsamples_native, which decodes stimtimes.txt and + stims.mat from the epoch directory, applies + stimtimes2stimtimes_mapping.txt and mti2stimtimes_mapping.txt when + present (1-based indices to keep, NaN dropped), raises if the trigger + counts differ, raises if a trial's Spike2 stimulus id differs from + mod(mti.stimid, 256), and maps setup/off/clear into Spike2 time with + timeshift = stimtimes(i) - mti{i}.startStopTimes(2). + read_stimtimes_txt and getstimscript come from vhlib.StimDecode + (vhlab-library-python, already a dependency) and dropnan from vlt.data + (vhlab-toolbox-python), as MATLAB calls the MATLAB versions; none is + reimplemented here. vhlab-NewStim-python has neither and is not needed: + only MTI2's stimid and startStopTimes fields are read, never the + stimscript object. ndi.daq.metadatareader.NewStimStims is the companion + that reads stimulus PARAMETERS from the same stims.mat; it does not + touch MTI2 timing, so the two do not overlap. + THE STIMULUS-MISMATCH MESSAGE FOLLOWS VH-Lab/NDI-matlab#1114 (branch + claude/friendly-euler-eugjd9, commit 9fe55861a), not yet merged into + NDI-matlab main when this was written: it names the epoch directory + first ("Epoch t00006: stimulus number N does not line up ..."), gives + the path, and points at the two mapping files. MATLAB's identifier + ndi:setup:daq:reader:mfdaq:stimulus:vhlabvisspike2:StimulusMismatch is + carried as VHLabVisSpike2StimulusMismatchError.identifier (a + ValueError). matlab_branch is deliberately NOT set: the file is on main + (section 8 of docs/developer_notes/ndi_matlab_python_bridge.yaml), and + when #1114 merges the drift check will flag this entry; re-examine + then, and the only change expected is the message this already uses. + The count-mismatch error carries no MATLAB identifier and is a plain + ValueError with MATLAB's wording. + + properties: + - name: NDI_DAQREADER_CLASS + type_python: "str" + access: "class variable" + decision_log: "Value: 'ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2'. Exact match." + + methods: + - name: vhlabvisspike2 + kind: constructor + input_arguments: + - name: identifier + type_python: "str | None" + default: "None" + - name: session + type_python: "Any | None" + default: "None" + - name: document + type_python: "Any | None" + default: "None" + output_arguments: + - name: reader_obj + type_python: "ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2" + decision_log: "Inherited from cedspike2, as MATLAB's constructor only calls its parent." + + - name: epochclock + input_arguments: + - name: epochfiles + type_python: "list[str]" + output_arguments: + - name: ec + type_python: "list[ndi_time_clocktype]" + decision_log: "Exact match: a single dev_local_time." + + - name: getchannelsepoch + input_arguments: + - name: epochfiles + type_python: "list[str]" + output_arguments: + - name: channels + type_python: "list[ChannelInfo]" + decision_log: > + Exact match: mk1, mk2, mk3 (marker), e1, e2, e3 (event), no time + channel. Each ChannelInfo also carries its number, as the other + Python readers' do. + + - name: readevents_epochsamples_native + input_arguments: + - name: channeltype + type_python: "str | list[str]" + - name: channel + type_python: "int | list[int]" + - name: epochfiles + type_python: "list[str]" + - name: t0 + type_python: "float" + - name: t1 + type_python: "float" + output_arguments: + - name: timestamps + type_python: "np.ndarray | list[np.ndarray]" + - name: data + type_python: "np.ndarray | list[np.ndarray]" + decision_log: > + Semantic Parity: channel numbers are 1-based. Same decoding, checks, + mapping files, error wording and t0 <= t <= t1 window as MATLAB; one + channel returns bare arrays, several return lists. The decoding is + factored into the static helper decode_stimulus_records(dirname) + (Python-only) so it can be called and tested on its own. One MATLAB + quirk is not copied: MATLAB stores marker results at + timestamps{i} but appends event results at timestamps{end+1}, so a + request mixing the two can misplace entries; here results are + returned in request order. A requested 'md' channel returns + nothing, as in MATLAB; any other unknown type raises + ValueError("Unknown channel."). + + - name: samplerate + input_arguments: + - name: epochfiles + type_python: "list[str]" + - name: channeltype + type_python: "str | list[str]" + - name: channel + type_python: "int | list[int]" + output_arguments: + - name: sr + type_python: "np.ndarray" + decision_log: > + MATLAB returns a scalar NaN ("these are all events"); Python returns + NaN per requested channel as an array, the shape every Python + samplerate returns. diff --git a/src/ndi/setup/daq/reader/mfdaq/stimulus/vhlabvisspike2.py b/src/ndi/setup/daq/reader/mfdaq/stimulus/vhlabvisspike2.py index 6b1227f6..5033d9a5 100644 --- a/src/ndi/setup/daq/reader/mfdaq/stimulus/vhlabvisspike2.py +++ b/src/ndi/setup/daq/reader/mfdaq/stimulus/vhlabvisspike2.py @@ -1,42 +1,313 @@ """ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2 — VH Lab visual stimulus Spike2 reader. -Extends the CED Spike2 reader with support for VH Lab visual stimulus -files (stims.mat, stimtimes.txt, verticalblanking.txt). +Device object for the vhlab visual stimulus computer. -MATLAB equivalent: ``+ndi/+setup/+daq/+reader/+mfdaq/+stimulus/vhlabvisspike2.m`` +This device reads the ``stimtimes.txt``, ``verticalblanking.txt``, +``stims.mat``, and ``spike2data.smr`` files that are present in directories +where a VHLAB stimulus computer (running NewStim/RunExperiment) has produced +triggers that have been acquired on a CED Spike2 system running the VHLAB +Spike2 script. + +This device produces the following event channels in each epoch. They are +not read from the CED SMR file but instead are read from the .txt files that +are generated by the vhlab scripts. -The associated DAQ system configuration expects these epoch files: +============= ================================== +Channel name Signal description +============= ================================== +mk1 stimulus on/off +mk2 stimid +mk3 stimulus open/close (setup/clear) +e1 frame trigger +e2 vertical refresh trigger +e3 pretime trigger +============= ================================== -- ``reference.txt`` — epoch reference file -- ``stimtimes.txt`` — stimulus timing information -- ``verticalblanking.txt`` — vertical blanking timestamps -- ``stims.mat`` — NewStim stimulus script parameters -- ``spike2data.smr`` — CED Spike2 electrophysiology data +MATLAB equivalent: ``+ndi/+setup/+daq/+reader/+mfdaq/+stimulus/vhlabvisspike2.m`` -Stimulus metadata is read by the companion -:class:`~ndi.daq.metadatareader.newstim_stims.ndi_daq_metadatareader_NewStimStims`. +Python-specific Notes: + ``read_stimtimes_txt`` and ``getstimscript`` are VH Lab library functions; + they come from ``vhlib.StimDecode`` (vhlab-library-python, a dependency of + this package), and ``dropnan`` from ``vlt.data`` (vhlab-toolbox-python), + exactly as MATLAB calls the MATLAB versions. Stimulus parameters are read + by the companion + :class:`~ndi.daq.metadatareader.newstim_stims.ndi_daq_metadatareader_NewStimStims`. """ from __future__ import annotations import logging +import os +import numpy as np + +from ndi.daq.mfdaq import ChannelInfo, standardize_channel_type from ndi.daq.reader.mfdaq.cedspike2 import ndi_daq_reader_mfdaq_cedspike2 +from ndi.time import DEV_LOCAL_TIME logger = logging.getLogger(__name__) +STIMTIMES_MAPPING_FILE = "stimtimes2stimtimes_mapping.txt" +MTI_MAPPING_FILE = "mti2stimtimes_mapping.txt" + + +class VHLabVisSpike2StimulusMismatchError(ValueError): + """Spike2's stimulus id for a trial does not match the stimulus computer's. + + MATLAB counterpart: ``error('ndi:setup:daq:reader:mfdaq:stimulus:vhlabvisspike2:StimulusMismatch', ...)`` + raised by ``readevents_epochsamples_native``. The identifier is kept on + the class so a caller can match on it the way MATLAB code matches + ``ME.identifier``. + """ + + identifier = "ndi:setup:daq:reader:mfdaq:stimulus:vhlabvisspike2:StimulusMismatch" + + +def _load_mapping(filename: str) -> np.ndarray: + """MATLAB ``vlt.data.dropnan(load(filename, '-ascii'))`` as 0-based indices.""" + from vlt.data import dropnan + + mapping = np.loadtxt(filename, ndmin=1, dtype=float).ravel() + # MATLAB indices are 1-based; these index Python lists/arrays. + return dropnan(mapping).astype(int) - 1 + + +def _mti_list(mti) -> list: + """The MTI2 cell array as a list (the .mat loader squeezes a 1-entry cell).""" + if mti is None: + return [] + if isinstance(mti, np.ndarray): + return list(mti.ravel()) + if isinstance(mti, (list, tuple)): + return list(mti) + return [mti] + + +def _field(record, name): + """Read a struct field from a scipy mat_struct, a record, or a dict.""" + if isinstance(record, dict): + return record[name] + if isinstance(record, np.void) or ( + isinstance(record, np.ndarray) and record.dtype.names is not None + ): + return record[name] + return getattr(record, name) + class ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2(ndi_daq_reader_mfdaq_cedspike2): """VH Lab visual stimulus reader built on CED Spike2. - Inherits all channel reading and sample-rate logic from the CED Spike2 - reader. The stimulus-specific behaviour (extracting stim parameters, - timing alignment) is handled by the metadata reader - ``ndi.daq.metadatareader.NewStimStims`` configured alongside this - reader in the DAQ system. + The stimulus event channels (``mk1``-``mk3``, ``e1``-``e3``) are decoded + from ``stimtimes.txt`` and ``stims.mat`` in the epoch directory, aligned + to Spike2 time; everything else (``t0_t1``) is inherited from the CED + Spike2 reader. """ NDI_DAQREADER_CLASS = "ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2" + def epochclock(self, epochfiles: list[str]): + """Return the clock types for an epoch: a single ``dev_local_time``. + + MATLAB: ``ec = {ndi.time.clocktype('dev_local_time')}``. + """ + return [DEV_LOCAL_TIME] + + def getchannelsepoch(self, epochfiles: list[str]) -> list[ChannelInfo]: + """List the channels that are available on this device. + + ============ ========================== + Channel name Signal description + ============ ========================== + mk1 stimulus on/off + mk2 stimid + mk3 stimulus open/close + e1 frame trigger + e2 vertical refresh trigger + e3 pretime trigger + ============ ========================== + """ + return [ + ChannelInfo(name="mk1", type="marker", time_channel=None, number=1), + ChannelInfo(name="mk2", type="marker", time_channel=None, number=2), + ChannelInfo(name="mk3", type="marker", time_channel=None, number=3), + ChannelInfo(name="e1", type="event", time_channel=None, number=1), + ChannelInfo(name="e2", type="event", time_channel=None, number=2), + ChannelInfo(name="e3", type="event", time_channel=None, number=3), + ] + + def samplerate(self, epochfiles, channeltype, channel): + """Get the sample rate for specific channels. + + These are all events, so it doesn't much matter; MATLAB returns + ``NaN``. Here it is ``NaN`` for each requested channel, as an array, + the shape every Python ``samplerate`` returns. + """ + n = 1 if isinstance(channel, (int, np.integer)) else len(channel) + return np.full(n, np.nan) + + @staticmethod + def decode_stimulus_records( + dirname: str, + ) -> tuple[np.ndarray, np.ndarray, list[np.ndarray], np.ndarray, np.ndarray, np.ndarray]: + """Align the Spike2 and stimulus-computer records of one epoch directory. + + The decoding half of MATLAB's ``readevents_epochsamples_native``: + reads ``stimtimes.txt`` (``read_stimtimes_txt``) and ``stims.mat`` + (``getstimscript``), applies ``stimtimes2stimtimes_mapping.txt`` and + ``mti2stimtimes_mapping.txt`` when present (1-based indices to keep, + ``NaN`` entries dropped), checks that the two records have the same + number of triggers and agree on every stimulus id (mod 256), and maps + the stimulus computer's setup/off/clear times into Spike2 time. + + Returns: + ``(stimid, stimtimes, frametimes, stimofftimes, stimsetuptimes, + stimcleartimes)``. ``stimid`` holds the stimulus computer's full + ids (Spike2 only knows them mod 256). + + Raises: + ValueError: the trigger counts differ. + VHLabVisSpike2StimulusMismatchError: a trial's stimulus id differs. + """ + from vhlib.StimDecode import getstimscript, read_stimtimes_txt + + stimid, stimtimes, frametimes = read_stimtimes_txt(dirname) + stimid = np.asarray(stimid, dtype=float).ravel() + stimtimes = np.asarray(stimtimes, dtype=float).ravel() + frametimes = list(frametimes) + + mappingfile1 = os.path.join(dirname, STIMTIMES_MAPPING_FILE) + if os.path.isfile(mappingfile1): + mapping = _load_mapping(mappingfile1) + stimid = stimid[mapping] + stimtimes = stimtimes[mapping] + frametimes = [frametimes[k] for k in mapping] + + _ss, mti = getstimscript(dirname) + mti = _mti_list(mti) + mappingfile2 = os.path.join(dirname, MTI_MAPPING_FILE) + if os.path.isfile(mappingfile2): + mapping = _load_mapping(mappingfile2) + mti = [mti[k] for k in mapping] + + if len(mti) != len(stimtimes): + raise ValueError( + "Error: The number of stim triggers present in the stimtimes.txt file " + f"({len(stimtimes)}) differs from what is expected from the content of " + f"stims.mat file ({len(mti)}) in {dirname}." + ) + + n = len(mti) + stimofftimes = np.zeros(n) + stimsetuptimes = np.zeros(n) + stimcleartimes = np.zeros(n) + # the epoch directory's name is the vhlab epoch id (e.g. t00006) + epoch_name = os.path.basename(os.path.normpath(dirname)) + for i in range(n): + mti_stimid = float(np.asarray(_field(mti[i], "stimid")).ravel()[0]) + if stimid[i] != np.mod(mti_stimid, 256): + raise VHLabVisSpike2StimulusMismatchError( + f"Epoch {epoch_name}: stimulus number {i + 1} does not line up between " + "Spike2 and stimulus computer record: " + f"Spike2 says {stimid[i]:g} while stimulus computer says {mti_stimid:g} " + f"({dirname}). If the two records are offset, a " + f"{STIMTIMES_MAPPING_FILE} or {MTI_MAPPING_FILE} file in that directory " + "can pair them." + ) + # needed because now we only know stims on Spike2 up to mod 256 + stimid[i] = mti_stimid + sst = np.asarray(_field(mti[i], "startStopTimes"), dtype=float).ravel() + # spike2time = mactime + timeshift + timeshift = stimtimes[i] - sst[1] + stimofftimes[i] = sst[2] + timeshift + stimsetuptimes[i] = sst[0] + timeshift + stimcleartimes[i] = sst[3] + timeshift + + return stimid, stimtimes, frametimes, stimofftimes, stimsetuptimes, stimcleartimes + + def readevents_epochsamples_native(self, channeltype, channel, epochfiles, t0, t1): + """Read events or markers of specified channels for a specified epoch. + + ``channeltype`` is a list of channel types (``'event'``, ``'marker'``, + etc.); a single string applies to all channels. ``channel`` holds the + (1-based) channel numbers. ``epochfiles`` is the list of file names + associated with the epoch. + + Returns ``(timestamps, data)``: for markers, ``data`` holds the marker + code (``mk1``/``mk3``: +1 on, -1 off; ``mk2``: the stimulus id); for + events it is 1. If more than one channel is requested, each is a list + with one entry per channel. Only events with ``t0 <= t <= t1`` are + returned. + """ + if isinstance(channel, (int, np.integer)): + channel = [int(channel)] + if isinstance(channeltype, str): + channeltype = [channeltype] * len(channel) + + pathnames = [os.path.dirname(f) for f in epochfiles] + fnames = [os.path.splitext(os.path.basename(f))[0] for f in epochfiles] + + # do the decoding + ( + stimid, + stimtimes, + frametimes, + stimofftimes, + stimsetuptimes, + stimcleartimes, + ) = self.decode_stimulus_records(pathnames[0]) + + def _interleave(on: np.ndarray, off: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + # alternate on and off times in the final product + times = np.column_stack([on, off]).ravel() + codes = np.column_stack([np.ones(len(on)), -np.ones(len(off))]).ravel() + return times, codes + + timestamps: list[np.ndarray] = [] + data: list[np.ndarray] = [] + + for ct, ch in zip(channeltype, channel): + kind = standardize_channel_type(ct) + ch = int(ch) + if kind == "marker": + if ch == 1: + ts, d = _interleave(stimtimes, stimofftimes) + elif ch == 2: + ts, d = stimtimes.copy(), stimid.copy() + elif ch == 3: + ts, d = _interleave(stimsetuptimes, stimcleartimes) + else: + raise ValueError("Unknown channel.") + elif kind == "event": + if ch == 1: # frametimes + ts = ( + np.concatenate([np.asarray(f, dtype=float).ravel() for f in frametimes]) + if frametimes + else np.array([]) + ) + elif ch == 2: # vertical refresh + vbfile = epochfiles[fnames.index("verticalblanking")] + ts = np.loadtxt(vbfile, ndmin=1, dtype=float).ravel() + elif ch == 3: # background trigger, simulated + ts = stimsetuptimes.copy() + else: + continue + d = np.ones(len(ts)) + elif kind in ("metadata", "md"): + continue + else: + raise ValueError("Unknown channel.") + timestamps.append(ts) + data.append(d) + + for i in range(len(timestamps)): + keep = (timestamps[i] >= t0) & (timestamps[i] <= t1) + timestamps[i] = timestamps[i][keep] + data[i] = data[i][keep] + + # if only 1 channel entry to return, make it non-list + if len(data) == 1: + return timestamps[0], data[0] + return timestamps, data + def __repr__(self) -> str: return f"ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2(id={self.id[:8]}...)" diff --git a/tests/test_setup_vhlabvisspike2.py b/tests/test_setup_vhlabvisspike2.py new file mode 100644 index 00000000..312eabd5 --- /dev/null +++ b/tests/test_setup_vhlabvisspike2.py @@ -0,0 +1,178 @@ +"""Stimulus decoding of ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2. + +MATLAB counterpart: ``+ndi/+setup/+daq/+reader/+mfdaq/+stimulus/vhlabvisspike2.m`` +(``readevents_epochsamples_native``). Each test writes a small synthetic +epoch directory -- ``stimtimes.txt`` as the VH Lab Spike2 script writes it, +and a minimal ``stims.mat`` holding a ``saveScript`` and an ``MTI2`` cell +array -- and reads the stimulus channels back through the reader. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +scipy_io = pytest.importorskip("scipy.io") +pytest.importorskip("vhlib.StimDecode") + +from ndi.setup.daq.reader.mfdaq.stimulus.vhlabvisspike2 import ( # noqa: E402 + VHLabVisSpike2StimulusMismatchError, + ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2, +) + +# Stimulus-computer clock: trigger (startStopTimes[1]) at 100 + 10*k, with +# setup 0.5 s before, off 2 s after, clear 2.5 s after. +MAC_T0 = 100.0 +SPIKE2_T0 = 5.0 + + +def _mti_entry(k: int, stimid: int) -> dict: + trig = MAC_T0 + 10.0 * k + return { + "stimid": float(stimid), + "startStopTimes": np.array([trig - 0.5, trig, trig + 2.0, trig + 2.5]), + } + + +def _make_epoch( + tmp_path, + spike2_ids, + mti_ids, + name="t00006", + stimtimes_mapping=None, + mti_mapping=None, +): + """Write an epoch directory; Spike2 trigger k is at SPIKE2_T0 + 10*k.""" + d = tmp_path / name + d.mkdir() + lines = [] + for k, sid in enumerate(spike2_ids): + t = SPIKE2_T0 + 10.0 * k + frames = [t + 0.01, t + 0.02] + lines.append(" ".join(f"{v:.6f}" for v in [sid, t, *frames])) + (d / "stimtimes.txt").write_text("\n".join(lines) + "\n") + + mti = np.empty(len(mti_ids), dtype=object) + for k, sid in enumerate(mti_ids): + mti[k] = _mti_entry(k, sid) + scipy_io.savemat( + str(d / "stims.mat"), + {"saveScript": {"name": "synthetic"}, "MTI2": mti}, + ) + (d / "verticalblanking.txt").write_text("5.0\n5.5\n6.0\n") + (d / "reference.txt").write_text("") + + if stimtimes_mapping is not None: + (d / "stimtimes2stimtimes_mapping.txt").write_text( + "\n".join(str(v) for v in stimtimes_mapping) + "\n" + ) + if mti_mapping is not None: + (d / "mti2stimtimes_mapping.txt").write_text("\n".join(str(v) for v in mti_mapping) + "\n") + + epochfiles = [ + str(d / f) + for f in ( + "reference.txt", + "stimtimes.txt", + "verticalblanking.txt", + "stims.mat", + "spike2data.smr", + ) + ] + return d, epochfiles + + +@pytest.fixture +def reader(): + return ndi_setup_daq_reader_mfdaq_stimulus_vhlabvisspike2() + + +def test_channels(reader): + names = [c.name for c in reader.getchannelsepoch([])] + assert names == ["mk1", "mk2", "mk3", "e1", "e2", "e3"] + assert np.isnan(reader.samplerate([], "mk", [1, 2])).all() + + +def test_records_agree(reader, tmp_path): + # 300 is sent to Spike2 as 300 mod 256 = 44; the full id comes back. + _, ef = _make_epoch(tmp_path, [1, 2, 44], [1, 2, 300]) + + ts, d = reader.readevents_epochsamples("mk", [2], ef, -np.inf, np.inf) + np.testing.assert_allclose(ts, [5.0, 15.0, 25.0]) + np.testing.assert_array_equal(d, [1, 2, 300]) + + # on/off alternate; off = trigger + 2 in Spike2 time. + ts, d = reader.readevents_epochsamples("mk", [1], ef, -np.inf, np.inf) + np.testing.assert_allclose(ts, [5.0, 7.0, 15.0, 17.0, 25.0, 27.0]) + np.testing.assert_array_equal(d, [1, -1, 1, -1, 1, -1]) + + # setup/clear = trigger - 0.5 / + 2.5. + ts, d = reader.readevents_epochsamples("mk", [3], ef, -np.inf, np.inf) + np.testing.assert_allclose(ts, [4.5, 7.5, 14.5, 17.5, 24.5, 27.5]) + np.testing.assert_array_equal(d, [1, -1, 1, -1, 1, -1]) + + # events: frames, vertical blanking, simulated pretime trigger. + tss, ds = reader.readevents_epochsamples(["e", "e", "e"], [1, 2, 3], ef, -np.inf, np.inf) + np.testing.assert_allclose(tss[0], [5.01, 5.02, 15.01, 15.02, 25.01, 25.02]) + np.testing.assert_allclose(tss[1], [5.0, 5.5, 6.0]) + np.testing.assert_allclose(tss[2], [4.5, 14.5, 24.5]) + assert all((x == 1).all() for x in ds) + + +def test_time_window(reader, tmp_path): + _, ef = _make_epoch(tmp_path, [1, 2, 3], [1, 2, 3]) + ts, d = reader.readevents_epochsamples("mk", [2], ef, 10.0, 25.0) + np.testing.assert_allclose(ts, [15.0, 25.0]) + np.testing.assert_array_equal(d, [2, 3]) + + +def test_count_mismatch(reader, tmp_path): + d, ef = _make_epoch(tmp_path, [1, 2], [1, 2, 3]) + with pytest.raises(ValueError, match=r"stimtimes.txt file \(2\).*stims.mat file \(3\)"): + reader.readevents_epochsamples("mk", [1], ef, -np.inf, np.inf) + + +def test_stimulus_id_mismatch_names_epoch(reader, tmp_path): + d, ef = _make_epoch(tmp_path, [1, 5, 3], [1, 2, 3], name="t00006") + with pytest.raises(VHLabVisSpike2StimulusMismatchError) as exc_info: + reader.readevents_epochsamples("mk", [1], ef, -np.inf, np.inf) + msg = str(exc_info.value) + assert msg.startswith("Epoch t00006: stimulus number 2 does not line up") + assert "Spike2 says 5 while stimulus computer says 2" in msg + assert str(d) in msg + assert "stimtimes2stimtimes_mapping.txt" in msg and "mti2stimtimes_mapping.txt" in msg + assert exc_info.value.identifier == ( + "ndi:setup:daq:reader:mfdaq:stimulus:vhlabvisspike2:StimulusMismatch" + ) + assert isinstance(exc_info.value, ValueError) + + +def test_mapping_fixes_stopped_early_run(reader, tmp_path): + # The run was stopped early: Spike2 saw 3 triggers, stims.mat lists 5; + # the first 3 agree. Without a mapping the counts differ. + (tmp_path / "a").mkdir() + _, ef = _make_epoch(tmp_path / "a", [7, 8, 9], [7, 8, 9, 10, 11]) + with pytest.raises(ValueError, match="differs"): + reader.readevents_epochsamples("mk", [2], ef, -np.inf, np.inf) + + (tmp_path / "b").mkdir() + _, ef = _make_epoch(tmp_path / "b", [7, 8, 9], [7, 8, 9, 10, 11], mti_mapping=[1, 2, 3]) + ts, d = reader.readevents_epochsamples("mk", [2], ef, -np.inf, np.inf) + np.testing.assert_allclose(ts, [5.0, 15.0, 25.0]) + np.testing.assert_array_equal(d, [7, 8, 9]) + + +def test_stimtimes_mapping_drops_nan(reader, tmp_path): + # A spurious second Spike2 trigger is dropped by the stimtimes mapping; + # NaN entries in a mapping file are ignored, as vlt.data.dropnan does. + _, ef = _make_epoch( + tmp_path, + [1, 99, 2], + [1, 2], + stimtimes_mapping=[1, "NaN", 3], + ) + ts, d = reader.readevents_epochsamples("mk", [2], ef, -np.inf, np.inf) + np.testing.assert_allclose(ts, [5.0, 25.0]) + np.testing.assert_array_equal(d, [1, 2]) + tf, _ = reader.readevents_epochsamples("e", [1], ef, -np.inf, np.inf) + np.testing.assert_allclose(tf, [5.01, 5.02, 25.01, 25.02])