diff --git a/.github/workflows/run_demos_python.yml b/.github/workflows/run_demos_python.yml new file mode 100644 index 0000000..3c700a9 --- /dev/null +++ b/.github/workflows/run_demos_python.yml @@ -0,0 +1,57 @@ +name: Python demos + +# Uses the cron schedule for github actions +# +# will run at 00H00 run on the 1 and 15 of every month +# +# https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#scheduled-events +# +# ┌───────────── minute (0 - 59) +# │ ┌───────────── hour (0 - 23) +# │ │ ┌───────────── day of the month (1 - 31) +# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC): * means all +# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT): * means all +# │ │ │ │ │ +# │ │ │ │ │ +# │ │ │ │ │ +# +# - cron "0 0 1,15 * *" + +on: + schedule: + - cron: "0 0 1,15 * *" + +jobs: + demos: + continue-on-error: true + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ["3.9"] + + steps: + - name: Shallow clone GLMsingle + uses: actions/checkout@v3 + with: + submodules: "recursive" + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools + pip install . + pip install -r requirements_dev.txt + + - name: Display Python version and packages + run: | + python -c "import sys; print(sys.version)" + pip list + + - name: Run notebooks + run: pytest --nbmake --nbmake-timeout=3000 "./examples" diff --git a/.github/workflows/run_tests_python.yml b/.github/workflows/run_tests_python.yml new file mode 100644 index 0000000..39f8b39 --- /dev/null +++ b/.github/workflows/run_tests_python.yml @@ -0,0 +1,54 @@ +name: Python tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["*"] + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest, windows-latest] + python-version: ["3.7", "3.8", "3.9"] # fails on 3.10 + fail-fast: true # cancel all jobs if one fails + + steps: + - name: Shallow clone GLMsingle + uses: actions/checkout@v3 + with: + submodules: "recursive" + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools + pip install . + pip install -r requirements_dev.txt + + - name: Display Python version and packages + run: | + python -c "import sys; print(sys.version)" + pip list + + - name: Prepare data + run: make tests/data/nsdcoreexampledataset.mat + + - name: Run tests and generate coverage report + run: pytest -v --cov glmsingle --cov-report xml tests + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + file: ./coverage.xml + flags: python + name: codecov-umbrella + fail_ci_if_error: true + verbose: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baaa794..43572ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Information for anyone who would like to contribute to this repository. ├── .git ├── .github │ └── workflows # Github continuous integration set up -├── examples +├── examples # Python demos: jupyter notebooks │ ├── data │ ├── example1outputs │ ├── example2outputs @@ -20,11 +20,11 @@ Information for anyone who would like to contribute to this repository. │ ├── ols │ ├── ssq │ └── utils -├── matlab # Matlab implementation -│ ├── examples +├── matlab # MATLAB implementation +│ ├── examples # MATLAB demos │ ├── fracridge │ └── utilities -└── tests # Python and Matlab tests +└── tests # Python and MATLAB tests └── data ``` @@ -47,10 +47,62 @@ Information for anyone who would like to contribute to this repository. ## Python +All the packages required to help with the python development of GLMsingle can +be installed with: + +```bash +pip install -r requirements_dev.txt +``` + ### Style guide +[black](https://black.readthedocs.io/en/stable/) (code formater) and +[flake8](https://flake8.pycqa.org/en/latest/) (style guide enforcement) are used +on the test code base. + +Ypu can use make to run them automatically with + +```bash +make lint/black # to run black +make lint/flake8 # to run flake8 +make lint # to run both +``` + ### Tests +The tests can be run with with pytest via the make command: + +```bash +make test-python +``` + #### Demos -### Continuous integration \ No newline at end of file +The jupyter notebook are tested with the +[`nbmake` plugin for pytest](https://pypi.org/project/nbmake/). + +They can be run with the make command: + +```bash +make test-notebooks +``` + +### Continuous integration + +We use Github to run several workflows for continuous integration. + +#### Tests + +The python tests are run by the workflow: +`.github/workflows/run_tests_python.yaml`. + +Those tests should be run with every push on the `master` branch and on pull +request that target the `master` branch. + +#### Demos + +The demos in the `examples` folder are run automatically in Github CI at regular +intervals. + +The jupyter notebooks are run by the workflow +`.github/workflows/run_demos_python.yaml`. diff --git a/Makefile b/Makefile index 75e39ce..5f9169f 100644 --- a/Makefile +++ b/Makefile @@ -97,17 +97,15 @@ lint/black: ## check style with black black tests test-python: tests/data/nsdcoreexampledataset.mat ## run tests quickly with the default Python - pytest + pytest -vv test-notebooks: - pytest --nbmake --nbmake-timeout=3000 "./examples" + pytest -vv --nbmake --nbmake-timeout=3000 "./examples" test-all: ## run tests on every Python version with tox tox coverage-python: ## check code coverage quickly with the default Python - coverage run --source glmsingle -m pytest - coverage report -m - coverage html + pytest -vv --cov glmsingle --cov-report html:htmlcov $(BROWSER) htmlcov/index.html install: clean ## install the package to the active Python's site-packages diff --git a/README.md b/README.md index 336ed01..47a23e0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + + +[![Python tests](https://github.com/cvnlab/GLMsingle/actions/workflows/run_tests_python.yml/badge.svg)](https://github.com/cvnlab/GLMsingle/actions/workflows/run_tests_python.yml) +[![Python demos](https://github.com/cvnlab/GLMsingle/actions/workflows/run_demos_python.yml/badge.svg)](https://github.com/cvnlab/GLMsingle/actions/workflows/run_demos_python.yml) +[![Python-coverage](https://codecov.io/gh/Remi-Gau/GLMsingle/branch/main/graph/badge.svg?token=H75TAAUVSW)](https://codecov.io/gh/Remi-Gau/GLMsingle) # GLMsingle ![image](https://user-images.githubusercontent.com/35503086/151108958-24479034-c7f7-4734-b903-9046ba6a78ac.png) diff --git a/examples/example1.ipynb b/examples/example1.ipynb index b7fc301..8fc7552 100644 --- a/examples/example1.ipynb +++ b/examples/example1.ipynb @@ -85,7 +85,7 @@ "\n", "# note: the fracridge repository is also necessary to run this code\n", "# for example, you could do:\n", - "# git clone https://github.com/nrdg/fracridge.git" + "# git clone https://github.com/nrdg/fracridge.git\n" ] }, { diff --git a/examples/example2.ipynb b/examples/example2.ipynb index b21dba6..c5dbfe5 100644 --- a/examples/example2.ipynb +++ b/examples/example2.ipynb @@ -56,7 +56,7 @@ "\n", "# note: the fracridge repository is also necessary to run this code\n", "# for example, you could do:\n", - "# git clone https://github.com/nrdg/fracridge.git" + "# git clone https://github.com/nrdg/fracridge.git\n" ] }, { diff --git a/requirements.txt b/requirements.txt index b0de2fa..6daf04e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -numpy -scipy -sklearn -matplotlib -tqdm -fracridge -nibabel -h5py +fracridge>=1.4.3 +h5py>=3.1.0 +matplotlib>=3.3.4 +nibabel>=3.2.2 +numpy>=1.17.0 +scikit-learn>=0.23.2 +scipy>=1.5.4 +tqdm>=4.63.1 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 8bf205e..af2058a 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,11 +1,13 @@ +# Base requirements +-r requirements.txt + # Matlab dev miss_hit # Python dev +black flake8 -tox -coverage pytest -black -nbmake pytest-cov +nbmake +tox diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..99ef9c9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[flake8] +max-line-length = 99 + +ignore = D203, W503 + +exclude = + .git, + docs, + __pycache__, + old, + build, + dist + 'matlab/fracridge' + +max-complexity = 10 + +[tool:pytest] +testpaths = tests \ No newline at end of file diff --git a/setup.py b/setup.py index 731c9c2..ce66528 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='GLMsingle', - version='0.0.1', + version='1.0.0', description='Python GLMsingle', url='https://github.com/kendrickkay/GLMsingle', long_description=long_description, @@ -28,5 +28,6 @@ packages=find_packages(), include_package_data=True, zip_safe=False, - install_requires=requires + install_requires=requires, + python_requires='>=3.6, <3.10' ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..bb6783a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit test package for glmsingle.""" diff --git a/tests/expected/python/README.md b/tests/expected/python/README.md new file mode 100644 index 0000000..6bf1409 --- /dev/null +++ b/tests/expected/python/README.md @@ -0,0 +1,52 @@ +# Generate expected data + +Generated from the output of one run from the files in `tests/outputs/python` + +```python +import numpy as np + +tmp = np.load("TYPEB_FITHRF.npy", allow_pickle=True) +results = tmp.item(0)["HRFindex"] +np.save("TYPEB_FITHRF_HRFindex.npy", results) + +tmp = np.load("TYPEC_FITHRF_GLMDENOISE.npy", allow_pickle=True) +results = tmp.item(0)["HRFindex"] +np.save("TYPEC_FITHRF_HRFindex.npy", results) + +tmp = np.load("TYPED_FITHRF_GLMDENOISE_RR.npy", allow_pickle=True) +results = tmp.item(0)["HRFindex"] +np.save("TYPED_FITHRF_HRFindex.npy", results) + +results = tmp.item(0)["R2"] +np.save("TYPED_FITHRF_R2.npy", results) +``` + + diff --git a/tests/expected/python/TYPEB_FITHRF_HRFindex.npy b/tests/expected/python/TYPEB_FITHRF_HRFindex.npy new file mode 100644 index 0000000..6c2f1a5 Binary files /dev/null and b/tests/expected/python/TYPEB_FITHRF_HRFindex.npy differ diff --git a/tests/expected/python/TYPEC_FITHRF_HRFindex.npy b/tests/expected/python/TYPEC_FITHRF_HRFindex.npy new file mode 100644 index 0000000..6c2f1a5 Binary files /dev/null and b/tests/expected/python/TYPEC_FITHRF_HRFindex.npy differ diff --git a/tests/expected/python/TYPED_FITHRF_HRFindex.npy b/tests/expected/python/TYPED_FITHRF_HRFindex.npy new file mode 100644 index 0000000..3e1cd35 Binary files /dev/null and b/tests/expected/python/TYPED_FITHRF_HRFindex.npy differ diff --git a/tests/expected/python/TYPED_FITHRF_R2.npy b/tests/expected/python/TYPED_FITHRF_R2.npy new file mode 100644 index 0000000..b6667bf Binary files /dev/null and b/tests/expected/python/TYPED_FITHRF_R2.npy differ diff --git a/tests/test_GLM_single.py b/tests/test_GLM_single.py new file mode 100644 index 0000000..b644eec --- /dev/null +++ b/tests/test_GLM_single.py @@ -0,0 +1,75 @@ +from os.path import join +from os.path import abspath +from os.path import dirname +import scipy +import scipy.io as sio +import numpy as np +from glmsingle.glmsingle import GLM_single + +test_dir = dirname(abspath(__file__)) +expected_dir = join(test_dir, "expected", "python") +data_dir = join(test_dir, "data") +data_file = join(data_dir, "nsdcoreexampledataset.mat") + +output_dir = join(test_dir, "outputs") + + +expected = {"typeb": {}, "typec": {}, "typed": {}} + +# TODO use same expected data for Python and MATLAB ? +# TODO in both cases expected results should probably be in a set of CSV files +# tmp = sio.loadmat(join(expected_dir, "TYPEB_FITHRF.mat")) +# expected["typeb"]["HRFindex"] = tmp["HRFindex"] + +tmp = np.load(join(expected_dir, "TYPEB_FITHRF_HRFindex.npy"), allow_pickle=True) +expected["typeb"]["HRFindex"] = tmp +tmp = np.load(join(expected_dir, "TYPEC_FITHRF_HRFindex.npy"), allow_pickle=True) +expected["typec"]["HRFindex"] = tmp +tmp = np.load(join(expected_dir, "TYPED_FITHRF_HRFindex.npy"), allow_pickle=True) +expected["typed"]["HRFindex"] = tmp +tmp = np.load(join(expected_dir, "TYPED_FITHRF_R2.npy"), allow_pickle=True) +expected["typed"]["R2"] = tmp + + +def test_GLM_single_system(): + + X = sio.loadmat(data_file) + + data = [] + design = [] + for r in range(3): + data.append(X["data"][0, r][50:70, 7:27, 0:1, :]) + design.append(scipy.sparse.csr_matrix.toarray(X["design"][0, r])) + + stimdur = X["stimdur"][0][0] + tr = X["tr"][0][0] + + opt = {"wantmemoryoutputs": [1, 1, 1, 1]} + + # OPTIONAL: PUT THIS IN? + # opt['wantlibrary'] = 0 + + glmsingle_obj = GLM_single(opt) + glm_results = glmsingle_obj.fit( + design, data, stimdur, tr, outputdir=join(output_dir, "python") + ) + + # TODO read results directly from "glm_results" and not from file on disk? + results = {"typeb": {}, "typec": {}, "typed": {}} + + tmp = np.load(join(output_dir, "python", "TYPEB_FITHRF.npy"), allow_pickle=True) + results["typeb"]["HRFindex"] = tmp.item(0)["HRFindex"] + tmp = np.load( + join(output_dir, "python", "TYPEC_FITHRF_GLMDENOISE.npy"), allow_pickle=True + ) + results["typec"]["HRFindex"] = tmp.item(0)["HRFindex"] + tmp = np.load( + join(output_dir, "python", "TYPED_FITHRF_GLMDENOISE_RR.npy"), allow_pickle=True + ) + results["typed"]["HRFindex"] = tmp.item(0)["HRFindex"] + results["typed"]["R2"] = tmp.item(0)["R2"] + + assert (results["typeb"]["HRFindex"] == expected["typeb"]["HRFindex"]).all + assert (results["typec"]["HRFindex"] == expected["typec"]["HRFindex"]).all + assert (results["typed"]["HRFindex"] == expected["typed"]["HRFindex"]).all + assert (results["typed"]["R2"] == expected["typed"]["R2"]).all