Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/ndr/format/intan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@
)
from ndr.format.intan.read_Intan_RHD2000_directory import read_Intan_RHD2000_directory
from ndr.format.intan.read_Intan_RHD2000_header import read_Intan_RHD2000_header
from ndr.format.intan.read_IntanRHD2000_one_file_per_channel_type import (
read_IntanRHD2000_one_file_per_channel_type,
)
38 changes: 37 additions & 1 deletion src/ndr/format/intan/ndr_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,16 @@ functions:
matlab_path: "+ndr/+format/+intan/read_Intan_RHD2000_directory.m"
status: regular_port
python_path: "ndr/format/intan/read_Intan_RHD2000_directory.py"
matlab_last_sync_hash: "cae4b00"
matlab_last_sync_hash: "b461bf6"
decision_log: >
Adds "one file per signal type" support alongside the existing
"one file per channel" path (NDR-matlab #138). Layout is detected
from a *amplifier.dat probe; on hit the reader dispatches to
read_IntanRHD2000_one_file_per_channel_type and, for digital
channels, extracts each requested channel's bit from the packed
16-bit word using its native_order. info.rhd and time.dat are
resolved through a local fixdatfilename helper so a lab or Intan
timestamp prefix (e.g., "febc0_u000_000_") still matches.
input_arguments:
- name: directoryname
type_python: "str | Path"
Expand All @@ -116,6 +125,33 @@ functions:
- name: total_time
type_python: "float"

- name: read_IntanRHD2000_one_file_per_channel_type
matlab_path: "+ndr/+format/+intan/read_IntanRHD2000_one_file_per_channel_type.m"
status: regular_port
python_path: "ndr/format/intan/read_IntanRHD2000_one_file_per_channel_type.py"
matlab_last_sync_hash: "b461bf6"
decision_log: >
New helper introduced in NDR-matlab #138 that reads one interleaved
signal-type file at a time from the Intan "one file per signal type"
layout, resolving optional filename prefixes and returning the raw
packed 16-bit word for digital channels (the caller extracts the bit).
input_arguments:
- name: directory_name
type_python: "str | Path"
- name: channel_type
type_python: "int"
- name: channel_index
type_python: "int"
- name: num_channels
type_python: "int"
- name: s0
type_python: "int"
- name: s1
type_python: "int"
output_arguments:
- name: data
type_python: "numpy.ndarray"

- name: cat_Intan_RHD2000_files
matlab_path: "+ndr/+format/+intan/cat_Intan_RHD2000_files.m"
status: regular_port
Expand Down
131 changes: 131 additions & 0 deletions src/ndr/format/intan/read_IntanRHD2000_one_file_per_channel_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Read Intan "one file per signal type" data.

Port of +ndr/+format/+intan/read_IntanRHD2000_one_file_per_channel_type.m
"""

from __future__ import annotations

from pathlib import Path

import numpy as np

_FILENAME_BY_CHANNEL_TYPE = {
1: "time.dat",
2: "amplifier.dat",
3: "auxiliary.dat",
4: "supply.dat",
# 5: temperature -- Intan does not write a standalone file for this in
# the "one file per signal type" format; temperature is interleaved
# with the supply-voltage stream. Kept out of the map on purpose so a
# caller that requests it hits the ValueError below.
6: "analogin.dat",
7: "digitalin.dat",
8: "digitalout.dat",
}

_SAMPLE_SIZE_BYTES = {1: 4, 2: 2, 3: 2, 4: 2, 6: 2, 7: 2, 8: 2}
_SAMPLE_DTYPE = {
1: np.int32,
2: np.int16,
3: np.uint16,
4: np.uint16,
6: np.uint16,
7: np.uint16,
8: np.uint16,
}


def read_IntanRHD2000_one_file_per_channel_type(
directory_name: str | Path,
channel_type: int,
channel_index: int,
num_channels: int,
s0: int,
s1: int,
) -> np.ndarray:
"""Read one channel's samples from the Intan "one file per signal type" layout.

Parameters
----------
directory_name : str or Path
Directory containing the ``.dat`` files. Files may carry an Intan
timestamp prefix or a lab-specific prefix
(e.g. ``febc0_u000_000_amplifier.dat``); the reader resolves the
prefix automatically.
channel_type : int
Signal type: ``1`` time.dat, ``2`` amplifier.dat, ``3`` auxiliary.dat,
``4`` supply.dat, ``6`` analogin.dat, ``7`` digitalin.dat,
``8`` digitalout.dat. Temperature (5) is not saved as a standalone
file in this layout.
channel_index : int
1-based position of the channel within the enabled channels of this
type. Ignored for digital channels (the caller extracts the bit).
num_channels : int
Total number of enabled channels of this type.
s0, s1 : int
1-based inclusive sample range.

Returns
-------
numpy.ndarray
For analog channels, a 1-D array of the requested samples in the
file's native dtype. For digital channels, the full packed 16-bit
word per sample; the caller extracts the requested bit.

Filenames match the Intan-RHX / IntanToNWB / python-neo spec.

See also: ``read_Intan_RHD2000_directory``.
"""
directory_name = Path(directory_name)
if not directory_name.is_dir():
raise NotADirectoryError(f"Directory does not exist: {directory_name}")
if channel_type not in _FILENAME_BY_CHANNEL_TYPE:
raise ValueError(f"Unknown channel_type {channel_type}")
if channel_index < 1 or num_channels < 1:
raise ValueError("channel_index and num_channels must be positive integers")
if s0 < 1 or s1 < s0:
raise ValueError("s0 must be >= 1 and s1 must be >= s0")

filename_post = _FILENAME_BY_CHANNEL_TYPE[channel_type]
fn = _fixdatfilename(directory_name / filename_post)
if fn is None:
raise FileNotFoundError(
f"Could not find data file matching *{filename_post} in {directory_name}."
)

sample_size = _SAMPLE_SIZE_BYTES[channel_type]
dtype = np.dtype(_SAMPLE_DTYPE[channel_type]).newbyteorder("<")
count = s1 - s0 + 1

with open(fn, "rb") as fid:
if channel_type < 7:
# Interleaved: seek past (s0-1) full sample groups, then past
# (channel_index-1) samples within the current group, and stride
# over the other channels on each subsequent read.
fid.seek(sample_size * (num_channels * (s0 - 1) + (channel_index - 1)))
if num_channels == 1:
return np.fromfile(fid, dtype=dtype, count=count)
# Read count samples with a stride of num_channels. numpy has no
# strided fread, so read the whole run and slice.
span = num_channels * (count - 1) + 1
raw = np.fromfile(fid, dtype=dtype, count=span)
return raw[::num_channels]
# Digital: one packed 16-bit word per sample; caller extracts bit.
fid.seek(sample_size * (s0 - 1))
return np.fromfile(fid, dtype=dtype, count=count)


def _fixdatfilename(filename: Path) -> Path | None:
"""Resolve a filename that may carry an Intan timestamp or lab prefix.

Returns the exact path if it exists, otherwise the first sibling matching
``*<basename>`` in the same directory, otherwise ``None``.
"""
if filename.is_file():
return filename
parent = filename.parent
stem_ext = filename.name
matches = sorted(parent.glob(f"*{stem_ext}"))
if matches:
return matches[0]
return None
91 changes: 73 additions & 18 deletions src/ndr/format/intan/read_Intan_RHD2000_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@
import numpy as np

from ndr.format.intan.read_Intan_RHD2000_header import read_Intan_RHD2000_header
from ndr.format.intan.read_IntanRHD2000_one_file_per_channel_type import (
read_IntanRHD2000_one_file_per_channel_type,
)


def _fixdatfilename(path: Path) -> Path | None:
"""Resolve a filename that may carry an Intan timestamp or lab prefix.

Returns the exact path if it exists, otherwise the first sibling matching
``*<basename>`` in the same directory, otherwise ``None``. Mirrors the
``fixdatfilename`` helper in the MATLAB reader so recordings named
``<prefix>_amplifier.dat`` (Intan timestamp or lab prefix) still resolve.
"""
if path.is_file():
return path
matches = sorted(path.parent.glob(f"*{path.name}"))
if matches:
return matches[0]
return None


def read_Intan_RHD2000_directory(
Expand Down Expand Up @@ -64,15 +83,22 @@ def read_Intan_RHD2000_directory(
directoryname = Path(directoryname)

if header is None:
header = read_Intan_RHD2000_header(directoryname / "info.rhd")
info_rhd = _fixdatfilename(directoryname / "info.rhd")
if info_rhd is None:
raise FileNotFoundError(
f"Could not find info.rhd (or a *info.rhd variant) in {directoryname}."
)
header = read_Intan_RHD2000_header(info_rhd)

if isinstance(channel_numbers, int):
channel_numbers = [channel_numbers]

# Determine total samples from time.dat file size
time_file = directoryname / "time.dat"
if not time_file.exists():
raise FileNotFoundError(f"No file {time_file}, required file.")
time_file = _fixdatfilename(directoryname / "time.dat")
if time_file is None:
raise FileNotFoundError(
f"No file {directoryname / 'time.dat'} (or a *time.dat variant), required file."
)

total_samples = time_file.stat().st_size // 4 # int32 = 4 bytes
sr = header["frequency_parameters"]["amplifier_sample_rate"]
Expand Down Expand Up @@ -160,6 +186,15 @@ def read_Intan_RHD2000_directory(
conversion_shift[6] = -32768
conversion_scale[6] = 0.0003125

# Detect the on-disk layout. "one file per signal type" writes one .dat
# per signal type (amplifier.dat, auxiliary.dat, supply.dat, analogin.dat,
# digitalin.dat, digitalout.dat) with all channels of that type
# interleaved sample-by-sample; "one file per channel" writes one .dat
# per channel (amp-A-000.dat, aux-A-AUX1.dat, ...). The presence of a
# *amplifier.dat file (possibly with an Intan timestamp or lab prefix)
# distinguishes them.
one_file_per_signal_type = _fixdatfilename(directoryname / "amplifier.dat") is not None

data = np.empty((num_samples, 0), dtype=np.float64)

if channel_type_int == 1:
Expand Down Expand Up @@ -189,21 +224,41 @@ def read_Intan_RHD2000_directory(
f"Channel {ch_num} not in range 1 ... {len(hinfo)} listed in header."
)
ch_info = hinfo[ch_num - 1] # convert 1-based to 0-based
fname = f"{prefix}{ch_info['custom_channel_name']}.dat"
fpath = directoryname / fname
with open(fpath, "rb") as fid:
fid.seek(sbytes * s0)
raw = np.fromfile(fid, dtype=dtype, count=num_samples)
raw_f = raw.astype(np.float64)
if shift != 0:
raw_f -= shift
if channel_type_int in (7, 8):
# Per-channel files store the 16-bit packed word with only the
# corresponding native_order bit potentially set; normalize
# to 0/1 rather than applying the analog conversion scale.
raw_f = (raw_f != 0).astype(np.float64)
if one_file_per_signal_type:
raw = read_IntanRHD2000_one_file_per_channel_type(
directoryname,
channel_type_int,
ch_num,
len(hinfo),
s0 + 1, # helper is 1-based like the MATLAB source
s1 + 1,
)
raw_f = raw.astype(np.float64)
if channel_type_int in (7, 8):
# "one file per signal type" digital files store the
# full 16-bit packed word each sample; extract this
# channel's bit using its native_order (0..15).
bit_pos = int(ch_info["native_order"])
raw_f = (
(np.asarray(raw, dtype=np.uint16) & np.uint16(1 << bit_pos)) != 0
).astype(np.float64)
else:
raw_f *= scale
fname = f"{prefix}{ch_info['custom_channel_name']}.dat"
fpath = directoryname / fname
with open(fpath, "rb") as fid:
fid.seek(sbytes * s0)
raw = np.fromfile(fid, dtype=dtype, count=num_samples)
raw_f = raw.astype(np.float64)
if channel_type_int in (7, 8):
# Per-channel files store the 16-bit packed word with only
# the corresponding native_order bit potentially set;
# normalize to 0/1 rather than applying the analog
# conversion scale.
raw_f = (raw_f != 0).astype(np.float64)
if shift != 0:
raw_f = raw_f - shift
if channel_type_int not in (7, 8):
raw_f = raw_f * scale
columns.append(raw_f)

data = np.column_stack(columns) if columns else np.empty((num_samples, 0))
Expand Down
7 changes: 5 additions & 2 deletions src/ndr/reader/intan_rhd.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,11 @@ def filenamefromepochfiles(self, filename_array: list[str]) -> tuple[str, str, b
fileMode = "multiFile"
else:
filename = rhd_files[0]
# Check if this is the one-file-per-channel format
if Path(filename).stem == "info":
# Directory mode when the .rhd is Intan's per-signal-type header
# ("info.rhd" or a "<prefix>_info.rhd" variant) AND a sibling
# *time.dat is present in the epoch file list.
stem = Path(filename).stem
if stem == "info" or stem.endswith("info"):
time_dat_files = [f for f in filename_array if f.endswith("time.dat")]
if time_dat_files:
isdirectory = True
Expand Down
7 changes: 6 additions & 1 deletion src/ndr/reader/ndr_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,12 @@ classes:
matlab_path: "+ndr/+reader/intan_rhd.m"
status: regular_port
python_path: "ndr/reader/intan_rhd.py"
matlab_last_sync_hash: "67addc7"
matlab_last_sync_hash: "b461bf6"
decision_log: >
filenamefromepochfiles now recognizes a "<prefix>_info.rhd" variant
as the directory-mode header (NDR-matlab #138) in addition to the
bare info.rhd, matching how KJNielsen-style Intan recordings put
their per-signal-type files on disk.
python_class: "ndr_reader_intan__rhd"
inherits: base
methods:
Expand Down
Loading
Loading