From 38c49ab337ebdf80978fea47e60475077c1c8e21 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:14:24 +0000 Subject: [PATCH 1/4] Implement pyraview.readFile in MATLAB and python Added functions to read level files directly with start/stop sample indices. - MATLAB: `pyraview.readFile(filename, s0, s1)` - Python: `pyraview.read_file(filename, s0, s1)` Verified that the Dataset type considers samples as min/max pairs (planar layout). Added tests for both implementations. --- src/matlab/+pyraview/readFile.m | 114 +++++++++++++++++++++++ src/matlab/test_readfile.m | 107 +++++++++++++++++++++ src/python/pyraview/__init__.py | 89 ++++++++++++++++++ src/python/tests/test_readfile.py | 148 ++++++++++++++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 src/matlab/+pyraview/readFile.m create mode 100644 src/matlab/test_readfile.m create mode 100644 src/python/tests/test_readfile.py diff --git a/src/matlab/+pyraview/readFile.m b/src/matlab/+pyraview/readFile.m new file mode 100644 index 0000000..a8e752f --- /dev/null +++ b/src/matlab/+pyraview/readFile.m @@ -0,0 +1,114 @@ +function d = readFile(filename, s0, s1) +%READFILE Reads a chunk of data from a Pyraview level file. +% d = pyraview.readFile(filename, s0, s1) reads data from sample index s0 to s1. +% s0 and s1 are 0-based sample indices. +% s0 can be -Inf to indicate the start of the file. +% s1 can be Inf to indicate the end of the file. +% +% Returns a 3-D matrix d of size (samples x channels x 2). +% d(:, :, 1) contains the minimum values. +% d(:, :, 2) contains the maximum values. + + if ~isfile(filename) + error('Pyraview:FileNotFound', 'File not found: %s', filename); + end + + % Read header + try + h = pyraview.pyraview_get_header_mex(filename); + catch e + error('Pyraview:HeaderError', 'Failed to read header: %s', e.message); + end + + % Determine data type and item size + switch h.dataType + case 0, precision = 'int8'; itemSize = 1; + case 1, precision = 'uint8'; itemSize = 1; + case 2, precision = 'int16'; itemSize = 2; + case 3, precision = 'uint16'; itemSize = 2; + case 4, precision = 'int32'; itemSize = 4; + case 5, precision = 'uint32'; itemSize = 4; + case 6, precision = 'int64'; itemSize = 8; + case 7, precision = 'uint64'; itemSize = 8; + case 8, precision = 'single'; itemSize = 4; + case 9, precision = 'double'; itemSize = 8; + otherwise + error('Pyraview:UnknownType', 'Unknown data type code: %d', h.dataType); + end + + % Determine total samples + dDir = dir(filename); + fileSize = dDir.bytes; + headerSize = 1024; + dataArea = fileSize - headerSize; + + numChannels = double(h.channelCount); + frameSize = numChannels * 2 * itemSize; + totalSamples = floor(dataArea / frameSize); + + % Handle s0 and s1 + if isinf(s0) && s0 < 0 + startSample = 0; + else + startSample = s0; + end + + if isinf(s1) && s1 > 0 + endSample = totalSamples - 1; + else + endSample = s1; + end + + % Validate indices + if startSample < 0 + startSample = 0; + end + + if endSample >= totalSamples + endSample = totalSamples - 1; + end + + if startSample > endSample + d = zeros(0, numChannels, 2, precision); + return; + end + + numSamplesToRead = endSample - startSample + 1; + + % Allocate output + d = zeros(numSamplesToRead, numChannels, 2, precision); + + % Open file + f = fopen(filename, 'rb'); + if f == -1 + error('Pyraview:FileOpenError', 'Could not open file: %s', filename); + end + + cleanupObj = onCleanup(@() fclose(f)); + + for ch = 1:numChannels + % Calculate offset + % Planar layout: [Header] [Ch1 Data] [Ch2 Data] ... + % Ch Data size = totalSamples * 2 * itemSize + + chStartOffset = headerSize + (ch-1) * totalSamples * 2 * itemSize; + readOffset = chStartOffset + startSample * 2 * itemSize; + + fseek(f, readOffset, 'bof'); + + % Read min/max pairs + % precision needs to be char for fread + raw = fread(f, numSamplesToRead * 2, ['*' char(precision)]); + + if length(raw) < numSamplesToRead * 2 + warning('Pyraview:ShortRead', 'Short read on channel %d. Expected %d, got %d.', ch, numSamplesToRead*2, length(raw)); + % Fill what we got + nRead = floor(length(raw)/2); + d(1:nRead, ch, 1) = raw(1:2:2*nRead); + d(1:nRead, ch, 2) = raw(2:2:2*nRead); + else + d(:, ch, 1) = raw(1:2:end); + d(:, ch, 2) = raw(2:2:end); + end + end +end diff --git a/src/matlab/test_readfile.m b/src/matlab/test_readfile.m new file mode 100644 index 0000000..c3a081f --- /dev/null +++ b/src/matlab/test_readfile.m @@ -0,0 +1,107 @@ +function tests = test_readfile + tests = functiontests(localfunctions); +end + +function setupOnce(testCase) + % Verify MEX file exists + [~, mexName] = fileparts('pyraview'); + mexExt = mexext; + fullMexPath = fullfile(pwd, 'src', 'matlab', ['pyraview.' mexExt]); + + if ~exist(fullMexPath, 'file') + % Try relative to where this file is? + currentFileDir = fileparts(mfilename('fullpath')); + fullMexPath = fullfile(currentFileDir, ['pyraview.' mexExt]); + if ~exist(fullMexPath, 'file') + % If MEX is missing, we might be in an environment where we can't run this. + % But we proceed assuming user has built it. + warning('MEX file not found at expected location.'); + end + addpath(currentFileDir); + addpath(fullfile(currentFileDir, '+pyraview')); + else + addpath(fileparts(fullMexPath)); + addpath(fullfile(fileparts(fullMexPath), '+pyraview')); + end +end + +function test_read_basic(testCase) + % Create a temporary file with known data + filename = [tempname '.bin']; + c = onCleanup(@() delete(filename)); + + numChannels = 2; + numSamples = 10; + dataType = 'int16'; + itemSize = 2; + + % Header (1024 bytes) + fid = fopen(filename, 'wb'); + + % Magic + fwrite(fid, 'PYRA', 'char'); + % Version + fwrite(fid, 1, 'uint32'); + % DataType (2 for int16) + fwrite(fid, 2, 'uint32'); + % Channels + fwrite(fid, numChannels, 'uint32'); + % SampleRate + fwrite(fid, 1000.0, 'double'); + % NativeRate + fwrite(fid, 1000.0, 'double'); + % StartTime + fwrite(fid, 0.0, 'double'); + % Decimation + fwrite(fid, 1, 'uint32'); + % Reserved + fwrite(fid, zeros(980, 1, 'uint8'), 'uint8'); + + % Data: Planar layout + % Ch1: 0,1, 2,3, ... + % Ch2: 100,101, 102,103, ... + + dataCh1 = zeros(numSamples * 2, 1, dataType); + for i = 1:numSamples + dataCh1((i-1)*2 + 1) = (i-1)*2; % Min + dataCh1((i-1)*2 + 2) = (i-1)*2 + 1; % Max + end + + dataCh2 = zeros(numSamples * 2, 1, dataType); + for i = 1:numSamples + dataCh2((i-1)*2 + 1) = 100 + (i-1)*2; % Min + dataCh2((i-1)*2 + 2) = 100 + (i-1)*2 + 1; % Max + end + + fwrite(fid, dataCh1, dataType); + fwrite(fid, dataCh2, dataType); + + fclose(fid); + + % Test read full + d = pyraview.readFile(filename, 0, numSamples-1); + + testCase.verifyEqual(size(d), [numSamples, numChannels, 2]); + testCase.verifyEqual(d(:, 1, 1), dataCh1(1:2:end)); + testCase.verifyEqual(d(:, 1, 2), dataCh1(2:2:end)); + testCase.verifyEqual(d(:, 2, 1), dataCh2(1:2:end)); + testCase.verifyEqual(d(:, 2, 2), dataCh2(2:2:end)); + + % Test read partial + s0 = 2; s1 = 4; + dPart = pyraview.readFile(filename, s0, s1); + testCase.verifyEqual(size(dPart), [3, numChannels, 2]); + testCase.verifyEqual(dPart(:, 1, 1), dataCh1(5:2:9)); % Indices 2,3,4 -> 0-based index 4,6,8 in flat array? No. + % Sample 2 is index 2. Flat index: 2*2 = 4 (Min), 5 (Max). + % Sample 3 is index 3. Flat index: 6 (Min), 7 (Max). + % Sample 4 is index 4. Flat index: 8 (Min), 9 (Max). + % dataCh1(1:2:end) is Mins. Elements at 3, 4, 5 (1-based). + + expectedMinsCh1 = dataCh1(1:2:end); + expectedMinsCh1 = expectedMinsCh1(s0+1 : s1+1); + testCase.verifyEqual(dPart(:, 1, 1), expectedMinsCh1); + + % Test Inf + dInf = pyraview.readFile(filename, -Inf, Inf); + testCase.verifyEqual(dInf, d); +end diff --git a/src/python/pyraview/__init__.py b/src/python/pyraview/__init__.py index 52bb947..f5e51d7 100644 --- a/src/python/pyraview/__init__.py +++ b/src/python/pyraview/__init__.py @@ -376,3 +376,92 @@ def get_view_data(self, t_start, t_end, pixels): t_vec = self.start_time + (idx_start + np.arange(num_samples_to_read)) / selected_file['rate'] return t_vec, data_out + +def read_file(filename, s0, s1): + """ + Reads a chunk of data from a Pyraview level file. + + Args: + filename (str): Path to the file. + s0 (int or float): Start sample index (0-based). Can be float('-inf'). + s1 (int or float): End sample index (0-based). Can be float('inf'). + + Returns: + np.ndarray: 3D array of shape (samples, channels, 2). + d[:, :, 0] is min values, d[:, :, 1] is max values. + """ + if not os.path.exists(filename): + raise FileNotFoundError(f"File not found: {filename}") + + # Read header + h = PyraviewHeader() + if _lib.pyraview_get_header(filename.encode('utf-8'), ctypes.byref(h)) != 0: + raise RuntimeError("Failed to read Pyraview header") + + num_channels = h.channelCount + + # Map type + dtype_map_rev = { + 0: np.int8, 1: np.uint8, + 2: np.int16, 3: np.uint16, + 4: np.int32, 5: np.uint32, + 6: np.int64, 7: np.uint64, + 8: np.float32, 9: np.float64 + } + if h.dataType not in dtype_map_rev: + raise ValueError(f"Unknown data type: {h.dataType}") + + dt = dtype_map_rev[h.dataType] + item_size = np.dtype(dt).itemsize + + # Calculate file structure + file_size = os.path.getsize(filename) + header_size = 1024 + data_area = file_size - header_size + + # Check if data area is valid + if data_area < 0: + return np.zeros((0, num_channels, 2), dtype=dt) + + # Planar layout: [Header][Ch0][Ch1]... + # Each channel block: TotalSamples * 2 * ItemSize + total_samples = data_area // (num_channels * 2 * item_size) + + # Handle indices + start_sample = 0 if (s0 == float('-inf') or s0 < 0) else int(s0) + + if s1 == float('inf'): + end_sample = total_samples - 1 + else: + end_sample = int(s1) + + if end_sample >= total_samples: + end_sample = total_samples - 1 + + if start_sample > end_sample: + return np.zeros((0, num_channels, 2), dtype=dt) + + num_samples_to_read = end_sample - start_sample + 1 + + # Allocate output + d = np.zeros((num_samples_to_read, num_channels, 2), dtype=dt) + + with open(filename, 'rb') as f: + samples_per_channel_in_file = total_samples + + for ch in range(num_channels): + ch_start_offset = header_size + (ch * samples_per_channel_in_file * 2 * item_size) + read_offset = ch_start_offset + (start_sample * 2 * item_size) + + f.seek(read_offset) + raw_bytes = f.read(num_samples_to_read * 2 * item_size) + + raw_data = np.frombuffer(raw_bytes, dtype=dt) + + # Handle short read + n_read = len(raw_data) // 2 + if n_read > 0: + d[:n_read, ch, 0] = raw_data[0 : 2*n_read : 2] + d[:n_read, ch, 1] = raw_data[1 : 2*n_read : 2] + + return d diff --git a/src/python/tests/test_readfile.py b/src/python/tests/test_readfile.py new file mode 100644 index 0000000..24e9f48 --- /dev/null +++ b/src/python/tests/test_readfile.py @@ -0,0 +1,148 @@ +import unittest +import os +import sys +import numpy as np +import struct +import tempfile +import ctypes +from unittest.mock import MagicMock, patch + +# Add src/python to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'python'))) + +# Mock ctypes.CDLL globally for this test file +patcher = patch('ctypes.CDLL') +mock_cdll = patcher.start() +mock_lib = MagicMock() +mock_cdll.return_value = mock_lib + +try: + import pyraview +except ImportError: + # If it fails for some other reason + pyraview = None + +class TestReadFile(unittest.TestCase): + @classmethod + def tearDownClass(cls): + patcher.stop() + + def setUp(self): + if pyraview is None: + self.skipTest("pyraview module could not be imported") + + self.tmp_fd, self.tmp_path = tempfile.mkstemp() + os.close(self.tmp_fd) + + # Configure mock_lib.pyraview_get_header side effect + def get_header_side_effect(fname, header_ptr): + # Read the file to get real header data + with open(fname, 'rb') as f: + data = f.read(1024) + if len(data) < 1024: + return -1 + + # Unpack + # magic (4s), ver (I), type (I), ch (I), rate (d), native (d), start (d), dec (I) + # 4+4+4+4+8+8+8+4 = 44 bytes + vals = struct.unpack('4sIIIdddI', data[:44]) + + # header_ptr is a ctypes.byref object (CArgObject), use _obj to access the structure + h = header_ptr._obj + h.magic = vals[0] + h.version = vals[1] + h.dataType = vals[2] + h.channelCount = vals[3] + h.sampleRate = vals[4] + h.nativeRate = vals[5] + h.startTime = vals[6] + h.decimationFactor = vals[7] + + return 0 + + # We need to set side_effect on the mock object that pyraview imported + # pyraview._lib is mock_lib + pyraview._lib.pyraview_get_header.side_effect = get_header_side_effect + pyraview._lib.pyraview_get_header.restype = ctypes.c_int + + def tearDown(self): + if os.path.exists(self.tmp_path): + os.remove(self.tmp_path) + + def create_dummy_file(self, num_samples, num_channels, data_type_code, data_type_np): + with open(self.tmp_path, 'wb') as f: + # Header + f.write(b'PYRA') + f.write(struct.pack('I', 1)) + f.write(struct.pack('I', data_type_code)) + f.write(struct.pack('I', num_channels)) + f.write(struct.pack('d', 1000.0)) + f.write(struct.pack('d', 1000.0)) + f.write(struct.pack('d', 0.0)) + f.write(struct.pack('I', 1)) + f.write(b'\x00' * 980) + + # Data + # Planar: Ch0 [Sample0 Min, Sample0 Max...], Ch1 [...] + + # Generate deterministic data + data = np.zeros((num_samples, num_channels, 2), dtype=data_type_np) + for ch in range(num_channels): + for i in range(num_samples): + min_val = (ch * 1000) + (i * 2) + max_val = (ch * 1000) + (i * 2) + 1 + data[i, ch, 0] = min_val + data[i, ch, 1] = max_val + + # Write planar + for ch in range(num_channels): + # Interleave min/max for channel + ch_data = np.zeros((num_samples * 2,), dtype=data_type_np) + ch_data[0::2] = data[:, ch, 0] + ch_data[1::2] = data[:, ch, 1] + f.write(ch_data.tobytes()) + + return data + + def test_read_int16(self): + num_samples = 100 + num_channels = 2 + data_type_code = 2 # int16 + data_type_np = np.int16 + + expected_data = self.create_dummy_file(num_samples, num_channels, data_type_code, data_type_np) + + # Test full read + d = pyraview.read_file(self.tmp_path, 0, num_samples-1) + self.assertEqual(d.shape, (num_samples, num_channels, 2)) + np.testing.assert_array_equal(d, expected_data) + + # Test partial read + s0 = 10 + s1 = 20 + d_part = pyraview.read_file(self.tmp_path, s0, s1) + self.assertEqual(d_part.shape, (11, num_channels, 2)) + np.testing.assert_array_equal(d_part, expected_data[s0:s1+1]) + + # Test Inf + d_inf = pyraview.read_file(self.tmp_path, float('-inf'), float('inf')) + np.testing.assert_array_equal(d_inf, expected_data) + + def test_read_float64(self): + num_samples = 50 + num_channels = 1 + data_type_code = 9 # float64 + data_type_np = np.float64 + + expected_data = self.create_dummy_file(num_samples, num_channels, data_type_code, data_type_np) + + d = pyraview.read_file(self.tmp_path, 0, num_samples-1) + np.testing.assert_array_equal(d, expected_data) + + def test_empty_read(self): + self.create_dummy_file(10, 1, 2, np.int16) + d = pyraview.read_file(self.tmp_path, 5, 4) + self.assertEqual(d.shape, (0, 1, 2)) + +if __name__ == '__main__': + unittest.main() From cd3a552b2d05dea87020b70fdb9b57cec37a158b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:27:33 +0000 Subject: [PATCH 2/4] Implement pyraview.readFile in MATLAB and Python Added functions to read level files directly with start/stop sample indices. - MATLAB: `pyraview.readFile(filename, s0, s1)` - Python: `pyraview.read_file(filename, s0, s1)` Both functions support reading min/max pairs from the planar file layout and handle -Inf/Inf for start/end indices. Updated documentation (API.md) to reflect the new functions and the transition to C++ implementation. Refactored MATLAB tests to use class-based unit tests. Enhanced Python tests with more comprehensive coverage and mocking. --- docs/API.md | 30 +++++ src/matlab/+pyraview/readFile.m | 41 ++++-- src/matlab/test_readfile.m | 216 ++++++++++++++++-------------- src/python/pyraview/__init__.py | 25 +++- src/python/tests/test_readfile.py | 94 +++++++++---- 5 files changed, 269 insertions(+), 137 deletions(-) diff --git a/docs/API.md b/docs/API.md index c5a4852..ecc9978 100644 --- a/docs/API.md +++ b/docs/API.md @@ -68,3 +68,33 @@ Arguments: Returns: - `status`: 0 on success. Throws error on failure. + +### `D = pyraview.readFile(filename, s0, s1)` +Reads a specific range of samples from a level file. + +Arguments: +- `filename`: String path to the `.bin` level file. +- `s0`: Start sample index (0-based). Can be `-Inf`. +- `s1`: End sample index (0-based). Can be `Inf`. + +Returns: +- `D`: A 3D matrix of size `(Samples x Channels x 2)`. + - `D(:, :, 1)`: Minimum values. + - `D(:, :, 2)`: Maximum values. + +--- + +## Python API (`src/python/pyraview/__init__.py`) + +### `read_file(filename, s0, s1)` +Reads a specific range of samples from a level file. + +Arguments: +- `filename`: String path to the file. +- `s0`: Start sample index (int or float). Use `float('-inf')` for beginning. +- `s1`: End sample index (int or float). Use `float('inf')` for end. + +Returns: +- Numpy array of shape `(Samples, Channels, 2)`. + - `[:, :, 0]`: Minimum values. + - `[:, :, 1]`: Maximum values. diff --git a/src/matlab/+pyraview/readFile.m b/src/matlab/+pyraview/readFile.m index a8e752f..de99d71 100644 --- a/src/matlab/+pyraview/readFile.m +++ b/src/matlab/+pyraview/readFile.m @@ -1,13 +1,38 @@ function d = readFile(filename, s0, s1) -%READFILE Reads a chunk of data from a Pyraview level file. -% d = pyraview.readFile(filename, s0, s1) reads data from sample index s0 to s1. -% s0 and s1 are 0-based sample indices. -% s0 can be -Inf to indicate the start of the file. -% s1 can be Inf to indicate the end of the file. +%READFILE Reads a specific range of samples from a Pyraview level file. % -% Returns a 3-D matrix d of size (samples x channels x 2). -% d(:, :, 1) contains the minimum values. -% d(:, :, 2) contains the maximum values. +% D = pyraview.readFile(FILENAME, S0, S1) reads data from the file specified +% by FILENAME, starting at sample index S0 and ending at sample index S1 +% (inclusive, 0-based indexing). +% +% Inputs: +% FILENAME - String or character vector specifying the path to the Pyraview level file. +% S0 - Scalar numeric. The starting sample index (0-based). +% Can be -Inf to indicate the beginning of the file. +% S1 - Scalar numeric. The ending sample index (0-based). +% Can be Inf to indicate the end of the file. +% +% Outputs: +% D - A 3-D matrix of size (Samples x Channels x 2). +% The data type matches the file's internal data type. +% D(:, :, 1) contains the minimum values for each sample. +% D(:, :, 2) contains the maximum values for each sample. +% +% Example: +% % Read samples 100 to 200 from 'data_L1.bin' +% d = pyraview.readFile('data_L1.bin', 100, 200); +% +% % Read from the beginning to sample 500 +% d = pyraview.readFile('data_L1.bin', -Inf, 500); +% +% % Read from sample 1000 to the end +% d = pyraview.readFile('data_L1.bin', 1000, Inf); +% +% Notes: +% - Indices are clamped to the file's valid sample range. +% - Returns an empty array if the requested range is invalid or empty. +% - Pyraview files use a planar layout where channels are stored contiguously. +% - Each "sample" in a level file consists of a Min/Max pair. if ~isfile(filename) error('Pyraview:FileNotFound', 'File not found: %s', filename); diff --git a/src/matlab/test_readfile.m b/src/matlab/test_readfile.m index c3a081f..6ac5e2c 100644 --- a/src/matlab/test_readfile.m +++ b/src/matlab/test_readfile.m @@ -1,107 +1,127 @@ -function tests = test_readfile - tests = functiontests(localfunctions); -end +classdef test_readfile < matlab.unittest.TestCase + + properties + TempFilename + CleanupObj + NumChannels = 2 + NumSamples = 10 + DataType = 'int16' + ItemSize = 2 + DataCh1 + DataCh2 + end -function setupOnce(testCase) - % Verify MEX file exists - [~, mexName] = fileparts('pyraview'); - mexExt = mexext; - fullMexPath = fullfile(pwd, 'src', 'matlab', ['pyraview.' mexExt]); - - if ~exist(fullMexPath, 'file') - % Try relative to where this file is? - currentFileDir = fileparts(mfilename('fullpath')); - fullMexPath = fullfile(currentFileDir, ['pyraview.' mexExt]); - if ~exist(fullMexPath, 'file') - % If MEX is missing, we might be in an environment where we can't run this. - % But we proceed assuming user has built it. - warning('MEX file not found at expected location.'); + methods(TestClassSetup) + function setupPaths(testCase) + % Verify MEX file exists + [~, mexName] = fileparts('pyraview'); + mexExt = mexext; + fullMexPath = fullfile(pwd, 'src', 'matlab', ['pyraview.' mexExt]); + + if ~exist(fullMexPath, 'file') + % Try relative to where this file is + currentFileDir = fileparts(mfilename('fullpath')); + fullMexPath = fullfile(currentFileDir, ['pyraview.' mexExt]); + if ~exist(fullMexPath, 'file') + warning('MEX file not found at expected location.'); + end + addpath(currentFileDir); + addpath(fullfile(currentFileDir, '+pyraview')); + else + addpath(fileparts(fullMexPath)); + addpath(fullfile(fileparts(fullMexPath), '+pyraview')); + end end - addpath(currentFileDir); - addpath(fullfile(currentFileDir, '+pyraview')); - else - addpath(fileparts(fullMexPath)); - addpath(fullfile(fileparts(fullMexPath), '+pyraview')); end -end -function test_read_basic(testCase) - % Create a temporary file with known data - filename = [tempname '.bin']; - c = onCleanup(@() delete(filename)); - - numChannels = 2; - numSamples = 10; - dataType = 'int16'; - itemSize = 2; - - % Header (1024 bytes) - fid = fopen(filename, 'wb'); - - % Magic - fwrite(fid, 'PYRA', 'char'); - % Version - fwrite(fid, 1, 'uint32'); - % DataType (2 for int16) - fwrite(fid, 2, 'uint32'); - % Channels - fwrite(fid, numChannels, 'uint32'); - % SampleRate - fwrite(fid, 1000.0, 'double'); - % NativeRate - fwrite(fid, 1000.0, 'double'); - % StartTime - fwrite(fid, 0.0, 'double'); - % Decimation - fwrite(fid, 1, 'uint32'); - % Reserved - fwrite(fid, zeros(980, 1, 'uint8'), 'uint8'); - - % Data: Planar layout - % Ch1: 0,1, 2,3, ... - % Ch2: 100,101, 102,103, ... - - dataCh1 = zeros(numSamples * 2, 1, dataType); - for i = 1:numSamples - dataCh1((i-1)*2 + 1) = (i-1)*2; % Min - dataCh1((i-1)*2 + 2) = (i-1)*2 + 1; % Max + methods(TestMethodSetup) + function createTestFile(testCase) + testCase.TempFilename = [tempname '.bin']; + testCase.CleanupObj = onCleanup(@() delete(testCase.TempFilename)); + + % Generate data + testCase.DataCh1 = zeros(testCase.NumSamples * 2, 1, testCase.DataType); + for i = 1:testCase.NumSamples + testCase.DataCh1((i-1)*2 + 1) = (i-1)*2; % Min + testCase.DataCh1((i-1)*2 + 2) = (i-1)*2 + 1; % Max + end + + testCase.DataCh2 = zeros(testCase.NumSamples * 2, 1, testCase.DataType); + for i = 1:testCase.NumSamples + testCase.DataCh2((i-1)*2 + 1) = 100 + (i-1)*2; % Min + testCase.DataCh2((i-1)*2 + 2) = 100 + (i-1)*2 + 1; % Max + end + + % Write file + fid = fopen(testCase.TempFilename, 'wb'); + + % Header + fwrite(fid, 'PYRA', 'char'); + fwrite(fid, 1, 'uint32'); + fwrite(fid, 2, 'uint32'); % int16 + fwrite(fid, testCase.NumChannels, 'uint32'); + fwrite(fid, 1000.0, 'double'); + fwrite(fid, 1000.0, 'double'); + fwrite(fid, 0.0, 'double'); + fwrite(fid, 1, 'uint32'); + fwrite(fid, zeros(980, 1, 'uint8'), 'uint8'); + + % Data (Planar) + fwrite(fid, testCase.DataCh1, testCase.DataType); + fwrite(fid, testCase.DataCh2, testCase.DataType); + + fclose(fid); + end end - dataCh2 = zeros(numSamples * 2, 1, dataType); - for i = 1:numSamples - dataCh2((i-1)*2 + 1) = 100 + (i-1)*2; % Min - dataCh2((i-1)*2 + 2) = 100 + (i-1)*2 + 1; % Max + methods(TestMethodTeardown) + function deleteTestFile(testCase) + delete(testCase.CleanupObj); + end end - fwrite(fid, dataCh1, dataType); - fwrite(fid, dataCh2, dataType); - - fclose(fid); - - % Test read full - d = pyraview.readFile(filename, 0, numSamples-1); - - testCase.verifyEqual(size(d), [numSamples, numChannels, 2]); - testCase.verifyEqual(d(:, 1, 1), dataCh1(1:2:end)); - testCase.verifyEqual(d(:, 1, 2), dataCh1(2:2:end)); - testCase.verifyEqual(d(:, 2, 1), dataCh2(1:2:end)); - testCase.verifyEqual(d(:, 2, 2), dataCh2(2:2:end)); - - % Test read partial - s0 = 2; s1 = 4; - dPart = pyraview.readFile(filename, s0, s1); - testCase.verifyEqual(size(dPart), [3, numChannels, 2]); - testCase.verifyEqual(dPart(:, 1, 1), dataCh1(5:2:9)); % Indices 2,3,4 -> 0-based index 4,6,8 in flat array? No. - % Sample 2 is index 2. Flat index: 2*2 = 4 (Min), 5 (Max). - % Sample 3 is index 3. Flat index: 6 (Min), 7 (Max). - % Sample 4 is index 4. Flat index: 8 (Min), 9 (Max). - % dataCh1(1:2:end) is Mins. Elements at 3, 4, 5 (1-based). - - expectedMinsCh1 = dataCh1(1:2:end); - expectedMinsCh1 = expectedMinsCh1(s0+1 : s1+1); - testCase.verifyEqual(dPart(:, 1, 1), expectedMinsCh1); - - % Test Inf - dInf = pyraview.readFile(filename, -Inf, Inf); - testCase.verifyEqual(dInf, d); + methods(Test) + function testReadFull(testCase) + d = pyraview.readFile(testCase.TempFilename, 0, testCase.NumSamples-1); + + testCase.verifyEqual(size(d), [testCase.NumSamples, testCase.NumChannels, 2]); + testCase.verifyEqual(d(:, 1, 1), testCase.DataCh1(1:2:end)); % Ch1 Mins + testCase.verifyEqual(d(:, 1, 2), testCase.DataCh1(2:2:end)); % Ch1 Maxs + testCase.verifyEqual(d(:, 2, 1), testCase.DataCh2(1:2:end)); % Ch2 Mins + testCase.verifyEqual(d(:, 2, 2), testCase.DataCh2(2:2:end)); % Ch2 Maxs + end + + function testReadPartial(testCase) + s0 = 2; s1 = 4; + d = pyraview.readFile(testCase.TempFilename, s0, s1); + + numRead = s1 - s0 + 1; + testCase.verifyEqual(size(d), [numRead, testCase.NumChannels, 2]); + + % Check Ch1 Mins + expectedMins = testCase.DataCh1(1:2:end); + expectedMins = expectedMins(s0+1 : s1+1); % Matlab 1-based indexing + testCase.verifyEqual(d(:, 1, 1), expectedMins); + end + + function testReadInf(testCase) + d = pyraview.readFile(testCase.TempFilename, -Inf, Inf); + + testCase.verifyEqual(size(d), [testCase.NumSamples, testCase.NumChannels, 2]); + testCase.verifyEqual(d(:, 1, 1), testCase.DataCh1(1:2:end)); + end + + function testReadEmpty(testCase) + d = pyraview.readFile(testCase.TempFilename, 5, 4); + testCase.verifyEqual(size(d), [0, testCase.NumChannels, 2]); + end + + function testReadOutOfBounds(testCase) + % Request beyond end, should be clamped + d = pyraview.readFile(testCase.TempFilename, testCase.NumSamples-2, testCase.NumSamples+100); + + numRead = 2; % Samples 8 and 9 (0-based) + testCase.verifyEqual(size(d, 1), numRead); + end + end end diff --git a/src/python/pyraview/__init__.py b/src/python/pyraview/__init__.py index f5e51d7..e32a101 100644 --- a/src/python/pyraview/__init__.py +++ b/src/python/pyraview/__init__.py @@ -379,16 +379,29 @@ def get_view_data(self, t_start, t_end, pixels): def read_file(filename, s0, s1): """ - Reads a chunk of data from a Pyraview level file. + Reads a specific range of samples from a Pyraview level file. + + This function reads Min/Max pairs for each sample in the specified range. + Pyraview level files store data in a planar format (Channel 0, then Channel 1, etc.). Args: - filename (str): Path to the file. - s0 (int or float): Start sample index (0-based). Can be float('-inf'). - s1 (int or float): End sample index (0-based). Can be float('inf'). + filename (str): Path to the Pyraview level file. + s0 (int or float): Start sample index (0-based). + Use float('-inf') to start from the beginning of the file. + s1 (int or float): End sample index (0-based, inclusive). + Use float('inf') to read until the end of the file. Returns: - np.ndarray: 3D array of shape (samples, channels, 2). - d[:, :, 0] is min values, d[:, :, 1] is max values. + np.ndarray: A 3D numpy array with shape (Samples, Channels, 2). + - result[:, :, 0] contains the Minimum values. + - result[:, :, 1] contains the Maximum values. + The data type of the array corresponds to the file's internal data type. + + Examples: + >>> # Read samples 0 to 99 + >>> data = pyraview.read_file('my_data_L1.bin', 0, 99) + >>> # Read everything from sample 1000 onwards + >>> data = pyraview.read_file('my_data_L1.bin', 1000, float('inf')) """ if not os.path.exists(filename): raise FileNotFoundError(f"File not found: {filename}") diff --git a/src/python/tests/test_readfile.py b/src/python/tests/test_readfile.py index 24e9f48..7fb0c05 100644 --- a/src/python/tests/test_readfile.py +++ b/src/python/tests/test_readfile.py @@ -23,6 +23,11 @@ pyraview = None class TestReadFile(unittest.TestCase): + """ + Unit tests for pyraview.read_file function. + Mocks the underlying C library to test logic independently. + """ + @classmethod def tearDownClass(cls): patcher.stop() @@ -37,28 +42,31 @@ def setUp(self): # Configure mock_lib.pyraview_get_header side effect def get_header_side_effect(fname, header_ptr): # Read the file to get real header data - with open(fname, 'rb') as f: - data = f.read(1024) - if len(data) < 1024: - return -1 - - # Unpack - # magic (4s), ver (I), type (I), ch (I), rate (d), native (d), start (d), dec (I) - # 4+4+4+4+8+8+8+4 = 44 bytes - vals = struct.unpack('4sIIIdddI', data[:44]) - - # header_ptr is a ctypes.byref object (CArgObject), use _obj to access the structure - h = header_ptr._obj - h.magic = vals[0] - h.version = vals[1] - h.dataType = vals[2] - h.channelCount = vals[3] - h.sampleRate = vals[4] - h.nativeRate = vals[5] - h.startTime = vals[6] - h.decimationFactor = vals[7] - - return 0 + try: + with open(fname, 'rb') as f: + data = f.read(1024) + if len(data) < 1024: + return -1 + + # Unpack + # magic (4s), ver (I), type (I), ch (I), rate (d), native (d), start (d), dec (I) + # 4+4+4+4+8+8+8+4 = 44 bytes + vals = struct.unpack('4sIIIdddI', data[:44]) + + # header_ptr is a ctypes.byref object (CArgObject), use _obj to access the structure + h = header_ptr._obj + h.magic = vals[0] + h.version = vals[1] + h.dataType = vals[2] + h.channelCount = vals[3] + h.sampleRate = vals[4] + h.nativeRate = vals[5] + h.startTime = vals[6] + h.decimationFactor = vals[7] + + return 0 + except FileNotFoundError: + return -1 # We need to set side_effect on the mock object that pyraview imported # pyraview._lib is mock_lib @@ -104,7 +112,8 @@ def create_dummy_file(self, num_samples, num_channels, data_type_code, data_type return data - def test_read_int16(self): + def test_read_int16_full(self): + """Test reading full range of int16 data.""" num_samples = 100 num_channels = 2 data_type_code = 2 # int16 @@ -117,18 +126,35 @@ def test_read_int16(self): self.assertEqual(d.shape, (num_samples, num_channels, 2)) np.testing.assert_array_equal(d, expected_data) - # Test partial read + def test_read_partial(self): + """Test reading a subset of samples.""" + num_samples = 100 + num_channels = 2 + data_type_code = 2 # int16 + data_type_np = np.int16 + + expected_data = self.create_dummy_file(num_samples, num_channels, data_type_code, data_type_np) + s0 = 10 s1 = 20 d_part = pyraview.read_file(self.tmp_path, s0, s1) self.assertEqual(d_part.shape, (11, num_channels, 2)) np.testing.assert_array_equal(d_part, expected_data[s0:s1+1]) - # Test Inf + def test_read_inf(self): + """Test reading with -Inf and Inf bounds.""" + num_samples = 100 + num_channels = 2 + data_type_code = 2 + data_type_np = np.int16 + + expected_data = self.create_dummy_file(num_samples, num_channels, data_type_code, data_type_np) + d_inf = pyraview.read_file(self.tmp_path, float('-inf'), float('inf')) np.testing.assert_array_equal(d_inf, expected_data) def test_read_float64(self): + """Test reading float64 data.""" num_samples = 50 num_channels = 1 data_type_code = 9 # float64 @@ -140,9 +166,27 @@ def test_read_float64(self): np.testing.assert_array_equal(d, expected_data) def test_empty_read(self): + """Test reading an empty range (start > end).""" self.create_dummy_file(10, 1, 2, np.int16) d = pyraview.read_file(self.tmp_path, 5, 4) self.assertEqual(d.shape, (0, 1, 2)) + def test_out_of_bounds(self): + """Test reading beyond file end (should clamp).""" + num_samples = 10 + expected = self.create_dummy_file(num_samples, 1, 2, np.int16) + + # Read past end + d = pyraview.read_file(self.tmp_path, num_samples-2, num_samples+100) + + # Should get last 2 samples + self.assertEqual(d.shape[0], 2) + np.testing.assert_array_equal(d, expected[-2:]) + + def test_file_not_found(self): + """Test reading a non-existent file.""" + with self.assertRaises(FileNotFoundError): + pyraview.read_file('nonexistent.bin', 0, 10) + if __name__ == '__main__': unittest.main() From 0a5ec1e3e8a817395e7d1c6357988172abc3f5b5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:08:32 +0000 Subject: [PATCH 3/4] Implement pyraview.readFile in MATLAB and Python Added functions to read level files directly with start/stop sample indices. - MATLAB: `pyraview.readFile(filename, s0, s1)` - Python: `pyraview.read_file(filename, s0, s1)` Both functions support reading min/max pairs from the planar file layout and handle -Inf/Inf for start/end indices. Updated documentation (API.md) to reflect the new functions and the transition to C++ implementation. Refactored MATLAB tests to use class-based unit tests. Enhanced Python tests with more comprehensive coverage and mocking. From fad5d7b15383a7dbc2ce32dc48370b30588c3466 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:26:17 +0000 Subject: [PATCH 4/4] Implement pyraview.readFile in MATLAB and Python Added functions to read level files directly with start/stop sample indices. - MATLAB: `pyraview.readFile(filename, s0, s1)` - Python: `pyraview.read_file(filename, s0, s1)` Both functions support reading min/max pairs from the planar file layout and handle -Inf/Inf for start/end indices. Updated documentation (API.md) to reflect the new functions and the transition to C++ implementation. Refactored MATLAB tests to use class-based unit tests in the correct package location (`+pyraview/+unittest`). Enhanced Python tests with more comprehensive coverage and mocking. --- docs/API.md | 38 +++++++++---------- .../+pyraview/+unittest}/test_readfile.m | 0 2 files changed, 18 insertions(+), 20 deletions(-) rename src/matlab/{ => tests/+pyraview/+unittest}/test_readfile.m (100%) diff --git a/docs/API.md b/docs/API.md index ecc9978..215ce72 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,8 @@ # Pyraview API Reference -## C API (`src/c/pyraview.c`, `include/pyraview_header.h`) +## C/C++ API (`src/c/pyraview.cpp`, `include/pyraview_header.h`) + +The core implementation is written in C++11 for efficient multi-threading, but exposes a C-compatible API for easy integration with other languages. ### `pyraview_process_chunk` Processes a chunk of raw data and updates decimation pyramids. @@ -26,7 +28,7 @@ Arguments: - `levelSteps`: Pointer to array of decimation factors (e.g., `[100, 10, 10]`). - `numLevels`: Number of elements in `levelSteps`. - `nativeRate`: Original sampling rate (Hz). -- `numThreads`: Number of OpenMP threads (0 for auto). +- `numThreads`: Number of worker threads (0 for auto). Returns: - 0 on success. @@ -34,7 +36,7 @@ Returns: --- -## Python API (`src/python/pyraview.py`) +## Python API (`src/python/pyraview/__init__.py`) ### `process_chunk(data, file_prefix, level_steps, native_rate, append=False, layout='SxC', num_threads=0)` Wrapper for the C function. @@ -52,6 +54,19 @@ Arguments: Returns: - 0 on success. Raises `RuntimeError` on failure. +### `read_file(filename, s0, s1)` +Reads a specific range of samples from a level file. + +Arguments: +- `filename`: String path to the file. +- `s0`: Start sample index (int or float). Use `float('-inf')` for beginning. +- `s1`: End sample index (int or float). Use `float('inf')` for end. + +Returns: +- Numpy array of shape `(Samples, Channels, 2)`. + - `[:, :, 0]`: Minimum values. + - `[:, :, 1]`: Maximum values. + --- ## Matlab API (`src/matlab/pyraview_mex.c`) @@ -81,20 +96,3 @@ Returns: - `D`: A 3D matrix of size `(Samples x Channels x 2)`. - `D(:, :, 1)`: Minimum values. - `D(:, :, 2)`: Maximum values. - ---- - -## Python API (`src/python/pyraview/__init__.py`) - -### `read_file(filename, s0, s1)` -Reads a specific range of samples from a level file. - -Arguments: -- `filename`: String path to the file. -- `s0`: Start sample index (int or float). Use `float('-inf')` for beginning. -- `s1`: End sample index (int or float). Use `float('inf')` for end. - -Returns: -- Numpy array of shape `(Samples, Channels, 2)`. - - `[:, :, 0]`: Minimum values. - - `[:, :, 1]`: Maximum values. diff --git a/src/matlab/test_readfile.m b/src/matlab/tests/+pyraview/+unittest/test_readfile.m similarity index 100% rename from src/matlab/test_readfile.m rename to src/matlab/tests/+pyraview/+unittest/test_readfile.m