diff --git a/.all-contributorsrc b/.all-contributorsrc index a3e0a5d..431b2ba 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -29,6 +29,17 @@ "infra" ] }, + { + "login": "beccaclements99", + "name": "Becca Clements", + "avatar_url": "https://avatars.githubusercontent.com/u/95257798?v=4", + "profile": "https://github.com/beccaclements99", + "contributions": [ + "bug", + "code", + "userTesting" + ] + }, { "login": "avigotsky", "name": "Andrew Vigotsky", @@ -38,6 +49,16 @@ "code" ] }, + { + "login": "ccomellalue", + "name": "ccomellalue", + "avatar_url": "https://avatars.githubusercontent.com/u/209748720?v=4", + "profile": "https://github.com/ccomellalue", + "contributions": [ + "code", + "ideas" + ] + }, { "login": "merelvdthiel", "name": "merelvdthiel", diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7d1c5..ccdce20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +# 0.28.3 (Thu Mar 12 2026) + +:tada: This release contains work from a new contributor! :tada: + +Thank you, Becca Clements ([@beccaclements99](https://github.com/beccaclements99)), for all your work! + +#### 🐛 Bug Fix + +- Fix usage of previously computed lag maps to run a (new) lagged regression [#154](https://github.com/smoia/phys2cvr/pull/154) ([@beccaclements99](https://github.com/beccaclements99) [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot]) [@smoia](https://github.com/smoia)) +- Give an error message if peaks are not provided AND comp_endtidal is True [#156](https://github.com/smoia/phys2cvr/pull/156) ([@beccaclements99](https://github.com/beccaclements99)) + +#### ⚠️ Pushed to `master` + +- tests: Reorganise tests, remove or mark xfails the failing ones ([@smoia](https://github.com/smoia)) +- docs: update licenses ([@smoia](https://github.com/smoia)) +- docs: Fix logos ([@smoia](https://github.com/smoia)) + +#### ⚠️ Tests + +- Fix workflow halting on missing peaks when they are not required (i.e. when skipping end-tidal convolution) [#153](https://github.com/smoia/phys2cvr/pull/153) ([@smoia](https://github.com/smoia) [@beccaclements99](https://github.com/beccaclements99) [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot])) + +#### 🏠 Internal + +- [pre-commit.ci] pre-commit autoupdate [#152](https://github.com/smoia/phys2cvr/pull/152) ([@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot])) +- Bump actions/checkout from 5 to 6 [#151](https://github.com/smoia/phys2cvr/pull/151) ([@dependabot[bot]](https://github.com/dependabot[bot])) + +#### Authors: 4 + +- [@dependabot[bot]](https://github.com/dependabot[bot]) +- [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot]) +- Becca Clements ([@beccaclements99](https://github.com/beccaclements99)) +- Stefano Moia ([@smoia](https://github.com/smoia)) + +--- + # 0.28.2 (Wed Nov 19 2025) #### 🐛 Bug Fix diff --git a/README.md b/README.md index c67265e..01caf92 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ phys2cvr [![Supports python version](https://img.shields.io/pypi/pyversions/phys2cvr?style=shield&logo=python)](https://pypi.org/project/phys2cvr/) -[![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat)](#contributors) +[![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat)](#contributors) A python-based tool to generate regressor for and/or estimate CVR maps and their lag. @@ -92,7 +92,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Stefano Moia
Stefano Moia

💻 🤔 🚇 📆 Kristina Zvolanek
Kristina Zvolanek

💻 🐛 🚇 + Becca Clements
Becca Clements

🐛 💻 📓 Andrew Vigotsky
Andrew Vigotsky

💻 + ccomellalue
ccomellalue

💻 🤔 + + merelvdthiel
merelvdthiel

📖 razkin
razkin

🎨 📖 ⚠️ @@ -108,7 +112,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d License ------- -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/cli/run.py b/phys2cvr/cli/run.py index e07b9f6..2597b92 100644 --- a/phys2cvr/cli/run.py +++ b/phys2cvr/cli/run.py @@ -701,7 +701,7 @@ def _check_opt_conf(parser): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/io.py b/phys2cvr/io.py index 60cf05f..4028c79 100644 --- a/phys2cvr/io.py +++ b/phys2cvr/io.py @@ -119,6 +119,7 @@ def load_txt(fname, shape=None): '.txt': ' ', '.1d': ' ', '.par': ' ', + '': ' ', } mtx = np.genfromtxt(fname, delimiter=delimiter_map.get(ext)) @@ -235,10 +236,11 @@ def load_array(fname, shape=''): """ _, _, ext = utils.check_ext(EXT_ARRAY, fname, scan=True, remove=True) - if ext.lower() in EXT_1D: - return load_txt(fname, shape=shape) - if ext.lower() in EXT_MAT: - return load_mat(fname, shape=shape) + if ext: + if ext.lower() in EXT_1D: + return load_txt(fname, shape=shape) + if ext.lower() in EXT_MAT: + return load_mat(fname, shape=shape) raise NotImplementedError( f'{fname} file extension {ext} was not found or is not supported yet' @@ -268,7 +270,7 @@ def load_physio(fname): def export_regressor( - regressors_matrix, ntp, outname, suffix='petco2hrf', ext='.1D', axis=-1 + regressors_matrix, ntp, outprefix, suffix='petco2hrf', ext='.1D', axis=-1 ): """ Export generated regressors for fMRI analysis. @@ -279,7 +281,7 @@ def export_regressor( The regressors that needs to be exported, in its original sample ntp : int The number of fMRI timepoints - outname : str or path + outprefix : str or path Prefix of the output file - can contain a path. suffix : str, optional The suffix of the output file. @@ -301,7 +303,7 @@ def export_regressor( regressors_demeaned = regressors_matrix - regressors_matrix.mean( axis=axis, keepdims=True ) - np.savetxt(f'{outname}_{suffix}{ext}', regressors_demeaned, fmt='%.6f') + np.savetxt(f'{outprefix}_{suffix}{ext}', regressors_demeaned, fmt='%.6f') return regressors_demeaned @@ -324,7 +326,7 @@ def export_nifti(data, img, fname): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/regressors.py b/phys2cvr/regressors.py index 9b42de7..c213666 100644 --- a/phys2cvr/regressors.py +++ b/phys2cvr/regressors.py @@ -100,7 +100,7 @@ def compute_petco2hrf( 'Arrays with more than 2 dimensions are not supported.' ) - if comp_endtidal: + if comp_endtidal and pidx is not None: petco2 = endtidal_interpolation(co2, pidx, axis=-1) # Plot PetCO2 vs CO2 @@ -111,6 +111,10 @@ def compute_petco2hrf( # Demean and export petco2 = petco2 - petco2.mean() np.savetxt(f'{outprefix}_petco2.1D', petco2, fmt='%.18f') + elif comp_endtidal and pidx is None: + raise ValueError( + 'End-tidal interpolation was requested, but peaks were not provided.' + ) else: LGR.info( 'Skipping End Tidal interpolation of PetCO2 trace (if you provided raw CO2 ' @@ -412,7 +416,7 @@ def create_physio_regressor( """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/signal.py b/phys2cvr/signal.py index b290c85..597d079 100644 --- a/phys2cvr/signal.py +++ b/phys2cvr/signal.py @@ -70,7 +70,7 @@ def create_hrf(freq=40): u = np.arange(0, p[6] / dt + 1, 1) - p[5] / dt a1 = p[0] / p[2] - b1 = 1 / p[3] + b1 = 1 / p[2] a2 = p[1] / p[3] b2 = 1 / p[3] @@ -325,7 +325,7 @@ def resample_signal_freqs(ts, freq1, freq2, axis=-1): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/stats.py b/phys2cvr/stats.py index e1fa273..df3f6b6 100644 --- a/phys2cvr/stats.py +++ b/phys2cvr/stats.py @@ -365,7 +365,7 @@ def regression( """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/tests/test_io.py b/phys2cvr/tests/test_io.py index f875211..54e5ec3 100644 --- a/phys2cvr/tests/test_io.py +++ b/phys2cvr/tests/test_io.py @@ -2,9 +2,14 @@ """Tests for io.""" import os +import sys import nibabel as nib import numpy as np +import peakdet as pk +import pymatreader +import pytest +from scipy.io import savemat from phys2cvr import io @@ -27,8 +32,78 @@ def test_load_nifti_get_mask(nifti_data, nifti_mask): assert isinstance(img, nib.nifti1.Nifti1Image) +@pytest.mark.parametrize( + 'extension, delimiter', + [ + ['.csv', ','], + ['.csv.gz', ','], + ['.tsv', '\t'], + ['.tsv.gz', '\t'], + ['.txt', ' '], + ['.1d', ' '], + ['.par', ' '], + ['', ' '], + ], +) +def test_load_txt(extension, delimiter, testdir): + """Test load_txt.""" + a = np.random.randn(5) + n = os.join.path(testdir, f'zoe{extension}') + np.savetxt(n, a, delimiter=delimiter) + b = io.load_txt(n) + + assert (a == b).all() + os.remove(n) + + +def test_load_mat(): + """Test load_mat.""" + a = np.random.randn(5) + b = 'likealeaf' + n = 'inthewind' + + savemat(n, {'data': a, 'other': b}) + + c = io.load_mat(n) + + assert (a == c).all() + os.remove(n) + + +def test_load_array(): + """Test load_mat.""" + a = np.random.randn(5) + b = 'likealeaf' + n = 'inthewind.mat' + m = 'zoe.txt' + np.savetxt(m, a) + + savemat(n, {'data': a, 'other': b}) + + d = io.load_array(n) + e = io.load_array(m) + + assert (a == d).all() + assert (a == e).all() + os.remove(n) + os.remove(m) + + +def test_load_physio(): + d = pk.Physio(np.random.randn(40), fs=10) + d = pk.peakfind_physio(d) + n = 'kaylee.phys' + pk.save_physio(n, d) + p = io.load_physio(n) + + assert (p[0] == d.data).all() + assert (p[1] == d.peaks).all() + assert p[2] == 10 + os.remove(n) + + def test_export_regressor(testdir): - petco2hrf_shift = np.random.rand(10) + petco2hrf_shift = np.random.randn(10) ntp = 10 outname = os.path.join(testdir, 'test_regressor') suffix = 'petco2hrf' @@ -49,7 +124,7 @@ def test_export_regressor(testdir): def test_export_nifti(testdir): # create some test data - data = np.random.rand(10, 10, 10) + data = np.random.randn(10, 10, 10) affine = np.eye(4) header = nib.Nifti1Header() img = nib.Nifti1Image(data, affine, header) @@ -66,8 +141,39 @@ def test_export_nifti(testdir): assert np.allclose(out_img.get_fdata(), data, atol=1e-6, rtol=0) assert np.allclose(out_img.affine, affine, atol=1e-6, rtol=0) assert out_img.header.__dict__.keys() == header.__dict__.keys() + os.remove(fname) # Eventually check that headers have the same content (although they don't now # for good reasons!) # ## Break tests +def test_break_load_mat(): + """Break load_mat.""" + sys.modules['pymatreader'] = None + with pytest.raises(ImportError) as errorinfo: + io.load_mat('simon') + assert 'is required' in str(errorinfo.value) + sys.modules['pymatreader'] = pymatreader + + a = 'heart' + n = 'ofgold' + savemat(n, {'data': a}) + + with pytest.raises(EOFError) as errorinfo: + io.load_mat(n) + assert f'{n} does not seem' in str(errorinfo.value) + os.remove(n) + + +def test_break_load_xls(): + """Break load_xls.""" + with pytest.raises(NotImplementedError) as errorinfo: + io.load_xls('firefly') + assert 'loading is not' in str(errorinfo.value) + + +def test_break_load_array(): + """Break load_xls.""" + with pytest.raises(NotImplementedError) as errorinfo: + io.load_array('firefly.leaf') + assert 'file extension' in str(errorinfo.value) diff --git a/phys2cvr/tests/test_regressors.py b/phys2cvr/tests/test_regressors.py new file mode 100644 index 0000000..3e66fa6 --- /dev/null +++ b/phys2cvr/tests/test_regressors.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Tests for io.""" + +import os +from unittest.mock import patch + +import numpy as np +import pytest + +from phys2cvr import regressors + +# ## Unit tests + + +def test_create_legendre(): + L = regressors.create_legendre(3, 10) + assert L.shape == (10, 4) + + L = regressors.create_legendre(1, 5) + assert np.allclose(L[:, 1], np.linspace(-1, 1, 5)) + + L = regressors.create_legendre(0, 7) + assert L.shape == (7, 1) + assert np.all(L[:, 0] == 1) + + +@patch( + 'phys2cvr.regressors.endtidal_interpolation', return_value=np.array([1, 2, 3, 4]) +) +@patch('phys2cvr.regressors.plot_two_timeseries') +@patch('phys2cvr.regressors.convolve_signal', return_value=np.array([9, 8, 7, 6])) +def test_compute_petco2hrf(mock_conv, mock_plot, mock_endtidal, testdir): + out = os.path.join(testdir, 'Lune') + co2 = np.array([1, 2, 3, 4]) + pidx = np.array([0, 2]) + r = regressors.compute_petco2hrf(co2, pidx, 1.0, out) + assert np.all(r == np.array([9, 8, 7, 6])) + os.remove(f'{out}_petco2.1D') + + +def test_compute_petco2hrf_skip(testdir): + out = os.path.join(testdir, 'Catherine') + r = regressors.compute_petco2hrf( + np.array([1, 2, 3]), + np.array([0]), + 1.0, + out, + comp_endtidal=False, + response_function=None, + ) + assert np.allclose(r, np.array([-1, 0, 1])) # returns petco2 demeaned + + +@patch('phys2cvr.regressors.x_corr', return_value=(None, 3, np.array([1, 2, 3]))) +@patch('phys2cvr.regressors.plot_xcorr') +def test_compute_bulk_shift(mock_plotx, mock_xcorr, testdir): + out = os.path.join(testdir, 'Monoco') + func = np.arange(10.0) + pet = np.arange(20.0) + s = regressors.compute_bulk_shift(func, pet, 1.0, out) + assert s == 3 + assert os.path.exists(f'{out}_optshift.1D') + os.remove(f'{out}_optshift.1D') + + +@patch('phys2cvr.regressors.export_regressor', return_value=np.zeros((5, 5))) +def test_create_fine_shift_regressors(mock_export, testdir): + out = os.path.join(testdir, 'Esquie') + pet = np.arange(50.0) + r = regressors.create_fine_shift_regressors(pet, 3, 2, 1.0, 10, 20, out) + assert r.shape == (5, 5) + + +@patch('phys2cvr.regressors.export_regressor', return_value=np.zeros((3, 3))) +def test_create_fine_shift_regressors_padding(mock_export, testdir): + out = os.path.join(testdir, 'Maelle') + pet = np.arange(10.0) + r = regressors.create_fine_shift_regressors(pet, 9, 4, 1.0, 5, 8, out) + assert r.shape == (3, 3) + + +@patch('phys2cvr.regressors.resample_signal_freqs', return_value=np.arange(20.0)) +@patch('phys2cvr.regressors.plot_two_timeseries') +@patch('phys2cvr.regressors.export_regressor', return_value=np.arange(10.0)) +def test_create_physio_regressor(mock_exp, mock_plot, mock_bs, testdir): + out = os.path.join(testdir, 'Sciel') + func = np.arange(10.0) + pet = np.arange(30.0) + d, lags = regressors.create_physio_regressor( + func, pet, 1.0, 1.0, out, lag_max=2, skip_xcorr=True + ) + assert d.shape == (10,) + assert lags is not None + + +@patch('phys2cvr.regressors.resample_signal_freqs', return_value=np.arange(20.0)) +@patch('phys2cvr.regressors.export_regressor', return_value=np.arange(10.0)) +def test_create_physio_regressor_no_lag_max(mock_exp, mock_bs, testdir): + out = os.path.join(testdir, 'Francois') + func = np.arange(10.0) + pet = np.arange(30.0) + d, lags = regressors.create_physio_regressor( + func, pet, 1.0, 1.0, out, lag_max=None, skip_xcorr=True + ) + assert d.shape == (10,) + assert lags is None + os.remove(f'{out}_petco2hrf_vs_avgroi.png') + + +# ## Break tests + + +def test_break_compute_petco2hrf(): + with pytest.raises(NotImplementedError) as errorinfo: + regressors.compute_petco2hrf(np.zeros((2, 2)), np.array([0]), 1.0, 'x') + assert 'Arrays with more' in str(errorinfo.value) diff --git a/phys2cvr/tests/test_signal.py b/phys2cvr/tests/test_signal.py index 84ae188..0c302c4 100644 --- a/phys2cvr/tests/test_signal.py +++ b/phys2cvr/tests/test_signal.py @@ -2,8 +2,11 @@ """Tests for io.""" import os +from pathlib import PosixPath +from unittest.mock import patch import numpy as np +import pytest from phys2cvr import signal @@ -62,3 +65,99 @@ def test_create_hrf(): expected_peak_index = 25 actual_peak_index = np.argmax(hrf) assert actual_peak_index == expected_peak_index + + +def test_filter_signal(): + x = np.sin(np.linspace(0, 10, 200)) + out = signal.filter_signal(x, tr=1.0) + assert out.shape == x.shape + + x = np.random.randn(5, 200) + out = signal.filter_signal(x, tr=1.0, axis=-1) + assert out.shape == x.shape + + +def test_endtidal_interpolation(): + sig = np.array([0, 1, 0, 2, 0]) + peaks = [1, 3] + out = signal.endtidal_interpolation(sig, peaks) + assert out.shape == sig.shape + assert np.allclose(out[[1, 3]], sig[[1, 3]]) + + sig = np.arange(10.0) + peaks = [8, 2, 5] + out = signal.endtidal_interpolation(sig, peaks) + assert out.shape == sig.shape + assert np.allclose(out[[2, 5, 8]], sig[[2, 5, 8]]) + + +def test_convolve_signal(testdir): + x = np.ones(20) + out = signal.convolve_signal(x, freq=10, response_function='hrf') + assert out.ndim == 1 + assert out.shape[0] > x.shape[0] + # Add assertion on first 20 points of hrf at 10 Hz) + + x = np.arange(10.0) + out = signal.convolve_signal(x, freq=10, response_function=None) + assert np.allclose(out, x) + + x = np.arange(5.0) + rf = [1, 0, -1] + out = signal.convolve_signal(x, freq=10, response_function=rf) + assert out.ndim == 1 + + p = os.path.join(testdir, 'rf.1D') + np.savetxt(p, np.array([1.0, 2.0, 3.0])) + x = np.ones(10) + with patch('phys2cvr.io.load_array', return_value=np.array([1.0, 2.0, 3.0])): + out = signal.convolve_signal(x, 10, response_function=str(p)) + assert out.ndim == 1 + + +def test_resample_signal_samples_basic(): + x = np.arange(10.0) + out = signal.resample_signal_samples(x, 20) + assert out.shape[0] == 20 + + x = np.random.randn(5, 10) + out = signal.resample_signal_samples(x, 15, axis=-1) + assert out.shape == (5, 15) + + +def test_resample_signal_freqs_basic(): + x = np.arange(10.0) + out = signal.resample_signal_freqs(x, 1, 2) + assert out.shape[0] > x.shape[0] + + x = np.random.randn(4, 10) + out = signal.resample_signal_freqs(x, 1, 3, axis=-1) + assert out.shape[1] > 10 + + +# ## Break tests + + +def test_break_convolve_signal_invalid_array(testdir): + x = np.arange(5.0) + rf = ['a', 'b'] + with pytest.raises(ValueError): + signal.convolve_signal(x, freq=10, response_function=rf) + + x = np.zeros((3, 3, 3)) + with pytest.raises(NotImplementedError): + signal.convolve_signal(x, freq=10) + + p = os.path.join(testdir, 'does_not_exist.1D') + x = np.ones(10) + with pytest.raises(OSError): + signal.convolve_signal(x, 10, response_function=PosixPath(p)) + + p = os.path.join(testdir, 'does_not_exist.1D') + x = np.ones(10) + with pytest.raises(NotImplementedError): + signal.convolve_signal(x, 10, response_function=str(p)) + + x = np.ones(10) + with pytest.raises(NotImplementedError): + signal.convolve_signal(x, 10, response_function='not_a_rf') diff --git a/phys2cvr/tests/test_stats.py b/phys2cvr/tests/test_stats.py index 8fbf474..cd06a8c 100644 --- a/phys2cvr/tests/test_stats.py +++ b/phys2cvr/tests/test_stats.py @@ -2,7 +2,160 @@ """Tests for io.""" import numpy as np +import pytest from phys2cvr import stats + # ## Unit tests +def test_x_corr(): + func = np.array([1.0, -2.0, 3.0]) + co2 = np.array([0.0, 1.0, -2.0, 3.0, 4.0]) + + # Manually compute expected correlations + # sco2 windows: [0,1,-2], [1,-2,3], [-2,3,4] + # zscores: + # func z: [-1.224745, 0, 1.224745] + # window z, then dot product / N + val, idx, arr = stats.x_corr(func, co2) + + sco2 = np.array([[0.0, 1.0, -2.0], [1.0, -2.0, 3.0], [-2.0, 3.0, 4.0]]) + # Pre-computed expected values + expected = np.corrcoef(func, sco2)[1:, 0] + + assert isinstance(val, float) + assert isinstance(idx, (int | np.int64 | np.int32)) + assert isinstance(arr, np.ndarray) + assert arr.shape[0] == co2.size - func.size + 1 + assert np.allclose(arr, expected, atol=1e-6) + assert val == pytest.approx(expected.max()) + assert idx == expected.argmax() + + func = np.array([1.0, -2.0, 1.0]) + co2 = np.array([1.0, -1.0, 2.0, -1.0, 1.0]) + sco2 = np.array([[1, -1, 2], [-1, 2, -1], [2, -1, 1]]) + + val, idx, arr = stats.x_corr(func, co2, abs_xcorr=True) + expected_corr = np.corrcoef(func, sco2)[1:, 0] + + assert val >= 0 + assert arr.size == 3 + assert np.allclose(arr, expected_corr) + assert val == pytest.approx(expected.max()) + assert idx == 1 + + +def test_ols(): + Y = np.array([1, 2, 3, 4], float) + X = np.column_stack([np.ones(4), np.arange(4)]) + betas, t, r = stats.ols(Y, X) + assert betas.shape == (2, 1) + assert t.shape == (2, 1) + assert r.shape == (1,) + assert np.isfinite(r).all() + + # Fit Y = b0*1 + b1*x → exact solution + X = np.column_stack([np.ones(5), np.arange(5)]) + Y = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + + betas, tstats, r = stats.ols(Y, X) + + assert np.allclose(betas.flatten(), [0.0, 1.0]) + assert r == pytest.approx(1.0) + + # t-stats for perfect fit → huge (but finite after clipping) + assert tstats[1, 0] > 1000 + + X = np.column_stack([np.ones(4), np.arange(4)]) + Y = np.array([1.0, 2.0, 1.0, 2.0]) # variance around intercept only + + _, _, r = stats.ols(Y, X, r2model='intercept') + + # r^2 intercept = R^2 of X vs intercept-only model + # Should be >0 because x explains alternating pattern + assert r[0] > 0.2 + + +def test_ols_residuals(): + X = np.column_stack([np.ones(3), np.arange(3)]) + Y = np.array([1.0, 3.0, 5.0]) + # Perfect Y = 1 + 2x + res = stats.ols(Y, X, residuals=True) + + assert res.shape == (3, 1) + assert np.allclose(res.flatten(), 0.0) + + +def test_regression(): + data = np.random.randn(4, 4, 4, 10) + regr = np.random.randn(10, 1) + b, t, r = stats.regression(data, regr) + assert b.shape == data.shape[:-1] + assert t.shape == data.shape[:-1] + assert r.shape == data.shape[:-1] + + mask = np.zeros((4, 4, 4)) + mask[1, 1, 1] = 1 + b, t, r = stats.regression(data, regr, mask=mask) + assert b[1, 1, 1] != 0 + assert b.sum() != 0 + assert r.shape == data.shape[:-1] + + data = np.zeros((1, 1, 1, 5)) + data[0, 0, 0] = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + + regr = np.arange(5)[:, None] # perfect predictor + + b, t, r = stats.regression(data, regr) + + assert b[0, 0, 0] == pytest.approx(1.0) + assert r[0, 0, 0] == pytest.approx(1.0) + assert t[0, 0, 0] > 1000 + + data = np.zeros((2, 2, 2, 4)) + data[..., :] = np.array([1.0, 2.0, 3.0, 4.0]) + + regr = np.arange(4)[:, None] + + mask = np.zeros((2, 2, 2)) + mask[0, 0, 0] = 1 + + b, t, r = stats.regression(data, regr, mask=mask) + + assert b[0, 0, 0] == pytest.approx(1.0) + assert r[0, 0, 0] == pytest.approx(1.0) + assert b.sum() == b[0, 0, 0] + assert r.sum() == r[0, 0, 0] + + +# ## Break tests + + +def test_break_x_corr(): + func = np.ones(5) + co2 = np.ones(6) + with pytest.raises(ValueError): + stats.x_corr(func, co2, offset=5) + + with pytest.raises(NotImplementedError): + stats.x_corr(func, co2, offset=-1) + + +def test_break_ols(): + Y = np.arange(5) + X = np.column_stack([np.ones(5), np.arange(5)]) + with pytest.raises(ValueError): + stats.ols(Y, X, r2model='invalid') + + Y = np.zeros((2, 2, 2)) + X = np.ones((2, 3)) + with pytest.raises(NotImplementedError): + stats.ols(Y, X) + + +def test_break_regression(): + data = np.random.randn(4, 4, 4, 10) + regr = np.random.randn(10, 1) + denoise = np.random.randn(9, 2) + with pytest.raises(ValueError): + stats.regression(data, regr, denoise_mat=denoise) diff --git a/phys2cvr/tests/test_utils.py b/phys2cvr/tests/test_utils.py index ad12bb0..148c15c 100644 --- a/phys2cvr/tests/test_utils.py +++ b/phys2cvr/tests/test_utils.py @@ -11,19 +11,18 @@ # ## Unit tests -@pytest.mark.xfail(strict=False, reason="Something's off with lists") @pytest.mark.parametrize( 'var, dtype, out', [ (10, 'int', 10), (10.0, 'int', 10), ('10', 'int', 10), - ([10], 'int', [10]), + ([10], 'int', 10), (None, 'int', None), (10, 'float', 10.0), (10.0, 'float', 10.0), ('10.0', 'float', 10.0), - ([10.0], 'float', [10.0]), + ([10.0], 'float', 10.0), (None, 'float', None), (10, 'str', '10'), (10.0, 'str', '10.0'), @@ -108,6 +107,19 @@ def test_break_if_declared_force_type(): assert 'not supported' in str(errorinfo.value) +# Check this one +@pytest.mark.parametrize( + 'var, dtype', + [ + ([10], 'int'), + ([10.0], 'float'), + ], +) +def test_if_declared_force_type_errors(var, dtype): + with pytest.raises(TypeError): + utils.if_declared_force_type(var, dtype) + + @pytest.mark.xfail(strict=False, reason='Message changed') def test_break_check_nifti_dim_missing_dims(): with pytest.raises(ValueError) as errorinfo: diff --git a/phys2cvr/utils.py b/phys2cvr/utils.py index 4754e73..8edf75b 100644 --- a/phys2cvr/utils.py +++ b/phys2cvr/utils.py @@ -246,7 +246,7 @@ def save_bash_call(fname, outdir): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/viz.py b/phys2cvr/viz.py index 5c8f4db..980d176 100644 --- a/phys2cvr/viz.py +++ b/phys2cvr/viz.py @@ -111,7 +111,7 @@ def plot_xcorr(xcorr, outprefix, freq=None): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/phys2cvr/workflows.py b/phys2cvr/workflows.py index 03a0276..df463f8 100755 --- a/phys2cvr/workflows.py +++ b/phys2cvr/workflows.py @@ -311,6 +311,7 @@ def phys2cvr( f'R^2 model {r2model} not supported. Supported models are {stats.R2MODEL}' ) + LGR.info('Load functional data') if func_is_1d: if tr: func_avg = io.load_array(fname_func) @@ -345,6 +346,7 @@ def phys2cvr( # Read mask (and mask func) if provided if fname_mask: + LGR.info('Load mask to restrict operations on functional data') _, mask, _ = io.load_nifti_get_mask(fname_mask, is_mask=True) if func.shape[:3] != mask.shape: raise ValueError(f'{fname_mask} and {fname_func} have different sizes!') @@ -364,6 +366,7 @@ def phys2cvr( # Read roi if provided if fname_roi: + LGR.info('Load ROI to obtain a reference from functional data') _, roi, _ = io.load_nifti_get_mask(fname_roi, is_mask=True) if func.shape[:3] != roi.shape: raise ValueError(f'{fname_roi} and {fname_func} have different sizes!') @@ -382,6 +385,7 @@ def phys2cvr( LGR.info(f'Obtaining filtered average signal in {roiref}') func_avg = signal.filter_signal(func_avg, tr, lowcut, highcut, butter_order) + LGR.info('Load physiological data') if fname_co2 is None: LGR.info(f'Computing "CVR" (approximation) maps using {fname_func} only') if func_is_1d: @@ -427,6 +431,9 @@ def phys2cvr( 'file containing its peaks was provided. ' ' Please provide peak file!' ) + else: + # pidx to None for compatibility + pidx = None if freq is None: raise NameError( @@ -462,6 +469,39 @@ def phys2cvr( else: petco2hrf = co2 + # If the user provided a lag map, read it and extract information from it + if lag_map: + LGR.info('Load lag map') + lag, _, _ = io.load_nifti_get_mask(lag_map) + if func.shape[:3] != lag.shape: + raise ValueError(f'{lag_map} and {fname_func} have different sizes!') + + # Read lag_step and lag_max from file (or try to) + lag = lag * mask + + lag_list = np.unique(lag[mask]) + + if lag_step is None: + # np.unique sorts results already + lag_step = np.unique(np.round(lag_list[1:] - lag_list[:-1], 3)) + if lag_step.size > 1: + # Check if extra steps found are multiple of the first within numerical tolerance + if not np.isclose(np.mod(lag_step, lag_step[0]), 0).all(): + raise ValueError( + f'phys2cvr found different delta lags in {lag_map}: {lag_step}' + ) + + lag_step = lag_step[0] + LGR.warning(f'phys2cvr detected a delta lag of {lag_step} seconds') + else: + LGR.warning(f'Forcing delta lag to be {lag_step}') + + if lag_max is None: + lag_max = np.abs(lag_list).max() + LGR.warning(f'phys2cvr detected a max lag of {lag_max} seconds') + else: + LGR.warning(f'Forcing max lag to be {lag_max}') + # If a regressor dir is specified, try load the data, # If failing or otherwise, compute the regressors. if run_regression and regr_dir is not None: @@ -476,35 +516,6 @@ def phys2cvr( regr_dir = None if regr_dir is None: - if lag_map: - lag, _, _ = io.load_nifti_get_mask(lag_map) - if func.shape[:3] != lag.shape: - raise ValueError(f'{lag_map} and {fname_func} have different sizes!') - - # Read lag_step and lag_max from file (or try to) - lag = lag * mask - - lag_list = np.unique(lag[mask > 0]) - - if lag_step is None: - lag_step = np.unique(np.round(lag_list[1:] - lag_list[:-1], 3)) - if lag_step.size > 1: - raise ValueError( - f'phys2cvr found different delta lags in {lag_map}' - ) - else: - lag_step = lag_step[0] - LGR.warning(f'phys2cvr detected a delta lag of {lag_step} seconds') - else: - LGR.warning(f'Forcing delta lag to be {lag_step}') - - step = int(lag_step * freq) - - if lag_max is None: - lag_max = np.abs(lag_list).max() - LGR.warning(f'phys2cvr detected a max lag of {lag_max} seconds') - else: - LGR.warning(f'Forcing max lag to be {lag_max}') regr, regr_shifts = create_physio_regressor( func_avg, petco2hrf, @@ -608,28 +619,16 @@ def phys2cvr( r_square, oimg, f'{fname_out_func}_r_square_simple{fname_ext}' ) - if ( - lagged_regression - and regr_shifts is not None - and ((lag_max and lag_step) or lag_map) - ): - if lag_max: - LGR.info( - f'Running lagged CVR estimation with max lag = {lag_max}! ' - '(might take a while...)' - ) - elif lag_map is not None: + if lagged_regression and regr_shifts is not None and (lag_max and lag_step): + # If user specified a lag map, run regression based on it (see "Load lag map") + if lag_map is not None: LGR.info( f'Running lagged CVR estimation with lag map {lag_map}! ' '(might take a while...)' ) - if legacy: - nrep = int(lag_max * freq * 2) - else: - nrep = int(lag_max * freq * 2) + 1 - # If user specified a lag map, use that one to regress things - if lag_map: + step = int(lag_step * freq) + lag_idx = np.round((lag + lag_max) * freq / step).astype(int) lag_idx_list = np.unique(lag_idx) @@ -637,8 +636,8 @@ def phys2cvr( beta = np.empty_like(lag, dtype='float32') tstat = np.empty_like(lag, dtype='float32') - for index, i in enumerate(lag_idx_list): - LGR.info(f'Perform L-GLM number {index + 1} of {len(lag_idx_list)}') + for n, i in enumerate(lag_idx_list): + LGR.info(f'Perform L-GLM number {n + 1} of {len(lag_idx_list)}') regr = regr_shifts[(i * step), :, np.newaxis] x1D = os.path.join(outdir, 'mat', f'mat_{i:04g}.1D') @@ -656,6 +655,16 @@ def phys2cvr( ) else: + LGR.info( + f'Running lagged CVR estimation with max lag = {lag_max}! ' + '(might take a while...)' + ) + + if legacy: + nrep = int(lag_max * freq * 2) + else: + nrep = int(lag_max * freq * 2) + 1 + # Check the number of repetitions first if lag_step: step = int(lag_step * freq) @@ -762,7 +771,7 @@ def _main(argv=None): """ -Copyright 2021-2025, Stefano Moia & phys2cvr contributors. +Copyright 2021-2026, Stefano Moia & phys2cvr contributors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.