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/.gitignore b/.gitignore index 5b1770c..fe5c451 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ venv/ .pytest_cache/ test_logs/ +*.egg-info/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0632272 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[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", + "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*"] 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