Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:

jobs:
auto-release:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
# Set skip ci to avoid loops
if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')"
# Set bash as default shell for jobs
Expand All @@ -19,7 +19,7 @@ jobs:
shell: bash
steps:
- name: Checkout source
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
# Fetch all history for all branches and tags
fetch-depth: 0
Expand Down
9 changes: 5 additions & 4 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ on:
jobs:
deploy:

runs-on: ubuntu-20.04
runs-on: ubuntu-22.04

steps:
- name: Checkout source
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: 3.11
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand All @@ -29,4 +29,5 @@ jobs:
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine check dist/*
twine upload dist/*
26 changes: 26 additions & 0 deletions phys2bids/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,32 @@ def _get_parser():
"or just one TR if it is consistent throughout the session.",
default=None,
)
optional.add_argument(
"-esttakes",
"--estimate_takes",
dest="estimate_takes",
action="store_true",
help="Run automatic algorithm to estimate clusters of triggers, i.e. the "
"'takes' or 'runs' of fMRI. Useful when sequences were stopped and restarted, "
"or when you don't know how many triggers or trs you have in each take. "
"This might work 95%% of the time. Default is False.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"This might work 95%% of the time" is a funny sentence! Can we be clearer on why it may not? Or can we say something like "please check this output/plot carefully to see if it has done X task correctly"

default=False,
)
optional.add_argument(
"-ci",
"--confidence-interval",
dest="ci",
# Here always as float, later it will check if the float is an integer instead.
type=float,
help="The Confidence Interval (CI) to use in the estimation of the trigger clusters. "
"The cluster algorithm considers triggers with duration (in samples) within this "
"CI as part of the same group, thus the same. If CI is an integer, it will consider "
"that amount of samples around the distance. If CI is a float and < 1, it will "
"consider that percentage of the trigger duration. CI cannot be a float > 1. "
"Default is 1. Change to .25 if there is a CMRR DWI sequence or when recording "
"sub-triggers.",
default=1,
)
optional.add_argument(
"-thr",
"--threshold",
Expand Down
15 changes: 14 additions & 1 deletion phys2bids/phys2bids.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from phys2bids import _version, bids, utils, viz
from phys2bids.cli.run import _get_parser
from phys2bids.physio_obj import BlueprintOutput
from phys2bids.slice4phys import slice4phys
from phys2bids.slice4phys import estimate_ntp_and_tr, slice4phys

from . import __version__
from .due import Doi, due
Expand Down Expand Up @@ -141,6 +141,8 @@ def phys2bids(
chsel=None,
num_timepoints_expected=None,
tr=None,
estimate_takes=False,
ci=1,
thr=None,
pad=9,
ch_name=[],
Expand Down Expand Up @@ -260,6 +262,12 @@ def phys2bids(
from phys2bids.io import load_gep

phys_in = load_gep(infile)
elif ftype == "smr":
from phys2bids.io import load_smr

phys_in = load_smr(infile, chtrig)
else:
raise RuntimeError(f'Unsupported extension "{ftype}"')

LGR.info("Checking that units of measure are BIDS compatible")
for index, unit in enumerate(phys_in.units):
Expand Down Expand Up @@ -298,6 +306,11 @@ def phys2bids(
LGR.info("Renaming channels with given names")
phys_in.rename_channels(ch_name)

# If requested, run the automatic detection of timepoints and groups

if estimate_takes:
num_timepoints_expected, tr = estimate_ntp_and_tr(phys_in, thr=None, ci=1)

# Checking acquisition type via user's input
if tr is not None and num_timepoints_expected is not None:
# Multi-run acquisition type section
Expand Down
11 changes: 6 additions & 5 deletions phys2bids/physio_obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def delete_at_index(self, idx):
del self.units[idx]

if self.trigger_idx == idx:
LGR.warning("Removing trigger channel - are you sure you are doing" "the right thing?")
LGR.warning("Removing trigger channel - are you sure you are doing the right thing?")
self.trigger_idx = 0

def check_trigger_amount(self, thr=None, num_timepoints_expected=0, tr=0):
Expand Down Expand Up @@ -527,7 +527,7 @@ def check_trigger_amount(self, thr=None, num_timepoints_expected=0, tr=0):
num_timepoints_found = np.count_nonzero(np.ediff1d(timepoints.astype(np.int8)) > 0)
if flag == 1:
LGR.info(
f"The number of timepoints according to the std_thr method "
f"The number of timepoints according to the average threshold method "
f"is {num_timepoints_found}. The computed threshold is {thr:.4f}"
)
else:
Expand All @@ -550,7 +550,8 @@ def check_trigger_amount(self, thr=None, num_timepoints_expected=0, tr=0):

elif num_timepoints_found < num_timepoints_expected:
timepoints_missing = num_timepoints_expected - num_timepoints_found
LGR.warning(f"Found {timepoints_missing} timepoints" " less than expected!")
LGR.warning(f"Found {timepoints_missing} timepoints less than expected!")
# if tr checks for any truthy value, including not 0 nor empty things.
if tr:
LGR.warning(
"Correcting time offset, assuming missing "
Expand All @@ -559,14 +560,14 @@ def check_trigger_amount(self, thr=None, num_timepoints_expected=0, tr=0):
)
time_offset -= timepoints_missing * tr
else:
LGR.warning("Can't correct time offset - you should " "specify the TR")
LGR.warning("Can't correct time offset - you should specify the TR")

else:
LGR.info("Found just the right amount of timepoints!")

else:
LGR.warning(
"The necessary options to find the amount of timepoints " "were not provided."
"The necessary options to find the amount of timepoints were not provided."
)
self.thr = thr
self.time_offset = time_offset
Expand Down
121 changes: 121 additions & 0 deletions phys2bids/slice4phys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,127 @@
LGR = logging.getLogger(__name__)


def estimate_ntp_and_tr(phys_in, thr=None, ci=1):
"""
Find groups of trigger in a spiky signal like the trigger channel signal.
Parameters
----------
phys_in : BlueprintInput object
A BlueprintInput object containing a physiological acquisition
thr : None, optional
The threshold for automatic spike detection. Default is to use the average of
the signal.
ci : int or float, optional
Confidence Interval (CI) to use in the estimation of the trigger clusters. The
cluster algorithm considers triggers with duration (in samples) within this CI
as part of the same group, thus the same. If CI is an integer, it will consider
that amount of samples. If CI is a float and < 1, it will consider that
percentage of the trigger duration. CI cannot be a float > 1. Default is 1.
Change to .25 if there is a CMRR DWI sequence or when recording sub-triggers.
Returns
-------
ntp
The list of number of timepoints found for each take.
tr
The list of corresponding TR, computed as average samples per group / frequency.
Raises
------
Exception
If it doesn't find at least a group.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
If it doesn't find at least a group.
If it doesn't find at least one group.

ValueError
If CI is a float above 1 or if it is not an int or a float.
"""
LGR.info('Running automatic clustering of triggers to find timepoints and tr of each "take"')
trigger = phys_in.timeseries[phys_in.trigger_idx]

thr = np.mean(trigger) if thr is None else thr
timepoints = trigger > thr
spikes = np.flatnonzero(np.ediff1d(timepoints.astype(np.int8)) > 0)
interspike_interval = np.diff(spikes)
unique_isi, counts = np.unique(interspike_interval, return_counts=True)

# The following line is for python < 3.12. From 3.12, ci.is_integer() is enough.
if isinstance(ci, int) or isinstance(ci, float) and ci.is_integer():
upper_ci_isi = unique_isi + ci
elif isinstance(ci, float) and ci < 1:
upper_ci_isi = unique_isi * (1 + ci)
elif isinstance(ci, float) and ci > 1:
raise ValueError("Confidence intervals percentages above 1 are not supported.")
else:
raise ValueError("Confidence intervals must be either integers or floats.")

# Loop through the uniques ISI and group them within the specified CI.
# Also compute the average TR of the group.
isi_groups = {}
average_tr = {}
k = 0
current_group = [unique_isi[0]]

# np.unique returns sorted elements → unique_isi[0] == min(unique_isi), so THIS WORKS.
for n, i in enumerate(range(1, len(unique_isi))):
if unique_isi[i] <= upper_ci_isi[n]:
current_group.append(unique_isi[i])
else:
isi_groups[k] = current_group
average_tr[k] = np.mean(current_group) / phys_in.freq[0]
k += 1
current_group = [unique_isi[i]]

isi_groups[k] = current_group
average_tr[k] = np.mean(current_group) / phys_in.freq[0]

# Invert the isi_group into value per group
group_by_isi = {isi: group for group, isis in isi_groups.items() for isi in isis}

# Use the found groups to find the number of timepoints and assign the right TR
estimated_ntp = []
estimated_tr = []

i = 0
while i < interspike_interval.size - 1:
current_group = group_by_isi.get(interspike_interval[i])
for n in range(i + 1, interspike_interval.size):
if current_group != group_by_isi.get(interspike_interval[n]):
break
# Repeat one last time outside of for loop
estimated_ntp += [n - i]
estimated_tr += [average_tr[current_group]]
i = n

if len(estimated_ntp) < 1:
raise Exception("This should not happen. Something went very wrong.")
# The algorithm found n groups, the last of which has two timepoints less due to
# diff computations. Each real group of n>1 triggers counts one trigger less but is
# followed by a "fake" group of 1 trigger that is actually the interval to the next
# group. That does not hold if there is a real group of 1 trigger.
# Loop through the estiamtions to fix all that.
ntp = []
tr = []
i = 0

while i < len(estimated_ntp):
if estimated_ntp[i] == 1:
ntp.append(estimated_ntp[i])
tr.append(estimated_tr[i])
i += 1
elif i + 1 < len(estimated_ntp):
ntp.append(estimated_ntp[i] + estimated_ntp[i + 1])
tr.append(estimated_tr[i])
i += 2
else:
ntp.append(estimated_ntp[i] + 2)
tr.append(estimated_tr[i])
i += 1

LGR.info(
f"The automatic clustering found {len(ntp)} groups of triggers long: {ntp} with respective TR: {tr}"
)
return ntp, tr


def find_takes(phys_in, ntp_list, tr_list, thr=None, padding=9):
"""
Find takes slicing index.
Expand Down
2 changes: 1 addition & 1 deletion phys2bids/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

LGR = logging.getLogger(__name__)

SUPPORTED_FTYPES = ("acq", "txt", "mat", "gep")
SUPPORTED_FTYPES = ("acq", "txt", "mat", "gep", "smr")


def check_input_ext(filename, ext):
Expand Down
12 changes: 4 additions & 8 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,14 @@
name = phys2bids
url = https://github.com/physiopy/phys2bids
download_url = https://github.com/physiopy/phys2bids
author = phys2bids developers
maintainer = Stefano Moia
author = The Physiopy Community
maintainer = The Physiopy Community
maintainer_email = [email protected]
classifiers =
Development Status :: 5 - Production/Stable
Intended Audience :: Science/Research
License :: OSI Approved :: Apache Software License
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3
license = Apache-2.0
description = Python library to convert physiological data files into BIDS format
long_description = file:README.md
Expand Down Expand Up @@ -64,7 +60,7 @@ test =
coverage
%(interfaces)s
%(style)s
all =
dev =
%(doc)s
%(duecredit)s
%(interfaces)s
Expand Down