Skip to content
Merged
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
33 changes: 33 additions & 0 deletions brukerapi/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def __init__(self, path, **state):
if self.subtype:
self.subtype = self.subtype[1:] # remove the dot from the suffix
self._properties = []
self._declared_properties = set()

if self.type not in DEFAULT_STATES:
raise UnsupportedDatasetType(self.type)
Expand Down Expand Up @@ -531,12 +532,40 @@ def unload_properties(self):
if hasattr(self, property):
delattr(self, property)
self._properties = []
self._declared_properties = set()
self._state["load_properties"] = False

def reload_properties(self):
self.unload_properties()
self.load_properties()

def get(self, name, default=None):
"""Value of property `name`, or `default` where it does not resolve.

Which properties a dataset carries is ParaVision-version dependent: a
recipe whose conditions do not match, or whose parameters the files do
not contain, leaves its property unset. Attribute access reports that as
an :class:`AttributeError`, which a caller can only handle by wrapping
every read in ``try``/``except`` -- and that swallows misspellings and
genuine errors from a recipe along with it.

A name no configuration declares still raises, so a typo does not
quietly become `default`::

dataset.get("TE") # 4.0, or None where there is no echo time
dataset.get("TE", "n/a") # 4.0, or "n/a"
dataset.get("TEE") # AttributeError

Parameters are a separate layer, read with ``dataset[key]`` or
``dataset.parameters``.
"""
try:
return getattr(self, name)
except AttributeError:
if name in self._declared_properties:
return default
raise

def add_property_file(self, path):
with open(path) as f:
for property in json.load(f).items():
Expand All @@ -553,6 +582,10 @@ def _add_property(self, property):

:param property: tuple containing the name of the property and a list of possible commands to create it
"""
# Declared whether or not a recipe resolves, so that `get` can tell an
# unresolved property from a name that was never a property at all.
self._declared_properties.add(property[0])

if property[0] == "scheme_id" and self._state.get("scheme_id") is not None:
scheme_id = self._state["scheme_id"]
if scheme_id not in {"CART_2D", "CART_3D", "FIELD_MAP", "RADIAL", "EPI", "dEPI", "SPECTROSCOPY", "CSI", "SPIRAL", "ZTE"}:
Expand Down
6 changes: 6 additions & 0 deletions docs/source/compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ Data contract and limitations
movie frames), not slices.
* d3proc is an optional compatibility source for legacy/minimal 2dseq word
type and image-size metadata after Visu and RECO metadata have been tried.
* Which derived properties a dataset carries is version dependent, so a recipe
whose conditions do not match, or whose parameters the files do not contain,
leaves its property unset. ``Dataset.get(name, default=None)`` reads one with
a default; a name no configuration declares still raises ``AttributeError``,
and a property that fails for a reason of its own -- ``affine`` on a scan
with no image geometry -- still raises that reason.
* ``Dataset.metadata`` groups the Visu parameters the way the format defines
them -- administration, subject, study, series, equipment and acquisition --
and the ``SUBJECT_*`` parameters of the study file, which ``subject`` in
Expand Down
14 changes: 14 additions & 0 deletions docs/source/tutorials/how-to-2dseq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ It is possible to directly access some of the most wanted measurement parameters
>> dataset.flip_angle
>> 10.0

Which of these a dataset carries is ParaVision-version dependent, so a property
whose recipe does not resolve is simply not set. ``get`` reads one with a
default instead of an ``AttributeError``; a name that is not a property at all
still raises, so a misspelling does not quietly become the default.

.. code-block:: python

>> dataset.get('TE')
>> 3.0
>> dataset.get('TE', 'n/a') # where the scan records no echo time
>> 'n/a'
>> dataset.get('TEE')
AttributeError: 'Dataset' object has no attribute 'TEE'

The ``visu_pars`` file is used to construct a 2dseq dataset. ``reco`` and
``d3proc`` are optional compatibility sources for legacy/minimal instances;
they supply reconstruction word type and image-size metadata only when Visu
Expand Down
35 changes: 35 additions & 0 deletions test/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from brukerapi.cli import report as cli_report
from brukerapi.cli import split as cli_split
from brukerapi.dataset import LOAD_STAGES, Dataset
from brukerapi.exceptions import UnsupportedDatasetType
from brukerapi.folders import Folder
from brukerapi.splitters import SlicePackageSplitter
from test.synthetic import stacked_positions, write_2dseq, write_jcampdx
Expand Down Expand Up @@ -81,6 +82,40 @@ def test_metadata_reports_the_same_string_as_the_property_that_reads_it(tmp_path
assert dataset.subj_id == dataset.metadata["visu_subject"]["name"]


def test_get_returns_a_default_where_a_property_does_not_resolve(tmp_path):
"""Which properties a dataset carries is ParaVision-version dependent.

A recipe whose parameters the files do not contain leaves its property
unset, which attribute access can only report as an AttributeError -- so a
caller had to wrap every read in try/except, and that swallows typos and
genuine recipe errors along with the absence.
"""
with_echo_time = Dataset(study(tmp_path / "with", extra={"VisuAcqEchoTime": 3.5}), load=LOAD_STAGES["properties"])
without = Dataset(study(tmp_path / "without"), load=LOAD_STAGES["properties"])

assert with_echo_time.get("TE") == with_echo_time.TE == 3.5
assert not hasattr(without, "TE")
assert without.get("TE") is None
assert without.get("TE", "n/a") == "n/a"


def test_get_still_raises_for_a_name_that_is_not_a_property(tmp_path):
"""A misspelling must not quietly become the default."""
dataset = Dataset(study(tmp_path), load=LOAD_STAGES["properties"])

with pytest.raises(AttributeError, match="TEE"):
dataset.get("TEE")

# Nor may `get` turn a property's own diagnosis into an absence: a
# spectroscopy scan has no image geometry, and says so.
spectroscopy = Dataset(
write_2dseq(tmp_path / "spectroscopy" / "9" / "pdata" / "1", dim=1, dim_desc=("spectroscopic",), size=(16,), extent=(1.0,), frame_groups=()),
load=LOAD_STAGES["properties"],
)
with pytest.raises(UnsupportedDatasetType, match="rather than purely spatial"):
spectroscopy.get("affine")


def test_metadata_can_be_exported(tmp_path):
dataset = Dataset(study(tmp_path, extra=EQUIPMENT), load=LOAD_STAGES["properties"])

Expand Down