From 714208f4e3088933b650d7000707e3e1ecb8b987 Mon Sep 17 00:00:00 2001 From: 4gwe Date: Thu, 10 Sep 2026 12:34:57 +0200 Subject: [PATCH 1/7] Test: Improve pspm_convert_hb2hp test coverage (#597) --- test/pspm_convert_hb2hp_test.m | 208 +++++++++++++++++++++++++++------ 1 file changed, 171 insertions(+), 37 deletions(-) diff --git a/test/pspm_convert_hb2hp_test.m b/test/pspm_convert_hb2hp_test.m index 5585aceb..6baba09a 100644 --- a/test/pspm_convert_hb2hp_test.m +++ b/test/pspm_convert_hb2hp_test.m @@ -49,19 +49,19 @@ function invalid_input(this) this.verifyWarning(@() pspm_convert_hb2hp('abc'), 'ID:invalid_input'); % Verify not a string sample rate this.verifyWarning(@() pspm_convert_hb2hp('abc','abc'), 'ID:invalid_input'); - % Verify not a numeric channel + % Verify options is not a struct this.verifyWarning(@() pspm_convert_hb2hp('abc',2,'abc'), 'ID:invalid_input'); - % - % % Verify that call of pspm_load_data fails - % this.verifyWarning(@() pspm_convert_hb2hp(files{1},100), 'ID:nonexistent_file'); - % % Verify that interpolation does not have enough points - % % this.verifyWarning(@() pspm_convert_hb2hp(files{2}, 100), 'ID:too_strict_limits'); - % % Verify that call of pspm_write_channel fails - % options.channel_action = 'abc'; - % this.verifyWarning(@() pspm_convert_hb2hp(files{3},100,[],options),'ID:invalid_input'); - % %options.channel_action = 'add'; - % %this.verifyWarningFree(@()pspm_convert_hb2hp(files{1},100,[],options)); + fn = this.input_filename; + + options.channel_action = 'abc'; + this.verifyWarning( @() pspm_convert_hb2hp(fn, 100, options), 'ID:invalid_input'); + + options = struct('limit_lower', 2, 'limit_upper', 1); + this.verifyWarning( @() pspm_convert_hb2hp(fn, 100, options), 'ID:invalid_input'); + + options = struct('limit_lower', -1); + this.verifyWarning( @() pspm_convert_hb2hp(fn, 100, options), 'ID:invalid_input'); end @@ -77,22 +77,61 @@ function basic_conversion(this) this.verifyEqual(data{outchannel}.header.chantype, 'hp'); end +function selects_hb_channel_by_index(this) + fn = [tempname, '.mat']; + + channels = { ... + struct('chantype', 'hb'), ... + struct('chantype', 'hb') ... + }; + + testdata = pspm_testdata_gen(channels, 5); + + % Channel 1 -> HP = 1000 ms + testdata.data{1}.data = ... + [0.5; 1.5; 2.5; 3.5; 4.5]; + + % Channel 2 -> HP = 500 ms + testdata.data{2}.data = ... + [0.5; 1.0; 1.5; 2.0; 2.5; 3.0; 3.5; 4.0; 4.5]; + + save(fn, '-struct', 'testdata'); + cleanup = onCleanup(@() delete(fn)); + + options.channel = 1; + options.channel_action = 'add'; + + [sts, outchannel] = pspm_convert_hb2hp(fn, 2, options); + this.verifyEqual(sts, 1); + + [sts, ~, data] = pspm_load_data(fn); + this.verifyEqual(sts, 1); + + hp = data{outchannel}; + + % Explicitly selecting channel 1 must produce 1000 ms. + this.verifyEqual(hp.data, 1000 * ones(size(hp.data)), 'AbsTol', 1e-10); +end + function too_strict_limits(this) fn = this.input_filename; sr = 1; - options = struct('limit_lower', 11, 'limit_upper', 11); + options = struct('limit_lower', 10, 'limit_upper', 11); - % options = struct('limit_lower', 10, 'limit_upper', 11); - % this.verifyWarning(@() pspm_convert_hb2hp(fn, sr, options), ... - % 'ID:too_strict_limits'); - - % this.verifyWarningFree(@() pspm_convert_hb2hp(this.input_filename, sr)); [sts, outchannel] = pspm_convert_hb2hp(fn, sr,options); this.verifyEqual(sts, 1); [sts, infos, data, filestruct] = pspm_load_data(fn); this.verifyEqual(sts, 1); this.verifyEqual(data{outchannel}.header.chantype, 'hp'); - this.verifyTrue(any(isnan(data{outchannel}.data))) + this.verifyTrue(all(isnan(data{outchannel}.data))); +end + +function too_strict_limits_warning(this) + fn = this.input_filename; + sr = 1; + + options = struct('limit_lower', 10, 'limit_upper', 11); + this.verifyWarning(@() pspm_convert_hb2hp(fn, sr, options), 'ID:too_strict_limits'); end function add_replace_channel_action(this) @@ -100,39 +139,134 @@ function add_replace_channel_action(this) sr = 1; options.channel_action = 'add'; - [sts, infos, ~, filestruct] = pspm_load_data(fn); + [sts, ~, ~, filestruct] = pspm_load_data(fn); this.verifyEqual(sts, 1); - % display(infos.history) - % add 1st hp - [sts, outchannel] = pspm_convert_hb2hp(fn, sr, options); + original_num_channels = filestruct.numofchan; + + % add 1st hp + [sts, hp1] = pspm_convert_hb2hp(fn, sr, options); this.verifyEqual(sts, 1); - [sts, ~, data, ~ ] = pspm_load_data(fn); + + [sts, ~, data, ~] = pspm_load_data(fn); this.verifyEqual(sts, 1); - this.verifyEqual(data{outchannel}.header.chantype, 'hp'); - this.verifyEqual(filestruct.numofchan + 1 , numel(data)); - % display(infos.history) + this.verifyEqual(data{hp1}.header.chantype, 'hp'); + this.verifyEqual(original_num_channels + 1, numel(data)); % add 2nd hp - [sts, outchannel] = pspm_convert_hb2hp(fn, sr, options); + [sts, hp2] = pspm_convert_hb2hp(fn, sr, options); this.verifyEqual(sts, 1); - [sts, ~, data, ~ ] = pspm_load_data(fn); + [sts, ~, data, ~] = pspm_load_data(fn); this.verifyEqual(sts, 1); - this.verifyEqual(data{outchannel}.header.chantype, 'hp'); - this.verifyEqual(filestruct.numofchan + 2 , numel(data)); + this.verifyEqual(data{hp2}.header.chantype, 'hp'); + this.verifyEqual(original_num_channels + 2, numel(data)); + % Make sure the two added channels are different channels + this.verifyNotEqual(hp1, hp2); - % replace hp (last) - options.channel_action = 'replace'; - [sts, outchannel] = pspm_convert_hb2hp(fn, sr, options); + % replace last hp + options.channel_action = 'replace'; + + [sts, hp_replaced] = pspm_convert_hb2hp(fn, sr, options); + this.verifyEqual(sts, 1); + + [sts, ~, data, ~] = pspm_load_data(fn); this.verifyEqual(sts, 1); - [sts, infos, data, ~] = pspm_load_data(fn); + + % Number of channels must stay the same + this.verifyEqual(original_num_channels + 2, numel(data)); + + % The last HP channel should have been replaced + this.verifyEqual(hp_replaced, hp2); + + % Replaced channel is still HP + this.verifyEqual(data{hp_replaced}.header.chantype, 'hp'); +end + +function known_heart_period_values(this) + fn = [tempname, '.mat']; + + duration = 5; + sr = 2; + + channels = {struct('chantype', 'hb')}; + testdata = pspm_testdata_gen(channels, duration); + testdata.data{1}.data = [0.5; 1.5; 3.0; 4.0]; + + save(fn, '-struct', 'testdata'); + cleanup = onCleanup(@() delete(fn)); - % infos.history + % Convert HB -> HP. + [sts, outchannel] = pspm_convert_hb2hp(fn, sr); this.verifyEqual(sts, 1); - this.verifyEqual(data{outchannel}.header.chantype, 'hp'); - this.verifyEqual(filestruct.numofchan + 2 , numel(data)); + + [sts, ~, data, ~] = pspm_load_data(fn); + this.verifyEqual(sts, 1); + + hp = data{outchannel}; + + % Expected interpolated HP signal. + expected_hp = [ ... + 1000; + 1000; + 1000; + 1166.666666666667; + 1333.333333333333; + 1500; + 1250; + 1000; + 1000; + 1000 + ]; + + % Verify actual numerical conversion. + this.verifyEqual(hp.data, expected_hp, 'AbsTol', 1e-10); + + % Verify metadata. + this.verifyEqual(hp.header.chantype, 'hp'); + this.verifyEqual(hp.header.units, 'ms'); + this.verifyEqual(hp.header.sr, sr); +end + +function selects_last_hb_channel_by_default(this) + fn = [tempname, '.mat']; + + channels = { ... + struct('chantype', 'hb'), ... + struct('chantype', 'hb') ... + }; + + testdata = pspm_testdata_gen(channels, 5); + + % Channel 1: IBI = 1 s -> HP = 1000 ms + testdata.data{1}.data = [0.5; 1.5; 2.5; 3.5; 4.5]; + + % Channel 2: IBI = 0.5 s -> HP = 500 ms + testdata.data{2}.data = ... + [0.5; 1.0; 1.5; 2.0; 2.5; 3.0; 3.5; 4.0; 4.5]; + + save(fn, '-struct', 'testdata'); + cleanup = onCleanup(@() delete(fn)); + + sr = 2; + options.channel_action = 'add'; + + [sts, outchannel] = pspm_convert_hb2hp(fn, sr, options); + + this.verifyEqual(sts, 1); + + [sts, ~, data] = pspm_load_data(fn); + this.verifyEqual(sts, 1); + + hp = data{outchannel}; + + this.verifyEqual(hp.header.chantype, 'hp'); + this.verifyEqual(hp.header.units, 'ms'); + this.verifyEqual(hp.header.sr, sr); + + % Default 'hb' must select the LAST HB channel. + this.verifyEqual(hp.data, 500 * ones(size(hp.data)), 'AbsTol', 1e-10); end end From ab059576c5c6f5fd693c2cf8fc4186f48535b83d Mon Sep 17 00:00:00 2001 From: 4gwe Date: Thu, 10 Sep 2026 16:57:19 +0200 Subject: [PATCH 2/7] Fix: Improve pspm_convert_ppg2hb error handling and edge-case handling --- src/pspm_convert_ppg2hb.m | 21 +++-- src/pspm_options.m | 3 +- test/pspm_convert_ppg2hb_test.m | 140 +++++++++++++++++++++++++++++++- test/pspm_testdata_gen.m | 2 +- 4 files changed, 156 insertions(+), 10 deletions(-) diff --git a/src/pspm_convert_ppg2hb.m b/src/pspm_convert_ppg2hb.m index 8f797302..e64583b6 100644 --- a/src/pspm_convert_ppg2hb.m +++ b/src/pspm_convert_ppg2hb.m @@ -53,6 +53,7 @@ % Written in 2016 by Samuel Gerster (University of Zurich) % Tobias Moser (University of Zurich) % Updated in 2024 by Dominik Bach/Uzay Gokay (Uni Bonn) +% Updated in 2026 by Bernhard von Raußendorf (Uni Bonn) %% Initialise global settings @@ -118,7 +119,7 @@ psts = pspm_check_python_modules('heartpy'); if psts < 1, return; end - filtered_ppg = py.heartpy.filter_signal(ppg, ... + filtered_ppg = py.heartpy.filter_signal(ppg.', ... pyargs('cutoff', [1,20], ... 'filtertype', 'bandpass', ... 'sample_rate', sr, ... @@ -128,17 +129,21 @@ tup = py.heartpy.process(filtered_ppg, pyargs('sample_rate', sr)); wd = tup{1}; m = tup{2}; + py_peak_list = py.array.array('d',(wd{'peaklist'})); - py_removed = py.array.array('d',(wd{'removed_beats'})); + binary_peak_list = double(py.array.array('d', wd{'binary_peaklist'})); + peak_list = double(py_peak_list) ; - rejected_peaks = double(py_removed); + % rejected_peaks = double(py_removed); + peak_list = peak_list(logical(binary_peak_list)); + msg = sprintf(['Heart beat detection from PPG with HeartPy ',... 'HB-timeseries added to data on %s'],... datetime("today")); hb = peak_list(:) / sr; - catch - msg = sprintf('HeartPy did not find any heart beats on %s', datetime("today")); - hb = []; +catch ME + warning('ID:heartpy_error', 'HeartPy processing failed: %s', ME.message); + return; end else %% large spike mode @@ -148,6 +153,7 @@ % to compensate for absolute value and therefore twice as mani maxima) [pks,pis] = findpeaks(abs(ppg),... 'MinPeakDistance',30/200*sr); + if isempty(pks),warning('ID:NoPulse', 'No pulse found, nothing done.');return;end % to protect from 0/0 -> NaN % Ensure at least one spike is removed by adapting quantil to realistic % values, given number of detected spikes q = floor(length(pks)*(1-options.lsm/100))/length(pks); @@ -230,6 +236,8 @@ [~,hb] = findpeaks(ppg_corr/max(ppg_corr),... sr,... 'MinPeakdistance',min_pulse_period/sr); + msg = sprintf('Heart beat detection from ppg with cross correlation HB-timeseries added to data on %s', datetime("today")); + end @@ -237,7 +245,6 @@ %-------------------------------------------------------------------------- % save data fprintf('Saving data.'); -msg = sprintf('Heart beat detection from ppg with cross correlation HB-timeseries added to data on %s', datetime("today")); newdata.data = hb(:); newdata.header.sr = 1; diff --git a/src/pspm_options.m b/src/pspm_options.m index 6d14caaa..2bb064d2 100644 --- a/src/pspm_options.m +++ b/src/pspm_options.m @@ -108,7 +108,8 @@ options = autofill_channel_action(options); options = autofill(options, 'channel', 'ppg', '*Int*Char' ); options = autofill(options, 'diagnostics', 0, 1 ); - options = autofill(options, 'lsm', 0, [0,100-10^-10] ); + options = autofill(options, 'lsm', 0, 'integer>=', 0 ); + options = autofill(options, 'lsm', 0, '<', 100 ); options = autofill(options, 'method', 'classic', {'classic', 'heartpy'}); options = autofill(options, 'python_path', '', '*Char' ); options = autofill(options, 'missing', {}, '*Char*Num' ); diff --git a/test/pspm_convert_ppg2hb_test.m b/test/pspm_convert_ppg2hb_test.m index 22c5fe6d..dace4869 100644 --- a/test/pspm_convert_ppg2hb_test.m +++ b/test/pspm_convert_ppg2hb_test.m @@ -41,6 +41,17 @@ function invalid_input(this) options.method = 'wrong_method'; this.verifyWarning(@() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); + +options = struct('channel_action', 'wrong'); +this.verifyWarning( @() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); + +options = struct('lsm', 1000); +this.verifyWarning( @() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); + +options = struct('diagnostics', 2); +this.verifyWarning( @() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); + + end function basic_conversion_classic(this) @@ -129,6 +140,7 @@ function no_pulse_found(this) outdata.data = data; outdata.options.overwrite = 1; + nsts = pspm_load_data(fn, outdata); this.verifyEqual(nsts, 1); @@ -139,6 +151,132 @@ function no_pulse_found(this) options.diagnostics = false; this.verifyWarning(@() pspm_convert_ppg2hb(fn, options), 'ID:NoPulse'); +end + +function one_pulse_found(this) + + fn = this.input_filename; + + [nsts, infos, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + for i = 1:numel(data) + if strcmpi(data{i}.header.chantype, 'ppg') + data{i}.data(:) = 0; + + middle_sample = round(numel(data{i}.data) / 2); + data{i}.data(middle_sample) = 1; + + break; + end + end + + outdata.infos = infos; + outdata.data = data; + outdata.options.overwrite = 1; + + nsts = pspm_load_data(fn, outdata); + this.verifyEqual(nsts, 1); + + options = struct(); + options.method = 'classic'; + options.channel = 'ppg'; + options.channel_action = 'add'; + options.diagnostics = false; + + this.verifyWarning( @() pspm_convert_ppg2hb(fn, options), 'ID:OnePulse'); + +end + +function selects_last_ppg_channel_by_default(this) + + fn = this.input_filename; + + [nsts, infos, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + % Find an existing valid PPG channel. + ppg_index = []; + + for i = 1:numel(data) + if strcmpi(data{i}.header.chantype, 'ppg') + ppg_index = i; + break; + end + end + + this.assertFalse(isempty(ppg_index)); + + % Add a second PPG channel containing no pulses. + second_ppg = data{ppg_index}; + second_ppg.data(:) = 0; + + data{end + 1} = second_ppg; + + outdata.infos = infos; + outdata.data = data; + outdata.options.overwrite = 1; + + nsts = pspm_load_data(fn, outdata); + this.verifyEqual(nsts, 1); + + options = struct(); + options.method = 'classic'; + options.channel = 'ppg'; + options.channel_action = 'add'; + options.diagnostics = false; + + % Default 'ppg' must select the last PPG channel. + this.verifyWarning( @() pspm_convert_ppg2hb(fn, options), 'ID:NoPulse'); + +end + +function selects_ppg_channel_by_index(this) + fn = this.input_filename; + + [nsts, infos, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + ppg_index = []; + + for i = 1:numel(data) + if strcmpi(data{i}.header.chantype, 'ppg') + ppg_index = i; + break; + end + end + + this.assertFalse(isempty(ppg_index)); + + % Add invalid second PPG channel. + second_ppg = data{ppg_index}; + second_ppg.data(:) = 0; + data{end + 1} = second_ppg; + + outdata.infos = infos; + outdata.data = data; + outdata.options.overwrite = 1; + + nsts = pspm_load_data(fn, outdata); + this.verifyEqual(nsts, 1); + + options = struct(); + options.method = 'classic'; + options.channel = ppg_index; + options.channel_action = 'add'; + options.diagnostics = false; + + [sts, outchannel] = pspm_convert_ppg2hb(fn, options); + + this.verifyEqual(sts, 1); + this.verifyGreaterThan(outchannel, 0); + + [nsts, ~, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + this.verifyEqual(data{outchannel}.header.chantype, 'hb'); + +end + end end -end \ No newline at end of file diff --git a/test/pspm_testdata_gen.m b/test/pspm_testdata_gen.m index 9a8f5515..2244f606 100755 --- a/test/pspm_testdata_gen.m +++ b/test/pspm_testdata_gen.m @@ -1,5 +1,5 @@ function outfile = pspm_testdata_gen(channels, duration, filename) - % ● Description +% ● Description % SCR_TESTDATA_GEN generates simple testdata % % The function generates testdata for multiple channels. For continuous From be495ce46d7f59910688ec15f5a1011c853328f4 Mon Sep 17 00:00:00 2001 From: 4gwe Date: Thu, 10 Sep 2026 17:27:39 +0200 Subject: [PATCH 3/7] CI: Install HeartPy and add pspm_convert_ppg2hb tests --- .github/workflows/main.yml | 7 +- test/pspm_convert_ppg2hb_test.m | 171 +++++++++++++++++++++++++++++++- test/pspm_test.m | 2 + test/pspm_test_github_actions.m | 2 + 4 files changed, 176 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2c7edc2d..2d3ccb9e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,8 +20,11 @@ jobs: with: python-version: '3.10' - - name: Install bioread - run: python -m pip install bioread==3.0.1 + - name: Install Python dependencies + run: python -m pip install bioread==3.0.1 heartpy + - name: Verify Python dependencies + run: python -c "import bioread, heartpy; print('Python dependencies available')" + - name: Detect python run: python 'test/pspm_py_find_location.py' diff --git a/test/pspm_convert_ppg2hb_test.m b/test/pspm_convert_ppg2hb_test.m index dace4869..822ff805 100644 --- a/test/pspm_convert_ppg2hb_test.m +++ b/test/pspm_convert_ppg2hb_test.m @@ -7,6 +7,10 @@ input_filename = fullfile('ImportTestData', 'ppg', 'totest_pspm_SCAN_test.mat'); end +properties (TestParameter) + method = {'classic', 'heartpy'}; +end + methods (TestClassSetup) function check_original_file(this) this.assertTrue(exist(this.original_filename, 'file') == 2, ... @@ -51,14 +55,25 @@ function invalid_input(this) options = struct('diagnostics', 2); this.verifyWarning( @() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); +options = struct('lsm', 10.5); +this.verifyWarning( @() pspm_convert_ppg2hb(this.input_filename, options), 'ID:invalid_input'); end -function basic_conversion_classic(this) +function basic_conversion_classic(this,method) + + if strcmp(method, 'heartpy') + psts = pspm_check_python; + this.assumeEqual(psts, 1, 'Python is not available.'); + + [psts, ~] = pspm_check_python_modules('heartpy'); + this.assumeEqual(psts, 1, 'HeartPy is not available.'); + end + fn = this.input_filename; options = struct(); -options.method = 'classic'; +options.method = method; options.channel = 'ppg'; options.channel_action = 'add'; options.diagnostics = false; @@ -89,11 +104,11 @@ function basic_conversion_classic(this) % 'Heart beat detection from ppg')); end -function channel_action_add_replace(this) +function channel_action_add_replace(this,method) fn = this.input_filename; options = struct(); -options.method = 'classic'; +options.method = method; options.channel = 'ppg'; options.channel_action = 'add'; options.diagnostics = false; @@ -278,5 +293,153 @@ function selects_ppg_channel_by_index(this) end +function basic_conversion_with_lsm(this) + fn = this.input_filename; + + options = struct(); + options.method = 'classic'; + options.channel = 'ppg'; + options.channel_action = 'add'; + options.diagnostics = false; + options.lsm = 10; + + [sts, outchannel] = pspm_convert_ppg2hb(fn, options); + + this.verifyEqual(sts, 1); + + [nsts, ~, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + this.verifyEqual(data{outchannel}.header.chantype, 'hb'); + this.verifyGreaterThan(numel(data{outchannel}.data), 1); + this.verifyTrue(all(diff(data{outchannel}.data) > 0)); + +end + +function no_pulse_found_with_lsm(this) + fn = this.input_filename; + + [nsts, infos, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + for i = 1:numel(data) + if strcmpi(data{i}.header.chantype, 'ppg') + data{i}.data(:) = 0; + break; + end + end + + outdata.infos = infos; + outdata.data = data; + outdata.options.overwrite = 1; + + nsts = pspm_load_data(fn, outdata); + this.verifyEqual(nsts, 1); + + options = struct(); + options.method = 'classic'; + options.channel = 'ppg'; + options.channel_action = 'add'; + options.diagnostics = false; + options.lsm = 10; + + this.verifyWarning( ... + @() pspm_convert_ppg2hb(fn, options), ... + 'ID:NoPulse'); + +end + +function heartpy_rejects_invalid_peak(this) + + % Skip test if Python / HeartPy is unavailable. + psts = pspm_check_python; + this.assumeEqual(psts, 1, 'Python is not available.'); + + [psts, ~] = pspm_check_python_modules('heartpy'); + this.assumeEqual(psts, 1, 'HeartPy is not available.'); + + fn = this.input_filename; + + [nsts, infos, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + % Find PPG channel. + ppg_index = []; + + for i = 1:numel(data) + if strcmpi(data{i}.header.chantype, 'ppg') + ppg_index = i; + break; + end + end + + this.assertFalse(isempty(ppg_index)); + + sr = data{ppg_index}.header.sr; + n_samples = numel(data{ppg_index}.data); + duration = (n_samples - 1) / sr; + + % Generate regular beats every second. + beat_times = (2:1:floor(duration)-2)'; + + % Add one premature beat 200 ms after a regular beat. + middle = round(numel(beat_times) / 2); + preceding_beat = beat_times(middle); + artifact_time = preceding_beat + 0.2; + + impulses = zeros(n_samples, 1); + + beat_indices = round(beat_times * sr) + 1; + artifact_index = round(artifact_time * sr) + 1; + + impulses(beat_indices) = 1; + impulses(artifact_index) = 1; + + % Turn impulses into smooth PPG-like pulses. + pulse_t = (-round(0.08 * sr):round(0.08 * sr))' / sr; + pulse = exp(-0.5 * (pulse_t / 0.02).^2); + + synthetic_ppg = conv(impulses, pulse, 'same'); + + data{ppg_index}.data = synthetic_ppg; + + % Save synthetic PPG. + outdata.infos = infos; + outdata.data = data; + outdata.options.overwrite = 1; + + nsts = pspm_load_data(fn, outdata); + this.verifyEqual(nsts, 1); + + % Run PsPM HeartPy conversion. + options = struct(); + options.method = 'heartpy'; + options.channel = ppg_index; + options.channel_action = 'add'; + options.diagnostics = false; + + [sts, outchannel] = pspm_convert_ppg2hb(fn, options); + this.verifyEqual(sts, 1); + + [nsts, ~, data] = pspm_load_data(fn); + this.verifyEqual(nsts, 1); + + hb = data{outchannel}.data; + + tolerance = 0.05; + + % Normal surrounding beats must be present. + this.verifyTrue( ... + any(abs(hb - preceding_beat) < tolerance)); + + this.verifyTrue( ... + any(abs(hb - (preceding_beat + 1)) < tolerance)); + + % Premature peak must have been rejected by HeartPy. + this.verifyFalse( ... + any(abs(hb - artifact_time) < tolerance)); + +end + end end diff --git a/test/pspm_test.m b/test/pspm_test.m index 1a546bee..ac9319b8 100644 --- a/test/pspm_test.m +++ b/test/pspm_test.m @@ -24,6 +24,8 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_bf_test), ... TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... + TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... + TestSuite.fromClass(?pspm_convert_hb2hp_test), ... TestSuite.fromClass(?pspm_dcm_test), ... TestSuite.fromClass(?pspm_doc_test), ... TestSuite.fromClass(?pspm_combine_markerchannels_test), ... diff --git a/test/pspm_test_github_actions.m b/test/pspm_test_github_actions.m index 3066a4fb..81907f34 100644 --- a/test/pspm_test_github_actions.m +++ b/test/pspm_test_github_actions.m @@ -23,6 +23,8 @@ function pspm_test_github_actions(varargin) TestSuite.fromClass(?pspm_bf_test), ... TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... + TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... + TestSuite.fromClass(?pspm_convert_hb2hp_test), ... TestSuite.fromClass(?pspm_check_python_test), ... TestSuite.fromClass(?pspm_check_python_modules_test), ... TestSuite.fromClass(?pspm_dcm_test), ... From 8ddb9f52e248985eeededeee7a50e5e3f36bbaf3 Mon Sep 17 00:00:00 2001 From: 4gwe Date: Fri, 11 Sep 2026 13:26:22 +0200 Subject: [PATCH 4/7] Test: Extend pspm_convert_ppg2hb coverage and add HeartPy CI support --- .github/workflows/main.yml | 3 ++- src/pspm_convert_ppg2hb.m | 5 ++--- test/pspm_convert_ppg2hb_test.m | 11 ++++++++++- test/pspm_test.m | 3 ++- test/pspm_test_github_actions.m | 3 ++- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d3ccb9e..a2b90ca2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,8 @@ jobs: python-version: '3.10' - name: Install Python dependencies - run: python -m pip install bioread==3.0.1 heartpy + run: python -m pip install bioread==3.0.1 heartpy==1.2.7 + - name: Verify Python dependencies run: python -c "import bioread, heartpy; print('Python dependencies available')" diff --git a/src/pspm_convert_ppg2hb.m b/src/pspm_convert_ppg2hb.m index e64583b6..79f1d626 100644 --- a/src/pspm_convert_ppg2hb.m +++ b/src/pspm_convert_ppg2hb.m @@ -128,13 +128,12 @@ try tup = py.heartpy.process(filtered_ppg, pyargs('sample_rate', sr)); wd = tup{1}; - m = tup{2}; + % m = tup{2}; py_peak_list = py.array.array('d',(wd{'peaklist'})); binary_peak_list = double(py.array.array('d', wd{'binary_peaklist'})); - peak_list = double(py_peak_list) ; - % rejected_peaks = double(py_removed); + peak_list = double(py_peak_list); peak_list = peak_list(logical(binary_peak_list)); msg = sprintf(['Heart beat detection from PPG with HeartPy ',... diff --git a/test/pspm_convert_ppg2hb_test.m b/test/pspm_convert_ppg2hb_test.m index 822ff805..de06a492 100644 --- a/test/pspm_convert_ppg2hb_test.m +++ b/test/pspm_convert_ppg2hb_test.m @@ -60,7 +60,7 @@ function invalid_input(this) end -function basic_conversion_classic(this,method) +function basic_conversion(this,method) if strcmp(method, 'heartpy') psts = pspm_check_python; @@ -105,6 +105,15 @@ function basic_conversion_classic(this,method) end function channel_action_add_replace(this,method) + +if strcmp(method, 'heartpy') + psts = pspm_check_python; + this.assumeEqual(psts, 1, 'Python is not available.'); + + [psts, ~] = pspm_check_python_modules('heartpy'); + this.assumeEqual(psts, 1, 'HeartPy is not available.'); +end + fn = this.input_filename; options = struct(); diff --git a/test/pspm_test.m b/test/pspm_test.m index ac9319b8..637518cc 100644 --- a/test/pspm_test.m +++ b/test/pspm_test.m @@ -25,7 +25,8 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + % TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_dcm_test), ... TestSuite.fromClass(?pspm_doc_test), ... TestSuite.fromClass(?pspm_combine_markerchannels_test), ... diff --git a/test/pspm_test_github_actions.m b/test/pspm_test_github_actions.m index 81907f34..ae9731f5 100644 --- a/test/pspm_test_github_actions.m +++ b/test/pspm_test_github_actions.m @@ -24,7 +24,8 @@ function pspm_test_github_actions(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + % TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_check_python_test), ... TestSuite.fromClass(?pspm_check_python_modules_test), ... TestSuite.fromClass(?pspm_dcm_test), ... From 57fabfd4ca23ce82c7d8644cbb42d329996ad8c1 Mon Sep 17 00:00:00 2001 From: 4gwe Date: Fri, 11 Sep 2026 13:34:32 +0200 Subject: [PATCH 5/7] fix --- test/pspm_test.m | 1 - test/pspm_test_github_actions.m | 1 - 2 files changed, 2 deletions(-) diff --git a/test/pspm_test.m b/test/pspm_test.m index 637518cc..079e2488 100644 --- a/test/pspm_test.m +++ b/test/pspm_test.m @@ -25,7 +25,6 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - % TestSuite.fromClass(?pspm_convert_hb2hp_test), ... TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_dcm_test), ... TestSuite.fromClass(?pspm_doc_test), ... diff --git a/test/pspm_test_github_actions.m b/test/pspm_test_github_actions.m index ae9731f5..789a49df 100644 --- a/test/pspm_test_github_actions.m +++ b/test/pspm_test_github_actions.m @@ -24,7 +24,6 @@ function pspm_test_github_actions(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - % TestSuite.fromClass(?pspm_convert_hb2hp_test), ... TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_check_python_test), ... TestSuite.fromClass(?pspm_check_python_modules_test), ... From 7f81fa537c33e41f59abd84475a43fe0c299ca2c Mon Sep 17 00:00:00 2001 From: 4gwe Date: Fri, 11 Sep 2026 16:21:17 +0200 Subject: [PATCH 6/7] fix --- test/pspm_test.m | 3 ++- test/pspm_test_github_actions.m | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/pspm_test.m b/test/pspm_test.m index 079e2488..5f46ccc4 100644 --- a/test/pspm_test.m +++ b/test/pspm_test.m @@ -25,7 +25,8 @@ function pspm_test(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... + ... TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + ... TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_dcm_test), ... TestSuite.fromClass(?pspm_doc_test), ... TestSuite.fromClass(?pspm_combine_markerchannels_test), ... diff --git a/test/pspm_test_github_actions.m b/test/pspm_test_github_actions.m index 789a49df..2931d7aa 100644 --- a/test/pspm_test_github_actions.m +++ b/test/pspm_test_github_actions.m @@ -24,7 +24,8 @@ function pspm_test_github_actions(varargin) TestSuite.fromClass(?pspm_convert_gaze_test), ... TestSuite.fromClass(?pspm_convert_unit_test), ... TestSuite.fromClass(?pspm_convert_ecg2hb_test), ... - TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... + ... TestSuite.fromClass(?pspm_convert_hb2hp_test), ... + ... TestSuite.fromClass(?pspm_convert_ppg2hb_test), ... TestSuite.fromClass(?pspm_check_python_test), ... TestSuite.fromClass(?pspm_check_python_modules_test), ... TestSuite.fromClass(?pspm_dcm_test), ... From 98b670d0e17b524130463e14249a7f7c63034bbe Mon Sep 17 00:00:00 2001 From: 4gwe Date: Fri, 11 Sep 2026 16:35:25 +0200 Subject: [PATCH 7/7] small changes --- src/pspm_convert_ppg2hb.m | 1 - test/pspm_convert_ppg2hb_test.m | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pspm_convert_ppg2hb.m b/src/pspm_convert_ppg2hb.m index 79f1d626..0f6239a1 100644 --- a/src/pspm_convert_ppg2hb.m +++ b/src/pspm_convert_ppg2hb.m @@ -128,7 +128,6 @@ try tup = py.heartpy.process(filtered_ppg, pyargs('sample_rate', sr)); wd = tup{1}; - % m = tup{2}; py_peak_list = py.array.array('d',(wd{'peaklist'})); binary_peak_list = double(py.array.array('d', wd{'binary_peaklist'})); diff --git a/test/pspm_convert_ppg2hb_test.m b/test/pspm_convert_ppg2hb_test.m index de06a492..1556b162 100644 --- a/test/pspm_convert_ppg2hb_test.m +++ b/test/pspm_convert_ppg2hb_test.m @@ -428,7 +428,7 @@ function heartpy_rejects_invalid_peak(this) options.diagnostics = false; [sts, outchannel] = pspm_convert_ppg2hb(fn, options); - this.verifyEqual(sts, 1); + this.assertEqual(sts, 1); [nsts, ~, data] = pspm_load_data(fn); this.verifyEqual(nsts, 1);