Repository: bids-standard/pybids

Issue:
Add Analysis.get_tr() API
Currently, querying the TR is somewhat convoluted (see https://github.com/bids-standard/pybids/issues/124#issuecomment-439104593). A simple high-level API could be:

```Python
class Analysis:
    def get_tr(self, **selectors):
        all_trs = some_query(**selectors)
        unique_trs = filter_by_allclose(all_trs)
        if len(unique_trs) > 1:
            raise ValueError("Unique TR cannot be found given selectors {!r}".format(selectors))
        return unique_trs[0]
```

For single dataset analyses, `.get_tr()` should provide a valid value, making this useful for Jupyter-notebook style exploration, but I would expect application programmers to provide as many entities as possible to reduce risk of exceptions.
Add Analysis.get_tr() API
Currently, querying the TR is somewhat convoluted (see https://github.com/bids-standard/pybids/issues/124#issuecomment-439104593). A simple high-level API could be:

```Python
class Analysis:
    def get_tr(self, **selectors):
        all_trs = some_query(**selectors)
        unique_trs = filter_by_allclose(all_trs)
        if len(unique_trs) > 1:
            raise ValueError("Unique TR cannot be found given selectors {!r}".format(selectors))
        return unique_trs[0]
```

For single dataset analyses, `.get_tr()` should provide a valid value, making this useful for Jupyter-notebook style exploration, but I would expect application programmers to provide as many entities as possible to reduce risk of exceptions.


Discussion:
Is there a reason to add this specifically to `Analysis`? It seems like it could be useful even when just working with a `BIDSLayout`, so I incline to add it to the latter. 

Edit: just saw this came up in #124. I'm still leaning towards putting it in `BIDSLayout`; it's true that there could be multiple tasks at the layout level, but I'm not sure that's an argument against putting `get_tr` there; as you say above, it's probably good practice to be as specific as one can anyway.
Yeah, layout is fine with me.
Is there a reason to add this specifically to `Analysis`? It seems like it could be useful even when just working with a `BIDSLayout`, so I incline to add it to the latter. 

Edit: just saw this came up in #124. I'm still leaning towards putting it in `BIDSLayout`; it's true that there could be multiple tasks at the layout level, but I'm not sure that's an argument against putting `get_tr` there; as you say above, it's probably good practice to be as specific as one can anyway.
Yeah, layout is fine with me.

Resolving patch:
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -546,6 +546,37 @@ def _get_fieldmaps(self, path):
                     fieldmap_set.append(cur_fieldmap)
         return fieldmap_set
 
+    def get_tr(self, derivatives=False, **selectors):
+        """ Returns the scanning repetition time (TR) for one or more runs.
+
+        Args:
+            derivatives (bool): If True, also checks derivatives images.
+            selectors: Optional keywords used to constrain the selected runs.
+                Can be any arguments valid for a .get call (e.g., BIDS entities
+                or JSON sidecar keys).
+        
+        Returns: A single float.
+
+        Notes: Raises an exception if more than one unique TR is found.
+        """
+        # Constrain search to functional images
+        selectors['suffix'] = 'bold'
+        selectors['datatype'] = 'func'
+        images = self.get(extensions=['.nii', '.nii.gz'], derivatives=derivatives,
+                          **selectors)
+        if not images:
+            raise ValueError("No functional images that match criteria found.")
+        
+        all_trs = set()
+        for img in images:
+            md = self.get_metadata(img.path, suffix='bold', full_search=True)
+            all_trs.add(round(float(md['RepetitionTime']), 5))
+ 
+        if len(all_trs) > 1:
+            raise ValueError("Unique TR cannot be found given selectors {!r}"
+                             .format(selectors))
+        return all_trs.pop()
+
     def get_collections(self, level, types=None, variables=None, merge=False,
                         sampling_rate=None, skip_empty=False, **kwargs):
         """Return one or more variable Collections in the BIDS project.


Test patch:
diff --git a/bids/layout/tests/test_layout.py b/bids/layout/tests/test_layout.py
--- a/bids/layout/tests/test_layout.py
+++ b/bids/layout/tests/test_layout.py
@@ -264,3 +264,19 @@ def test_derivative_getters():
     with pytest.raises(AttributeError):
         bare_layout.get_spaces()
     assert set(full_layout.get_spaces()) == {'MNI152NLin2009cAsym', 'T1w'}
+
+
+def test_get_tr(layout_7t_trt):
+    # Bad subject, should fail
+    with pytest.raises(ValueError) as exc:
+        layout_7t_trt.get_tr(subject="zzz")
+        assert exc.value.message.startswith("No functional images")
+    # There are multiple tasks with different TRs, so this should fail
+    with pytest.raises(ValueError) as exc:
+        layout_7t_trt.get_tr(subject=['01', '02'])
+        assert exc.value.message.startswith("Unique TR")
+    # This should work
+    tr = layout_7t_trt.get_tr(subject=['01', '02'], acquisition="fullbrain")
+    assert tr == 3.0
+    tr = layout_7t_trt.get_tr(subject=['01', '02'], acquisition="prefrontal")
+    assert tr == 4.0
\ No newline at end of file