From 2b25252c7e25875dd292332848776a06a5394884 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:29:40 +0000 Subject: [PATCH 1/3] Add pyproject.toml and port Intan RHD2000 header reader Make vhlab-toolbox-python pip-installable by adding pyproject.toml with setuptools build backend and proper dependencies (numpy, pandas, scipy, h5py). Port read_Intan_RHD2000_header from MATLAB to vlt/hardware/intan.py, supporting both v1.x (60 samples/block) and v2.x (128 samples/block) RHD files. The function reads binary headers including magic number verification, sample rate, frequency parameters, channel definitions, and computes num_samples from file size. Add comprehensive tests with synthetic .rhd file generation covering header reading, t0/t1 computation, channel info, frequency parameters, invalid files, version 2 format, and header-only files. https://claude.ai/code/session_01AQ3fUwbRvMsTS2pyJpQXRk --- pyproject.toml | 21 +++ tests/vlt/hardware/__init__.py | 0 tests/vlt/hardware/test_intan.py | 218 +++++++++++++++++++++++++++ vlt/hardware/__init__.py | 0 vlt/hardware/intan.py | 250 +++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 pyproject.toml create mode 100644 tests/vlt/hardware/__init__.py create mode 100644 tests/vlt/hardware/test_intan.py create mode 100644 vlt/hardware/__init__.py create mode 100644 vlt/hardware/intan.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bdbd02e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "vhlab-toolbox-python" +version = "0.1.0" +description = "Python port of vhlab-toolbox-matlab" +requires-python = ">=3.9" +dependencies = [ + "numpy", + "pandas", + "scipy", + "h5py", +] + +[project.urls] +Repository = "https://github.com/VH-Lab/vhlab-toolbox-python" + +[tool.setuptools.packages.find] +include = ["vlt*"] diff --git a/tests/vlt/hardware/__init__.py b/tests/vlt/hardware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/vlt/hardware/test_intan.py b/tests/vlt/hardware/test_intan.py new file mode 100644 index 0000000..d9873e8 --- /dev/null +++ b/tests/vlt/hardware/test_intan.py @@ -0,0 +1,218 @@ +"""Tests for vlt.hardware.intan module.""" + +import os +import struct +import tempfile + +import pytest + +from vlt.hardware.intan import read_Intan_RHD2000_header + + +def _write_qstring(fid, s): + """Write a Qt-style QString to a binary file.""" + if not s: + fid.write(struct.pack('= 1.1) + num_temp_channels = 1 + fid.write(struct.pack('= 1.3) + fid.write(struct.pack('= 2.0) + if version_major > 1: + _write_qstring(fid, '') + + # Signal groups: put all channels in one group + total_channels = (num_amp_channels + num_aux_channels + + num_supply_channels + num_adc_channels + + num_dig_in_channels + num_dig_out_channels) + fid.write(struct.pack(' 0: + bytes_per_block += num_samples_per_block * 2 # digital in + if num_dig_out_channels > 0: + bytes_per_block += num_samples_per_block * 2 # digital out + + # Write dummy data blocks + fid.write(b'\x00' * (bytes_per_block * num_data_blocks)) + + return header_size + + +class TestReadIntanRHD2000Header: + """Tests for read_Intan_RHD2000_header.""" + + def test_basic_header_reading(self, tmp_path): + """Test reading a synthetic RHD file with known parameters.""" + filepath = str(tmp_path / 'test.rhd') + create_synthetic_rhd(filepath, sample_rate=20000.0, + num_amp_channels=2, num_data_blocks=1000) + + header = read_Intan_RHD2000_header(filepath) + + assert header['sample_rate'] == 20000.0 + assert header['num_amplifier_channels'] == 2 + assert header['num_aux_input_channels'] == 3 + assert header['num_supply_voltage_channels'] == 1 + # 1000 blocks * 60 samples/block = 60000 samples + assert header['num_samples'] == 60000 + + def test_sample_rate_and_num_samples(self, tmp_path): + """Test that sample_rate and num_samples are correct for t0_t1 computation. + + With 1000 data blocks at 60 samples/block = 60000 samples, + t0 = 0, t1 = (60000 - 1) / 20000 = 2.99995 + """ + filepath = str(tmp_path / 'test.rhd') + create_synthetic_rhd(filepath, sample_rate=20000.0, num_data_blocks=1000) + + header = read_Intan_RHD2000_header(filepath) + + t0 = 0 + t1 = (header['num_samples'] - 1) / header['sample_rate'] + assert t0 == 0 + assert abs(t1 - 2.99995) < 1e-10 + + def test_channel_info_populated(self, tmp_path): + """Test that channel info dicts contain expected fields.""" + filepath = str(tmp_path / 'test.rhd') + create_synthetic_rhd(filepath, num_amp_channels=4) + + header = read_Intan_RHD2000_header(filepath) + + assert len(header['amplifier_channels']) == 4 + ch = header['amplifier_channels'][0] + assert 'native_channel_name' in ch + assert 'custom_channel_name' in ch + assert 'chip_channel' in ch + assert ch['signal_type'] == 0 + + def test_frequency_parameters(self, tmp_path): + """Test that frequency parameters are read correctly.""" + filepath = str(tmp_path / 'test.rhd') + create_synthetic_rhd(filepath, sample_rate=20000.0) + + header = read_Intan_RHD2000_header(filepath) + + freq = header['frequency_parameters'] + assert freq['amplifier_sample_rate'] == 20000.0 + assert freq['aux_input_sample_rate'] == 5000.0 + assert freq['notch_filter_frequency'] == 60 + assert freq['dsp_enabled'] == 1 + + def test_invalid_magic_number(self, tmp_path): + """Test that an invalid magic number raises ValueError.""" + filepath = str(tmp_path / 'bad.rhd') + with open(filepath, 'wb') as f: + f.write(struct.pack(' dict: + """Read the header of an Intan RHD2000 .rhd file. + + Port of vlt.hardware.intan.read_Intan_RHD2000_header from MATLAB. + + Args: + filename: Path to .rhd file + + Returns: + Dict with keys including: + - sample_rate: float + - num_amplifier_channels: int + - num_aux_input_channels: int + - num_supply_voltage_channels: int + - num_board_adc_channels: int + - num_board_dig_in_channels: int + - num_board_dig_out_channels: int + - num_samples: int (computed from file size) + - amplifier_channels: list of channel info dicts + - frequency_parameters: dict + """ + filesize = os.path.getsize(filename) + + with open(filename, 'rb') as fid: + # 1. Magic number + magic_number, = struct.unpack(' 1: + num_samples_per_data_block = 128 + + freq['amplifier_sample_rate'] = sample_rate + freq['aux_input_sample_rate'] = sample_rate / 4 + freq['supply_voltage_sample_rate'] = sample_rate / num_samples_per_data_block + freq['board_adc_sample_rate'] = sample_rate + freq['board_dig_in_sample_rate'] = sample_rate + + # 5. Notes (3 QStrings) + notes = { + 'note1': _read_qstring(fid), + 'note2': _read_qstring(fid), + 'note3': _read_qstring(fid), + } + + # 6. Num temp sensor channels (version >= 1.1) + num_temp_sensor_channels = 0 + if version['major'] > 1 or (version['major'] == 1 and version['minor'] >= 1): + num_temp_sensor_channels, = struct.unpack('= 1.3) + eval_board_mode = 0 + if version['major'] > 1 or (version['major'] == 1 and version['minor'] >= 3): + eval_board_mode, = struct.unpack('= 2.0) + reference_channel = '' + if version['major'] > 1: + reference_channel = _read_qstring(fid) + + # 9. Signal groups and channels + number_of_signal_groups, = struct.unpack(' 0: + bytes_per_block += num_samples_per_data_block * 2 + # Board digital out: num_samples_per_data_block * 1 * 2 + if num_board_dig_out_channels > 0: + bytes_per_block += num_samples_per_data_block * 2 + + bytes_remaining = filesize - header_size + if bytes_per_block > 0: + num_blocks = bytes_remaining // bytes_per_block + else: + num_blocks = 0 + + num_samples = num_samples_per_data_block * num_blocks + + header = { + 'version': version, + 'sample_rate': float(sample_rate), + 'num_samples_per_data_block': num_samples_per_data_block, + 'frequency_parameters': freq, + 'notes': notes, + 'num_temp_sensor_channels': num_temp_sensor_channels, + 'eval_board_mode': eval_board_mode, + 'reference_channel': reference_channel, + 'amplifier_channels': amplifier_channels, + 'aux_input_channels': aux_input_channels, + 'supply_voltage_channels': supply_voltage_channels, + 'board_adc_channels': board_adc_channels, + 'board_dig_in_channels': board_dig_in_channels, + 'board_dig_out_channels': board_dig_out_channels, + 'num_amplifier_channels': num_amplifier_channels, + 'num_aux_input_channels': num_aux_input_channels, + 'num_supply_voltage_channels': num_supply_voltage_channels, + 'num_board_adc_channels': num_board_adc_channels, + 'num_board_dig_in_channels': num_board_dig_in_channels, + 'num_board_dig_out_channels': num_board_dig_out_channels, + 'num_samples': num_samples, + 'header_size': header_size, + } + + return header From c5add72931853ff29325a393f1659961ad91fd2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:30:51 +0000 Subject: [PATCH 2/3] Add *.egg-info/ to .gitignore Ignore setuptools egg-info build artifacts generated by pip install -e. https://claude.ai/code/session_01AQ3fUwbRvMsTS2pyJpQXRk --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5b1770c..fe5c451 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ venv/ .pytest_cache/ test_logs/ +*.egg-info/ From 05958197047141155a88cd1b9bde52481499f93d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 23:49:19 +0000 Subject: [PATCH 3/3] Add CI workflow with ruff lint and pytest across Python 3.10-3.12 Add GitHub Actions CI workflow with: - Lint job using ruff with rules configured to pass on existing codebase - Test job with matrix strategy for Python 3.10, 3.11, and 3.12 Also add matplotlib to dependencies, pytest as optional test dependency, and configure pytest importlib mode to resolve test name collisions. https://claude.ai/code/session_01AQ3fUwbRvMsTS2pyJpQXRk --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 21 +++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7e6c94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check vlt/ tests/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[test]" + - run: python -m pytest tests/ -v diff --git a/pyproject.toml b/pyproject.toml index bdbd02e..0632272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,31 @@ dependencies = [ "pandas", "scipy", "h5py", + "matplotlib", ] +[project.optional-dependencies] +test = ["pytest"] + [project.urls] Repository = "https://github.com/VH-Lab/vhlab-toolbox-python" +[tool.pytest.ini_options] +addopts = "--import-mode=importlib" + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = [ + "E501", # line too long — existing code + "E701", # multiple statements on one line — existing style + "E741", # ambiguous variable name — existing style + "F401", # unused imports — many in existing code + "F841", # local variable assigned but never used — existing code + "E722", # bare except — existing code + "E402", # module-level import not at top — existing code + "W293", # blank line contains whitespace — existing code + "W605", # invalid escape sequence — existing code +] + [tool.setuptools.packages.find] include = ["vlt*"]