From de9eeaffd9ed38bcd586358fbe7f25653bb8ac0b Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:12:31 -0500 Subject: [PATCH 01/37] Replaced numpy.str_ with numpy.bytes_ to support numpy 2 --- requirements.txt | 2 +- snirf/pysnirf2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2351790..d43b6be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ h5py -numpy +numpy>=2.0.0 setuptools pip termcolor diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index ee4db7f..044c81c 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -144,7 +144,7 @@ def _close_logger(logger: logging.LoggerAdapter): _INT_DTYPES = [int, np.int32, np.int64] _FLOAT_DTYPES = [float, np.float64] -_STR_DTYPES = [str, np.string_] +_STR_DTYPES = [str, np.bytes_] # -- Dataset creators --------------------------------------- From 7233502e1f44ad00d3b75aadb60547aa17eb772c Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:12:46 -0500 Subject: [PATCH 02/37] path fiddling in test.py --- tests/test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index 2c85364..6461d88 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,6 +1,4 @@ import unittest -import snirf -from snirf import Snirf, validateSnirf, loadSnirf, saveSnirf import h5py import os import sys @@ -10,6 +8,13 @@ from collections.abc import Set, Mapping import numpy as np import shutil +try: + import snirf + from snirf import Snirf, validateSnirf, loadSnirf, saveSnirf +except ImportError: + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + import snirf + from snirf import Snirf, validateSnirf, loadSnirf, saveSnirf VERBOSE = True # Additional print statements in each test From 2e025c9b6c5e536fc6a73f22350cd3993346b0b0 Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:18:15 -0500 Subject: [PATCH 03/37] Upgrade to Python >3.9 --- README.md | 2 +- setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 191df08..fef6756 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Developed and maintained by the [Boston University Neurophotonics Center](https: ## Installation `pip install snirf` -pysnirf2 requires Python > 3.6. +pysnirf2 requires Python > 3.9. # Features diff --git a/setup.py b/setup.py index bbe648b..8320143 100644 --- a/setup.py +++ b/setup.py @@ -75,10 +75,10 @@ def run(self): long_description=long_description, long_description_content_type='text/markdown', author_email='sstucker@bu.edu', - python_requires='>=3.6.0', + python_requires='>=3.9.0', install_requires=[ 'h5py>=3.1.0', - 'numpy', + 'numpy>2.0.0', 'setuptools', 'pip', 'termcolor', From ced4c876870ed244cdb3a85dc756f976f196f0da Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:20:47 -0500 Subject: [PATCH 04/37] README --- gen/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/README.md b/gen/README.md index 46ed8bc..a35acdb 100644 --- a/gen/README.md +++ b/gen/README.md @@ -14,5 +14,5 @@ This ensures easy maintenance of the project as the specification develops. 1. Ensure that [data.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/data.py) contains correct data to parse the latest spec. Make sure `SPEC_SRC` and `VERSION` are up to date. 2. IMPORTANT! Back up or commit local changes to the code via git. The generation process may delete your changes. -3. Using a Python > 3.6 environment equipped with [gen/requirements.txt](https://github.com/BUNPC/pysnirf2/blob/main/gen/requirements.txt), run [gen.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/gen.py) from the project root +3. Using a Python > 3.9 environment equipped with [gen/requirements.txt](https://github.com/BUNPC/pysnirf2/blob/main/gen/requirements.txt), run [gen.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/gen.py) from the project root 4. Test the resulting library From 85da3fcd6bc05b176e4db19299f629cd9c14bd5e Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:21:29 -0500 Subject: [PATCH 05/37] Removed < 3.9 python versions from test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a08050c..0bebc7f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: strategy: max-parallel: 5 matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11'] defaults: run: shell: bash -el {0} From 6e40d60a671526e90ca4a5cff073403e6d1861c3 Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 13:38:49 -0500 Subject: [PATCH 06/37] 1.2-draft gen --- gen/data.py | 4 +- snirf/pysnirf2.py | 1636 ++++++++++++++------ snirf_specification_retrieved_26_12_24.txt | 1231 +++++++++++++++ 3 files changed, 2356 insertions(+), 515 deletions(-) create mode 100644 snirf_specification_retrieved_26_12_24.txt diff --git a/gen/data.py b/gen/data.py index 5e97fd3..a48cdb0 100644 --- a/gen/data.py +++ b/gen/data.py @@ -1,5 +1,5 @@ -SPEC_SRC = 'https://raw.githubusercontent.com/fNIRS/snirf/v1.1/snirf_specification.md' -SPEC_VERSION = 'v1.1' # Version of the spec linked above +SPEC_SRC = 'https://raw.githubusercontent.com/fNIRS/snirf/refs/heads/master/snirf_specification.md' +SPEC_VERSION = 'v1.2-draft' # Version of the spec linked above """ These types are fragments of the string codes used to describe the types of diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 044c81c..151da16 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -560,6 +560,7 @@ class ValidationIssue: 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue """ + def __init__(self, name: str, location: str): self.location = location # A location in the Snirf file matching an HDF5 name self.name = name # The name of the issue, a key in _CODES above @@ -599,6 +600,7 @@ class ValidationResult: = validateSnirf() ``` """ + def __init__(self): """`ValidationResult` should only be created by a `Snirf` instance's `validate` method.""" self._issues = [] @@ -864,6 +866,7 @@ class SnirfConfig: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property """ + def __init__(self): self.logger: logging.Logger = _logger # The logger that the interface will write to self.dynamic_loading: bool = False # If False, data is loaded in the constructor, if True, data is loaded on access @@ -892,6 +895,7 @@ class _PresentDatasetType(): class Group(ABC): + def __init__(self, varg, cfg: SnirfConfig): """Wrapper for an HDF5 Group element defined by SNIRF. @@ -1395,8 +1399,8 @@ def _recursive_hdf5_copy(g_dst: Group, g_src: Group): # ================================================================================ # <<< BEGIN TEMPLATE INSERT >>> -# generated by sstucker on 2023-09-18 -# version v1.1 SNIRF specification parsed from https://raw.githubusercontent.com/fNIRS/snirf/v1.1/snirf_specification.md +# generated by sstucker on 2024-12-26 +# version v1.2-draft SNIRF specification parsed from https://raw.githubusercontent.com/fNIRS/snirf/refs/heads/master/snirf_specification.md class MetaDataTags(Group): @@ -1411,6 +1415,7 @@ class MetaDataTags(Group): The below five metadata records are minimally required in a SNIRF file """ + def __init__(self, var, cfg: SnirfConfig): super().__init__(var, cfg) self._SubjectID = _AbsentDataset # "s"* @@ -1903,242 +1908,1007 @@ def _validate(self, result: ValidationResult): result._add(name, 'INVALID_DATASET_TYPE') -class Probe(Group): - """Wrapper for Group of type `probe`. +class MeasurementLists(Group): + """Wrapper for Group of type `measurementLists`. - This is a structured variable that describes the probe (source-detector) - geometry. This variable has a number of required fields. + The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). + + This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. + + The arrays of `measurementLists` are: """ + def __init__(self, var, cfg: SnirfConfig): super().__init__(var, cfg) - self._wavelengths = _AbsentDataset # [,...]* - self._wavelengthsEmission = _AbsentDataset # [,...] - self._sourcePos2D = _AbsentDataset # [[,...]]*1 - self._sourcePos3D = _AbsentDataset # [[,...]]*1 - self._detectorPos2D = _AbsentDataset # [[,...]]*2 - self._detectorPos3D = _AbsentDataset # [[,...]]*2 - self._frequencies = _AbsentDataset # [,...] - self._timeDelays = _AbsentDataset # [,...] - self._timeDelayWidths = _AbsentDataset # [,...] - self._momentOrders = _AbsentDataset # [,...] - self._correlationTimeDelays = _AbsentDataset # [,...] - self._correlationTimeDelayWidths = _AbsentDataset # [,...] - self._sourceLabels = _AbsentDataset # [["s",...]] - self._detectorLabels = _AbsentDataset # ["s",...] - self._landmarkPos2D = _AbsentDataset # [[,...]] - self._landmarkPos3D = _AbsentDataset # [[,...]] - self._landmarkLabels = _AbsentDataset # ["s",...] - self._coordinateSystem = _AbsentDataset # "s" - self._coordinateSystemDescription = _AbsentDataset # "s" - self._useLocalIndex = _AbsentDataset # + self._sourceIndex = _AbsentDataset # [,...]* + self._detectorIndex = _AbsentDataset # [,...]* + self._wavelengthIndex = _AbsentDataset # [,...]* + self._wavelengthActual = _AbsentDataset # [,...] + self._wavelengthEmissionActual = _AbsentDataset # [,...] + self._dataType = _AbsentDataset # [,...]* + self._dataUnit = _AbsentDataset # ["s",...] + self._dataTypeLabel = _AbsentDataset # ["s",...] + self._dataTypeIndex = _AbsentDataset # [,...]* + self._sourcePower = _AbsentDataset # [,...] + self._detectorGain = _AbsentDataset # [,...] self._snirf_names = [ - 'wavelengths', - 'wavelengthsEmission', - 'sourcePos2D', - 'sourcePos3D', - 'detectorPos2D', - 'detectorPos3D', - 'frequencies', - 'timeDelays', - 'timeDelayWidths', - 'momentOrders', - 'correlationTimeDelays', - 'correlationTimeDelayWidths', - 'sourceLabels', - 'detectorLabels', - 'landmarkPos2D', - 'landmarkPos3D', - 'landmarkLabels', - 'coordinateSystem', - 'coordinateSystemDescription', - 'useLocalIndex', + 'sourceIndex', + 'detectorIndex', + 'wavelengthIndex', + 'wavelengthActual', + 'wavelengthEmissionActual', + 'dataType', + 'dataUnit', + 'dataTypeLabel', + 'dataTypeIndex', + 'sourcePower', + 'detectorGain', ] self._indexed_groups = [] - if 'wavelengths' in self._h: - if not self._cfg.dynamic_loading: - self._wavelengths = _read_float_array(self._h['wavelengths']) - else: # if the dataset is found on disk but dynamic_loading=True - self._wavelengths = _PresentDataset - else: # if the dataset is not found on disk - self._wavelengths = _AbsentDataset - if 'wavelengthsEmission' in self._h: - if not self._cfg.dynamic_loading: - self._wavelengthsEmission = _read_float_array( - self._h['wavelengthsEmission']) - else: # if the dataset is found on disk but dynamic_loading=True - self._wavelengthsEmission = _PresentDataset - else: # if the dataset is not found on disk - self._wavelengthsEmission = _AbsentDataset - if 'sourcePos2D' in self._h: - if not self._cfg.dynamic_loading: - self._sourcePos2D = _read_float_array(self._h['sourcePos2D']) - else: # if the dataset is found on disk but dynamic_loading=True - self._sourcePos2D = _PresentDataset - else: # if the dataset is not found on disk - self._sourcePos2D = _AbsentDataset - if 'sourcePos3D' in self._h: - if not self._cfg.dynamic_loading: - self._sourcePos3D = _read_float_array(self._h['sourcePos3D']) - else: # if the dataset is found on disk but dynamic_loading=True - self._sourcePos3D = _PresentDataset - else: # if the dataset is not found on disk - self._sourcePos3D = _AbsentDataset - if 'detectorPos2D' in self._h: - if not self._cfg.dynamic_loading: - self._detectorPos2D = _read_float_array( - self._h['detectorPos2D']) - else: # if the dataset is found on disk but dynamic_loading=True - self._detectorPos2D = _PresentDataset - else: # if the dataset is not found on disk - self._detectorPos2D = _AbsentDataset - if 'detectorPos3D' in self._h: - if not self._cfg.dynamic_loading: - self._detectorPos3D = _read_float_array( - self._h['detectorPos3D']) - else: # if the dataset is found on disk but dynamic_loading=True - self._detectorPos3D = _PresentDataset - else: # if the dataset is not found on disk - self._detectorPos3D = _AbsentDataset - if 'frequencies' in self._h: - if not self._cfg.dynamic_loading: - self._frequencies = _read_float_array(self._h['frequencies']) - else: # if the dataset is found on disk but dynamic_loading=True - self._frequencies = _PresentDataset - else: # if the dataset is not found on disk - self._frequencies = _AbsentDataset - if 'timeDelays' in self._h: - if not self._cfg.dynamic_loading: - self._timeDelays = _read_float_array(self._h['timeDelays']) - else: # if the dataset is found on disk but dynamic_loading=True - self._timeDelays = _PresentDataset - else: # if the dataset is not found on disk - self._timeDelays = _AbsentDataset - if 'timeDelayWidths' in self._h: - if not self._cfg.dynamic_loading: - self._timeDelayWidths = _read_float_array( - self._h['timeDelayWidths']) - else: # if the dataset is found on disk but dynamic_loading=True - self._timeDelayWidths = _PresentDataset - else: # if the dataset is not found on disk - self._timeDelayWidths = _AbsentDataset - if 'momentOrders' in self._h: + if 'sourceIndex' in self._h: if not self._cfg.dynamic_loading: - self._momentOrders = _read_float_array(self._h['momentOrders']) + self._sourceIndex = _read_int_array(self._h['sourceIndex']) else: # if the dataset is found on disk but dynamic_loading=True - self._momentOrders = _PresentDataset + self._sourceIndex = _PresentDataset else: # if the dataset is not found on disk - self._momentOrders = _AbsentDataset - if 'correlationTimeDelays' in self._h: + self._sourceIndex = _AbsentDataset + if 'detectorIndex' in self._h: if not self._cfg.dynamic_loading: - self._correlationTimeDelays = _read_float_array( - self._h['correlationTimeDelays']) + self._detectorIndex = _read_int_array(self._h['detectorIndex']) else: # if the dataset is found on disk but dynamic_loading=True - self._correlationTimeDelays = _PresentDataset + self._detectorIndex = _PresentDataset else: # if the dataset is not found on disk - self._correlationTimeDelays = _AbsentDataset - if 'correlationTimeDelayWidths' in self._h: + self._detectorIndex = _AbsentDataset + if 'wavelengthIndex' in self._h: if not self._cfg.dynamic_loading: - self._correlationTimeDelayWidths = _read_float_array( - self._h['correlationTimeDelayWidths']) + self._wavelengthIndex = _read_int_array( + self._h['wavelengthIndex']) else: # if the dataset is found on disk but dynamic_loading=True - self._correlationTimeDelayWidths = _PresentDataset + self._wavelengthIndex = _PresentDataset else: # if the dataset is not found on disk - self._correlationTimeDelayWidths = _AbsentDataset - if 'sourceLabels' in self._h: + self._wavelengthIndex = _AbsentDataset + if 'wavelengthActual' in self._h: if not self._cfg.dynamic_loading: - self._sourceLabels = _read_string_array( - self._h['sourceLabels']) + self._wavelengthActual = _read_float_array( + self._h['wavelengthActual']) else: # if the dataset is found on disk but dynamic_loading=True - self._sourceLabels = _PresentDataset + self._wavelengthActual = _PresentDataset else: # if the dataset is not found on disk - self._sourceLabels = _AbsentDataset - if 'detectorLabels' in self._h: + self._wavelengthActual = _AbsentDataset + if 'wavelengthEmissionActual' in self._h: if not self._cfg.dynamic_loading: - self._detectorLabels = _read_string_array( - self._h['detectorLabels']) + self._wavelengthEmissionActual = _read_float_array( + self._h['wavelengthEmissionActual']) else: # if the dataset is found on disk but dynamic_loading=True - self._detectorLabels = _PresentDataset + self._wavelengthEmissionActual = _PresentDataset else: # if the dataset is not found on disk - self._detectorLabels = _AbsentDataset - if 'landmarkPos2D' in self._h: + self._wavelengthEmissionActual = _AbsentDataset + if 'dataType' in self._h: if not self._cfg.dynamic_loading: - self._landmarkPos2D = _read_float_array( - self._h['landmarkPos2D']) + self._dataType = _read_int_array(self._h['dataType']) else: # if the dataset is found on disk but dynamic_loading=True - self._landmarkPos2D = _PresentDataset + self._dataType = _PresentDataset else: # if the dataset is not found on disk - self._landmarkPos2D = _AbsentDataset - if 'landmarkPos3D' in self._h: + self._dataType = _AbsentDataset + if 'dataUnit' in self._h: if not self._cfg.dynamic_loading: - self._landmarkPos3D = _read_float_array( - self._h['landmarkPos3D']) + self._dataUnit = _read_string_array(self._h['dataUnit']) else: # if the dataset is found on disk but dynamic_loading=True - self._landmarkPos3D = _PresentDataset + self._dataUnit = _PresentDataset else: # if the dataset is not found on disk - self._landmarkPos3D = _AbsentDataset - if 'landmarkLabels' in self._h: + self._dataUnit = _AbsentDataset + if 'dataTypeLabel' in self._h: if not self._cfg.dynamic_loading: - self._landmarkLabels = _read_string_array( - self._h['landmarkLabels']) + self._dataTypeLabel = _read_string_array( + self._h['dataTypeLabel']) else: # if the dataset is found on disk but dynamic_loading=True - self._landmarkLabels = _PresentDataset + self._dataTypeLabel = _PresentDataset else: # if the dataset is not found on disk - self._landmarkLabels = _AbsentDataset - if 'coordinateSystem' in self._h: + self._dataTypeLabel = _AbsentDataset + if 'dataTypeIndex' in self._h: if not self._cfg.dynamic_loading: - self._coordinateSystem = _read_string( - self._h['coordinateSystem']) + self._dataTypeIndex = _read_int_array(self._h['dataTypeIndex']) else: # if the dataset is found on disk but dynamic_loading=True - self._coordinateSystem = _PresentDataset + self._dataTypeIndex = _PresentDataset else: # if the dataset is not found on disk - self._coordinateSystem = _AbsentDataset - if 'coordinateSystemDescription' in self._h: + self._dataTypeIndex = _AbsentDataset + if 'sourcePower' in self._h: if not self._cfg.dynamic_loading: - self._coordinateSystemDescription = _read_string( - self._h['coordinateSystemDescription']) + self._sourcePower = _read_float_array(self._h['sourcePower']) else: # if the dataset is found on disk but dynamic_loading=True - self._coordinateSystemDescription = _PresentDataset + self._sourcePower = _PresentDataset else: # if the dataset is not found on disk - self._coordinateSystemDescription = _AbsentDataset - if 'useLocalIndex' in self._h: + self._sourcePower = _AbsentDataset + if 'detectorGain' in self._h: if not self._cfg.dynamic_loading: - self._useLocalIndex = _read_int(self._h['useLocalIndex']) + self._detectorGain = _read_float_array(self._h['detectorGain']) else: # if the dataset is found on disk but dynamic_loading=True - self._useLocalIndex = _PresentDataset + self._detectorGain = _PresentDataset else: # if the dataset is not found on disk - self._useLocalIndex = _AbsentDataset + self._detectorGain = _AbsentDataset @property - def wavelengths(self): - """SNIRF field `wavelengths`. + def sourceIndex(self): + """SNIRF field `sourceIndex`. If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter - This field describes the "nominal" wavelengths used (in `nm` unit). This is indexed by the - `wavelengthIndex` of the measurementList variable. For example, `probe.wavelengths` = [690, - 780, 830]; implies that the measurements were taken at three wavelengths (690 nm, - 780 nm, and 830 nm). The wavelength index of - `measurementList(k).wavelengthIndex` variable refers to this field. - `measurementList(k).wavelengthIndex` = 2 means the kth measurement - was at 780 nm. + Source indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._sourceIndex) is type(_AbsentDataset): + return None + if type(self._sourceIndex) is type(_PresentDataset): + return _read_int_array(self._h['sourceIndex']) + self._cfg.logger.info('Dynamically loaded %s/sourceIndex from %s', + self.location, self.filename) + return self._sourceIndex - Please note that this field stores the "nominal" wavelengths. If the precise - (measured) wavelengths differ from the nominal wavelengths, one can store those - in the `measurementList.wavelengthActual` field in a per-channel fashion. + @sourceIndex.setter + def sourceIndex(self, value): + self._sourceIndex = value + # self._cfg.logger.info('Assignment to %s/sourceIndex in %s', self.location, self.filename) - The number of wavelengths is not limited (except that at least two are needed - to calculate the two forms of hemoglobin). Each source-detector pair would - generally have measurements at all wavelengths. + @sourceIndex.deleter + def sourceIndex(self): + self._sourceIndex = _AbsentDataset + self._cfg.logger.info('Deleted %s/sourceIndex from %s', self.location, + self.filename) - This field must present, but can be empty, for example, in the case that the stored - data are processed data (`dataType=99999`, see Appendix). + @property + def detectorIndex(self): + """SNIRF field `detectorIndex`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + Detector indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._wavelengths) is type(_AbsentDataset): + if type(self._detectorIndex) is type(_AbsentDataset): + return None + if type(self._detectorIndex) is type(_PresentDataset): + return _read_int_array(self._h['detectorIndex']) + self._cfg.logger.info( + 'Dynamically loaded %s/detectorIndex from %s', self.location, + self.filename) + return self._detectorIndex + + @detectorIndex.setter + def detectorIndex(self, value): + self._detectorIndex = value + # self._cfg.logger.info('Assignment to %s/detectorIndex in %s', self.location, self.filename) + + @detectorIndex.deleter + def detectorIndex(self): + self._detectorIndex = _AbsentDataset + self._cfg.logger.info('Deleted %s/detectorIndex from %s', + self.location, self.filename) + + @property + def wavelengthIndex(self): + """SNIRF field `wavelengthIndex`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._wavelengthIndex) is type(_AbsentDataset): + return None + if type(self._wavelengthIndex) is type(_PresentDataset): + return _read_int_array(self._h['wavelengthIndex']) + self._cfg.logger.info( + 'Dynamically loaded %s/wavelengthIndex from %s', self.location, + self.filename) + return self._wavelengthIndex + + @wavelengthIndex.setter + def wavelengthIndex(self, value): + self._wavelengthIndex = value + # self._cfg.logger.info('Assignment to %s/wavelengthIndex in %s', self.location, self.filename) + + @wavelengthIndex.deleter + def wavelengthIndex(self): + self._wavelengthIndex = _AbsentDataset + self._cfg.logger.info('Deleted %s/wavelengthIndex from %s', + self.location, self.filename) + + @property + def wavelengthActual(self): + """SNIRF field `wavelengthActual`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + Actual (measured) wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._wavelengthActual) is type(_AbsentDataset): + return None + if type(self._wavelengthActual) is type(_PresentDataset): + return _read_float_array(self._h['wavelengthActual']) + self._cfg.logger.info( + 'Dynamically loaded %s/wavelengthActual from %s', + self.location, self.filename) + return self._wavelengthActual + + @wavelengthActual.setter + def wavelengthActual(self, value): + self._wavelengthActual = value + # self._cfg.logger.info('Assignment to %s/wavelengthActual in %s', self.location, self.filename) + + @wavelengthActual.deleter + def wavelengthActual(self): + self._wavelengthActual = _AbsentDataset + self._cfg.logger.info('Deleted %s/wavelengthActual from %s', + self.location, self.filename) + + @property + def wavelengthEmissionActual(self): + """SNIRF field `wavelengthEmissionActual`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + Actual (measured) emission wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._wavelengthEmissionActual) is type(_AbsentDataset): + return None + if type(self._wavelengthEmissionActual) is type(_PresentDataset): + return _read_float_array(self._h['wavelengthEmissionActual']) + self._cfg.logger.info( + 'Dynamically loaded %s/wavelengthEmissionActual from %s', + self.location, self.filename) + return self._wavelengthEmissionActual + + @wavelengthEmissionActual.setter + def wavelengthEmissionActual(self, value): + self._wavelengthEmissionActual = value + # self._cfg.logger.info('Assignment to %s/wavelengthEmissionActual in %s', self.location, self.filename) + + @wavelengthEmissionActual.deleter + def wavelengthEmissionActual(self): + self._wavelengthEmissionActual = _AbsentDataset + self._cfg.logger.info('Deleted %s/wavelengthEmissionActual from %s', + self.location, self.filename) + + @property + def dataType(self): + """SNIRF field `dataType`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. See Appendix for list of possible values. + + """ + if type(self._dataType) is type(_AbsentDataset): + return None + if type(self._dataType) is type(_PresentDataset): + return _read_int_array(self._h['dataType']) + self._cfg.logger.info('Dynamically loaded %s/dataType from %s', + self.location, self.filename) + return self._dataType + + @dataType.setter + def dataType(self, value): + self._dataType = value + # self._cfg.logger.info('Assignment to %s/dataType in %s', self.location, self.filename) + + @dataType.deleter + def dataType(self): + self._dataType = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataType from %s', self.location, + self.filename) + + @property + def dataUnit(self): + """SNIRF field `dataUnit`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + International System of Units (SI units) identifier for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._dataUnit) is type(_AbsentDataset): + return None + if type(self._dataUnit) is type(_PresentDataset): + return _read_string_array(self._h['dataUnit']) + self._cfg.logger.info('Dynamically loaded %s/dataUnit from %s', + self.location, self.filename) + return self._dataUnit + + @dataUnit.setter + def dataUnit(self, value): + self._dataUnit = value + # self._cfg.logger.info('Assignment to %s/dataUnit in %s', self.location, self.filename) + + @dataUnit.deleter + def dataUnit(self): + self._dataUnit = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataUnit from %s', self.location, + self.filename) + + @property + def dataTypeLabel(self): + """SNIRF field `dataTypeLabel`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + Data-type label. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + """ + if type(self._dataTypeLabel) is type(_AbsentDataset): + return None + if type(self._dataTypeLabel) is type(_PresentDataset): + return _read_string_array(self._h['dataTypeLabel']) + self._cfg.logger.info( + 'Dynamically loaded %s/dataTypeLabel from %s', self.location, + self.filename) + return self._dataTypeLabel + + @dataTypeLabel.setter + def dataTypeLabel(self, value): + self._dataTypeLabel = value + # self._cfg.logger.info('Assignment to %s/dataTypeLabel in %s', self.location, self.filename) + + @dataTypeLabel.deleter + def dataTypeLabel(self): + self._dataTypeLabel = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataTypeLabel from %s', + self.location, self.filename) + + @property + def dataTypeIndex(self): + """SNIRF field `dataTypeIndex`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + Data-type specific parameter indices. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so `dataTimeIndex` must be a 2-D array with 2 columns that index the additional parameters. + + """ + if type(self._dataTypeIndex) is type(_AbsentDataset): + return None + if type(self._dataTypeIndex) is type(_PresentDataset): + return _read_int_array(self._h['dataTypeIndex']) + self._cfg.logger.info( + 'Dynamically loaded %s/dataTypeIndex from %s', self.location, + self.filename) + return self._dataTypeIndex + + @dataTypeIndex.setter + def dataTypeIndex(self, value): + self._dataTypeIndex = value + # self._cfg.logger.info('Assignment to %s/dataTypeIndex in %s', self.location, self.filename) + + @dataTypeIndex.deleter + def dataTypeIndex(self): + self._dataTypeIndex = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataTypeIndex from %s', + self.location, self.filename) + + @property + def sourcePower(self): + """SNIRF field `sourcePower`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + + """ + if type(self._sourcePower) is type(_AbsentDataset): + return None + if type(self._sourcePower) is type(_PresentDataset): + return _read_float_array(self._h['sourcePower']) + self._cfg.logger.info('Dynamically loaded %s/sourcePower from %s', + self.location, self.filename) + return self._sourcePower + + @sourcePower.setter + def sourcePower(self, value): + self._sourcePower = value + # self._cfg.logger.info('Assignment to %s/sourcePower in %s', self.location, self.filename) + + @sourcePower.deleter + def sourcePower(self): + self._sourcePower = _AbsentDataset + self._cfg.logger.info('Deleted %s/sourcePower from %s', self.location, + self.filename) + + @property + def detectorGain(self): + """SNIRF field `detectorGain`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + + """ + if type(self._detectorGain) is type(_AbsentDataset): + return None + if type(self._detectorGain) is type(_PresentDataset): + return _read_float_array(self._h['detectorGain']) + self._cfg.logger.info('Dynamically loaded %s/detectorGain from %s', + self.location, self.filename) + return self._detectorGain + + @detectorGain.setter + def detectorGain(self, value): + self._detectorGain = value + # self._cfg.logger.info('Assignment to %s/detectorGain in %s', self.location, self.filename) + + @detectorGain.deleter + def detectorGain(self): + self._detectorGain = _AbsentDataset + self._cfg.logger.info('Deleted %s/detectorGain from %s', self.location, + self.filename) + + def _save(self, *args): + if len(args) > 0 and type(args[0]) is h5py.File: + file = args[0] + if self.location not in file: + file.create_group(self.location) + # self._cfg.logger.info('Created Group at %s in %s', self.location, file) + else: + if self.location not in file: + # Assign the wrapper to the new HDF5 Group on disk + self._h = file.create_group(self.location) + # self._cfg.logger.info('Created Group at %s in %s', self.location, file) + if self._h != {}: + file = self._h.file + else: + raise ValueError('Cannot save an anonymous ' + + self.__class__.__name__ + + ' instance without a filename') + name = self.location + '/sourceIndex' + if type(self._sourceIndex) not in [type(_AbsentDataset), type(None)]: + data = self.sourceIndex # Use loader function via getter + if name in file: + del file[name] + _create_dataset_int_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/detectorIndex' + if type(self._detectorIndex) not in [type(_AbsentDataset), type(None)]: + data = self.detectorIndex # Use loader function via getter + if name in file: + del file[name] + _create_dataset_int_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/wavelengthIndex' + if type(self._wavelengthIndex) not in [ + type(_AbsentDataset), type(None) + ]: + data = self.wavelengthIndex # Use loader function via getter + if name in file: + del file[name] + _create_dataset_int_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/wavelengthActual' + if type(self._wavelengthActual) not in [ + type(_AbsentDataset), type(None) + ]: + data = self.wavelengthActual # Use loader function via getter + if name in file: + del file[name] + _create_dataset_float_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/wavelengthEmissionActual' + if type(self._wavelengthEmissionActual) not in [ + type(_AbsentDataset), type(None) + ]: + data = self.wavelengthEmissionActual # Use loader function via getter + if name in file: + del file[name] + _create_dataset_float_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/dataType' + if type(self._dataType) not in [type(_AbsentDataset), type(None)]: + data = self.dataType # Use loader function via getter + if name in file: + del file[name] + _create_dataset_int_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/dataUnit' + if type(self._dataUnit) not in [type(_AbsentDataset), type(None)]: + data = self.dataUnit # Use loader function via getter + if name in file: + del file[name] + _create_dataset_string_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/dataTypeLabel' + if type(self._dataTypeLabel) not in [type(_AbsentDataset), type(None)]: + data = self.dataTypeLabel # Use loader function via getter + if name in file: + del file[name] + _create_dataset_string_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/dataTypeIndex' + if type(self._dataTypeIndex) not in [type(_AbsentDataset), type(None)]: + data = self.dataTypeIndex # Use loader function via getter + if name in file: + del file[name] + _create_dataset_int_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/sourcePower' + if type(self._sourcePower) not in [type(_AbsentDataset), type(None)]: + data = self.sourcePower # Use loader function via getter + if name in file: + del file[name] + _create_dataset_float_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/detectorGain' + if type(self._detectorGain) not in [type(_AbsentDataset), type(None)]: + data = self.detectorGain # Use loader function via getter + if name in file: + del file[name] + _create_dataset_float_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + + def _validate(self, result: ValidationResult): + # Validate unwritten datasets after writing them to this tempfile + with h5py.File(TemporaryFile(), 'w') as tmp: + name = self.location + '/sourceIndex' + if type(self._sourceIndex) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._sourceIndex) is type( + _PresentDataset) or 'sourceIndex' in self._h: + dataset = self._h['sourceIndex'] + else: + dataset = _create_dataset_int_array( + tmp, 'sourceIndex', self._sourceIndex) + result._add(name, _validate_int_array(dataset, ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/detectorIndex' + if type(self._detectorIndex) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._detectorIndex) is type( + _PresentDataset) or 'detectorIndex' in self._h: + dataset = self._h['detectorIndex'] + else: + dataset = _create_dataset_int_array( + tmp, 'detectorIndex', self._detectorIndex) + result._add(name, _validate_int_array(dataset, ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/wavelengthIndex' + if type(self._wavelengthIndex) in [ + type(_AbsentDataset), type(None) + ]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._wavelengthIndex) is type( + _PresentDataset) or 'wavelengthIndex' in self._h: + dataset = self._h['wavelengthIndex'] + else: + dataset = _create_dataset_int_array( + tmp, 'wavelengthIndex', self._wavelengthIndex) + result._add(name, _validate_int_array(dataset, ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/wavelengthActual' + if type(self._wavelengthActual) in [ + type(_AbsentDataset), type(None) + ]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._wavelengthActual) is type( + _PresentDataset) or 'wavelengthActual' in self._h: + dataset = self._h['wavelengthActual'] + else: + dataset = _create_dataset_float_array( + tmp, 'wavelengthActual', self._wavelengthActual) + result._add(name, _validate_float_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/wavelengthEmissionActual' + if type(self._wavelengthEmissionActual) in [ + type(_AbsentDataset), type(None) + ]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._wavelengthEmissionActual) is type( + _PresentDataset + ) or 'wavelengthEmissionActual' in self._h: + dataset = self._h['wavelengthEmissionActual'] + else: + dataset = _create_dataset_float_array( + tmp, 'wavelengthEmissionActual', + self._wavelengthEmissionActual) + result._add(name, _validate_float_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/dataType' + if type(self._dataType) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._dataType) is type( + _PresentDataset) or 'dataType' in self._h: + dataset = self._h['dataType'] + else: + dataset = _create_dataset_int_array( + tmp, 'dataType', self._dataType) + result._add(name, _validate_int_array(dataset, ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/dataUnit' + if type(self._dataUnit) in [type(_AbsentDataset), type(None)]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._dataUnit) is type( + _PresentDataset) or 'dataUnit' in self._h: + dataset = self._h['dataUnit'] + else: + dataset = _create_dataset_string_array( + tmp, 'dataUnit', self._dataUnit) + result._add(name, _validate_string_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/dataTypeLabel' + if type(self._dataTypeLabel) in [type(_AbsentDataset), type(None)]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._dataTypeLabel) is type( + _PresentDataset) or 'dataTypeLabel' in self._h: + dataset = self._h['dataTypeLabel'] + else: + dataset = _create_dataset_string_array( + tmp, 'dataTypeLabel', self._dataTypeLabel) + result._add(name, _validate_string_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/dataTypeIndex' + if type(self._dataTypeIndex) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._dataTypeIndex) is type( + _PresentDataset) or 'dataTypeIndex' in self._h: + dataset = self._h['dataTypeIndex'] + else: + dataset = _create_dataset_int_array( + tmp, 'dataTypeIndex', self._dataTypeIndex) + result._add(name, _validate_int_array(dataset, ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/sourcePower' + if type(self._sourcePower) in [type(_AbsentDataset), type(None)]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._sourcePower) is type( + _PresentDataset) or 'sourcePower' in self._h: + dataset = self._h['sourcePower'] + else: + dataset = _create_dataset_float_array( + tmp, 'sourcePower', self._sourcePower) + result._add(name, _validate_float_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/detectorGain' + if type(self._detectorGain) in [type(_AbsentDataset), type(None)]: + result._add(name, 'OPTIONAL_DATASET_MISSING') + else: + try: + if type(self._detectorGain) is type( + _PresentDataset) or 'detectorGain' in self._h: + dataset = self._h['detectorGain'] + else: + dataset = _create_dataset_float_array( + tmp, 'detectorGain', self._detectorGain) + result._add(name, _validate_float_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') + for key in self._h.keys(): + if not any( + [key.startswith(name) for name in self._snirf_names]): + if type(self._h[key]) is h5py.Group: + result._add(self.location + '/' + key, + 'UNRECOGNIZED_GROUP') + elif type(self._h[key]) is h5py.Dataset: + result._add(self.location + '/' + key, + 'UNRECOGNIZED_DATASET') + + +class Probe(Group): + """Wrapper for Group of type `probe`. + + This is a structured variable that describes the probe (source-detector) + geometry. This variable has a number of required fields. + + """ + + def __init__(self, var, cfg: SnirfConfig): + super().__init__(var, cfg) + self._wavelengths = _AbsentDataset # [,...]* + self._wavelengthsEmission = _AbsentDataset # [,...] + self._sourcePos2D = _AbsentDataset # [[,...]]*1 + self._sourcePos3D = _AbsentDataset # [[,...]]*1 + self._detectorPos2D = _AbsentDataset # [[,...]]*2 + self._detectorPos3D = _AbsentDataset # [[,...]]*2 + self._frequencies = _AbsentDataset # [,...] + self._timeDelays = _AbsentDataset # [,...] + self._timeDelayWidths = _AbsentDataset # [,...] + self._momentOrders = _AbsentDataset # [,...] + self._correlationTimeDelays = _AbsentDataset # [,...] + self._correlationTimeDelayWidths = _AbsentDataset # [,...] + self._sourceLabels = _AbsentDataset # [["s",...]] + self._detectorLabels = _AbsentDataset # ["s",...] + self._landmarkPos2D = _AbsentDataset # [[,...]] + self._landmarkPos3D = _AbsentDataset # [[,...]] + self._landmarkLabels = _AbsentDataset # ["s",...] + self._coordinateSystem = _AbsentDataset # "s" + self._coordinateSystemDescription = _AbsentDataset # "s" + self._snirf_names = [ + 'wavelengths', + 'wavelengthsEmission', + 'sourcePos2D', + 'sourcePos3D', + 'detectorPos2D', + 'detectorPos3D', + 'frequencies', + 'timeDelays', + 'timeDelayWidths', + 'momentOrders', + 'correlationTimeDelays', + 'correlationTimeDelayWidths', + 'sourceLabels', + 'detectorLabels', + 'landmarkPos2D', + 'landmarkPos3D', + 'landmarkLabels', + 'coordinateSystem', + 'coordinateSystemDescription', + ] + + self._indexed_groups = [] + if 'wavelengths' in self._h: + if not self._cfg.dynamic_loading: + self._wavelengths = _read_float_array(self._h['wavelengths']) + else: # if the dataset is found on disk but dynamic_loading=True + self._wavelengths = _PresentDataset + else: # if the dataset is not found on disk + self._wavelengths = _AbsentDataset + if 'wavelengthsEmission' in self._h: + if not self._cfg.dynamic_loading: + self._wavelengthsEmission = _read_float_array( + self._h['wavelengthsEmission']) + else: # if the dataset is found on disk but dynamic_loading=True + self._wavelengthsEmission = _PresentDataset + else: # if the dataset is not found on disk + self._wavelengthsEmission = _AbsentDataset + if 'sourcePos2D' in self._h: + if not self._cfg.dynamic_loading: + self._sourcePos2D = _read_float_array(self._h['sourcePos2D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._sourcePos2D = _PresentDataset + else: # if the dataset is not found on disk + self._sourcePos2D = _AbsentDataset + if 'sourcePos3D' in self._h: + if not self._cfg.dynamic_loading: + self._sourcePos3D = _read_float_array(self._h['sourcePos3D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._sourcePos3D = _PresentDataset + else: # if the dataset is not found on disk + self._sourcePos3D = _AbsentDataset + if 'detectorPos2D' in self._h: + if not self._cfg.dynamic_loading: + self._detectorPos2D = _read_float_array( + self._h['detectorPos2D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._detectorPos2D = _PresentDataset + else: # if the dataset is not found on disk + self._detectorPos2D = _AbsentDataset + if 'detectorPos3D' in self._h: + if not self._cfg.dynamic_loading: + self._detectorPos3D = _read_float_array( + self._h['detectorPos3D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._detectorPos3D = _PresentDataset + else: # if the dataset is not found on disk + self._detectorPos3D = _AbsentDataset + if 'frequencies' in self._h: + if not self._cfg.dynamic_loading: + self._frequencies = _read_float_array(self._h['frequencies']) + else: # if the dataset is found on disk but dynamic_loading=True + self._frequencies = _PresentDataset + else: # if the dataset is not found on disk + self._frequencies = _AbsentDataset + if 'timeDelays' in self._h: + if not self._cfg.dynamic_loading: + self._timeDelays = _read_float_array(self._h['timeDelays']) + else: # if the dataset is found on disk but dynamic_loading=True + self._timeDelays = _PresentDataset + else: # if the dataset is not found on disk + self._timeDelays = _AbsentDataset + if 'timeDelayWidths' in self._h: + if not self._cfg.dynamic_loading: + self._timeDelayWidths = _read_float_array( + self._h['timeDelayWidths']) + else: # if the dataset is found on disk but dynamic_loading=True + self._timeDelayWidths = _PresentDataset + else: # if the dataset is not found on disk + self._timeDelayWidths = _AbsentDataset + if 'momentOrders' in self._h: + if not self._cfg.dynamic_loading: + self._momentOrders = _read_float_array(self._h['momentOrders']) + else: # if the dataset is found on disk but dynamic_loading=True + self._momentOrders = _PresentDataset + else: # if the dataset is not found on disk + self._momentOrders = _AbsentDataset + if 'correlationTimeDelays' in self._h: + if not self._cfg.dynamic_loading: + self._correlationTimeDelays = _read_float_array( + self._h['correlationTimeDelays']) + else: # if the dataset is found on disk but dynamic_loading=True + self._correlationTimeDelays = _PresentDataset + else: # if the dataset is not found on disk + self._correlationTimeDelays = _AbsentDataset + if 'correlationTimeDelayWidths' in self._h: + if not self._cfg.dynamic_loading: + self._correlationTimeDelayWidths = _read_float_array( + self._h['correlationTimeDelayWidths']) + else: # if the dataset is found on disk but dynamic_loading=True + self._correlationTimeDelayWidths = _PresentDataset + else: # if the dataset is not found on disk + self._correlationTimeDelayWidths = _AbsentDataset + if 'sourceLabels' in self._h: + if not self._cfg.dynamic_loading: + self._sourceLabels = _read_string_array( + self._h['sourceLabels']) + else: # if the dataset is found on disk but dynamic_loading=True + self._sourceLabels = _PresentDataset + else: # if the dataset is not found on disk + self._sourceLabels = _AbsentDataset + if 'detectorLabels' in self._h: + if not self._cfg.dynamic_loading: + self._detectorLabels = _read_string_array( + self._h['detectorLabels']) + else: # if the dataset is found on disk but dynamic_loading=True + self._detectorLabels = _PresentDataset + else: # if the dataset is not found on disk + self._detectorLabels = _AbsentDataset + if 'landmarkPos2D' in self._h: + if not self._cfg.dynamic_loading: + self._landmarkPos2D = _read_float_array( + self._h['landmarkPos2D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._landmarkPos2D = _PresentDataset + else: # if the dataset is not found on disk + self._landmarkPos2D = _AbsentDataset + if 'landmarkPos3D' in self._h: + if not self._cfg.dynamic_loading: + self._landmarkPos3D = _read_float_array( + self._h['landmarkPos3D']) + else: # if the dataset is found on disk but dynamic_loading=True + self._landmarkPos3D = _PresentDataset + else: # if the dataset is not found on disk + self._landmarkPos3D = _AbsentDataset + if 'landmarkLabels' in self._h: + if not self._cfg.dynamic_loading: + self._landmarkLabels = _read_string_array( + self._h['landmarkLabels']) + else: # if the dataset is found on disk but dynamic_loading=True + self._landmarkLabels = _PresentDataset + else: # if the dataset is not found on disk + self._landmarkLabels = _AbsentDataset + if 'coordinateSystem' in self._h: + if not self._cfg.dynamic_loading: + self._coordinateSystem = _read_string( + self._h['coordinateSystem']) + else: # if the dataset is found on disk but dynamic_loading=True + self._coordinateSystem = _PresentDataset + else: # if the dataset is not found on disk + self._coordinateSystem = _AbsentDataset + if 'coordinateSystemDescription' in self._h: + if not self._cfg.dynamic_loading: + self._coordinateSystemDescription = _read_string( + self._h['coordinateSystemDescription']) + else: # if the dataset is found on disk but dynamic_loading=True + self._coordinateSystemDescription = _PresentDataset + else: # if the dataset is not found on disk + self._coordinateSystemDescription = _AbsentDataset + + @property + def wavelengths(self): + """SNIRF field `wavelengths`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + This field describes the "nominal" wavelengths used (in `nm` unit). This is indexed by the + `wavelengthIndex` of the measurementList variable. For example, `probe.wavelengths` = [690, + 780, 830]; implies that the measurements were taken at three wavelengths (690 nm, + 780 nm, and 830 nm). The wavelength index of + `measurementList(k).wavelengthIndex` variable refers to this field. + `measurementList(k).wavelengthIndex` = 2 means the kth measurement + was at 780 nm. + + Please note that this field stores the "nominal" wavelengths. If the precise + (measured) wavelengths differ from the nominal wavelengths, one can store those + in the `measurementList.wavelengthActual` field in a per-channel fashion. + + The number of wavelengths is not limited (except that at least two are needed + to calculate the two forms of hemoglobin). Each source-detector pair would + generally have measurements at all wavelengths. + + This field must present, but can be empty, for example, in the case that the stored + data are processed data (`dataType=99999`, see Appendix). + + + """ + if type(self._wavelengths) is type(_AbsentDataset): return None if type(self._wavelengths) is type(_PresentDataset): return _read_float_array(self._h['wavelengths']) @@ -2768,55 +3538,18 @@ def coordinateSystemDescription(self): return _read_string(self._h['coordinateSystemDescription']) self._cfg.logger.info( 'Dynamically loaded %s/coordinateSystemDescription from %s', - self.location, self.filename) - return self._coordinateSystemDescription - - @coordinateSystemDescription.setter - def coordinateSystemDescription(self, value): - self._coordinateSystemDescription = value - # self._cfg.logger.info('Assignment to %s/coordinateSystemDescription in %s', self.location, self.filename) - - @coordinateSystemDescription.deleter - def coordinateSystemDescription(self): - self._coordinateSystemDescription = _AbsentDataset - self._cfg.logger.info('Deleted %s/coordinateSystemDescription from %s', - self.location, self.filename) - - @property - def useLocalIndex(self): - """SNIRF field `useLocalIndex`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - For modular NIRS systems, setting this flag to a non-zero integer indicates - that `measurementList(k).sourceIndex` and `measurementList(k).detectorIndex` - are module-specific local-indices. One must also include - `measurementList(k).moduleIndex`, or when cross-module channels present, both - `measurementList(k).sourceModuleIndex` and `measurementList(k).detectorModuleIndex` - in the `measurementList` structure in order to restore the global indices - of the sources/detectors. - - - """ - if type(self._useLocalIndex) is type(_AbsentDataset): - return None - if type(self._useLocalIndex) is type(_PresentDataset): - return _read_int(self._h['useLocalIndex']) - self._cfg.logger.info( - 'Dynamically loaded %s/useLocalIndex from %s', self.location, - self.filename) - return self._useLocalIndex + self.location, self.filename) + return self._coordinateSystemDescription - @useLocalIndex.setter - def useLocalIndex(self, value): - self._useLocalIndex = value - # self._cfg.logger.info('Assignment to %s/useLocalIndex in %s', self.location, self.filename) + @coordinateSystemDescription.setter + def coordinateSystemDescription(self, value): + self._coordinateSystemDescription = value + # self._cfg.logger.info('Assignment to %s/coordinateSystemDescription in %s', self.location, self.filename) - @useLocalIndex.deleter - def useLocalIndex(self): - self._useLocalIndex = _AbsentDataset - self._cfg.logger.info('Deleted %s/useLocalIndex from %s', + @coordinateSystemDescription.deleter + def coordinateSystemDescription(self): + self._coordinateSystemDescription = _AbsentDataset + self._cfg.logger.info('Deleted %s/coordinateSystemDescription from %s', self.location, self.filename) def _save(self, *args): @@ -3061,17 +3794,6 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/useLocalIndex' - if type(self._useLocalIndex) not in [type(_AbsentDataset), type(None)]: - data = self.useLocalIndex # Use loader function via getter - if name in file: - del file[name] - _create_dataset_int(file, name, data) - # self._cfg.logger.info('Creating Dataset %s in %s', name, file) - else: - if name in file: - del file[name] - self._cfg.logger.info('Deleted Dataset %s from %s', name, file) def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile @@ -3383,26 +4105,6 @@ def _validate(self, result: ValidationResult): result._add(name, _validate_string(dataset)) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/useLocalIndex' - if type(self._useLocalIndex) in [type(_AbsentDataset), type(None)]: - result._add(name, 'OPTIONAL_DATASET_MISSING') - else: - try: - if type(self._useLocalIndex) is type( - _PresentDataset) or 'useLocalIndex' in self._h: - dataset = self._h['useLocalIndex'] - else: - dataset = _create_dataset_int(tmp, 'useLocalIndex', - self._useLocalIndex) - err_code = _validate_int(dataset) - if _read_int(dataset) < 0 and err_code == 'OK': - result._add(name, 'NEGATIVE_INDEX') - elif _read_int(dataset) == 0 and err_code == 'OK': - result._add(name, 'INDEX_OF_ZERO') - else: - result._add(name, err_code) - except ValueError: # If the _create_dataset function can't convert the data - result._add(name, 'INVALID_DATASET_TYPE') for key in self._h.keys(): if not any( [key.startswith(name) for name in self._snirf_names]): @@ -3416,6 +4118,7 @@ def _validate(self, result: ValidationResult): class NirsElement(Group): """Wrapper for an element of indexed group `Nirs`.""" + def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._metaDataTags = _AbsentGroup # {.}* @@ -3708,15 +4411,20 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig): class DataElement(Group): """Wrapper for an element of indexed group `Data`.""" + def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._dataTimeSeries = _AbsentDataset # [[,...]]* + self._dataOffset = _AbsentDataset # [,...]* self._time = _AbsentDataset # [,...]* self._measurementList = _AbsentDataset # {i}* + self._measurementLists = _AbsentGroup # {.}* self._snirf_names = [ 'dataTimeSeries', + 'dataOffset', 'time', 'measurementList', + 'measurementLists', ] self._indexed_groups = [] @@ -3728,6 +4436,13 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._dataTimeSeries = _PresentDataset else: # if the dataset is not found on disk self._dataTimeSeries = _AbsentDataset + if 'dataOffset' in self._h: + if not self._cfg.dynamic_loading: + self._dataOffset = _read_float_array(self._h['dataOffset']) + else: # if the dataset is found on disk but dynamic_loading=True + self._dataOffset = _PresentDataset + else: # if the dataset is not found on disk + self._dataOffset = _AbsentDataset if 'time' in self._h: if not self._cfg.dynamic_loading: self._time = _read_float_array(self._h['time']) @@ -3738,6 +4453,13 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self.measurementList = MeasurementList(self, self._cfg) # Indexed group self._indexed_groups.append(self.measurementList) + if 'measurementLists' in self._h: + self._measurementLists = MeasurementLists( + self._h['measurementLists'].id, self._cfg) # Group + else: + self._measurementLists = MeasurementLists( + self.location + '/' + 'measurementLists', + self._cfg) # Anonymous group (wrapper only) @property def dataTimeSeries(self): @@ -3757,6 +4479,7 @@ def dataTimeSeries(self): Chunked data is allowed to support real-time streaming of data in this array. + """ if type(self._dataTimeSeries) is type(_AbsentDataset): return None @@ -3778,6 +4501,40 @@ def dataTimeSeries(self): self._cfg.logger.info('Deleted %s/dataTimeSeries from %s', self.location, self.filename) + @property + def dataOffset(self): + """SNIRF field `dataOffset`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + This stores an optional offset value per channel, which, when added to + `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. + + The length of this array is equal to the as represented + by the second dimension in the `dataTimeSeries`. + + + """ + if type(self._dataOffset) is type(_AbsentDataset): + return None + if type(self._dataOffset) is type(_PresentDataset): + return _read_float_array(self._h['dataOffset']) + self._cfg.logger.info('Dynamically loaded %s/dataOffset from %s', + self.location, self.filename) + return self._dataOffset + + @dataOffset.setter + def dataOffset(self, value): + self._dataOffset = value + # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) + + @dataOffset.deleter + def dataOffset(self): + self._dataOffset = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataOffset from %s', self.location, + self.filename) + @property def time(self): """SNIRF field `time`. @@ -3855,6 +4612,41 @@ def measurementList(self): self._cfg.logger.info('Deleted %s/measurementList from %s', self.location, self.filename) + @property + def measurementLists(self): + """SNIRF field `measurementLists`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). + + This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. + + The arrays of `measurementLists` are: + + """ + if type(self._measurementLists) is type(_AbsentGroup): + return None + return self._measurementLists + + @measurementLists.setter + def measurementLists(self, value): + if isinstance(value, MeasurementLists): + self._measurementLists = _recursive_hdf5_copy( + self._measurementLists, value) + else: + raise ValueError( + "Only a Group of type MeasurementLists can be assigned to measurementLists." + ) + # self._cfg.logger.info('Assignment to %s/measurementLists in %s', self.location, self.filename) + + @measurementLists.deleter + def measurementLists(self): + self._measurementLists = _AbsentGroup + self._cfg.logger.info('Deleted %s/measurementLists from %s', + self.location, self.filename) + def _save(self, *args): if len(args) > 0 and type(args[0]) is h5py.File: file = args[0] @@ -3885,6 +4677,17 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/dataOffset' + if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: + data = self.dataOffset # Use loader function via getter + if name in file: + del file[name] + _create_dataset_float_array(file, name, data, ndim=1) + # self._cfg.logger.info('Creating Dataset %s in %s', name, file) + else: + if name in file: + del file[name] + self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/time' if type(self._time) not in [type(_AbsentDataset), type(None)]: data = self.time # Use loader function via getter @@ -3897,6 +4700,15 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) self.measurementList._save(*args) + if type(self._measurementLists) is type( + _AbsentGroup) or self._measurementLists.is_empty(): + if 'measurementLists' in file: + del file['measurementLists'] + self._cfg.logger.info( + 'Deleted Group %s/measurementLists from %s', self.location, + file) + else: + self.measurementLists._save(*args) def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile @@ -3918,6 +4730,21 @@ def _validate(self, result: ValidationResult): ndims=[2])) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') + name = self.location + '/dataOffset' + if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') + else: + try: + if type(self._dataOffset) is type( + _PresentDataset) or 'dataOffset' in self._h: + dataset = self._h['dataOffset'] + else: + dataset = _create_dataset_float_array( + tmp, 'dataOffset', self._dataOffset) + result._add(name, _validate_float_array(dataset, + ndims=[1])) + except ValueError: # If the _create_dataset function can't convert the data + result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/time' if type(self._time) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -3938,6 +4765,15 @@ def _validate(self, result: ValidationResult): result._add(name, 'REQUIRED_INDEXED_GROUP_EMPTY') else: self.measurementList._validate(result) + name = self.location + '/measurementLists' + # If Group is not present in file and empty in the wrapper, it is missing + if type(self._measurementLists) in [ + type(_AbsentGroup), type(None) + ] or ('measurementLists' not in self._h + and self._measurementLists.is_empty()): + result._add(name, 'REQUIRED_GROUP_MISSING') + else: + self._measurementLists._validate(result) for key in self._h.keys(): if not any( [key.startswith(name) for name in self._snirf_names]): @@ -3974,6 +4810,7 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig): class MeasurementListElement(Group): """Wrapper for an element of indexed group `MeasurementList`.""" + def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._sourceIndex = _AbsentDataset # * @@ -3987,9 +4824,6 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._dataTypeIndex = _AbsentDataset # * self._sourcePower = _AbsentDataset # self._detectorGain = _AbsentDataset # - self._moduleIndex = _AbsentDataset # - self._sourceModuleIndex = _AbsentDataset # - self._detectorModuleIndex = _AbsentDataset # self._snirf_names = [ 'sourceIndex', 'detectorIndex', @@ -4002,9 +4836,6 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): 'dataTypeIndex', 'sourcePower', 'detectorGain', - 'moduleIndex', - 'sourceModuleIndex', - 'detectorModuleIndex', ] self._indexed_groups = [] @@ -4087,29 +4918,6 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._detectorGain = _PresentDataset else: # if the dataset is not found on disk self._detectorGain = _AbsentDataset - if 'moduleIndex' in self._h: - if not self._cfg.dynamic_loading: - self._moduleIndex = _read_int(self._h['moduleIndex']) - else: # if the dataset is found on disk but dynamic_loading=True - self._moduleIndex = _PresentDataset - else: # if the dataset is not found on disk - self._moduleIndex = _AbsentDataset - if 'sourceModuleIndex' in self._h: - if not self._cfg.dynamic_loading: - self._sourceModuleIndex = _read_int( - self._h['sourceModuleIndex']) - else: # if the dataset is found on disk but dynamic_loading=True - self._sourceModuleIndex = _PresentDataset - else: # if the dataset is not found on disk - self._sourceModuleIndex = _AbsentDataset - if 'detectorModuleIndex' in self._h: - if not self._cfg.dynamic_loading: - self._detectorModuleIndex = _read_int( - self._h['detectorModuleIndex']) - else: # if the dataset is found on disk but dynamic_loading=True - self._detectorModuleIndex = _PresentDataset - else: # if the dataset is not found on disk - self._detectorModuleIndex = _AbsentDataset @property def sourceIndex(self): @@ -4356,9 +5164,7 @@ def dataTypeIndex(self): If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter - Data-type specific parameter indices. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so the data type index must be a vector with 2 elements that index the additional parameters. One use of this parameter is as a - stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and - `measurementList(k).dataTypeLabel = 'HRF ...'` . + Data-type specific parameter index. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that where multiple parameters are required, the same index must be used into each (examples include data types such as Time Domain and Diffuse Correlation Spectroscopy). One use of this parameter is as a stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and `measurementList(k).dataTypeLabel = 'HRF ...'` . """ if type(self._dataTypeIndex) is type(_AbsentDataset): @@ -4419,106 +5225,6 @@ def detectorGain(self): Detector gain - """ - if type(self._detectorGain) is type(_AbsentDataset): - return None - if type(self._detectorGain) is type(_PresentDataset): - return _read_float(self._h['detectorGain']) - self._cfg.logger.info('Dynamically loaded %s/detectorGain from %s', - self.location, self.filename) - return self._detectorGain - - @detectorGain.setter - def detectorGain(self, value): - self._detectorGain = value - # self._cfg.logger.info('Assignment to %s/detectorGain in %s', self.location, self.filename) - - @detectorGain.deleter - def detectorGain(self): - self._detectorGain = _AbsentDataset - self._cfg.logger.info('Deleted %s/detectorGain from %s', self.location, - self.filename) - - @property - def moduleIndex(self): - """SNIRF field `moduleIndex`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - Index of a repeating module. If `moduleIndex` is provided while `useLocalIndex` - is set to `true`, then, both `measurementList(k).sourceIndex` and - `measurementList(k).detectorIndex` are assumed to be the local indices - of the same module specified by `moduleIndex`. If the source and - detector are located on different modules, one must use `sourceModuleIndex` - and `detectorModuleIndex` instead to specify separate parent module - indices. See below. - - - """ - if type(self._moduleIndex) is type(_AbsentDataset): - return None - if type(self._moduleIndex) is type(_PresentDataset): - return _read_int(self._h['moduleIndex']) - self._cfg.logger.info('Dynamically loaded %s/moduleIndex from %s', - self.location, self.filename) - return self._moduleIndex - - @moduleIndex.setter - def moduleIndex(self, value): - self._moduleIndex = value - # self._cfg.logger.info('Assignment to %s/moduleIndex in %s', self.location, self.filename) - - @moduleIndex.deleter - def moduleIndex(self): - self._moduleIndex = _AbsentDataset - self._cfg.logger.info('Deleted %s/moduleIndex from %s', self.location, - self.filename) - - @property - def sourceModuleIndex(self): - """SNIRF field `sourceModuleIndex`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - Index of the module that contains the source of the channel. - This index must be used together with `detectorModuleIndex`, and - can not be used when `moduleIndex` presents. - - """ - if type(self._sourceModuleIndex) is type(_AbsentDataset): - return None - if type(self._sourceModuleIndex) is type(_PresentDataset): - return _read_int(self._h['sourceModuleIndex']) - self._cfg.logger.info( - 'Dynamically loaded %s/sourceModuleIndex from %s', - self.location, self.filename) - return self._sourceModuleIndex - - @sourceModuleIndex.setter - def sourceModuleIndex(self, value): - self._sourceModuleIndex = value - # self._cfg.logger.info('Assignment to %s/sourceModuleIndex in %s', self.location, self.filename) - - @sourceModuleIndex.deleter - def sourceModuleIndex(self): - self._sourceModuleIndex = _AbsentDataset - self._cfg.logger.info('Deleted %s/sourceModuleIndex from %s', - self.location, self.filename) - - @property - def detectorModuleIndex(self): - """SNIRF field `detectorModuleIndex`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - Index of the module that contains the detector of the channel. - This index must be used together with `sourceModuleIndex`, and - can not be used when `moduleIndex` presents. - - For example, if `measurementList5` is a structure with `sourceIndex=2`, `detectorIndex=3`, `wavelengthIndex=1`, `dataType=1`, `dataTypeIndex=1` would imply that the data in the 5th column of the `dataTimeSeries` variable was @@ -4553,25 +5259,24 @@ def detectorModuleIndex(self): label for sources and detectors. """ - if type(self._detectorModuleIndex) is type(_AbsentDataset): + if type(self._detectorGain) is type(_AbsentDataset): return None - if type(self._detectorModuleIndex) is type(_PresentDataset): - return _read_int(self._h['detectorModuleIndex']) - self._cfg.logger.info( - 'Dynamically loaded %s/detectorModuleIndex from %s', - self.location, self.filename) - return self._detectorModuleIndex + if type(self._detectorGain) is type(_PresentDataset): + return _read_float(self._h['detectorGain']) + self._cfg.logger.info('Dynamically loaded %s/detectorGain from %s', + self.location, self.filename) + return self._detectorGain - @detectorModuleIndex.setter - def detectorModuleIndex(self, value): - self._detectorModuleIndex = value - # self._cfg.logger.info('Assignment to %s/detectorModuleIndex in %s', self.location, self.filename) + @detectorGain.setter + def detectorGain(self, value): + self._detectorGain = value + # self._cfg.logger.info('Assignment to %s/detectorGain in %s', self.location, self.filename) - @detectorModuleIndex.deleter - def detectorModuleIndex(self): - self._detectorModuleIndex = _AbsentDataset - self._cfg.logger.info('Deleted %s/detectorModuleIndex from %s', - self.location, self.filename) + @detectorGain.deleter + def detectorGain(self): + self._detectorGain = _AbsentDataset + self._cfg.logger.info('Deleted %s/detectorGain from %s', self.location, + self.filename) def _save(self, *args): if len(args) > 0 and type(args[0]) is h5py.File: @@ -4717,43 +5422,6 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/moduleIndex' - if type(self._moduleIndex) not in [type(_AbsentDataset), type(None)]: - data = self.moduleIndex # Use loader function via getter - if name in file: - del file[name] - _create_dataset_int(file, name, data) - # self._cfg.logger.info('Creating Dataset %s in %s', name, file) - else: - if name in file: - del file[name] - self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/sourceModuleIndex' - if type(self._sourceModuleIndex) not in [ - type(_AbsentDataset), type(None) - ]: - data = self.sourceModuleIndex # Use loader function via getter - if name in file: - del file[name] - _create_dataset_int(file, name, data) - # self._cfg.logger.info('Creating Dataset %s in %s', name, file) - else: - if name in file: - del file[name] - self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/detectorModuleIndex' - if type(self._detectorModuleIndex) not in [ - type(_AbsentDataset), type(None) - ]: - data = self.detectorModuleIndex # Use loader function via getter - if name in file: - del file[name] - _create_dataset_int(file, name, data) - # self._cfg.logger.info('Creating Dataset %s in %s', name, file) - else: - if name in file: - del file[name] - self._cfg.logger.info('Deleted Dataset %s from %s', name, file) def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile @@ -4944,72 +5612,6 @@ def _validate(self, result: ValidationResult): result._add(name, _validate_float(dataset)) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/moduleIndex' - if type(self._moduleIndex) in [type(_AbsentDataset), type(None)]: - result._add(name, 'OPTIONAL_DATASET_MISSING') - else: - try: - if type(self._moduleIndex) is type( - _PresentDataset) or 'moduleIndex' in self._h: - dataset = self._h['moduleIndex'] - else: - dataset = _create_dataset_int(tmp, 'moduleIndex', - self._moduleIndex) - err_code = _validate_int(dataset) - if _read_int(dataset) < 0 and err_code == 'OK': - result._add(name, 'NEGATIVE_INDEX') - elif _read_int(dataset) == 0 and err_code == 'OK': - result._add(name, 'INDEX_OF_ZERO') - else: - result._add(name, err_code) - except ValueError: # If the _create_dataset function can't convert the data - result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/sourceModuleIndex' - if type(self._sourceModuleIndex) in [ - type(_AbsentDataset), type(None) - ]: - result._add(name, 'OPTIONAL_DATASET_MISSING') - else: - try: - if type(self._sourceModuleIndex) is type( - _PresentDataset) or 'sourceModuleIndex' in self._h: - dataset = self._h['sourceModuleIndex'] - else: - dataset = _create_dataset_int(tmp, 'sourceModuleIndex', - self._sourceModuleIndex) - err_code = _validate_int(dataset) - if _read_int(dataset) < 0 and err_code == 'OK': - result._add(name, 'NEGATIVE_INDEX') - elif _read_int(dataset) == 0 and err_code == 'OK': - result._add(name, 'INDEX_OF_ZERO') - else: - result._add(name, err_code) - except ValueError: # If the _create_dataset function can't convert the data - result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/detectorModuleIndex' - if type(self._detectorModuleIndex) in [ - type(_AbsentDataset), type(None) - ]: - result._add(name, 'OPTIONAL_DATASET_MISSING') - else: - try: - if type(self._detectorModuleIndex) is type( - _PresentDataset - ) or 'detectorModuleIndex' in self._h: - dataset = self._h['detectorModuleIndex'] - else: - dataset = _create_dataset_int( - tmp, 'detectorModuleIndex', - self._detectorModuleIndex) - err_code = _validate_int(dataset) - if _read_int(dataset) < 0 and err_code == 'OK': - result._add(name, 'NEGATIVE_INDEX') - elif _read_int(dataset) == 0 and err_code == 'OK': - result._add(name, 'INDEX_OF_ZERO') - else: - result._add(name, err_code) - except ValueError: # If the _create_dataset function can't convert the data - result._add(name, 'INVALID_DATASET_TYPE') for key in self._h.keys(): if not any( [key.startswith(name) for name in self._snirf_names]): @@ -5049,6 +5651,7 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig): class StimElement(Group): """Wrapper for an element of indexed group `Stim`.""" + def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._name = _AbsentDataset # "s"+ @@ -5316,6 +5919,7 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig): class AuxElement(Group): """Wrapper for an element of indexed group `Aux`.""" + def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._name = _AbsentDataset # "s"+ @@ -5746,6 +6350,7 @@ def __init__(self, warn( 'Use `Snirf(, )` to open SNIRF file from path. Path-only construction is deprecated.', DeprecationWarning) + # fmode is '' if type(path) is str: if not path.endswith('.snirf'): path.replace('.', '') @@ -5950,6 +6555,7 @@ def _validate(self, result: ValidationResult): class MetaDataTags(MetaDataTags): + def add(self, name, value): """Add a new tag to the list. @@ -5985,6 +6591,7 @@ def remove(self, name): class StimElement(StimElement): + def _validate(self, result: ValidationResult): super()._validate(result) @@ -6002,6 +6609,7 @@ class Stim(Stim): class AuxElement(AuxElement): + def _validate(self, result: ValidationResult): super()._validate(result) @@ -6015,6 +6623,7 @@ class Aux(Aux): class DataElement(DataElement): + def _validate(self, result: ValidationResult): super()._validate(result) @@ -6031,6 +6640,7 @@ class Data(Data): class Probe(Probe): + def _validate(self, result: ValidationResult): # Override sourceLabels validation, can be 1D or 2D diff --git a/snirf_specification_retrieved_26_12_24.txt b/snirf_specification_retrieved_26_12_24.txt new file mode 100644 index 0000000..262fbe1 --- /dev/null +++ b/snirf_specification_retrieved_26_12_24.txt @@ -0,0 +1,1231 @@ +Shared Near Infrared Spectroscopy Format (SNIRF) Specification +============================================================== + +* **Document Version**: v1.1 +* **License**: This document is in the public domain. + +## Table of Content + +- [Introduction](#introduction) +- [Data format](#data-format) +- [SNIRF file specification](#snirf-file-specification) + * [SNIRF data format summary](#snirf-data-format-summary) + * [SNIRF data container definitions](#snirf-data-container-definitions) + * [formatVersion](#formatversion) + * [nirs](#nirsi) + * [metaDataTags](#nirsimetadatatags) + * [data](#nirsidataj) + * [data.dataTimeSeries](#nirsidatajdatatimeseries) + * [data.dataOffset](#nirsidatajdataoffset) + * [data.time](#nirsidatajtime) + * [data.measurementList](#nirsidatajmeasurementlistk) + * [data.measurementList.sourceIndex](#nirsidatajmeasurementlistksourceindex) + * [data.measurementList.detectorIndex](#nirsidatajmeasurementlistkdetectorindex) + * [data.measurementList.wavelengthIndex](#nirsidatajmeasurementlistkwavelengthindex) + * [data.measurementList.wavelengthActual](#nirsidatajmeasurementlistkwavelengthactual) + * [data.measurementList.wavelengthEmissionActual](#nirsidatajmeasurementlistkwavelengthemissionactual) + * [data.measurementList.dataType](#nirsidatajmeasurementlistkdatatype) + * [data.measurementList.dataUnit](#nirsidatajmeasurementlistkdataunit) + * [data.measurementList.dataTypeLabel](#nirsidatajmeasurementlistkdatatypelabel) + * [data.measurementList.dataTypeIndex](#nirsidatajmeasurementlistkdatatypeindex) + * [data.measurementList.sourcePower](#nirsidatajmeasurementlistksourcepower) + * [data.measurementList.detectorGain](#nirsidatajmeasurementlistkdetectorgain) + * [data.measurementLists](#nirsidatajmeasurementlists) + * [data.measurementLists.sourceIndex](#nirsidatajmeasurementlistssourceindex) + * [data.measurementLists.detectorIndex](#nirsidatajmeasurementlistsdetectorindex) + * [data.measurementLists.wavelengthIndex](#nirsidatajmeasurementlistswavelengthindex) + * [data.measurementLists.wavelengthActual](#nirsidatajmeasurementlistswavelengthactual) + * [data.measurementLists.wavelengthEmissionActual](#nirsidatajmeasurementlistswavelengthemissionactual) + * [data.measurementLists.dataType](#nirsidatajmeasurementlistsdatatype) + * [data.measurementLists.dataUnit](#nirsidatajmeasurementlistsdataunit) + * [data.measurementLists.dataTypeLabel](#nirsidatajmeasurementlistsdatatypelabel) + * [data.measurementLists.dataTypeIndex](#nirsidatajmeasurementlistsdatatypeindex) + * [data.measurementLists.sourcePower](#nirsidatajmeasurementlistssourcepower) + * [data.measurementLists.detectorGain](#nirsidatajmeasurementlistsdetectorgain) + * [stim](#nirsistimj) + * [stim.name](#nirsistimjname) + * [stim.data](#nirsistimjdata) + * [stim.dataLabels](#nirsistimjdatalabels) + * [probe](#nirsiprobe) + * [probe.wavelengths](#nirsiprobewavelengths) + * [probe.wavelengthsEmission](#nirsiprobewavelengthsemission) + * [probe.sourcePos2D](#nirsiprobesourcepos2d) + * [probe.sourcePos3D](#nirsiprobesourcepos3d) + * [probe.detectorPos2D](#nirsiprobedetectorpos2d) + * [probe.detectorPos3D](#nirsiprobedetectorpos3d) + * [probe.frequencies](#nirsiprobefrequencies) + * [probe.timeDelays](#nirsiprobetimedelays) + * [probe.timeDelayWidths](#nirsiprobetimedelaywidths) + * [probe.momentOrders](#nirsiprobemomentorders) + * [probe.correlationTimeDelays](#nirsiprobecorrelationtimedelays) + * [probe.correlationTimeDelayWidths](#nirsiprobecorrelationtimedelaywidths) + * [probe.sourceLabels](#nirsiprobesourcelabels) + * [probe.detectorLabels](#nirsiprobedetectorlabels) + * [probe.landmarkPos2D](#nirsiprobelandmarkpos2d) + * [probe.landmarkPos3D](#nirsiprobelandmarkpos3d) + * [probe.landmarkLabels](#nirsiprobelandmarklabelsj) + * [probe.CoordinateSystem](#nirsiprobecoordinatesystem) + * [probe.CoordinateSystemDescription](#nirsiprobecoordinatesystemdescription) + * [aux](#nirsiauxj) + * [aux.name](#nirsiauxjname) + * [aux.dataTimeSeries](#nirsiauxjdatatimeseries) + * [aux.dataUnit](#nirsiauxjdataunit) + * [aux.time](#nirsiauxjtime) + * [aux.timeOffset](#nirsiauxjtimeoffset) +- [Appendix](#appendix) +- [Acknowledgement](#acknowledgement) + + +## Introduction + +The file format specification uses the extension `.snirf`. These are HDF5 +format files, renamed with the `.snirf` extension. For a program to be +"SNIRF-compliant", it must be able to read and write the SNIRF file. + +The development of the SNIRF specification is conducted in an open manner using the GitHub +platform. To contribute or provide feedback visit [https://github.com/fNIRS/snirf](https://github.com/fNIRS/snirf). + +## Data format + +The HDF5 specifications are defined by the HDF5 group and found at +https://www.hdfgroup.org. It is expected that HDF5 future versions will remain +backwards compatibility in the foreseeable future. + +The HDF5 format defines "groups" (`H5G` class) and "datasets" (`H5D` class) +that are the two primary data organization and storage classes used in the +SNIRF specification. + +The structure of each data file has a minimum of required elements noted below. + +For each element in the data structure, one of the 4 types is assigned, +including + +- `group`: a structure containing sub-fields (defined in the `H5G` object + class). Arrays of groups, also known as the indexed-groups, are denoted + with numbers at the end (e.g. `/nirs/data1`, `/nirs/data2`) starting with + index 1. Array indices should be contiguous with no skipped values + (an empty group with no sub-member is permitted). +- `string`: a variable-length, null-terminated sequence of characters, i.e. `H5T_C_S1` + with size set to `H5T_VARIABLE`. At this time HDF5 does not have a UTF16 native type, + so `H5T_NATIVE_B16` will need to be converted to/from unicode-16 within the read/write code). + + > Strings MUST be stored in null-terminated 'variable-length' format to be considered valid. Fixed-length strings and variable-length strings are loaded differently by HDF5 interface implementations.* +- `integer`: the native integer types `H5T_NATIVE_INT` `H5T` datatype (alias of + `H5T_STD_I32BE` or `H5T_STD_I32LE`). Use of 64-bit `long` string types such as `H5T_STD_I64LE` is *not recommended*, although most HDF5 interface implementations will not have issues converting between the two implicitly. +- `numeric`: one of the native double or floating-point types; + `H5T_NATIVE_DOUBLE` or `H5T_NATIVE_FLOAT` in `H5T` (alias of + `H5T_IEEE_F64BE`,`H5T_IEEE_F64LE`, i.e. "double", or `H5T_IEEE_F32BE`, + `H5T_IEEE_F32LE`, i.e. "float") + +Datasets which are not arrays must be saved in [scalar dataspaces](http://davis.lbl.gov/Manuals/HDF5-1.8.7/UG/UG_frame12Dataspaces.html). It is NOT VALID to save Datasets which are not specified as arrays in simple dataspaces with 1 dimension and with size 1. HDF5 interface implementations distinguish between these two formats and exhibit different behavior depending on the format of the file. + +Valid arrays MUST: + +* Contain elements of a correct type as described above. +* Occupy a [simple dataspace](http://davis.lbl.gov/Manuals/HDF5-1.8.7/UG/UG_frame12Dataspaces.html). +* Have exactly the number of dimensions specified. A SNIRF field specified by this document as a `numeric 1-D array` must occupy a dataspace with `rank` of 1. + +> For code samples in various programming languages which demonstrate the writing of SNIRF-specified formats, see the [Appendix](#code-samples). + +## SNIRF file specification + +The SNIRF data format must have the initial `H5G` group type `/nirs` at the +initial file location. + +All indices (source, detector, wavelength, datatype etc) start at 1. + +All SNIRF data elements are associated with a unique HDF5 location path in the +form of `/root/parent/.../name`. All paths must use `/nirs` or `/nirs#` (indexed group array). +Note that the root `/nirs` can be either indexed or a non-indexed single entry. + +If a data element is an HDF5 group and contains multiple sub-groups, it is referred +to as an **indexed group**. Each element of the sub-group is uniquely identified +by appending a string-formatted index (starting from 1, with no preceding zeros) +in the name, for example, `/.../name1` denotes the first sub-group of data element +`name`, and `/.../name2` denotes the 2nd element, and so on. + +In the below sections, we use the notations `"(i)"` `"(j)"` or `"(k)"` inside the +HDF5 location paths to denote the indices of sub-elements when multiplicity presents. + + +### SNIRF data format summary + +Note that this table serves as machine-readable schema for the SNIRF format. Its format may not be altered. + +[//]: # (SCHEMA BEGIN) + +| SNIRF-formatted NIRS data structure | Meaning of the data | Type | +|---------------------------------------|----------------------------------------------|----------------| +| `/formatVersion` | * SNIRF format version | `"s"` * | +| `/nirs{i}` | * Root-group for 1 or more NIRS datasets | `{i}` * | +| `metaDataTags` | * Root-group for metadata headers | `{.}` * | +| `SubjectID` | * Subject identifier | `"s"` * | +| `MeasurementDate` | * Date of the measurement | `"s"` * | +| `MeasurementTime` | * Time of the measurement | `"s"` * | +| `LengthUnit` | * Length unit (case sensitive) | `"s"` * | +| `TimeUnit` | * Time unit (case sensitive) | `"s"` * | +| `FrequencyUnit` | * Frequency unit (case sensitive) | `"s"` * | +| ... | * Additional user-defined metadata entries | | +| `data{i}` | * Root-group for 1 or more data blocks | `{i}` * | +| `dataTimeSeries` | * Time-varying signals from all channels | `[[,...]]`* | +| `time` | * Time (in `TimeUnit` defined in metaDataTag)| `[,...]` * | +| `offset` | * Absolute offset for all channels | `[,...]` * | +| `measurementList{i}` | * Per-channel source-detector information | `{i}` * | +| `sourceIndex` | * Source index for a given channel | `` * | +| `detectorIndex` | * Detector index for a given channel | `` * | +| `wavelengthIndex` | * Wavelength index for a given channel | `` * | +| `wavelengthActual` | * Actual wavelength for a given channel | `` | +| `wavelengthEmissionActual` | * Actual emission wavelength for a channel | `` | +| `dataType` | * Data type for a given channel | `` * | +| `dataUnit` | * SI unit for a given channel | `"s"` | +| `dataTypeLabel` | * Data type name for a given channel | `"s"` | +| `dataTypeIndex` | * Data type index for a given channel | `` * | +| `sourcePower` | * Source power for a given channel | `` | +| `detectorGain` | * Detector gain for a given channel | `` | +| `measurementLists` | * source-detector information | `{.}` * | +| `sourceIndex` | * Source index for each channel | `[,...]`* | +| `detectorIndex` | * Detector index for each channel | `[,...]`* | +| `wavelengthIndex` | * Wavelength index for each channel | `[,...]`* | +| `wavelengthActual` | * Actual wavelength for each channel | `[,...]` | +| `wavelengthEmissionActual` | * Actual emission wavelength for each channel| `[,...]` | +| `dataType` | * Data type for each channel | `[,...]`* | +| `dataUnit` | * SI unit for each channel | `["s",...]` | +| `dataTypeLabel` | * Data type name for each channel | `["s",...]` | +| `dataTypeIndex` | * Data type index for each channel | `[,...]`* | +| `sourcePower` | * Source power for each channel | `[,...]` | +| `detectorGain` | * Detector gain for each channel | `[,...]` | +| `stim{i}` | * Root-group for stimulus measurements | `{i}` | +| `name` | * Name of the stimulus data | `"s"` + | +| `data` | * Data stream of the stimulus channel | `[[,...]]` +| +| `dataLabels` | * Names of additional columns of stim data | `["s",...]` | +| `probe` | * Root group for NIRS probe information | `{.}` * | +| `wavelengths` | * List of wavelengths (in nm) | `[,...]` * | +| `wavelengthsEmission` | * List of emission wavelengths (in nm) | `[,...]` | +| `sourcePos2D` | * Source 2-D positions in `LengthUnit` | `[[,...]]`*1| +| `sourcePos3D` | * Source 3-D positions in `LengthUnit` | `[[,...]]`*1| +| `detectorPos2D` | * Detector 2-D positions in `LengthUnit` | `[[,...]]`*2| +| `detectorPos3D` | * Detector 3-D positions in `LengthUnit` | `[[,...]]`*2| +| `frequencies` | * Modulation frequency list | `[,...]` | +| `timeDelays` | * Time delays for gated time-domain data | `[,...]` | +| `timeDelayWidths` | * Time delay width for gated time-domain data| `[,...]` | +| `momentOrders` | * Moment orders of the moment TD data | `[,...]` | +| `correlationTimeDelays` | * Time delays for DCS measurements | `[,...]` | +| `correlationTimeDelayWidths` | * Time delay width for DCS measurements | `[,...]` | +| `sourceLabels` | * String arrays specifying source names | `[["s",...]]` | +| `detectorLabels` | * String arrays specifying detector names | `["s",...]` | +| `landmarkPos2D` | * Anatomical landmark 2-D positions | `[[,...]]` | +| `landmarkPos3D` | * Anatomical landmark 3-D positions | `[[,...]]` | +| `landmarkLabels` | * String arrays specifying landmark names | `["s",...]` | +| `coordinateSystem` | * Coordinate system used in probe description| `"s"` | +| `coordinateSystemDescription` | * Description of coordinate system | `"s"` | +| `aux{i}` | * Root-group for auxiliary measurements | `{i}` | +| `name` | * Name of the auxiliary channel | `"s"` + | +| `dataTimeSeries` | * Data acquired from the auxiliary channel | `[[,...]]` +| +| `dataUnit` | * SI unit of the auxiliary channel | `"s"` | +| `time` | * Time (in `TimeUnit`) for auxiliary data | `[,...]` + | +| `timeOffset` | * Time offset of auxiliary channel data | `[,...]` | + +[//]: # (SCHEMA END) + +In the above schema table, the used notations are explained below: +* `{.}` represents a simple HDF5 group +* `{i}` represents an HDF5 group with one or multiple sub-groups (i.e. an indexed-group) +* `` represents an integer value +* `` represents a numeric value +* `"s"` represents a string of arbitrary length +* `[...]` represents a 1-D vector (dataset), can be empty +* `[[...]]` represents a 2-D array (dataset), can be empty +* `...` (optional) additional elements similar to the previous element +* `*` in the last column indicates a required subfield +* `*n` in the last column indicates that at least one of the subfields in the subgroup identified by `n` is required +* `+` in the last column indicates a required subfield if the optional parent object is included + +### SNIRF data container definitions + +#### /formatVersion +* **Presence**: required +* **Type**: string +* **Location**: `/formatVersion` + +This is a string that specifies the version of the file format. This document +describes format version "1.0" + +#### /nirs(i) +* **Presence**: required +* **Type**: indexed group +* **Location**: `/nirs(i)` + +This group stores one set of NIRS data. This can be extended by adding the count +number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to +allow the storage of 1 or more complete NIRS datasets inside a single SNIRF +document. For example, a two-subject hyperscanning can be stored using the notation +* `/nirs1` = first subject's data +* `/nirs2` = second subject's data +The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry +is present and is assumed to be entry 1. + + +#### /nirs(i)/metaDataTags +* **Presence**: required +* **Type**: group +* **Location**: `/nirs(i)/metaDataTags` + +The `metaDataTags` group contains the metadata associated with the measurements. +Each metadata record is represented as a dataset under this group - with the name of +the record, i.e. the key, as the dataset's name, and the value of the record as the +actual data stored in the dataset. Each metadata record can potentially have different +data types. Sub-groups should not be used to organize metadata records: a member of the `metaDataTags` Group must be a Dataset. + +The below five metadata records are minimally required in a SNIRF file + +#### /nirs(i)/metaDataTags/SubjectID +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/SubjectID` + +This record stores the string-valued ID of the study subject or experiment. + +#### /nirs(i)/metaDataTags/MeasurementDate +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/MeasurementDate` + +This record stores the date of the measurement as a string. The format of the date +string must either be `"unknown"`, or follow the ISO 8601 date string format `YYYY-MM-DD`, where +- `YYYY` is the 4-digit year +- `MM` is the 2-digit month (padding zero if a single digit) +- `DD` is the 2-digit date (padding zero if a single digit) + +#### /nirs(i)/metaDataTags/MeasurementTime +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/MeasurementTime` + +This record stores the time of the measurement as a string. The format of the time +string must either be `"unknown"` or follow the ISO 8601 time string format `hh:mm:ss.sTZD`, where +- `hh` is the 2-digit hour +- `mm` is the 2-digit minute +- `ss` is the 2-digit second +- `.s` is 1 or more digit representing a decimal fraction of a second (optional) +- `TZD` is the time zone designator (`Z` or `+hh:mm` or `-hh:mm`) + +#### /nirs(i)/metaDataTags/LengthUnit +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/LengthUnit` + +This record stores the **case-sensitive** SI length unit used in this +measurement. Sample length units include "mm", "cm", and "m". A value of +"um" is the same as "mm", i.e. micrometer. + +#### /nirs(i)/metaDataTags/TimeUnit +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/TimeUnit` + +This record stores the **case-sensitive** SI time unit used in this +measurement. Sample time units include "s", and "ms". A value of "us" +is the same as "ms", i.e. microsecond. + +#### /nirs(i)/metaDataTags/FrequencyUnit +* **Presence**: required as part of `metaDataTags` +* **Type**: string +* **Location**: `/nirs(i)/metaDataTags/FrequencyUnit` + +This record stores the **case-sensitive** SI frequency unit used in +this measurement. Sample frequency units "Hz", "MHz" and "GHz". Please +note that "mHz" is milli-Hz while "MHz" denotes "mega-Hz" according to +SI unit system. + +We do not limit the total number of metadata records in the `metaDataTags`. Users +can add additional customized metadata records; no duplicated metadata record names +are allowed. + +Additional metadata record samples can be found in the below table. + +| Metadata Key Name | Metadata value | +|-------------------|----------------| +|ManufacturerName | "Company Name" | +|Model | "Model Name" | +|SubjectName | "LastName, FirstName" | +|DateOfBirth | "YYYY-MM-DD" | +|AcquisitionStartTime | "1569465620" | +|StudyID | "Infant Brain Development" | +|StudyDescription | "In this study, we measure ...." | +|AccessionNumber | "##########################" | +|InstanceNumber | 2 | +|CalibrationFileName | "phantomcal_121015.snirf" | +|UnixTime | "1569465667" | + +The metadata records `"StudyID"` and `"AccessionNumber"` are unique strings that +can be used to link the current dataset to a particular study and a particular +procedure, respectively. The `"StudyID"` tag is similar to the DICOM tag "Study +ID" (0020,0010) and `"AccessionNumber"` is similar to the DICOM tag "Accession +Number"(0008,0050), as defined in the DICOM standard (ISO 12052). + +The metadata record `"InstanceNumber"` is defined similarly to the DICOM tag +"Instance Number" (0020,0013), and can be used as the sequence number to group +multiple datasets into a larger dataset - for example, concatenating streamed +data segments during a long measurement session. + +The metadata record `"UnixTime"` defines the Unix Epoch Time, i.e. the total elapse +time in seconds since 1970-01-01T00:00:00Z (UTC) minus the leap seconds. + +#### /nirs(i)/data(j) +* **Presence**: required +* **Type**: indexed group +* **Location**: `/nirs(i)/data(j)` + +This group stores one block of NIRS data. This can be extended adding the +count number (e.g. `data1`, `data2`,...) to the group name. This is intended to +allow the storage of 1 or more blocks of NIRS data from within the same `/nirs` +entry +* `/nirs/data1` = data block 1 +* `/nirs/data2` = data block 2 + + +#### /nirs(i)/data(j)/dataTimeSeries +* **Presence**: required +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/data(j)/dataTimeSeries` + +This is the actual raw or processed data variable. This variable has dimensions +of ` x `. Columns in +`dataTimeSeries` are mapped to the measurement list (`measurementList` variable +described below). + +`dataTimeSeries` can be compressed using the HDF5 filter (using the built-in +[`deflate`](https://portal.hdfgroup.org/display/HDF5/H5P_SET_DEFLATE) +filter or [3rd party filters such as `305-LZO` or `307-bzip2`](https://portal.hdfgroup.org/display/support/Registered+Filter+Plugins) + +Chunked data is allowed to support real-time streaming of data in this array. + + +#### /nirs(i)/data(j)/dataOffset +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/dataOffset` + +This stores an optional offset value per channel, which, when added to +`/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. + +The length of this array is equal to the as represented +by the second dimension in the `dataTimeSeries`. + + +#### /nirs(i)/data(j)/time +* **Presence**: required +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/time` + +The `time` variable. This provides the acquisition time of the measurement +relative to the time origin. This will usually be a straight line with slope +equal to the acquisition frequency, but does not need to be equal spacing. For +the special case of equal sample spacing an array of length `<2>` is allowed +where the first entry is the start time and the +second entry is the sample time spacing in `TimeUnit` specified in the +`metaDataTags`. The default time unit is in second ("s"). For example, +a time spacing of 0.2 (s) indicates a sampling rate of 5 Hz. + +* **Option 1** - The size of this variable is `` and + corresponds to the sample time of every data point +* **Option 2**- The size of this variable is `<2>` and corresponds to the start + time and sample spacing. + +Chunked data is allowed to support real-time streaming of data in this array. + +#### /nirs(i)/data(j)/measurementList(k) +* **Presence**: required if `measurementLists` is not present +* **Type**: indexed group +* **Location**: `/nirs(i)/data(j)/measurementList(k)` + +The measurement list. This variable serves to map the data array onto the probe +geometry (sources and detectors), data type, and wavelength. This variable is +an array structure that has the size `` that +describes the corresponding column in the data matrix. For example, the +`measurementList3` describes the third column of the data matrix (i.e. +`dataTimeSeries(:,3)`). + +Each element of the array is a structure which describes the measurement +conditions for this data with the following fields: + + +#### /nirs(i)/data(j)/measurementList(k)/sourceIndex +* **Presence**: required +* **Type**: integer +* **Location**: `/nirs(i)/data(j)/measurementList(k)/sourceIndex` + +Index of the source. + +#### /nirs(i)/data(j)/measurementList(k)/detectorIndex +* **Presence**: required +* **Type**: integer +* **Location**: `/nirs(i)/data(j)/measurementList(k)/detectorIndex` + +Index of the detector. + +#### /nirs(i)/data(j)/measurementList(k)/wavelengthIndex +* **Presence**: required +* **Type**: integer +* **Location**: `/nirs(i)/data(j)/measurementList(k)/wavelengthIndex` + +Index of the "nominal" wavelength (in `probe.wavelengths`). + +#### /nirs(i)/data(j)/measurementList(k)/wavelengthActual +* **Presence**: optional +* **Type**: numeric +* **Location**: `/nirs(i)/data(j)/measurementList(k)/wavelengthActual` + +Actual (measured) wavelength in nm, if available, for the source in a given channel. + +#### /nirs(i)/data(j)/measurementList(k)/wavelengthEmissionActual +* **Presence**: optional +* **Type**: numeric +* **Location**: `/nirs(i)/data(j)/measurementList(k)/wavelengthEmissionActual` + +Actual (measured) emission wavelength in nm, if available, for the source in a given channel. + +#### /nirs(i)/data(j)/measurementList(k)/dataType +* **Presence**: required +* **Type**: integer +* **Location**: `/nirs(i)/data(j)/measurementList(k)/dataType` + +Data-type identifier. See Appendix for list possible values. + +#### /nirs(i)/data(j)/measurementList(k)/dataUnit +* **Presence**: optional +* **Type**: string +* **Location**: `/nirs(i)/data(j)/measurementList(k)/dataUnit` + +International System of Units (SI units) identifier for the given channel. Encoding should follow the [CMIXF-12 standard](https://people.csail.mit.edu/jaffer/MIXF/CMIXF-12), avoiding special unicode symbols like U+03BC (m) or U+00B5 (u) and using '/' rather than 'per' for units such as `V/us`. The recommended export format is in unscaled units such as V, s, Mole. + +#### /nirs(i)/data(j)/measurementList(k)/dataTypeLabel +* **Presence**: optional +* **Type**: string +* **Location**: `/nirs(i)/data(j)/measurementList(k)/dataTypeLabel` + +Data-type label. Only required if dataType is "processed" (`99999`). See Appendix +for list of possible values. + +#### /nirs(i)/data(j)/measurementList(k)/dataTypeIndex +* **Presence**: required +* **Type**: integer +* **Location**: `/nirs(i)/data(j)/measurementList(k)/dataTypeIndex` + +Data-type specific parameter index. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that where multiple parameters are required, the same index must be used into each (examples include data types such as Time Domain and Diffuse Correlation Spectroscopy). One use of this parameter is as a stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and `measurementList(k).dataTypeLabel = 'HRF ...'` . + +#### /nirs(i)/data(j)/measurementList(k)/sourcePower +* **Presence**: optional +* **Type**: numeric +* **Location**: `/nirs(i)/data(j)/measurementList(k)/sourcePower` + +The units are not defined, unless the user takes the option of using a `metaDataTag` as described below. + +#### /nirs(i)/data(j)/measurementList(k)/detectorGain +* **Presence**: optional +* **Type**: numeric +* **Location**: `/nirs(i)/data(j)/measurementList(k)/detectorGain` + +Detector gain + +For example, if `measurementList5` is a structure with `sourceIndex=2`, +`detectorIndex=3`, `wavelengthIndex=1`, `dataType=1`, `dataTypeIndex=1` would +imply that the data in the 5th column of the `dataTimeSeries` variable was +measured with source #2 and detector #3 at wavelength #1. Wavelengths (in +nanometers) are described in the `probe.wavelengths` variable (described +later). The data type in this case is 1, implying that it was a continuous wave +measurement. The complete list of currently supported data types is found in +the Appendix. The data type index specifies additional data type specific +parameters that are further elaborated by other fields in the `probe` +structure, as detailed below. Note that the Time Domain and Diffuse Correlation +Spectroscopy data types have two additional parameters and so the data type +index must be a vector with 2 elements that index the additional parameters. + +`sourcePower` provides the option for information about the source power for +that channel to be saved along with the data. The units are not defined, unless +the user takes the option of using a `metaDataTag` described below to define, +for instance, `sourcePowerUnit`. `detectorGain` provides the option for +information about the detector gain for that channel to be saved along with the +data. + +Note: The source indices generally refer to the optode naming (probe +positions) and not necessarily the physical laser numbers on the instrument. +The same is true for the detector indices. Each source optode would generally, +but not necessarily, have 2 or more wavelengths (hence lasers) plugged into it +in order to calculate deoxy- and oxy-hemoglobin concentrations. The data from +these two wavelengths will be indexed by the same source, detector, and data +type values, but have different wavelength indices. Using the same source index +for lasers at the same location but with different wavelengths simplifies the +bookkeeping for converting intensity measurements into concentration changes. +As described below, optional variables `probe.sourceLabels` and +`probe.detectorLabels` are provided for indicating the instrument specific +label for sources and detectors. + +#### /nirs(i)/data(j)/measurementLists +* **Presence**: required if measurementList is not present +* **Type**: group +* **Location**: `/nirs(i)/data(j)/measurementLists` + +The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). + +This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. + +The arrays of `measurementLists` are: + +#### /nirs(i)/data(j)/measurementLists/sourceIndex +* **Presence**: required if measurementLists is present +* **Type**: integer 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/sourceIndex` + +Source indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/detectorIndex +* **Presence**: required if measurementLists is present +* **Type**: integer 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/detectorIndex` + +Detector indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/wavelengthIndex +* **Presence**: required if measurementLists is present +* **Type**: integer 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/wavelengthIndex` + +Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/wavelengthActual +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/wavelengthActual` + +Actual (measured) wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/wavelengthEmissionActual +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/wavelengthEmissionActual` + +Actual (measured) emission wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/dataType +* **Presence**: required if measurementLists is present +* **Type**: integer 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/dataType` + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. See Appendix for list of possible values. + +#### /nirs(i)/data(j)/measurementLists/dataUnit +* **Presence**: optional +* **Type**: string 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/dataUnit` + +International System of Units (SI units) identifier for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/dataTypeLabel +* **Presence**: optional +* **Type**: string 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/dataTypeLabel` + +Data-type label. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +#### /nirs(i)/data(j)/measurementLists/dataTypeIndex +* **Presence**: required if measurementLists is present +* **Type**: integer 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/dataTypeIndex` + +Data-type specific parameter indices. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so `dataTimeIndex` must be a 2-D array with 2 columns that index the additional parameters. + +#### /nirs(i)/data(j)/measurementLists/sourcePower +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/sourcePower` + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + +#### /nirs(i)/data(j)/measurementLists/detectorGain +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/data(j)/measurementLists/detectorGain` + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + +#### /nirs(i)/stim(j) +* **Presence**: optional +* **Type**: indexed group +* **Location**: `/nirs(i)/stim(j)` + +This is an array describing any stimulus conditions. Each element of the array +has the following required fields. + + +#### /nirs(i)/stim(j)/name +* **Presence**: required as part of `stim(i)` +* **Type**: string +* **Location**: `/nirs(i)/stim(j)/name` + +This is a string describing the jth stimulus condition. + + +#### /nirs(i)/stim(j)/data +* **Presence**: required as part of `stim(i)` +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/stim(j)/data` +* **Allowed attribute**: `names` + +This is a numeric 2-D array with at least 3 columns, specifying the stimulus +time course for the jth condition. Each row corresponds to a +specific stimulus trial. The first three columns indicate `[starttime duration value]`. +The starttime, in seconds, is the time relative to the time origin when the +stimulus takes on a value; the duration is the time in seconds that the stimulus +value continues, and value is the stimulus amplitude. The number of rows is +not constrained. (see examples in the appendix). + +Additional columns can be used to store user-specified data associated with +each stimulus trial. An optional record `/nirs(i)/stim(j)/dataLabels` can be +used to annotate the meanings of each data column. + +#### /nirs(i)/stim(j)/dataLabels +* **Presence**: optional +* **Type**: string 1-D array +* **Location**: `/nirs(i)/stim(j)/dataLabels(k)` + +This is a string array providing annotations for each data column in +`/nirs(i)/stim(j)/data`. Each element of the array must be a string; +the total length of this array must be the same as the column number +of `/nirs(i)/stim(j)/data`, including the first 3 required columns. + +#### /nirs(i)/probe +* **Presence**: required +* **Type**: group +* **Location**: `/nirs(i)/probe ` + +This is a structured variable that describes the probe (source-detector) +geometry. This variable has a number of required fields. + +#### /nirs(i)/probe/wavelengths +* **Presence**: required +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/wavelengths` + +This field describes the "nominal" wavelengths used (in `nm` unit). This is indexed by the +`wavelengthIndex` of the measurementList variable. For example, `probe.wavelengths` = [690, +780, 830]; implies that the measurements were taken at three wavelengths (690 nm, +780 nm, and 830 nm). The wavelength index of +`measurementList(k).wavelengthIndex` variable refers to this field. +`measurementList(k).wavelengthIndex` = 2 means the kth measurement +was at 780 nm. + +Please note that this field stores the "nominal" wavelengths. If the precise +(measured) wavelengths differ from the nominal wavelengths, one can store those +in the `measurementList.wavelengthActual` field in a per-channel fashion. + +The number of wavelengths is not limited (except that at least two are needed +to calculate the two forms of hemoglobin). Each source-detector pair would +generally have measurements at all wavelengths. + +This field must present, but can be empty, for example, in the case that the stored +data are processed data (`dataType=99999`, see Appendix). + + +#### /nirs(i)/probe/wavelengthsEmission +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/wavelengthsEmission` + +This field is required only for fluorescence data types, and describes the +"nominal" emission wavelengths used (in `nm` unit). The indexing of this variable is the same +wavelength index in measurementList used for `probe.wavelengths` such that the +excitation wavelength is paired with this emission wavelength for a given measurement. + +Please note that this field stores the "nominal" emission wavelengths. If the precise +(measured) emission wavelengths differ from the nominal ones, one can store those +in the `measurementList.wavelengthEmissionActual` field in a per-channel fashion. + + +#### /nirs(i)/probe/sourcePos2D +* **Presence**: at least one of `sourcePos2D` or `sourcePos3D` is required +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/sourcePos2D` + +This field describes the position (in `LengthUnit` units) of each source +optode. The positions are coordinates in a flattened 2D probe layout. +This field has size ` x 2`. For example, +`probe.sourcePos2D(1,:) = [1.4 1]`, and `LengthUnit='cm'` places source +number 1 at x=1.4 cm and y=1 cm. + + +#### /nirs(i)/probe/sourcePos3D +* **Presence**: at least one of `sourcePos2D` or `sourcePos3D` is required +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/sourcePos3D` + +This field describes the position (in `LengthUnit` units) of each source +optode in 3D. This field has size ` x 3`. + + +#### /nirs(i)/probe/detectorPos2D +* **Presence**: at least one of `detectorPos2D` or `detectorPos3D` is required +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/detectorPos2D` + +Same as `probe.sourcePos2D`, but describing the detector positions in a +flattened 2D probe layout. + + +#### /nirs(i)/probe/detectorPos3D +* **Presence**: at least one of `detectorPos2D` or `detectorPos3D` is required +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/detectorPos3D` + +This field describes the position (in `LengthUnit` units) of each detector +optode in 3D, defined similarly to `sourcePos3D`. + + +#### /nirs(i)/probe/frequencies +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/frequencies` + +This field describes the frequencies used (in `FrequencyUnit` units) for +frequency domain measurements. This field is only required for frequency +domain data types, and is indexed by `measurementList(k).dataTypeIndex`. + + +#### /nirs(i)/probe/timeDelays +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/timeDelays` + +This field describes the time delays (in `TimeUnit` units) used for gated time domain measurements. +This field is only required for gated time domain data types, and is indexed by +`measurementList(k).dataTypeIndex`. The indexing of this field is paired with +the indexing of `probe.timeDelayWidths`. + + +#### /nirs(i)/probe/timeDelayWidths +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/timeDelayWidths` + +This field describes the time delay widths (in `TimeUnit` units) used for gated time domain +measurements. This field is only required for gated time domain data types, and +is indexed by `measurementList(k).dataTypeIndex`. The indexing of this field +is paired with the indexing of `probe.timeDelays`. + + +#### /nirs(i)/probe/momentOrders +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/momentOrders` + +This field describes the moment orders of the temporal point spread function (TPSF) or the distribution of time-of-flight (DTOF) +for moment time domain measurements. This field is only required for moment time domain data types, and is indexed by `measurementList(k).dataTypeIndex`. +Note that the numeric value in this array is the exponent in the integral used for calculating the moments. For detailed/specific definitions of moments, see [Wabnitz et al, 2020](https://doi.org/10.1364/BOE.396585); for general definitions of moments see [here](https://en.wikipedia.org/wiki/Moment_(mathematics) ). + +In brief, given a TPSF or DTOF N(t) (photon counts vs. photon arrival time at the detector): \ +momentOrder = 0: total counts: `N_total = \intergral N(t)dt` \ +momentOrder = 1: mean time of flight: `m = = (1/N_total) \integral t N(t) dt` \ +momentOrder = 2: variance/second central moment: `V = (1/N_total) \integral (t - )^2 N(t) dt` \ +Please note that all moments (for orders >=1) are expected to be normalized by the total counts (i.e. n=0); Additionally all moments (for orders >= 2) are expected to be centralized. + + +#### /nirs(i)/probe/correlationTimeDelays +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/correlationTimeDelays` + +This field describes the time delays (in `TimeUnit` units) used for diffuse correlation spectroscopy +measurements. This field is only required for diffuse correlation spectroscopy +data types, and is indexed by `measurementList(k).dataTypeIndex`. The indexing +of this field is paired with the indexing of `probe.correlationTimeDelayWidths`. + + +#### /nirs(i)/probe/correlationTimeDelayWidths +* **Presence**: optional +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/probe/correlationTimeDelayWidth` + +This field describes the time delay widths (in `TimeUnit` units) used for diffuse correlation +spectroscopy measurements. This field is only required for gated time domain +data types, and is indexed by `measurementList(k).dataTypeIndex`. The indexing +of this field is paired with the indexing of `probe.correlationTimeDelays`. + + +#### /nirs(i)/probe/sourceLabels +* **Presence**: optional +* **Type**: string 2-D array +* **Location**: `/nirs(i)/probe/sourceLabels(j)` + +This is a string array providing user friendly or instrument specific labels +for each source. Each element of the array must be a unique string among both +`probe.sourceLabels` and `probe.detectorLabels`.This can be of size `x 1` or ` x `. This is indexed by `measurementList(k).sourceIndex` and +`measurementList(k).wavelengthIndex`. + + +#### /nirs(i)/probe/detectorLabels +* **Presence**: optional +* **Type**: string 1-D array +* **Location**: `/nirs(i)/probe/detectorLabels(j)` + +This is a string array providing user friendly or instrument specific labels +for each detector. Each element of the array must be a unique string among both +`probe.sourceLabels` and `probe.detectorLabels`. This is indexed by +`measurementList(k).detectorIndex`. + + +#### /nirs(i)/probe/landmarkPos2D +* **Presence**: optional +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/landmarkPos2D` + +This is a 2-D array storing the neurological landmark positions projected +along the 2-D (flattened) probe plane in order to map optical data from the +flattened optode positions to brain anatomy. This array should contain a minimum +of 2 columns, representing the x and y coordinates (in `LengthUnit` units) +of the 2-D projected landmark positions. If a 3rd column presents, it stores +the index to the labels of the given landmark. Label names are stored in the +`probe.landmarkLabels` subfield. An label index of 0 refers to an undefined landmark. + + +#### /nirs(i)/probe/landmarkPos3D +* **Presence**: optional +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/probe/landmarkPos3D` + +This is a 2-D array storing the neurological landmark positions measurement +from 3-D digitization and tracking systems to facilitate the registration and +mapping of optical data to brain anatomy. This array should contain a minimum +of 3 columns, representing the x, y and z coordinates (in `LengthUnit` units) +of the digitized landmark positions. If a 4th column presents, it stores the +index to the labels of the given landmark. Label names are stored in the +`probe.landmarkLabels` subfield. An label index of 0 refers to an undefined landmark. + + +#### /nirs(i)/probe/landmarkLabels(j) +* **Presence**: optional +* **Type**: string 1-D array +* **Location**: `/nirs(i)/probe/landmarkLabels(j)` + +This string array stores the names of the landmarks. The first string denotes +the name of the landmarks with an index of 1 in the 4th column of +`probe.landmark`, and so on. One can adopt the commonly used 10-20 landmark +names, such as "Nasion", "Inion", "Cz" etc, or use user-defined landmark +labels. The landmark label can also use the unique source and detector labels +defined in `probe.sourceLabels` and `probe.detectorLabels`, respectively, to +associate the given landmark to a specific source or detector. All strings are +ASCII encoded char arrays. + + +#### /nirs(i)/probe/coordinateSystem +* **Presence**: optional +* **Type**: string +* **Location**: `/nirs(i)/probe/coordinateSystem` + +Defines the coordinate system for sensor positions. +The string must be one of the coordinate systems listed in the +[BIDS specification (Appendix VII)](https://bids-specification.readthedocs.io/en/stable/99-appendices/08-coordinate-systems.html#standard-template-identifiers) +such as "MNI152NLin2009bAsym", "CapTrak" or "Other". +If the value "Other" is specified, then a defition of the coordinate +system must be provided in `/nirs(i)/probe/coordinateSystemDescription`. +See the [FieldTrip toolbox web page](https://www.fieldtriptoolbox.org/faq/coordsys/) +for detailed descriptions of different coordinate systems. + + +#### /nirs(i)/probe/coordinateSystemDescription +* **Presence**: optional +* **Type**: string +* **Location**: `/nirs(i)/probe/coordinateSystemDescription` + +Free-form text description of the coordinate system. +May also include a link to a documentation page or +paper describing the system in greater detail. +This field is required if the `coordinateSystem` field is set to "Other". + + +#### /nirs(i)/aux(j) +* **Presence**: optional +* **Type**: indexed group +* **Location**: `/nirs(i)/aux(j)` + +This optional array specifies any recorded auxiliary data. Each element of +`aux` has the following required fields: + +#### /nirs(i)/aux(j)/name +* **Presence**: optional; required if `aux` is used +* **Type**: string +* **Location**: `/nirs(i)/aux(j)/name` + +This is string describing the jth auxiliary data timecourse. While auxiliary data can be given any title, standard names for commonly used auxiliary channels (i.e. accelerometer data) are specified in the appendix. + +#### /nirs(i)/aux(j)/dataTimeSeries +* **Presence**: optional; required if `aux` is used +* **Type**: numeric 2-D array +* **Location**: `/nirs(i)/aux(j)/dataTimeSeries` + +This is the aux data variable. This variable has dimensions of ` x `. If multiple channels of related data are generated by a system, they may be encoded in the multiple columns of the time series (i.e. complex numbers). For example, a system containing more than one accelerometer may output this data as a set of `ACCEL_X`/`ACCEL_Y`/`ACCEL_Z` auxiliary time series, where each has the dimension of ` x `. Note that it is NOT recommended to encode the various accelerometer dimensions as multiple channels of the same `aux` Group: instead follow the `"ACCEL_X"`, `"ACCEL_Y"`, `"ACCEL_Z"` naming conventions described in the appendix. Chunked data is allowed to support real-time data streaming. + +#### /nirs(i)/aux(j)/dataUnit +* **Presence**: optional +* **Type**: string +* **Location**: `/nirs(i)/aux(j)/dataUnit` + +International System of Units (SI units) identifier for the given channel. Encoding should follow the [CMIXF-12 standard](https://people.csail.mit.edu/jaffer/MIXF/CMIXF-12), avoiding special unicode symbols like U+03BC (m) or U+00B5 (u) and using '/' rather than 'per' for units such as `V/us`. The recommended export format is in unscaled units such as V, s, Mole. + +#### /nirs(i)/aux(j)/time +* **Presence**: optional; required if `aux` is used +* **Type**: numeric 1-D array +* **Location**: `/nirs(i)/aux(j)/time` + +The time variable. This provides the acquisition time (in `TimeUnit` units) +of the aux measurement relative to the time origin. This will usually be +a straight line with slope equal to the acquisition frequency, but does +not need to be equal spacing. The size of this variable is +`` or `<2>` similar to definition of the +`/nirs(i)/data(j)/time` field. + +Chunked data is allowed to support real-time data streaming + +#### /nirs(i)/aux(j)/timeOffset +* **Presence**: optional +* **Type**: numeric +* **Location**: `/nirs(i)/aux(j)/timeOffset` + +This variable specifies the offset of the file time origin relative to absolute +(clock) time in `TimeUnit` units. + + +## Appendix + +### Supported `measurementList(k).dataType` values in `dataTimeSeries` + ++ 001-100: Raw - Continuous Wave (CW) + - 001 - Amplitude + - 051 - Fluorescence Amplitude + ++ 101-200: Raw - Frequency Domain (FD) + - 101 - AC Amplitude + - 102 - Phase + - 151 - Fluorescence Amplitude + - 152 - Fluorescence Phase + ++ 201-300: Raw - Time Domain - Gated (TD Gated) + - 201 - Amplitude + - 251 - Fluorescence Amplitude ++ 301-400: Raw - Time domain - Moments (TD Moments) + - 301 - Amplitude + - 351 - Fluorescence Amplitude ++ 401-500: Raw - Diffuse Correlation Spectroscopy (DCS): + - 401 - g2 + - 410 - BFi ++ 99999: Processed + + +### Supported `measurementList(k).dataTypeLabel` values in `dataTimeSeries` + +| Tag Name | Meanings | +|-----------|------------------------------------------------------------------| +|"dOD" | Change in optical density | +|"dMean" | Change in mean time-of-flight | +|"dVar" | Change in variance (2nd central moment) | +|"dSkew" | Change in skewness (3rd central moment) | +|"mua" | Absorption coefficient | +|"musp" | Scattering coefficient | +|"HbO" | Oxygenated hemoglobin (oxyhemoglobin) concentration | +|"HbR" | Deoxygenated hemoglobin (deoxyhemoglobin) concentration | +|"HbT" | Total hemoglobin concentration | +|"H2O" | Water content | +|"Lipid" | Lipid concentration | +|"StO2" | Tissue oxygen saturation | +|"BFi" | Blood flow index | +|"HRF dOD" | Hemodynamic response function for change in optical density | +|"HRF dMean"| HRF for change in mean time-of-flight | +|"HRF dVar" | HRF for change in variance (2nd central moment) | +|"HRF dSkew"| HRF for change in skewness (3rd central moment) | +|"HRF HbO" | Hemodynamic response function for oxyhemoglobin concentration | +|"HRF HbR" | Hemodynamic response function for deoxyhemoglobin concentration | +|"HRF HbT" | Hemodynamic response function for total hemoglobin concentration | +|"HRF BFi" | Hemodynamic response function for blood flow index | + + +### Supported `/nirs(i)/aux(j)/name` values + +| Tag Name | Meanings | +|-----------|------------------------------------------------------------------| +|"ACCEL_X" | Accelerometer data, first axis of orientation | +|"ACCEL_Y" | Accelerometer data, second axis of orientation | +|"ACCEL_Z" | Accelerometer data, third axis of orientation | +|"GYRO_X" | Gyrometer data, first axis of orientation | +|"GYRO_Y" | Gyrometer data, second axis of orientation | +|"GYRO_Z" | Gyrometer data, third axis of orientation | +|"MAGN_X" | Magnetometer data, first axis of orientation | +|"MAGN_Y" | Magnetometer data, second axis of orientation | +|"MAGN_Z" | Magnetometer data, third axis of orientation | + + +### Examples of stimulus waveforms + +Assume there are 10 time points, starting at zero, spaced 0.1s apart. If we +assume a stimulus to be a 0.2 second off, 0.2 second on repeating block, it +would be specified as follows: +``` + [0.2 0.2 1.0] + [0.6 0.2 1.0] +``` + +### Code samples + +The following code demonstrates how to use the Python `h5py` and `numpy` libraries and the MATLAB `H5ML.hdf5lib2` "low-level" interface to write specified SNIRF datatypes to disk as HDF5 Datasets of the proper format. + +#### String `"s"` + +**MATLAB** +```matlab +fid = H5F.open(, 'H5F_ACC_RDWR', 'H5P_DEFAULT') +sid = H5S.create('H5S_SCALAR') +tid = H5T.copy('H5T_C_S1'); +H5T.set_size(tid, 'H5T_VARIABLE'); +did = H5D.create(fid, , tid, sid, 'H5P_DEFAULT') +H5D.write(did, tid, 'H5S_ALL', 'H5S_ALL', 'H5P_DEFAULT', ) +``` +**Python** +```python +file = h5py.File(, 'r+') +varlen_str_dtype = h5py.string_dtype(encoding='ascii', length=None) +file.create_dataset(, dtype=varlen_str_dtype, data=) +``` + +#### numeric `` + +**MATLAB** +```matlab +fid = H5F.open(, 'H5F_ACC_RDWR', 'H5P_DEFAULT') +tid = H5T.copy('H5T_NATIVE_DOUBLE') +sid = H5S.create('H5S_SCALAR') +H5D.create(fid, , tid, sid, 'H5P_DEFAULT') +h5write(, , ) +``` +**Python** +```python +file = h5py.File(, 'r+') +file.create_dataset(, dtype='f8', data=) +``` + +#### integer `` +**MATLAB** +```matlab +fid = H5F.open(, 'H5F_ACC_RDWR', 'H5P_DEFAULT') +tid = H5T.copy('H5T_NATIVE_INT') +sid = H5S.create('H5S_SCALAR') +H5D.create(fid, , tid, sid, 'H5P_DEFAULT') +h5write(, , ) +``` +**Python** +```python +file = h5py.File(, 'r+') +file.create_dataset(, dtype='i4', data=) +``` + +#### string array `["s",...]` +**MATLAB** +```matlab +fid = H5F.open(, 'H5F_ACC_RDWR', 'H5P_DEFAULT') + +str_arr = {'Hello', 'World', 'foo', 'bar'} % values to write, a cell array of strings of any length + +sid = H5S.create_simple(1, numel(str_arr), H5ML.get_constant_value('H5S_UNLIMITED')); + +tid = H5T.copy('H5T_C_S1'); +H5T.set_size(tid, 'H5T_VARIABLE'); + +pid = H5P.create('H5P_DATASET_CREATE'); +H5P.set_chunk(pid, 2); + +did = H5D.create(fid, , tid, sid, pid) + +H5D.write(did, tid, 'H5S_ALL', 'H5S_ALL', 'H5P_DEFAULT', str_arr) +``` +**Python** +```python +array = numpy.array().astype('O') # A list of strings must be converted to a NumPy list with dtype 'O' +file = h5py.File(, 'r+') +varlen_str_dtype = h5py.string_dtype(encoding='ascii', length=None) +file.create_dataset(, dtype=varlen_str_dtype, data=array) +``` +#### numeric array `[,...]` or `[[,...]]` +**MATLAB** +> Note: Because MATLAB has no notion of arrays with fewer than 2 dimensions, using `size(data)` as the 3rd argument of +`h5create` will erroneously save arrays with 1 dimension as a row or column vector of 2 dimensions. In the 1D case, use `length(data)` as the 3rd argument of `h5create`. +```matlab +data = +h5create(, , length(data) / size(data), 'Datatype', 'double') +h5write(, , data) +``` +**Python** +```python +array = numpy.array().astype(numpy.float64) # A list or nested list of values should be converted to a NumPy array +file = h5py.File(, 'r+') +file.create_dataset(, dtype='f8', data=array) +``` + +#### integer array `[,...]` or `[[,...]]` + +**MATLAB** +> Note: Because MATLAB has no notion of arrays with fewer than 2 dimensions, using `size(data)` as the 3rd argument of +`h5create` will erroneously save arrays with 1 dimension as a row or column vector of 2 dimensions. In the 1D case, use `length(data)` as the 3rd argument of `h5create`. +```matlab +data = +h5create(, , length(data) / size(data), 'Datatype', 'int32') +h5write(, , data) +``` +**Python** +```python +array = numpy.array().astype(int) # A list or nested list of values should be converted to a NumPy array +file = h5py.File(, 'r+') +file.create_dataset(, dtype='i4', data=array) +``` + +## Acknowledgement + +This document was originally drafted by Blaise Frederic (bbfrederick at +mclean.harvard.edu) and David Boas (dboas at bu.edu). + +Other significant contributors to this specification include: +- Theodore Huppert (huppert1 at pitt.edu) +- Jay Dubb (jdubb at bu.edu) +- Qianqian Fang (q.fang at neu.edu) + +The following individuals representing academic, industrial, software, and +hardware interests are also contributing to and supporting the adoption of this +specification: + +### Software +- Ata Akin, Acibadem University +- Hasan Ayaz, Drexel University +- Joe Culver, University of Washington, neuroDOT +- Hamid Deghani, University of Birmingham, NIRFAST +- Adam Eggebrecht, University of Washington, neuroDOT +- Christophe Grova, McGill University, NIRSTORM +- Felipe Orihuela-Espina, Instituto Nacional de Astrofisica, Optica y Electronica, ICNNA +- Luca Pollonini, Houston Methodist, Phoebe +- Sungho Tak, Korea Basic Science Institute, NIRS-SPM +- Alessandro Torricelli, Politecnico di Milano +- Stanislaw Wojtkiewicz, University of Birmingham, NIRFAST +- Robert Luke, Macquarie University, MNE-NIRS +- Stephen Tucker, Boston University +- Michael Luhrs, Maastricht University, Brain Innovation B.V., Satori +- Robert Oostenveld, Radboud University, FieldTrip + +### Hardware +- Hirokazu Asaka, Hitachi +- Rob Cooper, Gower Labs Inc +- Mathieu Coursolle, Rogue Research +- Rueben Hill, Gower Labs Inc +- Jorn Horschig, Artinis Medical Systems B.V. +- Takumi Inakazu, Hitachi +- Lamija Pasalic, NIRx +- Davood Tashayyod, fNIR Devices and Biopac Inc +- Hanseok Yun, OBELAB Inc +- Zahra M. Aghajan, Kernel From 76151eb5c01d4c5c18154f1cd9e12333a5c1c900 Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 14:12:02 -0500 Subject: [PATCH 07/37] added option to download spec even when local copy is present --- gen/gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gen/gen.py b/gen/gen.py index 19b8c85..0ea0ff9 100644 --- a/gen/gen.py +++ b/gen/gen.py @@ -38,7 +38,7 @@ local_spec = SPEC_SRC.split('/')[-1].split('.')[0] + '_retrieved_' + datetime.now().strftime('%d_%m_%y') + '.txt' - if os.path.exists(local_spec): + if os.path.exists(local_spec) and input('Use local specification document ' + local_spec + '? y/n\n') == 'y': print('Loading specification from local document', local_spec, '...') with open(local_spec, 'r') as f: text = f.read() From 228fdbc368ace749eef634c5c9b2f40c483f31aa Mon Sep 17 00:00:00 2001 From: sstucker Date: Thu, 26 Dec 2024 14:12:21 -0500 Subject: [PATCH 08/37] gen from 1.2-draft --- snirf/pysnirf2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 151da16..acf8f00 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -4416,7 +4416,7 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._dataTimeSeries = _AbsentDataset # [[,...]]* self._dataOffset = _AbsentDataset # [,...]* - self._time = _AbsentDataset # [,...]* + self._time = _AbsentDataset # [,...] self._measurementList = _AbsentDataset # {i}* self._measurementLists = _AbsentGroup # {.}* self._snirf_names = [ @@ -4747,7 +4747,7 @@ def _validate(self, result: ValidationResult): result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/time' if type(self._time) in [type(_AbsentDataset), type(None)]: - result._add(name, 'REQUIRED_DATASET_MISSING') + result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: if type(self._time) is type( From e00393a759929ba3bf5a24f47480ddd3a5283222 Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 07:48:01 -0500 Subject: [PATCH 09/37] Added verbose test exceptions, began support for measurementLists --- tests/test.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test.py b/tests/test.py index 6461d88..44a75f3 100644 --- a/tests/test.py +++ b/tests/test.py @@ -170,8 +170,8 @@ def test_multidimensional_aux(self): if VERBOSE: s.validate().display() - self.assertTrue(s.validate(), msg="Incorrectly invalidated multidimensional aux signal!") - self.assertTrue(validateSnirf(file), msg="Incorrectly invalidated multidimensional aux signal in file on disk!") + self.assertTrue(s.validate(), msg="Incorrectly invalidated multidimensional aux signal:\n" + repr(s.validate())) + self.assertTrue(validateSnirf(file), msg="Incorrectly invalidated multidimensional aux signal in file on disk:\n" + repr(s.validate())) def test_assignment(self): """ @@ -187,6 +187,8 @@ def test_assignment(self): print('Loading', file, 'with dynamic_loading=' + str(mode)) # Reassignment of same probe with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data[0].measurementList) < 1: + continue # skip cases without measurementList same_probe = s.nirs[0].probe self.assertTrue(isinstance(same_probe, snirf.Probe), msg="Could not assign Probe reference") same_probe.sourcePos3D = np.random.random([31, 3]) @@ -348,7 +350,7 @@ def test_unknown_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertTrue('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to raise warning about unknown coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated') + self.assertTrue(s.validate(), msg='File was incorrectly invalidated:\n' + repr(s.validate())) def test_known_coordsys_name(self): @@ -374,7 +376,7 @@ def test_known_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertFalse('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to recognize known coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated') + self.assertTrue(s.validate(), msg='File was incorrectly invalidated:\n' + repr(s.validate())) def test_unspecified_metadatatags(self): @@ -390,7 +392,7 @@ def test_unspecified_metadatatags(self): s.nirs[0].metaDataTags.add('foo', 'Hello') s.nirs[0].metaDataTags.add('Bar', 'World') s.nirs[0].metaDataTags.add('_array_of_strings', ['foo', 'bar']) - self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file...') + self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file:\n' + repr(s.validate())) self.assertTrue(s.nirs[0].metaDataTags.foo == 'Hello', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags.Bar == 'World', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags._array_of_strings[0] == 'foo', msg='Failed to set the unspecified metadatatags') @@ -545,6 +547,9 @@ def test_validator_invalid_measurement_list(self): if VERBOSE: print('Loading', file + '.snirf', 'with dynamic_loading=' + str(mode)) s = Snirf(file, 'r+', dynamic_loading=mode) + if len(s.nirs[0].data[0].measurementList) < 1: + s.close() + continue # skip cases without measurementList s.nirs[0].data[0].measurementList.appendGroup() # Add extra ml if VERBOSE: print('Performing local validation on invalid ml', s) @@ -585,11 +590,9 @@ def test_edit_probe_group(self): 'S5_A', 'S6_A', 'S7_A', 'S8_A', 'S9_A', 'S10_A', 'S11_A', 'S12_A', 'S13_A', 'S14_A', 'S15_A'] - desired_probe_uselocalindex = 1 desired_probe_sourcepos3d = np.random.random([31, 3]) s.nirs[0].probe.sourceLabels = desired_probe_sourcelabels - s.nirs[0].probe.useLocalIndex = desired_probe_uselocalindex s.nirs[0].probe.sourcePos3D = desired_probe_sourcepos3d snirf_save_file = file.split('.')[0] + '_edited_snirf_save.snirf' @@ -607,7 +610,6 @@ def test_edit_probe_group(self): s2 = Snirf(edited_filename, 'r+', dynamic_loading=mode) self.assertTrue((s2.nirs[0].probe.sourceLabels == desired_probe_sourcelabels).all(), msg='Failed to edit sourceLabels properly in ' + edited_filename) - self.assertTrue(s2.nirs[0].probe.useLocalIndex == desired_probe_uselocalindex, msg='Failed to edit sourceLabels properly in ' + edited_filename) self.assertTrue((s2.nirs[0].probe.sourcePos3D == desired_probe_sourcepos3d).all(), msg='Failed to edit sourceLabels properly in ' + edited_filename) s2.close() From 2080abd414de2a8031cf892c71b3cdb0fa2c86d5 Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 07:55:27 -0500 Subject: [PATCH 10/37] Towards measurementLists, dataOffset support --- snirf/pysnirf2.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index acf8f00..b1cf141 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -43,7 +43,7 @@ class SnirfFormatError(Warning): - """Raised when SNIRF-specific error prevents file from loading properly.""" + """Raised when SNIRF-specific error prevents file from loading or saving properly.""" pass @@ -149,21 +149,21 @@ def _close_logger(logger: logging.LoggerAdapter): # -- Dataset creators --------------------------------------- -def _get_padded_shape(name: str, data: np.ndarray, - desired_ndim: int) -> np.ndarray: +def _get_padded_shape(name: str, data: np.ndarray, desired_ndim: int) -> np.ndarray: """Utility function which pads data shape to ndim.""" if desired_ndim is None: return data.shape - elif desired_ndim > data.ndim: - return np.concatenate( - [data.shape, - np.ones(int(desired_ndim) - int(data.ndim))]) - elif data.ndim == desired_ndim: + if data.ndim == desired_ndim: return np.shape(data) - else: - raise ValueError( - "Could not create dataset {}: ndim={} is incompatible with data which has shape {}." - .format(name, desired_ndim, data.shape)) + elif desired_ndim > data.ndim: + return np.concatenate([data.shape, np.ones(int(desired_ndim) - int(data.ndim))]) + elif desired_ndim < data.ndim: + flattened = [x for x in data.shape if x > 1] + if len(flattened) == desired_ndim: + warn("Dataset '{}' must have ndim {} but had erroneous shape {}. Singular dimensions were removed.".format(name, desired_ndim, data.shape)) + return flattened + else: + raise SnirfFormatError("Cannot coerce Dataset '{}' with shape {} to the required {} dimension(s)".format(name, data.shape, desired_ndim)) def _create_dataset(file: h5py.File, name: str, data): @@ -681,7 +681,7 @@ def display(self, severity=2): longest_code = max([len(code) for code in self.codes]) except ValueError: print('Empty ValidationResult: nothing to display') - s = object.__repr__(self) + '\n' + s = repr(self) + '\n' printed = [0, 0, 0, 0] for issue in self._issues: sev = issue.severity @@ -1335,9 +1335,12 @@ def _get_matching_keys(self, h=None): for key in h: numsplit = key.split(self._name) if len(numsplit) > 1 and len(numsplit[1]) > 0: - if len(numsplit[1]) == len(str(int(numsplit[1]))): - unordered.append(key) - indices.append(int(numsplit[1])) + try: + index = int(numsplit[1]) + except ValueError: # if name is not really an indexed group member (e.g. `measurementLists`) + continue + unordered.append(key) + indices.append(index) elif key.endswith( self._name): # Case of single Group with no index unordered.append(key) From 3588268dca6c318c88102dd460286dd0aebdc699 Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 08:20:09 -0500 Subject: [PATCH 11/37] Added check for alignment of ingested schema table and descriptions --- gen/gen.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/gen/gen.py b/gen/gen.py index 0ea0ff9..50891d7 100644 --- a/gen/gen.py +++ b/gen/gen.py @@ -87,7 +87,7 @@ # Get name: format pairs for each name if len(name) > 1: # Skip the empty row type_code = delim[-2].replace(' ', '').replace('`', '') - type_codes.append(type_code) + type_codes.append((type_code, name)) print('Found', len(type_codes), 'types in the table...') @@ -128,8 +128,16 @@ f.write(location.replace('(i)', '').replace('(j)', '').replace('(k)', '') + '\n') print('Wrote to locations.txt') + errf = False + for (type_code, location) in zip(type_codes, locations): + if type_code[1] not in location: + errf = True + print('Specification format issue: location {} aligned to name/type {} from schema table'.format(location, type_code)) + if errf: + sys.exit('pysnirf2 generation aborted.') + if len(locations) != len(type_codes) or len(locations) != len(descriptions): - sys.exit('Parsed ' + str(len(type_codes)) + ' type codes from the summary table but ' + sys.exit('Parsed ' + str(len(type_codes[0])) + ' type codes from the summary table but ' + str(len(locations)) + ' names from the definitions and ' + str(len(descriptions)) + ' descriptions: the specification hosted at ' + SPEC_SRC +' was parsed incorrectly. Try adjusting the delimiters and then debug the parsing code (gen.py).') @@ -144,7 +152,7 @@ }) for i, (location, description) in enumerate(zip(locations, descriptions)): - type_code = type_codes[i] + type_code = type_codes[i][0] name = location.split('/')[-1].split('(')[0] # Remove (i), (j) parent = location.split('/')[-2].split('(')[0] # Remove (i), (j) print('Found', location, 'with type', type_code) From 5d9d8f5887a81a413548847c6811ba4fcf5a9e92 Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 08:22:29 -0500 Subject: [PATCH 12/37] On write, pysnirf2 now attempts to correct arrays with erroneous singular dimensions (such as those produced by MATLAB) beyond the specified ndim. dataOffset and measurementList support added but not fully functional based on current official spec --- snirf/pysnirf2.py | 142 ++++++++++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 67 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index b1cf141..47c9e78 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -149,21 +149,28 @@ def _close_logger(logger: logging.LoggerAdapter): # -- Dataset creators --------------------------------------- -def _get_padded_shape(name: str, data: np.ndarray, desired_ndim: int) -> np.ndarray: +def _get_padded_shape(name: str, data: np.ndarray, + desired_ndim: int) -> np.ndarray: """Utility function which pads data shape to ndim.""" if desired_ndim is None: return data.shape if data.ndim == desired_ndim: return np.shape(data) elif desired_ndim > data.ndim: - return np.concatenate([data.shape, np.ones(int(desired_ndim) - int(data.ndim))]) + return np.concatenate( + [data.shape, + np.ones(int(desired_ndim) - int(data.ndim))]) elif desired_ndim < data.ndim: flattened = [x for x in data.shape if x > 1] if len(flattened) == desired_ndim: - warn("Dataset '{}' must have ndim {} but had erroneous shape {}. Singular dimensions were removed.".format(name, desired_ndim, data.shape)) + warn( + "Dataset '{}' must have ndim {} but had erroneous shape {}. Singular dimensions were removed." + .format(name, desired_ndim, data.shape)) return flattened else: - raise SnirfFormatError("Cannot coerce Dataset '{}' with shape {} to the required {} dimension(s)".format(name, data.shape, desired_ndim)) + raise SnirfFormatError( + "Cannot coerce Dataset '{}' with shape {} to the required {} dimension(s)" + .format(name, data.shape, desired_ndim)) def _create_dataset(file: h5py.File, name: str, data): @@ -1402,7 +1409,7 @@ def _recursive_hdf5_copy(g_dst: Group, g_src: Group): # ================================================================================ # <<< BEGIN TEMPLATE INSERT >>> -# generated by sstucker on 2024-12-26 +# generated by sstucker on 2024-12-27 # version v1.2-draft SNIRF specification parsed from https://raw.githubusercontent.com/fNIRS/snirf/refs/heads/master/snirf_specification.md @@ -4418,14 +4425,14 @@ class DataElement(Group): def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._dataTimeSeries = _AbsentDataset # [[,...]]* - self._dataOffset = _AbsentDataset # [,...]* - self._time = _AbsentDataset # [,...] + self._time = _AbsentDataset # [,...]* + self._dataOffset = _AbsentDataset # [,...] self._measurementList = _AbsentDataset # {i}* self._measurementLists = _AbsentGroup # {.}* self._snirf_names = [ 'dataTimeSeries', - 'dataOffset', 'time', + 'dataOffset', 'measurementList', 'measurementLists', ] @@ -4439,13 +4446,6 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._dataTimeSeries = _PresentDataset else: # if the dataset is not found on disk self._dataTimeSeries = _AbsentDataset - if 'dataOffset' in self._h: - if not self._cfg.dynamic_loading: - self._dataOffset = _read_float_array(self._h['dataOffset']) - else: # if the dataset is found on disk but dynamic_loading=True - self._dataOffset = _PresentDataset - else: # if the dataset is not found on disk - self._dataOffset = _AbsentDataset if 'time' in self._h: if not self._cfg.dynamic_loading: self._time = _read_float_array(self._h['time']) @@ -4453,6 +4453,13 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._time = _PresentDataset else: # if the dataset is not found on disk self._time = _AbsentDataset + if 'dataOffset' in self._h: + if not self._cfg.dynamic_loading: + self._dataOffset = _read_float_array(self._h['dataOffset']) + else: # if the dataset is found on disk but dynamic_loading=True + self._dataOffset = _PresentDataset + else: # if the dataset is not found on disk + self._dataOffset = _AbsentDataset self.measurementList = MeasurementList(self, self._cfg) # Indexed group self._indexed_groups.append(self.measurementList) @@ -4504,40 +4511,6 @@ def dataTimeSeries(self): self._cfg.logger.info('Deleted %s/dataTimeSeries from %s', self.location, self.filename) - @property - def dataOffset(self): - """SNIRF field `dataOffset`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - This stores an optional offset value per channel, which, when added to - `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. - - The length of this array is equal to the as represented - by the second dimension in the `dataTimeSeries`. - - - """ - if type(self._dataOffset) is type(_AbsentDataset): - return None - if type(self._dataOffset) is type(_PresentDataset): - return _read_float_array(self._h['dataOffset']) - self._cfg.logger.info('Dynamically loaded %s/dataOffset from %s', - self.location, self.filename) - return self._dataOffset - - @dataOffset.setter - def dataOffset(self, value): - self._dataOffset = value - # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) - - @dataOffset.deleter - def dataOffset(self): - self._dataOffset = _AbsentDataset - self._cfg.logger.info('Deleted %s/dataOffset from %s', self.location, - self.filename) - @property def time(self): """SNIRF field `time`. @@ -4561,6 +4534,7 @@ def time(self): Chunked data is allowed to support real-time streaming of data in this array. + """ if type(self._time) is type(_AbsentDataset): return None @@ -4581,6 +4555,40 @@ def time(self): self._cfg.logger.info('Deleted %s/time from %s', self.location, self.filename) + @property + def dataOffset(self): + """SNIRF field `dataOffset`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + This stores an optional offset value per channel, which, when added to + `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. + + The length of this array is equal to the as represented + by the second dimension in the `dataTimeSeries`. + + + """ + if type(self._dataOffset) is type(_AbsentDataset): + return None + if type(self._dataOffset) is type(_PresentDataset): + return _read_float_array(self._h['dataOffset']) + self._cfg.logger.info('Dynamically loaded %s/dataOffset from %s', + self.location, self.filename) + return self._dataOffset + + @dataOffset.setter + def dataOffset(self, value): + self._dataOffset = value + # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) + + @dataOffset.deleter + def dataOffset(self): + self._dataOffset = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataOffset from %s', self.location, + self.filename) + @property def measurementList(self): """SNIRF field `measurementList`. @@ -4680,9 +4688,9 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/dataOffset' - if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: - data = self.dataOffset # Use loader function via getter + name = self.location + '/time' + if type(self._time) not in [type(_AbsentDataset), type(None)]: + data = self.time # Use loader function via getter if name in file: del file[name] _create_dataset_float_array(file, name, data, ndim=1) @@ -4691,9 +4699,9 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/time' - if type(self._time) not in [type(_AbsentDataset), type(None)]: - data = self.time # Use loader function via getter + name = self.location + '/dataOffset' + if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: + data = self.dataOffset # Use loader function via getter if name in file: del file[name] _create_dataset_float_array(file, name, data, ndim=1) @@ -4733,32 +4741,32 @@ def _validate(self, result: ValidationResult): ndims=[2])) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/dataOffset' - if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: + name = self.location + '/time' + if type(self._time) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: - if type(self._dataOffset) is type( - _PresentDataset) or 'dataOffset' in self._h: - dataset = self._h['dataOffset'] + if type(self._time) is type( + _PresentDataset) or 'time' in self._h: + dataset = self._h['time'] else: dataset = _create_dataset_float_array( - tmp, 'dataOffset', self._dataOffset) + tmp, 'time', self._time) result._add(name, _validate_float_array(dataset, ndims=[1])) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/time' - if type(self._time) in [type(_AbsentDataset), type(None)]: + name = self.location + '/dataOffset' + if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: - if type(self._time) is type( - _PresentDataset) or 'time' in self._h: - dataset = self._h['time'] + if type(self._dataOffset) is type( + _PresentDataset) or 'dataOffset' in self._h: + dataset = self._h['dataOffset'] else: dataset = _create_dataset_float_array( - tmp, 'time', self._time) + tmp, 'dataOffset', self._dataOffset) result._add(name, _validate_float_array(dataset, ndims=[1])) except ValueError: # If the _create_dataset function can't convert the data From 8c901f566b94a8cba696e1f700d808d9ad48ef5d Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 10:17:06 -0500 Subject: [PATCH 13/37] measurementList/measurementLists are manually covalidated for presence --- snirf/pysnirf2.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 47c9e78..d3269d2 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -6636,7 +6636,23 @@ class Aux(Aux): class DataElement(DataElement): def _validate(self, result: ValidationResult): - super()._validate(result) + + # Override measurementList/measurementLists validation, only one is required + ml = self.measurementList is not None + mls = self.measurementLists is not None + if (ml and mls): + result._add(self.location + '/measurementList', 'OK') + result._add(self.location + '/measurementLists', 'OK') + elif (ml or mls): + result._add(self.location + '/measurementList', + ['OPTIONAL_DATASET_MISSING', 'OK'][int(ml)]) + result._add(self.location + '/measurementLists', + ['OPTIONAL_DATASET_MISSING', 'OK'][int(mls)]) + else: + result._add(self.location + '/measurementList', + ['REQUIRED_DATASET_MISSING', 'OK'][int(ml)]) + result._add(self.location + '/measurementLists', + ['REQUIRED_DATASET_MISSING', 'OK'][int(mls)]) if all(attr is not None for attr in [self.time, self.dataTimeSeries]): if self.time.size != np.shape(self.dataTimeSeries)[0]: @@ -6644,6 +6660,8 @@ def _validate(self, result: ValidationResult): if len(self.measurementList) != np.shape(self.dataTimeSeries)[1]: result._add(self.location, 'INVALID_MEASUREMENTLIST') + + super()._validate(result) class Data(Data): From 12f1bd13cc4e141849bdf8e53109079739399d8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2024 15:24:15 +0000 Subject: [PATCH 14/37] CI: Automated docs update --- docs/README.md | 3 +- docs/pysnirf2.md | 492 ++++++++++++++++++++++++++++++++--------------- 2 files changed, 342 insertions(+), 153 deletions(-) diff --git a/docs/README.md b/docs/README.md index b67bc01..ffcb92a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,13 +16,14 @@ - [`pysnirf2.IndexedGroup`](./pysnirf2.md#class-indexedgroup) - [`pysnirf2.MeasurementList`](./pysnirf2.md#class-measurementlist): Interface for indexed group `MeasurementList`. - [`pysnirf2.MeasurementListElement`](./pysnirf2.md#class-measurementlistelement): Wrapper for an element of indexed group `MeasurementList`. +- [`pysnirf2.MeasurementLists`](./pysnirf2.md#class-measurementlists): Wrapper for Group of type `measurementLists`. - [`pysnirf2.MetaDataTags`](./pysnirf2.md#class-metadatatags) - [`pysnirf2.Nirs`](./pysnirf2.md#class-nirs): Interface for indexed group `Nirs`. - [`pysnirf2.NirsElement`](./pysnirf2.md#class-nirselement): Wrapper for an element of indexed group `Nirs`. - [`pysnirf2.Probe`](./pysnirf2.md#class-probe) - [`pysnirf2.Snirf`](./pysnirf2.md#class-snirf) - [`pysnirf2.SnirfConfig`](./pysnirf2.md#class-snirfconfig): Structure containing Snirf-wide data and settings. -- [`pysnirf2.SnirfFormatError`](./pysnirf2.md#class-snirfformaterror): Raised when SNIRF-specific error prevents file from loading properly. +- [`pysnirf2.SnirfFormatError`](./pysnirf2.md#class-snirfformaterror): Raised when SNIRF-specific error prevents file from loading or saving properly. - [`pysnirf2.Stim`](./pysnirf2.md#class-stim) - [`pysnirf2.StimElement`](./pysnirf2.md#class-stimelement) - [`pysnirf2.ValidationIssue`](./pysnirf2.md#class-validationissue): Information about the validity of a given SNIRF file location. diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index 3b00316..3d00b6b 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -101,7 +101,7 @@ Returns truthy ValidationResult instance which holds detailed results of validat ## class `SnirfFormatError` -Raised when SNIRF-specific error prevents file from loading properly. +Raised when SNIRF-specific error prevents file from loading or saving properly. @@ -109,14 +109,14 @@ Raised when SNIRF-specific error prevents file from loading properly. --- - + ## class `ValidationIssue` Information about the validity of a given SNIRF file location. Properties: location: A relative HDF5 name corresponding to the location of the issue name: A string describing the issue. Must be predefined in `_CODES` id: An integer corresponding to the predefined error type severity: An integer ranking the serverity level of the issue. 0 OK, Nothing remarkable 1 Potentially useful `INFO` 2 `WARNING`, the file is valid but exhibits undefined behavior or features marked deprecation 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue - + ### method `__init__` @@ -133,7 +133,7 @@ __init__(name: str, location: str) --- - + ### method `dictize` @@ -146,7 +146,7 @@ Return dictionary representation of Issue. --- - + ## class `ValidationResult` The result of Snirf file validation routines. @@ -158,7 +158,7 @@ Validation results in a list of issues. Each issue records information about the = validateSnirf() ``` - + ### method `__init__` @@ -209,7 +209,7 @@ A list of the `WARNING` issues catalogued during validation. --- - + ### method `display` @@ -227,7 +227,7 @@ Reads the contents of an `h5py.Dataset` to an array of `dtype=str`. --- - + ### method `is_valid` @@ -239,7 +239,7 @@ Returns True if no `FATAL` issues were catalogued during validation. --- - + ### method `serialize` @@ -252,14 +252,14 @@ Render serialized JSON ValidationResult. --- - + ## class `SnirfConfig` Structure containing Snirf-wide data and settings. Properties: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property - + ### method `__init__` @@ -277,14 +277,14 @@ __init__() --- - + ## class `Group` - + ### method `__init__` @@ -324,7 +324,7 @@ None if not associataed with a Group on disk. --- - + ### method `is_empty` @@ -342,7 +342,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -370,14 +370,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -411,7 +411,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -429,7 +429,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -443,7 +443,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -462,7 +462,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -482,7 +482,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -500,7 +500,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -530,14 +530,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -650,7 +650,7 @@ None if not associataed with a Group on disk. --- - + ### method `add` @@ -669,7 +669,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -687,7 +687,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -705,7 +705,7 @@ Remove a tag from the list. You cannot remove a required tag. --- - + ### method `save` @@ -733,14 +733,218 @@ Group level save to a SNIRF file on disk. --- - + + +## class `MeasurementLists` +Wrapper for Group of type `measurementLists`. + +The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). + +This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. + +The arrays of `measurementLists` are: + + + +### method `__init__` + +```python +__init__(var, cfg: SnirfConfig) +``` + + + + + + +--- + +#### property dataType + +SNIRF field `dataType`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. See Appendix for list of possible values. + +--- + +#### property dataTypeIndex + +SNIRF field `dataTypeIndex`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Data-type specific parameter indices. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so `dataTimeIndex` must be a 2-D array with 2 columns that index the additional parameters. + +--- + +#### property dataTypeLabel + +SNIRF field `dataTypeLabel`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Data-type label. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +--- + +#### property dataUnit + +SNIRF field `dataUnit`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +International System of Units (SI units) identifier for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +--- + +#### property detectorGain + +SNIRF field `detectorGain`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + +--- + +#### property detectorIndex + +SNIRF field `detectorIndex`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Detector indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +--- + +#### property filename + +The filename the Snirf object was loaded from and will save to. + +None if not associated with a Group on disk. + +--- + +#### property location + +The HDF5 relative location indentifier. + +None if not associataed with a Group on disk. + +--- + +#### property sourceIndex + +SNIRF field `sourceIndex`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Source indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + + +--- + +#### property sourcePower + +SNIRF field `sourcePower`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. + +--- + +#### property wavelengthActual + +SNIRF field `wavelengthActual`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Actual (measured) wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + +--- + +#### property wavelengthEmissionActual + +SNIRF field `wavelengthEmissionActual`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Actual (measured) emission wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + + +--- + +#### property wavelengthIndex + +SNIRF field `wavelengthIndex`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. + + + +--- + + + +### method `is_empty` + +```python +is_empty() +``` + +If the Group has no member Groups or Datasets. + + + +**Returns:** + + - `bool`: True if empty, False if not + +--- + + + +### method `save` + +```python +save(*args) +``` + +Group level save to a SNIRF file on disk. + + + +**Args:** + + - `args` (str or h5py.File): A path to a closed SNIRF file on disk or an open `h5py.File` instance + + + +**Examples:** + save can be called on a Group already on disk to overwrite the current contents: ``` mysnirf.nirs[0].probe.save()``` + + or using a new filename to write the Group there: + >>> mysnirf.nirs[0].probe.save() + + + +--- + + ## class `Probe` - + ### method `__init__` @@ -943,16 +1147,6 @@ This field describes the time delays (in `TimeUnit` units) used for gated time d --- -#### property useLocalIndex - -SNIRF field `useLocalIndex`. - -If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter - -For modular NIRS systems, setting this flag to a non-zero integer indicates that `measurementList(k).sourceIndex` and `measurementList(k).detectorIndex` are module-specific local-indices. One must also include `measurementList(k).moduleIndex`, or when cross-module channels present, both `measurementList(k).sourceModuleIndex` and `measurementList(k).detectorModuleIndex` in the `measurementList` structure in order to restore the global indices of the sources/detectors. - ---- - #### property wavelengths SNIRF field `wavelengths`. @@ -983,7 +1177,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1001,7 +1195,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1029,12 +1223,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1123,7 +1317,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1141,7 +1335,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1169,7 +1363,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1180,7 +1374,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1203,7 +1397,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1221,7 +1415,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1235,7 +1429,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1254,7 +1448,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1274,7 +1468,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1292,7 +1486,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1322,14 +1516,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1342,6 +1536,18 @@ __init__(gid: GroupID, cfg: SnirfConfig) +--- + +#### property dataOffset + +SNIRF field `dataOffset`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +This stores an optional offset value per channel, which, when added to `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. + +The length of this array is equal to the as represented by the second dimension in the `dataTimeSeries`. + --- #### property dataTimeSeries @@ -1386,6 +1592,20 @@ Each element of the array is a structure which describes the measurement condit --- +#### property measurementLists + +SNIRF field `measurementLists`. + +If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter + +The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). + +This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. + +The arrays of `measurementLists` are: + +--- + #### property time SNIRF field `time`. @@ -1402,7 +1622,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1420,7 +1640,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1448,14 +1668,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1478,7 +1698,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1496,7 +1716,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1510,7 +1730,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1529,7 +1749,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1549,7 +1769,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1567,7 +1787,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1597,12 +1817,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1633,7 +1853,7 @@ SNIRF field `dataTypeIndex`. If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter -Data-type specific parameter indices. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so the data type index must be a vector with 2 elements that index the additional parameters. One use of this parameter is as a stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and `measurementList(k).dataTypeLabel = 'HRF ...'` . +Data-type specific parameter index. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that where multiple parameters are required, the same index must be used into each (examples include data types such as Time Domain and Diffuse Correlation Spectroscopy). One use of this parameter is as a stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and `measurementList(k).dataTypeLabel = 'HRF ...'` . --- @@ -1665,33 +1885,21 @@ If dynamic_loading=True, the data is loaded from the SNIRF file only when access Detector gain ---- - -#### property detectorIndex - -SNIRF field `detectorIndex`. +For example, if `measurementList5` is a structure with `sourceIndex=2`, `detectorIndex=3`, `wavelengthIndex=1`, `dataType=1`, `dataTypeIndex=1` would imply that the data in the 5th column of the `dataTimeSeries` variable was measured with source #2 and detector #3 at wavelength #1. Wavelengths (in nanometers) are described in the `probe.wavelengths` variable (described later). The data type in this case is 1, implying that it was a continuous wave measurement. The complete list of currently supported data types is found in the Appendix. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the `probe` structure, as detailed below. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so the data type index must be a vector with 2 elements that index the additional parameters. -If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter +`sourcePower` provides the option for information about the source power for that channel to be saved along with the data. The units are not defined, unless the user takes the option of using a `metaDataTag` described below to define, for instance, `sourcePowerUnit`. `detectorGain` provides the option for information about the detector gain for that channel to be saved along with the data. -Index of the detector. +Note: The source indices generally refer to the optode naming (probe positions) and not necessarily the physical laser numbers on the instrument. The same is true for the detector indices. Each source optode would generally, but not necessarily, have 2 or more wavelengths (hence lasers) plugged into it in order to calculate deoxy- and oxy-hemoglobin concentrations. The data from these two wavelengths will be indexed by the same source, detector, and data type values, but have different wavelength indices. Using the same source index for lasers at the same location but with different wavelengths simplifies the bookkeeping for converting intensity measurements into concentration changes. As described below, optional variables `probe.sourceLabels` and `probe.detectorLabels` are provided for indicating the instrument specific label for sources and detectors. --- -#### property detectorModuleIndex +#### property detectorIndex -SNIRF field `detectorModuleIndex`. +SNIRF field `detectorIndex`. If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter -Index of the module that contains the detector of the channel. This index must be used together with `sourceModuleIndex`, and can not be used when `moduleIndex` presents. - - - -For example, if `measurementList5` is a structure with `sourceIndex=2`, `detectorIndex=3`, `wavelengthIndex=1`, `dataType=1`, `dataTypeIndex=1` would imply that the data in the 5th column of the `dataTimeSeries` variable was measured with source #2 and detector #3 at wavelength #1. Wavelengths (in nanometers) are described in the `probe.wavelengths` variable (described later). The data type in this case is 1, implying that it was a continuous wave measurement. The complete list of currently supported data types is found in the Appendix. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the `probe` structure, as detailed below. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so the data type index must be a vector with 2 elements that index the additional parameters. - -`sourcePower` provides the option for information about the source power for that channel to be saved along with the data. The units are not defined, unless the user takes the option of using a `metaDataTag` described below to define, for instance, `sourcePowerUnit`. `detectorGain` provides the option for information about the detector gain for that channel to be saved along with the data. - -Note: The source indices generally refer to the optode naming (probe positions) and not necessarily the physical laser numbers on the instrument. The same is true for the detector indices. Each source optode would generally, but not necessarily, have 2 or more wavelengths (hence lasers) plugged into it in order to calculate deoxy- and oxy-hemoglobin concentrations. The data from these two wavelengths will be indexed by the same source, detector, and data type values, but have different wavelength indices. Using the same source index for lasers at the same location but with different wavelengths simplifies the bookkeeping for converting intensity measurements into concentration changes. As described below, optional variables `probe.sourceLabels` and `probe.detectorLabels` are provided for indicating the instrument specific label for sources and detectors. +Index of the detector. --- @@ -1711,16 +1919,6 @@ None if not associataed with a Group on disk. --- -#### property moduleIndex - -SNIRF field `moduleIndex`. - -If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter - -Index of a repeating module. If `moduleIndex` is provided while `useLocalIndex` is set to `true`, then, both `measurementList(k).sourceIndex` and `measurementList(k).detectorIndex` are assumed to be the local indices of the same module specified by `moduleIndex`. If the source and detector are located on different modules, one must use `sourceModuleIndex` and `detectorModuleIndex` instead to specify separate parent module indices. See below. - ---- - #### property sourceIndex SNIRF field `sourceIndex`. @@ -1731,16 +1929,6 @@ Index of the source. ---- - -#### property sourceModuleIndex - -SNIRF field `sourceModuleIndex`. - -If dynamic_loading=True, the data is loaded from the SNIRF file only when accessed through the getter - -Index of the module that contains the source of the channel. This index must be used together with `detectorModuleIndex`, and can not be used when `moduleIndex` presents. - --- #### property sourcePower @@ -1787,7 +1975,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -1805,7 +1993,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1833,7 +2021,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -1846,7 +2034,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -1869,7 +2057,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1887,7 +2075,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1901,7 +2089,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1920,7 +2108,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1940,7 +2128,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1958,7 +2146,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1988,14 +2176,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2062,7 +2250,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2080,7 +2268,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2108,14 +2296,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2138,7 +2326,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2156,7 +2344,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2170,7 +2358,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2189,7 +2377,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2209,7 +2397,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2227,7 +2415,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2257,14 +2445,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2349,7 +2537,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2367,7 +2555,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2395,14 +2583,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2425,7 +2613,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2443,7 +2631,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2457,7 +2645,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2476,7 +2664,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2496,7 +2684,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2514,7 +2702,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2544,14 +2732,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2604,7 +2792,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2620,7 +2808,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2634,7 +2822,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2652,7 +2840,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2682,7 +2870,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From 2e43ef6ebd66c2b4585b4fdab46de98d0e2816f3 Mon Sep 17 00:00:00 2001 From: sstucker Date: Fri, 27 Dec 2024 10:40:13 -0500 Subject: [PATCH 15/37] updated test files: removed moduleIndex --- tests/data/Simple_Probe_measLists.snirf | Bin 152906 -> 0 bytes ...f => v120dev-Simple_Probe_measLists.snirf} | Bin 149362 -> 134432 bytes ... v120dev-sub-01_task-inclusion_nirs.snirf} | Bin 7082088 -> 7082088 bytes ...rf => v120dev-sub-02_task-test_nirs.snirf} | Bin 8774656 -> 8774656 bytes ...rf => v120dev-sub-A_task-test_run-1.snirf} | Bin 2256744 -> 2229000 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/data/Simple_Probe_measLists.snirf rename tests/data/{Simple_Probe_measList.snirf => v120dev-Simple_Probe_measLists.snirf} (79%) rename tests/data/{sub-01_task-inclusion_nirs.snirf => v120dev-sub-01_task-inclusion_nirs.snirf} (99%) rename tests/data/{sub-02_task-test_nirs.snirf => v120dev-sub-02_task-test_nirs.snirf} (99%) rename tests/data/{sub-A_task-test_run-1.snirf => v120dev-sub-A_task-test_run-1.snirf} (89%) diff --git a/tests/data/Simple_Probe_measLists.snirf b/tests/data/Simple_Probe_measLists.snirf deleted file mode 100644 index c612b125ef0ab92d64a005a3493af92da96afafc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152906 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 diff --git a/tests/data/Simple_Probe_measList.snirf b/tests/data/v120dev-Simple_Probe_measLists.snirf similarity index 79% rename from tests/data/Simple_Probe_measList.snirf rename to tests/data/v120dev-Simple_Probe_measLists.snirf index 4f5266ff581fd2f55de5ffbaeff2235b8d30996a..66d7f195e4286dd999aee48cb6b677013a8f245c 100644 GIT binary patch delta 4286 zcmbVPU2IfU5I$$y(!E=L7LeVRQn2-J*loFG7u#*w-9quf z77PiZz+SF+3;wVWFbxFRD{4_+_L0Pw@_->RYM<1^XbKO)8=g5c=kBu5lyH;oIWy;* zIdkUB%$eRjuBFd-Q#BralDY6bt+trSuiSCwc&gjG;+^4Svk^~3yJCVLi^dbM%q51g zm8P?#h_e!F{e(_?@D@*(Zhm89J!c+kb@76^<%H@j!HJSe6YVcIo&U_1a$g?f&~utz ziSSy-xyQ|x3XSv(&NQ9byvX9-`PP8u=N_xt8x-`*-Z>RyHS^Mzbt_iYE?>25S&6mR z8?2Z>TxEbfgW#1~=Y$5YRi_0jv;xdhqJ36a3tHuxKGj2<`WJCB$+{_YPbRLrN`g}? zt_7d*)2gyB+|yfk?Sj@Z*&6it`xuZyTrUstR<4sPBp?d zK!Y*e@vc@wjG$c%?)9kMw`^)?0Pcf_Kl7}n^;UrZV~vjUfKSUH!6Gy^Ei!k^E@N+_ zk2jS4b86Ls17|m2L%+4y@0g71V?qJ*-}aO zxyL)=1_lCG;=m$dRHYPV&*N<50**T2MrY#0#T6W^hHx1J5%{}|vFs-}9T~u}mVlwc zjXxvY7xvAyp2d_Y+pIn10huk+&}9?YcBSBX5#l~|4t)VobV!z+4V1FUUn3|hRP6Oc zW&yvEG}JCMotV4PyA^Vy^JZ(nmvgJYpY%@bgNXHsKR-FRNX%iXXoy+BC>pXB&ZB6^ zs{D1h9#|jm4`jq%?F=>mFfm*@UjeM;|K+be(cC_`g$M2IIbLjLlYS6U_QP52-e&#^ z*X(bb`F!rPe{JTKx?nSwIvkSY=m$75=8mQluwQNFi;MSVQfo{{w=-M$#D+2f2Sb(i zDFrmtiTw461@t6A7Y|i$*G*hw^?T@A0jW!$|uWObfH0fBI)hVZ}H1! z)*Id2)4IoKORlTOCHA@zPV~f$P9v5S^~K1iw?sP)w!w()PDZxIqDd40W{zTPZQR(~ zW5n7H5EI-9U!&=)Hjn7qAZ3DhJxV=qsyGf&A|sYDC}*H~vk{LPqQHqN2Gxso=B(Qg zO(bRM1HRftH=Q8(#;zRURaW=x9f46T{T6+wMdzMHyu|+LE5180N@byco|K>;D^;G7u4qM*YM`)A zSB14#K@BLSWm&gC09jdBs1#V8c{*jP>k&cQ>-X^072P^wX;6Z!-*gU>CrX*RkN%I~ zOu3qoI)-$F#4;z4c74z$X<2Os!nn4bu<7GS*AU(PA<%2xYZ}|+l#U`akURsEkM$+G zdg5)yy4WrQJ+{k8ia73yE6)D#KBGehD{^E=9q5)}1ez4B@Tjk;Yrhef8!3ISg`?2d zxDF*3nL`VpFUi$;dB~yLSNWlN;`$-}#yVK2T$ejA8jujba1~GBmSK{6O7YS0#6{>< zX)2Dq_TCP@Cg86Y_K7bff7OqOsOX@`)@R9@o-O^DI66sHO3L`OX=n#PyjpAZKX0z|gZ#Vd(vF4Lz4<=-EdN zZT(xg)H@e_^B7A@vC;IHCn}~#zC;Vth-M=hae{w%Wn;sdCSbMD0kX8d@*0#tU#>}n z?O&4D`QvY*+Um5ex_%UcYCBbRQT?PUYY(sW%<;M9?20^ zQ0_^@k#P~Ju@EWvH^Y60b!xnoG5OK`xc3;|<&Ev_34^);6>_@iY?K!x%^MhNb0{7H z5t%U#V7b*jVQFBHP-EV2F;fwjv8(ss1#PFJj9$hoZ6tc+lBBUVT!gu(4OPhWmmLQXq%MY5R@n!s=MMo0JnbuccyExIci zNwBuAcw8(e#sAdcb{9%~``|3kbZVJ!>kZ*nqeCiFTPu#blC&EWbH0_WA=T1ix0d?9&-v^6cj!x_djJ!=S~0s delta 13597 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 diff --git a/tests/data/sub-02_task-test_nirs.snirf b/tests/data/v120dev-sub-02_task-test_nirs.snirf similarity index 99% rename from tests/data/sub-02_task-test_nirs.snirf rename to tests/data/v120dev-sub-02_task-test_nirs.snirf index 8907bd1a24a2e03c370ee76767020e82833d439a..7514e8d22aa43d4e6e1adfd5403abe6605e13071 100644 GIT binary patch delta 7425 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 delta 12344 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 Mr=#4b1);+K1LPQatpET3 diff --git a/tests/data/sub-A_task-test_run-1.snirf b/tests/data/v120dev-sub-A_task-test_run-1.snirf similarity index 89% rename from tests/data/sub-A_task-test_run-1.snirf rename to tests/data/v120dev-sub-A_task-test_run-1.snirf index 4941b643546525ea3c79b794a11c19e5ade57b95..0fe81adf0b71d61db4b4928864606d7db6adb2a3 100644 GIT binary patch delta 47760 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 dsI_{c( Date: Sun, 29 Dec 2024 17:37:35 -0500 Subject: [PATCH 16/37] Support for dataOffset; switched memory only SNIRF representations from tempfile to the h5py backend --- gen/data.py | 4 +- snirf/pysnirf2.py | 226 +++++++++++++++++++++++++++------------------- 2 files changed, 135 insertions(+), 95 deletions(-) diff --git a/gen/data.py b/gen/data.py index a48cdb0..f8b92b6 100644 --- a/gen/data.py +++ b/gen/data.py @@ -1,5 +1,5 @@ -SPEC_SRC = 'https://raw.githubusercontent.com/fNIRS/snirf/refs/heads/master/snirf_specification.md' -SPEC_VERSION = 'v1.2-draft' # Version of the spec linked above +SPEC_SRC = 'https://raw.githubusercontent.com/sstucker/snirf/refs/heads/master/snirf_specification.md' +SPEC_VERSION = '1.2-development' # Version of the spec linked above """ These types are fragments of the string codes used to describe the types of diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index d3269d2..7756b64 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -24,7 +24,7 @@ import numpy as np from warnings import warn from collections.abc import MutableSequence -from tempfile import TemporaryFile +import uuid import logging from typing import Tuple import time @@ -604,7 +604,7 @@ class ValidationResult: ``` = .validate() - = validateSnirf() + = validateSnirf() ``` """ @@ -997,8 +997,6 @@ def filename(self): @property def location(self): """The HDF5 relative location indentifier. - - None if not associataed with a Group on disk. """ if self._h != {}: return self._h.name @@ -1409,8 +1407,8 @@ def _recursive_hdf5_copy(g_dst: Group, g_src: Group): # ================================================================================ # <<< BEGIN TEMPLATE INSERT >>> -# generated by sstucker on 2024-12-27 -# version v1.2-draft SNIRF specification parsed from https://raw.githubusercontent.com/fNIRS/snirf/refs/heads/master/snirf_specification.md +# generated by sstucker on 2024-12-29 +# version 1.2-development SNIRF specification parsed from https://raw.githubusercontent.com/sstucker/snirf/refs/heads/master/snirf_specification.md class MetaDataTags(Group): @@ -1827,7 +1825,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/SubjectID' if type(self._SubjectID) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -2511,7 +2512,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/sourceIndex' if type(self._sourceIndex) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -2703,10 +2707,10 @@ def __init__(self, var, cfg: SnirfConfig): super().__init__(var, cfg) self._wavelengths = _AbsentDataset # [,...]* self._wavelengthsEmission = _AbsentDataset # [,...] - self._sourcePos2D = _AbsentDataset # [[,...]]*1 - self._sourcePos3D = _AbsentDataset # [[,...]]*1 - self._detectorPos2D = _AbsentDataset # [[,...]]*2 - self._detectorPos3D = _AbsentDataset # [[,...]]*2 + self._sourcePos2D = _AbsentDataset # [[,...]]*2 + self._sourcePos3D = _AbsentDataset # [[,...]]*2 + self._detectorPos2D = _AbsentDataset # [[,...]]*3 + self._detectorPos3D = _AbsentDataset # [[,...]]*3 self._frequencies = _AbsentDataset # [,...] self._timeDelays = _AbsentDataset # [,...] self._timeDelayWidths = _AbsentDataset # [,...] @@ -3807,7 +3811,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/wavelengths' if type(self._wavelengths) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -4350,7 +4357,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/metaDataTags' # If Group is not present in file and empty in the wrapper, it is missing if type(self._metaDataTags) in [ @@ -4425,14 +4435,14 @@ class DataElement(Group): def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): super().__init__(gid, cfg) self._dataTimeSeries = _AbsentDataset # [[,...]]* - self._time = _AbsentDataset # [,...]* self._dataOffset = _AbsentDataset # [,...] - self._measurementList = _AbsentDataset # {i}* - self._measurementLists = _AbsentGroup # {.}* + self._time = _AbsentDataset # [,...]* + self._measurementList = _AbsentDataset # {i}*1 + self._measurementLists = _AbsentGroup # {.}*1 self._snirf_names = [ 'dataTimeSeries', - 'time', 'dataOffset', + 'time', 'measurementList', 'measurementLists', ] @@ -4446,13 +4456,6 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._dataTimeSeries = _PresentDataset else: # if the dataset is not found on disk self._dataTimeSeries = _AbsentDataset - if 'time' in self._h: - if not self._cfg.dynamic_loading: - self._time = _read_float_array(self._h['time']) - else: # if the dataset is found on disk but dynamic_loading=True - self._time = _PresentDataset - else: # if the dataset is not found on disk - self._time = _AbsentDataset if 'dataOffset' in self._h: if not self._cfg.dynamic_loading: self._dataOffset = _read_float_array(self._h['dataOffset']) @@ -4460,6 +4463,13 @@ def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig): self._dataOffset = _PresentDataset else: # if the dataset is not found on disk self._dataOffset = _AbsentDataset + if 'time' in self._h: + if not self._cfg.dynamic_loading: + self._time = _read_float_array(self._h['time']) + else: # if the dataset is found on disk but dynamic_loading=True + self._time = _PresentDataset + else: # if the dataset is not found on disk + self._time = _AbsentDataset self.measurementList = MeasurementList(self, self._cfg) # Indexed group self._indexed_groups.append(self.measurementList) @@ -4511,6 +4521,40 @@ def dataTimeSeries(self): self._cfg.logger.info('Deleted %s/dataTimeSeries from %s', self.location, self.filename) + @property + def dataOffset(self): + """SNIRF field `dataOffset`. + + If dynamic_loading=True, the data is loaded from the SNIRF file only + when accessed through the getter + + This stores an optional offset value per channel, which, when added to + `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. + + The length of this array is equal to the as represented + by the second dimension in the `dataTimeSeries`. + + + """ + if type(self._dataOffset) is type(_AbsentDataset): + return None + if type(self._dataOffset) is type(_PresentDataset): + return _read_float_array(self._h['dataOffset']) + self._cfg.logger.info('Dynamically loaded %s/dataOffset from %s', + self.location, self.filename) + return self._dataOffset + + @dataOffset.setter + def dataOffset(self, value): + self._dataOffset = value + # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) + + @dataOffset.deleter + def dataOffset(self): + self._dataOffset = _AbsentDataset + self._cfg.logger.info('Deleted %s/dataOffset from %s', self.location, + self.filename) + @property def time(self): """SNIRF field `time`. @@ -4534,7 +4578,6 @@ def time(self): Chunked data is allowed to support real-time streaming of data in this array. - """ if type(self._time) is type(_AbsentDataset): return None @@ -4555,40 +4598,6 @@ def time(self): self._cfg.logger.info('Deleted %s/time from %s', self.location, self.filename) - @property - def dataOffset(self): - """SNIRF field `dataOffset`. - - If dynamic_loading=True, the data is loaded from the SNIRF file only - when accessed through the getter - - This stores an optional offset value per channel, which, when added to - `/nirs(i)/data(j)/dataTimeSeries`, results in absolute data values. - - The length of this array is equal to the as represented - by the second dimension in the `dataTimeSeries`. - - - """ - if type(self._dataOffset) is type(_AbsentDataset): - return None - if type(self._dataOffset) is type(_PresentDataset): - return _read_float_array(self._h['dataOffset']) - self._cfg.logger.info('Dynamically loaded %s/dataOffset from %s', - self.location, self.filename) - return self._dataOffset - - @dataOffset.setter - def dataOffset(self, value): - self._dataOffset = value - # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) - - @dataOffset.deleter - def dataOffset(self): - self._dataOffset = _AbsentDataset - self._cfg.logger.info('Deleted %s/dataOffset from %s', self.location, - self.filename) - @property def measurementList(self): """SNIRF field `measurementList`. @@ -4688,9 +4697,9 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/time' - if type(self._time) not in [type(_AbsentDataset), type(None)]: - data = self.time # Use loader function via getter + name = self.location + '/dataOffset' + if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: + data = self.dataOffset # Use loader function via getter if name in file: del file[name] _create_dataset_float_array(file, name, data, ndim=1) @@ -4699,9 +4708,9 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) - name = self.location + '/dataOffset' - if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: - data = self.dataOffset # Use loader function via getter + name = self.location + '/time' + if type(self._time) not in [type(_AbsentDataset), type(None)]: + data = self.time # Use loader function via getter if name in file: del file[name] _create_dataset_float_array(file, name, data, ndim=1) @@ -4723,7 +4732,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/dataTimeSeries' if type(self._dataTimeSeries) in [ type(_AbsentDataset), type(None) @@ -4741,32 +4753,32 @@ def _validate(self, result: ValidationResult): ndims=[2])) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/time' - if type(self._time) in [type(_AbsentDataset), type(None)]: - result._add(name, 'REQUIRED_DATASET_MISSING') + name = self.location + '/dataOffset' + if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: + result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: - if type(self._time) is type( - _PresentDataset) or 'time' in self._h: - dataset = self._h['time'] + if type(self._dataOffset) is type( + _PresentDataset) or 'dataOffset' in self._h: + dataset = self._h['dataOffset'] else: dataset = _create_dataset_float_array( - tmp, 'time', self._time) + tmp, 'dataOffset', self._dataOffset) result._add(name, _validate_float_array(dataset, ndims=[1])) except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') - name = self.location + '/dataOffset' - if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: - result._add(name, 'OPTIONAL_DATASET_MISSING') + name = self.location + '/time' + if type(self._time) in [type(_AbsentDataset), type(None)]: + result._add(name, 'REQUIRED_DATASET_MISSING') else: try: - if type(self._dataOffset) is type( - _PresentDataset) or 'dataOffset' in self._h: - dataset = self._h['dataOffset'] + if type(self._time) is type( + _PresentDataset) or 'time' in self._h: + dataset = self._h['time'] else: dataset = _create_dataset_float_array( - tmp, 'dataOffset', self._dataOffset) + tmp, 'time', self._time) result._add(name, _validate_float_array(dataset, ndims=[1])) except ValueError: # If the _create_dataset function can't convert the data @@ -5436,7 +5448,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/sourceIndex' if type(self._sourceIndex) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -5853,7 +5868,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/name' if type(self._name) in [type(_AbsentDataset), type(None)]: result._add(name, 'OPTIONAL_DATASET_MISSING') @@ -6218,7 +6236,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/name' if type(self._name) in [type(_AbsentDataset), type(None)]: result._add(name, 'OPTIONAL_DATASET_MISSING') @@ -6335,6 +6356,7 @@ def __init__(self, self._cfg = SnirfConfig() self._cfg.dynamic_loading = dynamic_loading self._cfg.fmode = '' + self._f = None # handle for filelikes and temporary files if len(args) > 0: path = args[0] if enable_logging: @@ -6380,7 +6402,8 @@ def __init__(self, self._cfg.logger.info('Loading from filelike object') if self._cfg.fmode == '': self._cfg.fmode = 'r' - self._h = h5py.File(path, 'r') + self._f = args[0] + self._h = h5py.File(self._f, 'r', backing_store=False) else: raise TypeError(str(path) + ' is not a valid filename') else: @@ -6392,7 +6415,10 @@ def __init__(self, self._cfg.logger = _create_logger('', None) # Do not log to file self._cfg.fmode = 'w' - self._h = h5py.File(TemporaryFile(), 'w') + self._h = h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) self._formatVersion = _AbsentDataset # "s"* self._nirs = _AbsentDataset # {i}* self._snirf_names = [ @@ -6506,7 +6532,10 @@ def _save(self, *args): def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: name = self.location + '/formatVersion' if type(self._formatVersion) in [type(_AbsentDataset), type(None)]: result._add(name, 'REQUIRED_DATASET_MISSING') @@ -6645,14 +6674,14 @@ def _validate(self, result: ValidationResult): result._add(self.location + '/measurementLists', 'OK') elif (ml or mls): result._add(self.location + '/measurementList', - ['OPTIONAL_DATASET_MISSING', 'OK'][int(ml)]) + ['OPTIONAL_DATASET_MISSING', 'OK'][int(ml)]) result._add(self.location + '/measurementLists', - ['OPTIONAL_DATASET_MISSING', 'OK'][int(mls)]) + ['OPTIONAL_DATASET_MISSING', 'OK'][int(mls)]) else: result._add(self.location + '/measurementList', - ['REQUIRED_DATASET_MISSING', 'OK'][int(ml)]) + ['REQUIRED_DATASET_MISSING', 'OK'][int(ml)]) result._add(self.location + '/measurementLists', - ['REQUIRED_DATASET_MISSING', 'OK'][int(mls)]) + ['REQUIRED_DATASET_MISSING', 'OK'][int(mls)]) if all(attr is not None for attr in [self.time, self.dataTimeSeries]): if self.time.size != np.shape(self.dataTimeSeries)[0]: @@ -6660,7 +6689,7 @@ def _validate(self, result: ValidationResult): if len(self.measurementList) != np.shape(self.dataTimeSeries)[1]: result._add(self.location, 'INVALID_MEASUREMENTLIST') - + super()._validate(result) @@ -6673,7 +6702,10 @@ class Probe(Probe): def _validate(self, result: ValidationResult): # Override sourceLabels validation, can be 1D or 2D - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), + 'w', + driver='core', + backing_store=False) as tmp: if type(self._sourceLabels) in [type(_AbsentDataset), type(None)]: result._add(self.location + '/sourceLabels', 'OPTIONAL_DATASET_MISSING') @@ -6814,6 +6846,14 @@ def close(self): self._cfg.logger.info('Closing Snirf file %s', self.filename) _close_logger(self._cfg.logger) self._h.close() + if self._f is not None: + self._f.close() + + def __del__(self): + try: + self.close() + except: + pass # Was already closed def __enter__(self): return self From 36829ab9923eee86e493ccc46a069d291ca3670f Mon Sep 17 00:00:00 2001 From: sstucker Date: Sun, 29 Dec 2024 21:20:48 -0500 Subject: [PATCH 17/37] Fix to tests; docstring --- snirf/pysnirf2.py | 3 +-- tests/test.py | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 7756b64..53f4dac 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -996,8 +996,7 @@ def filename(self): @property def location(self): - """The HDF5 relative location indentifier. - """ + """The HDF5 relative location indentifier.""" if self._h != {}: return self._h.name else: diff --git a/tests/test.py b/tests/test.py index 44a75f3..b6fd343 100644 --- a/tests/test.py +++ b/tests/test.py @@ -168,10 +168,9 @@ def test_multidimensional_aux(self): print("Created new aux channel:", s.nirs[0].aux[-1]) s.save() if VERBOSE: - s.validate().display() - self.assertTrue(s.validate(), msg="Incorrectly invalidated multidimensional aux signal:\n" + repr(s.validate())) - self.assertTrue(validateSnirf(file), msg="Incorrectly invalidated multidimensional aux signal in file on disk:\n" + repr(s.validate())) + self.assertTrue(s.validate(), msg="Incorrectly invalidated multidimensional aux signal") + self.assertTrue(validateSnirf(file), msg="Incorrectly invalidated multidimensional aux signal in file on disk") def test_assignment(self): """ @@ -350,7 +349,7 @@ def test_unknown_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertTrue('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to raise warning about unknown coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated:\n' + repr(s.validate())) + self.assertTrue(s.validate(), msg='File was incorrectly invalidated") def test_known_coordsys_name(self): @@ -376,7 +375,7 @@ def test_known_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertFalse('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to recognize known coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated:\n' + repr(s.validate())) + self.assertTrue(s.validate(), msg='File was incorrectly invalidated") def test_unspecified_metadatatags(self): @@ -392,7 +391,7 @@ def test_unspecified_metadatatags(self): s.nirs[0].metaDataTags.add('foo', 'Hello') s.nirs[0].metaDataTags.add('Bar', 'World') s.nirs[0].metaDataTags.add('_array_of_strings', ['foo', 'bar']) - self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file:\n' + repr(s.validate())) + self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file") self.assertTrue(s.nirs[0].metaDataTags.foo == 'Hello', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags.Bar == 'World', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags._array_of_strings[0] == 'foo', msg='Failed to set the unspecified metadatatags') From 53fc38cb68f8f28cd9afc3f2564029721add4fa4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 02:21:25 +0000 Subject: [PATCH 18/37] CI: Automated docs update --- docs/pysnirf2.md | 188 +++++++++++++++++++++-------------------------- 1 file changed, 84 insertions(+), 104 deletions(-) diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index 3d00b6b..a77dfe1 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -155,7 +155,7 @@ Validation results in a list of issues. Each issue records information about the ``` = .validate() - = validateSnirf() + = validateSnirf() ``` @@ -318,13 +318,11 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- - + ### method `is_empty` @@ -370,14 +368,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -411,7 +409,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -429,7 +427,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -443,7 +441,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -462,7 +460,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -482,7 +480,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -500,7 +498,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -530,14 +528,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -644,13 +642,11 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- - + ### method `add` @@ -669,7 +665,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -687,7 +683,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -831,8 +827,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property sourceIndex @@ -891,7 +885,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1 --- - + ### method `is_empty` @@ -937,14 +931,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Probe` - + ### method `__init__` @@ -1081,8 +1075,6 @@ This is a 2-D array storing the neurological landmark positions measurement fro The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property momentOrders @@ -1177,7 +1169,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1223,12 +1215,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1279,8 +1271,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property metaDataTags @@ -1317,7 +1307,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1363,7 +1353,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1374,7 +1364,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1397,7 +1387,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1415,7 +1405,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1429,7 +1419,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1448,7 +1438,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1468,7 +1458,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1486,7 +1476,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1516,14 +1506,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1576,8 +1566,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property measurementList @@ -1622,7 +1610,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1668,14 +1656,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1698,7 +1686,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1716,7 +1704,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1730,7 +1718,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1749,7 +1737,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1769,7 +1757,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1787,7 +1775,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1817,12 +1805,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1915,8 +1903,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property sourceIndex @@ -1975,7 +1961,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -2021,7 +2007,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -2034,7 +2020,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -2057,7 +2043,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2075,7 +2061,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2089,7 +2075,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2108,7 +2094,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2128,7 +2114,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2146,7 +2132,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2176,14 +2162,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2234,8 +2220,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property name @@ -2250,7 +2234,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2296,14 +2280,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2326,7 +2310,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2344,7 +2328,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2358,7 +2342,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2377,7 +2361,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2397,7 +2381,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2415,7 +2399,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2445,14 +2429,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2499,8 +2483,6 @@ None if not associated with a Group on disk. The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property name @@ -2537,7 +2519,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2583,14 +2565,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2613,7 +2595,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2631,7 +2613,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2645,7 +2627,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2664,7 +2646,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2684,7 +2666,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2702,7 +2684,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2732,14 +2714,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2776,8 +2758,6 @@ This is a string that specifies the version of the file format. This document The HDF5 relative location indentifier. -None if not associataed with a Group on disk. - --- #### property nirs @@ -2792,7 +2772,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2808,7 +2788,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2822,7 +2802,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2840,7 +2820,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2870,7 +2850,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From 1d6a8a29fe0176f8c4fb2d42e089fd30ac8b7e7e Mon Sep 17 00:00:00 2001 From: sstucker Date: Sun, 29 Dec 2024 21:25:33 -0500 Subject: [PATCH 19/37] "' --- tests/test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test.py b/tests/test.py index b6fd343..d3331f7 100644 --- a/tests/test.py +++ b/tests/test.py @@ -349,7 +349,7 @@ def test_unknown_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertTrue('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to raise warning about unknown coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated") + self.assertTrue(s.validate(), msg='File was incorrectly invalidated') def test_known_coordsys_name(self): @@ -375,7 +375,7 @@ def test_known_coordsys_name(self): if VERBOSE: result.display(severity=2) self.assertFalse('UNRECOGNIZED_COORDINATE_SYSTEM' in [issue.name for issue in result.warnings], msg='Failed to recognize known coordinate system in file saved to disk') - self.assertTrue(s.validate(), msg='File was incorrectly invalidated") + self.assertTrue(s.validate(), msg='File was incorrectly invalidated') def test_unspecified_metadatatags(self): @@ -391,7 +391,7 @@ def test_unspecified_metadatatags(self): s.nirs[0].metaDataTags.add('foo', 'Hello') s.nirs[0].metaDataTags.add('Bar', 'World') s.nirs[0].metaDataTags.add('_array_of_strings', ['foo', 'bar']) - self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file") + self.assertTrue(s.validate(), msg='adding the unspecified metaDataTags resulted in an INVALID file') self.assertTrue(s.nirs[0].metaDataTags.foo == 'Hello', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags.Bar == 'World', msg='Failed to set the unspecified metadatatags') self.assertTrue(s.nirs[0].metaDataTags._array_of_strings[0] == 'foo', msg='Failed to set the unspecified metadatatags') From 69a9a4db14852b1ffe235b37ad672ea24e20d7e9 Mon Sep 17 00:00:00 2001 From: sstucker Date: Sun, 29 Dec 2024 21:25:41 -0500 Subject: [PATCH 20/37] Template changes --- gen/pysnirf2.jinja | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja index 04344c8..9a928c9 100644 --- a/gen/pysnirf2.jinja +++ b/gen/pysnirf2.jinja @@ -222,7 +222,7 @@ {% macro gen_validator(NODE) %} def _validate(self, result: ValidationResult): # Validate unwritten datasets after writing them to this tempfile - with h5py.File(TemporaryFile(), 'w') as tmp: + with h5py.File(str(uuid.uuid4()), 'w', driver='core', backing_store=False) as tmp: {% for CHILD in NODE.children %} name = self.location + '/{{ CHILD.name }}' {% if TYPES.INDEXED_GROUP in CHILD.type %} @@ -379,6 +379,7 @@ class Snirf(Group): self._cfg = SnirfConfig() self._cfg.dynamic_loading = dynamic_loading self._cfg.fmode = '' + self._f = None # handle for filelikes and temporary files if len(args) > 0: path = args[0] if enable_logging: @@ -416,7 +417,8 @@ class Snirf(Group): self._cfg.logger.info('Loading from filelike object') if self._cfg.fmode == '': self._cfg.fmode = 'r' - self._h = h5py.File(path, 'r') + self._f = args[0] + self._h = h5py.File(self._f, 'r', backing_store=False) else: raise TypeError(str(path) + ' is not a valid filename') else: @@ -427,7 +429,7 @@ class Snirf(Group): else: self._cfg.logger = _create_logger('', None) # Do not log to file self._cfg.fmode = 'w' - self._h = h5py.File(TemporaryFile(), 'w') + self._h = h5py.File(str(uuid.uuid4()), 'w', driver='core', backing_store=False) {{ declare_members(ROOT) | indent }} {{ init_members(ROOT) | indent }} {{ gen_properties(ROOT) }} From 682fb1da69621fa30f417ad5d6fa433a5f4208d4 Mon Sep 17 00:00:00 2001 From: sstucker Date: Mon, 30 Dec 2024 11:05:03 -0500 Subject: [PATCH 21/37] Fixed validation of probe vs. measurementList(s) --- snirf/pysnirf2.py | 75 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 53f4dac..58604ba 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -503,13 +503,13 @@ def _read_float_array(dataset: h5py.Dataset) -> np.ndarray: ), 'INVALID_SOURCE_INDEX': (11, 3, - 'measurementList/sourceIndex exceeds length of probe/sourceLabels'), + 'measurementList(s)/sourceIndex exceeds length of probe/sourceLabels or the first axis of source position data'), 'INVALID_DETECTOR_INDEX': (12, 3, - 'measurementList/detectorIndex exceeds length of probe/detectorLabels'), + 'measurementList(s)/detectorIndex exceeds length of probe/detectorLabels or the first axis of source position data'), 'INVALID_WAVELENGTH_INDEX': (13, 3, - 'measurementList/waveLengthIndex exceeds length of probe/wavelengths'), + 'measurementList(s)/waveLengthIndex exceeds length of probe/wavelengths'), 'NEGATIVE_INDEX': (14, 3, 'An index is negative'), # Warnings (Severity 2) 'INDEX_OF_ZERO': (15, 2, 'An index of zero is usually undefined'), @@ -6663,6 +6663,19 @@ class Aux(Aux): class DataElement(DataElement): + def measurementList_to_measurementLists(self): + """Converts `measurementList` to a `measurementLists` structure if it is present. + + This method will create a new `measurementLists` Group structure and populate it with the contents of the `measurementList` indexed Group. + + The `measurementList` indexedGroup is not be removed. + """ + if len(self.measurementList) > 0: + for dataset_name in self.measurementList[0]._snirf_names: + vals = [getattr(ml, dataset_name) for ml in self.measurementList] + if any(val is not None for val in vals): + setattr(self.measurementLists, dataset_name, vals) + def _validate(self, result: ValidationResult): # Override measurementList/measurementLists validation, only one is required @@ -6763,6 +6776,14 @@ def _validate(self, result: ValidationResult): super()._validate(result) +class MeasurementLists(MeasurementLists): + + def _validate(self, result): + + + return super()._validate(result) + + class Snirf(Snirf): # overload @@ -6825,6 +6846,15 @@ def validate(self) -> ValidationResult: self._validate(result) return result + def measurementList_to_measurementLists(self): + """Convert the `measurementList` field of all `Data` elements to `measurementLists`. + + Does not delete the measurementList Dataset. + """ + for nirs in self.nirs: + for data in nirs.data: + data.measurementList_to_measurementLists() + # overload @property def filename(self): @@ -6869,38 +6899,57 @@ def __getitem__(self, key): return None def _validate(self, result: ValidationResult): - super()._validate(result) # TODO INVALID_FILENAME, INVALID_FILE detection + # Compare measurement list to probe for nirs in self.nirs: if type(nirs.probe) not in [type(None), type(_AbsentGroup)]: + lenSourceLabels = None + lenDetectorLabels = None + lenWavelengths = None + lenSources = None + lenDetectors = None if nirs.probe.sourceLabels is not None: lenSourceLabels = nirs.probe.sourceLabels.size - else: - lenSourceLabels = 0 if nirs.probe.detectorLabels is not None: lenDetectorLabels = nirs.probe.detectorLabels.size - else: - lenDetectorLabels = 0 if nirs.probe.wavelengths is not None: lenWavelengths = nirs.probe.wavelengths.size - else: - lenWavelengths = 0 + if nirs.probe.sourcePos2D is not None: + lenSources = nirs.probe.sourcePos2D.shape[0] + elif nirs.probe.sourcePos3D is not None: + lenSources = nirs.probe.sourcePos3D.shape[0] + if nirs.probe.detectorPos2D is not None: + lenDetectors = nirs.probe.detectorPos2D.shape[0] + elif nirs.probe.detectorPos3D is not None: + lenDetectors = nirs.probe.detectorPos3D.shape[0] for data in nirs.data: + if lenSourceLabels is not None and max(data.measurementLists.sourceIndex) > lenSourceLabels: + result._add(data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') + if lenSources is not None and max(data.measurementLists.sourceIndex) > lenSources: + result._add(data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') + if lenDetectorLabels is not None and max(data.measurementLists.detectorIndex) > lenDetectorLabels : + result._add(data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') + if lenDetectors is not None and max(data.measurementLists.detectorIndex) > lenDetectors: + result._add(data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') + if lenWavelengths is not None and max(data.measurementLists.wavelengthIndex) > lenWavelengths: # No wavelengths should raise a missing issue + result._add(data.measurementLists.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') for ml in data.measurementList: - if ml.sourceIndex is not None: + if ml.sourceIndex is not None and lenSourceLabels is not None: if ml.sourceIndex > lenSourceLabels: result._add(ml.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if ml.detectorIndex is not None: + if ml.detectorIndex is not None and lenDetectorLabels is not None: if ml.detectorIndex > lenDetectorLabels: result._add(ml.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if ml.wavelengthIndex is not None: + if ml.wavelengthIndex is not None and lenWavelengths is not None: if ml.wavelengthIndex > lenWavelengths: result._add(ml.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') + + super()._validate(result) # -- Interface functions ---------------------------------------------------- From faa50536f659ca37376a3b7c58d3a83c483b3c0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 16:05:42 +0000 Subject: [PATCH 22/37] CI: Automated docs update --- docs/README.md | 2 +- docs/pysnirf2.md | 48 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/docs/README.md b/docs/README.md index ffcb92a..b441baa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ - [`pysnirf2.IndexedGroup`](./pysnirf2.md#class-indexedgroup) - [`pysnirf2.MeasurementList`](./pysnirf2.md#class-measurementlist): Interface for indexed group `MeasurementList`. - [`pysnirf2.MeasurementListElement`](./pysnirf2.md#class-measurementlistelement): Wrapper for an element of indexed group `MeasurementList`. -- [`pysnirf2.MeasurementLists`](./pysnirf2.md#class-measurementlists): Wrapper for Group of type `measurementLists`. +- [`pysnirf2.MeasurementLists`](./pysnirf2.md#class-measurementlists) - [`pysnirf2.MetaDataTags`](./pysnirf2.md#class-metadatatags) - [`pysnirf2.Nirs`](./pysnirf2.md#class-nirs): Interface for indexed group `Nirs`. - [`pysnirf2.NirsElement`](./pysnirf2.md#class-nirselement): Wrapper for an element of indexed group `Nirs`. diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index a77dfe1..29e94e2 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -732,13 +732,9 @@ Group level save to a SNIRF file on disk. ## class `MeasurementLists` -Wrapper for Group of type `measurementLists`. -The group for measurement list variables which map the data array onto the probe geometry (sources and detectors), data type, and wavelength. This group's datasets are arrays with size ``, with each position describing the corresponding column in the data matrix. (i.e. the values at `measurementLists/sourceIndex(3)` and `measurementLists/detectorIndex(3)` correspond to `dataTimeSeries(:,3)`). -This group is required only if the indexed-group format `/nirs(i)/data(j)/measurementList(k)` is not used to encode the measurement list. `measurementLists` is an alternative that may offer better performance for larger probes. -The arrays of `measurementLists` are: @@ -1628,6 +1624,22 @@ If the Group has no member Groups or Datasets. --- + + +### method `measurementList_to_measurementLists` + +```python +measurementList_to_measurementLists() +``` + +Converts `measurementList` to a `measurementLists` structure if it is present. + +This method will create a new `measurementLists` Group structure and populate it with the contents of the `measurementList` indexed Group. + +The `measurementList` indexedGroup is not be removed. + +--- + ### method `save` @@ -2772,7 +2784,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2788,7 +2800,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2820,7 +2832,21 @@ If the Group has no member Groups or Datasets. --- - + + +### method `measurementList_to_measurementLists` + +```python +measurementList_to_measurementLists() +``` + +Convert the `measurementList` field of all `Data` elements to `measurementLists`. + +Does not delete the measurementList Dataset. + +--- + + ### method `save` @@ -2850,7 +2876,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From b44061f5ab7ed69e25e25df57c865943347a5787 Mon Sep 17 00:00:00 2001 From: Stephen Tucker Date: Mon, 30 Dec 2024 11:09:17 -0500 Subject: [PATCH 23/37] Delete tests/data/v120dev-Simple_Probe_measLists.snirf --- .../data/v120dev-Simple_Probe_measLists.snirf | Bin 134432 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/data/v120dev-Simple_Probe_measLists.snirf diff --git a/tests/data/v120dev-Simple_Probe_measLists.snirf b/tests/data/v120dev-Simple_Probe_measLists.snirf deleted file mode 100644 index 66d7f195e4286dd999aee48cb6b677013a8f245c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 134432 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 uTsT}LTojxLE(R_ZE)Gr%mk1|;%Yc)@<-y6| Date: Mon, 30 Dec 2024 14:01:34 -0500 Subject: [PATCH 24/37] Converting to numpy upon assignment of array; accidentally a logical --- gen/pysnirf2.jinja | 2 + snirf/pysnirf2.py | 133 ++++++++++++++++++++++++++------------------- 2 files changed, 80 insertions(+), 55 deletions(-) diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja index 9a928c9..85a41c2 100644 --- a/gen/pysnirf2.jinja +++ b/gen/pysnirf2.jinja @@ -117,6 +117,8 @@ self._{{ CHILD.name }} = _recursive_hdf5_copy(self._{{ CHILD.name }}, value) else: raise ValueError("Only a Group of type {{ sentencecase(CHILD.name) }} can be assigned to {{ CHILD.name }}.") + {% elif TYPES.ARRAY_1D in CHILD.type or TYPES.ARRAY_2D in CHILD.type %} + self._{{ CHILD.name }} = np.array(value) {% else %} self._{{ CHILD.name }} = value {% endif %} diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 58604ba..7ad5c62 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -503,10 +503,12 @@ def _read_float_array(dataset: h5py.Dataset) -> np.ndarray: ), 'INVALID_SOURCE_INDEX': (11, 3, - 'measurementList(s)/sourceIndex exceeds length of probe/sourceLabels or the first axis of source position data'), + 'measurementList(s)/sourceIndex exceeds length of probe/sourceLabels or the first axis of source position data' + ), 'INVALID_DETECTOR_INDEX': (12, 3, - 'measurementList(s)/detectorIndex exceeds length of probe/detectorLabels or the first axis of source position data'), + 'measurementList(s)/detectorIndex exceeds length of probe/detectorLabels or the first axis of source position data' + ), 'INVALID_WAVELENGTH_INDEX': (13, 3, 'measurementList(s)/waveLengthIndex exceeds length of probe/wavelengths'), @@ -1406,7 +1408,7 @@ def _recursive_hdf5_copy(g_dst: Group, g_src: Group): # ================================================================================ # <<< BEGIN TEMPLATE INSERT >>> -# generated by sstucker on 2024-12-29 +# generated by sstucker on 2024-12-30 # version 1.2-development SNIRF specification parsed from https://raw.githubusercontent.com/sstucker/snirf/refs/heads/master/snirf_specification.md @@ -2059,7 +2061,7 @@ def sourceIndex(self): @sourceIndex.setter def sourceIndex(self, value): - self._sourceIndex = value + self._sourceIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/sourceIndex in %s', self.location, self.filename) @sourceIndex.deleter @@ -2089,7 +2091,7 @@ def detectorIndex(self): @detectorIndex.setter def detectorIndex(self, value): - self._detectorIndex = value + self._detectorIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorIndex in %s', self.location, self.filename) @detectorIndex.deleter @@ -2119,7 +2121,7 @@ def wavelengthIndex(self): @wavelengthIndex.setter def wavelengthIndex(self, value): - self._wavelengthIndex = value + self._wavelengthIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthIndex in %s', self.location, self.filename) @wavelengthIndex.deleter @@ -2149,7 +2151,7 @@ def wavelengthActual(self): @wavelengthActual.setter def wavelengthActual(self, value): - self._wavelengthActual = value + self._wavelengthActual = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthActual in %s', self.location, self.filename) @wavelengthActual.deleter @@ -2179,7 +2181,7 @@ def wavelengthEmissionActual(self): @wavelengthEmissionActual.setter def wavelengthEmissionActual(self, value): - self._wavelengthEmissionActual = value + self._wavelengthEmissionActual = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthEmissionActual in %s', self.location, self.filename) @wavelengthEmissionActual.deleter @@ -2208,7 +2210,7 @@ def dataType(self): @dataType.setter def dataType(self, value): - self._dataType = value + self._dataType = np.array(value) # self._cfg.logger.info('Assignment to %s/dataType in %s', self.location, self.filename) @dataType.deleter @@ -2237,7 +2239,7 @@ def dataUnit(self): @dataUnit.setter def dataUnit(self, value): - self._dataUnit = value + self._dataUnit = np.array(value) # self._cfg.logger.info('Assignment to %s/dataUnit in %s', self.location, self.filename) @dataUnit.deleter @@ -2267,7 +2269,7 @@ def dataTypeLabel(self): @dataTypeLabel.setter def dataTypeLabel(self, value): - self._dataTypeLabel = value + self._dataTypeLabel = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTypeLabel in %s', self.location, self.filename) @dataTypeLabel.deleter @@ -2297,7 +2299,7 @@ def dataTypeIndex(self): @dataTypeIndex.setter def dataTypeIndex(self, value): - self._dataTypeIndex = value + self._dataTypeIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTypeIndex in %s', self.location, self.filename) @dataTypeIndex.deleter @@ -2326,7 +2328,7 @@ def sourcePower(self): @sourcePower.setter def sourcePower(self, value): - self._sourcePower = value + self._sourcePower = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePower in %s', self.location, self.filename) @sourcePower.deleter @@ -2355,7 +2357,7 @@ def detectorGain(self): @detectorGain.setter def detectorGain(self, value): - self._detectorGain = value + self._detectorGain = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorGain in %s', self.location, self.filename) @detectorGain.deleter @@ -2931,7 +2933,7 @@ def wavelengths(self): @wavelengths.setter def wavelengths(self, value): - self._wavelengths = value + self._wavelengths = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengths in %s', self.location, self.filename) @wavelengths.deleter @@ -2969,7 +2971,7 @@ def wavelengthsEmission(self): @wavelengthsEmission.setter def wavelengthsEmission(self, value): - self._wavelengthsEmission = value + self._wavelengthsEmission = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthsEmission in %s', self.location, self.filename) @wavelengthsEmission.deleter @@ -3003,7 +3005,7 @@ def sourcePos2D(self): @sourcePos2D.setter def sourcePos2D(self, value): - self._sourcePos2D = value + self._sourcePos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePos2D in %s', self.location, self.filename) @sourcePos2D.deleter @@ -3034,7 +3036,7 @@ def sourcePos3D(self): @sourcePos3D.setter def sourcePos3D(self, value): - self._sourcePos3D = value + self._sourcePos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePos3D in %s', self.location, self.filename) @sourcePos3D.deleter @@ -3066,7 +3068,7 @@ def detectorPos2D(self): @detectorPos2D.setter def detectorPos2D(self, value): - self._detectorPos2D = value + self._detectorPos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorPos2D in %s', self.location, self.filename) @detectorPos2D.deleter @@ -3098,7 +3100,7 @@ def detectorPos3D(self): @detectorPos3D.setter def detectorPos3D(self, value): - self._detectorPos3D = value + self._detectorPos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorPos3D in %s', self.location, self.filename) @detectorPos3D.deleter @@ -3130,7 +3132,7 @@ def frequencies(self): @frequencies.setter def frequencies(self, value): - self._frequencies = value + self._frequencies = np.array(value) # self._cfg.logger.info('Assignment to %s/frequencies in %s', self.location, self.filename) @frequencies.deleter @@ -3163,7 +3165,7 @@ def timeDelays(self): @timeDelays.setter def timeDelays(self, value): - self._timeDelays = value + self._timeDelays = np.array(value) # self._cfg.logger.info('Assignment to %s/timeDelays in %s', self.location, self.filename) @timeDelays.deleter @@ -3197,7 +3199,7 @@ def timeDelayWidths(self): @timeDelayWidths.setter def timeDelayWidths(self, value): - self._timeDelayWidths = value + self._timeDelayWidths = np.array(value) # self._cfg.logger.info('Assignment to %s/timeDelayWidths in %s', self.location, self.filename) @timeDelayWidths.deleter @@ -3235,7 +3237,7 @@ def momentOrders(self): @momentOrders.setter def momentOrders(self, value): - self._momentOrders = value + self._momentOrders = np.array(value) # self._cfg.logger.info('Assignment to %s/momentOrders in %s', self.location, self.filename) @momentOrders.deleter @@ -3269,7 +3271,7 @@ def correlationTimeDelays(self): @correlationTimeDelays.setter def correlationTimeDelays(self, value): - self._correlationTimeDelays = value + self._correlationTimeDelays = np.array(value) # self._cfg.logger.info('Assignment to %s/correlationTimeDelays in %s', self.location, self.filename) @correlationTimeDelays.deleter @@ -3303,7 +3305,7 @@ def correlationTimeDelayWidths(self): @correlationTimeDelayWidths.setter def correlationTimeDelayWidths(self, value): - self._correlationTimeDelayWidths = value + self._correlationTimeDelayWidths = np.array(value) # self._cfg.logger.info('Assignment to %s/correlationTimeDelayWidths in %s', self.location, self.filename) @correlationTimeDelayWidths.deleter @@ -3338,7 +3340,7 @@ def sourceLabels(self): @sourceLabels.setter def sourceLabels(self, value): - self._sourceLabels = value + self._sourceLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/sourceLabels in %s', self.location, self.filename) @sourceLabels.deleter @@ -3372,7 +3374,7 @@ def detectorLabels(self): @detectorLabels.setter def detectorLabels(self, value): - self._detectorLabels = value + self._detectorLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorLabels in %s', self.location, self.filename) @detectorLabels.deleter @@ -3409,7 +3411,7 @@ def landmarkPos2D(self): @landmarkPos2D.setter def landmarkPos2D(self, value): - self._landmarkPos2D = value + self._landmarkPos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkPos2D in %s', self.location, self.filename) @landmarkPos2D.deleter @@ -3446,7 +3448,7 @@ def landmarkPos3D(self): @landmarkPos3D.setter def landmarkPos3D(self, value): - self._landmarkPos3D = value + self._landmarkPos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkPos3D in %s', self.location, self.filename) @landmarkPos3D.deleter @@ -3484,7 +3486,7 @@ def landmarkLabels(self): @landmarkLabels.setter def landmarkLabels(self, value): - self._landmarkLabels = value + self._landmarkLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkLabels in %s', self.location, self.filename) @landmarkLabels.deleter @@ -4511,7 +4513,7 @@ def dataTimeSeries(self): @dataTimeSeries.setter def dataTimeSeries(self, value): - self._dataTimeSeries = value + self._dataTimeSeries = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename) @dataTimeSeries.deleter @@ -4545,7 +4547,7 @@ def dataOffset(self): @dataOffset.setter def dataOffset(self, value): - self._dataOffset = value + self._dataOffset = np.array(value) # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) @dataOffset.deleter @@ -4588,7 +4590,7 @@ def time(self): @time.setter def time(self, value): - self._time = value + self._time = np.array(value) # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename) @time.deleter @@ -5773,7 +5775,7 @@ def data(self): @data.setter def data(self, value): - self._data = value + self._data = np.array(value) # self._cfg.logger.info('Assignment to %s/data in %s', self.location, self.filename) @data.deleter @@ -5805,7 +5807,7 @@ def dataLabels(self): @dataLabels.setter def dataLabels(self, value): - self._dataLabels = value + self._dataLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/dataLabels in %s', self.location, self.filename) @dataLabels.deleter @@ -6052,7 +6054,7 @@ def dataTimeSeries(self): @dataTimeSeries.setter def dataTimeSeries(self, value): - self._dataTimeSeries = value + self._dataTimeSeries = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename) @dataTimeSeries.deleter @@ -6117,7 +6119,7 @@ def time(self): @time.setter def time(self, value): - self._time = value + self._time = np.array(value) # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename) @time.deleter @@ -6149,7 +6151,7 @@ def timeOffset(self): @timeOffset.setter def timeOffset(self, value): - self._timeOffset = value + self._timeOffset = np.array(value) # self._cfg.logger.info('Assignment to %s/timeOffset in %s', self.location, self.filename) @timeOffset.deleter @@ -6672,7 +6674,9 @@ def measurementList_to_measurementLists(self): """ if len(self.measurementList) > 0: for dataset_name in self.measurementList[0]._snirf_names: - vals = [getattr(ml, dataset_name) for ml in self.measurementList] + vals = [ + getattr(ml, dataset_name) for ml in self.measurementList + ] if any(val is not None for val in vals): setattr(self.measurementLists, dataset_name, vals) @@ -6779,7 +6783,6 @@ def _validate(self, result: ValidationResult): class MeasurementLists(MeasurementLists): def _validate(self, result): - return super()._validate(result) @@ -6911,11 +6914,11 @@ def _validate(self, result: ValidationResult): lenSources = None lenDetectors = None if nirs.probe.sourceLabels is not None: - lenSourceLabels = nirs.probe.sourceLabels.size + lenSourceLabels = len(nirs.probe.sourceLabels) if nirs.probe.detectorLabels is not None: - lenDetectorLabels = nirs.probe.detectorLabels.size + lenDetectorLabels = len(nirs.probe.detectorLabels) if nirs.probe.wavelengths is not None: - lenWavelengths = nirs.probe.wavelengths.size + lenWavelengths = len(nirs.probe.wavelengths) if nirs.probe.sourcePos2D is not None: lenSources = nirs.probe.sourcePos2D.shape[0] elif nirs.probe.sourcePos3D is not None: @@ -6924,17 +6927,37 @@ def _validate(self, result: ValidationResult): lenDetectors = nirs.probe.detectorPos2D.shape[0] elif nirs.probe.detectorPos3D is not None: lenDetectors = nirs.probe.detectorPos3D.shape[0] + for data in nirs.data: - if lenSourceLabels is not None and max(data.measurementLists.sourceIndex) > lenSourceLabels: - result._add(data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenSources is not None and max(data.measurementLists.sourceIndex) > lenSources: - result._add(data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and max(data.measurementLists.detectorIndex) > lenDetectorLabels : - result._add(data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and max(data.measurementLists.detectorIndex) > lenDetectors: - result._add(data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenWavelengths is not None and max(data.measurementLists.wavelengthIndex) > lenWavelengths: # No wavelengths should raise a missing issue - result._add(data.measurementLists.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') + if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and np.max( + data.measurementLists.sourceIndex + ) > lenSourceLabels: + result._add( + data.measurementLists.location + '/sourceIndex', + 'INVALID_SOURCE_INDEX') + if lenSources is not None and data.measurementLists.sourceIndex is not None and np.max( + data.measurementLists.sourceIndex) > lenSources: + result._add( + data.measurementLists.location + '/sourceIndex', + 'INVALID_SOURCE_INDEX') + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and np.max( + data.measurementLists.detectorIndex + ) > lenDetectorLabels: + result._add( + data.measurementLists.location + '/detectorIndex', + 'INVALID_DETECTOR_INDEX') + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and np.max( + data.measurementLists.detectorIndex + ) > lenDetectors: + result._add( + data.measurementLists.location + '/detectorIndex', + 'INVALID_DETECTOR_INDEX') + if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and np.max( + data.measurementLists.wavelengthIndex + ) > lenWavelengths: # No wavelengths should raise a missing issue + result._add( + data.measurementLists.location + + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') for ml in data.measurementList: if ml.sourceIndex is not None and lenSourceLabels is not None: if ml.sourceIndex > lenSourceLabels: @@ -6948,7 +6971,7 @@ def _validate(self, result: ValidationResult): if ml.wavelengthIndex > lenWavelengths: result._add(ml.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') - + super()._validate(result) From 3f23d1003b13b3ffbd829fc10180f20195d8b362 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 19:02:16 +0000 Subject: [PATCH 25/37] CI: Automated docs update --- docs/pysnirf2.md | 216 +++++++++++++++++++++++------------------------ 1 file changed, 108 insertions(+), 108 deletions(-) diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index 29e94e2..ffdd956 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -109,14 +109,14 @@ Raised when SNIRF-specific error prevents file from loading or saving properly. --- - + ## class `ValidationIssue` Information about the validity of a given SNIRF file location. Properties: location: A relative HDF5 name corresponding to the location of the issue name: A string describing the issue. Must be predefined in `_CODES` id: An integer corresponding to the predefined error type severity: An integer ranking the serverity level of the issue. 0 OK, Nothing remarkable 1 Potentially useful `INFO` 2 `WARNING`, the file is valid but exhibits undefined behavior or features marked deprecation 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue - + ### method `__init__` @@ -133,7 +133,7 @@ __init__(name: str, location: str) --- - + ### method `dictize` @@ -146,7 +146,7 @@ Return dictionary representation of Issue. --- - + ## class `ValidationResult` The result of Snirf file validation routines. @@ -158,7 +158,7 @@ Validation results in a list of issues. Each issue records information about the = validateSnirf() ``` - + ### method `__init__` @@ -209,7 +209,7 @@ A list of the `WARNING` issues catalogued during validation. --- - + ### method `display` @@ -227,7 +227,7 @@ Reads the contents of an `h5py.Dataset` to an array of `dtype=str`. --- - + ### method `is_valid` @@ -239,7 +239,7 @@ Returns True if no `FATAL` issues were catalogued during validation. --- - + ### method `serialize` @@ -252,14 +252,14 @@ Render serialized JSON ValidationResult. --- - + ## class `SnirfConfig` Structure containing Snirf-wide data and settings. Properties: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property - + ### method `__init__` @@ -277,14 +277,14 @@ __init__() --- - + ## class `Group` - + ### method `__init__` @@ -322,7 +322,7 @@ The HDF5 relative location indentifier. --- - + ### method `is_empty` @@ -340,7 +340,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -368,14 +368,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -409,7 +409,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -427,7 +427,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -441,7 +441,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -460,7 +460,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -480,7 +480,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -498,7 +498,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -528,14 +528,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -646,7 +646,7 @@ The HDF5 relative location indentifier. --- - + ### method `add` @@ -665,7 +665,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -683,7 +683,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -701,7 +701,7 @@ Remove a tag from the list. You cannot remove a required tag. --- - + ### method `save` @@ -729,14 +729,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementLists` - + ### method `__init__` @@ -881,7 +881,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1 --- - + ### method `is_empty` @@ -899,7 +899,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -927,14 +927,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Probe` - + ### method `__init__` @@ -1165,7 +1165,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1183,7 +1183,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1211,12 +1211,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1303,7 +1303,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1321,7 +1321,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1349,7 +1349,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1360,7 +1360,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1383,7 +1383,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1401,7 +1401,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1415,7 +1415,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1434,7 +1434,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1454,7 +1454,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1472,7 +1472,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1502,14 +1502,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1606,7 +1606,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1624,7 +1624,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -1640,7 +1640,7 @@ The `measurementList` indexedGroup is not be removed. --- - + ### method `save` @@ -1668,14 +1668,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1698,7 +1698,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1716,7 +1716,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1730,7 +1730,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1749,7 +1749,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1769,7 +1769,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1787,7 +1787,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1817,12 +1817,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1973,7 +1973,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -1991,7 +1991,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2019,7 +2019,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -2032,7 +2032,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -2055,7 +2055,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2073,7 +2073,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2087,7 +2087,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2106,7 +2106,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2126,7 +2126,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2144,7 +2144,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2174,14 +2174,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2246,7 +2246,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2264,7 +2264,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2292,14 +2292,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2322,7 +2322,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2340,7 +2340,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2354,7 +2354,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2373,7 +2373,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2393,7 +2393,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2411,7 +2411,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2441,14 +2441,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2531,7 +2531,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2549,7 +2549,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2577,14 +2577,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2607,7 +2607,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2625,7 +2625,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2639,7 +2639,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2658,7 +2658,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2678,7 +2678,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2696,7 +2696,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2726,14 +2726,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2784,7 +2784,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2800,7 +2800,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2814,7 +2814,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2832,7 +2832,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -2846,7 +2846,7 @@ Does not delete the measurementList Dataset. --- - + ### method `save` @@ -2876,7 +2876,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From 97c12c785bdb7b20e12f2e6165a1610226a4fc85 Mon Sep 17 00:00:00 2001 From: sstucker Date: Mon, 30 Dec 2024 14:58:50 -0500 Subject: [PATCH 26/37] Better input sanitization --- gen/pysnirf2.jinja | 15 +- snirf/pysnirf2.py | 608 ++++++++++++++++++++++----------------------- 2 files changed, 308 insertions(+), 315 deletions(-) diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja index 85a41c2..fcb3d39 100644 --- a/gen/pysnirf2.jinja +++ b/gen/pysnirf2.jinja @@ -76,11 +76,11 @@ {% if TYPES.INDEXED_GROUP in CHILD.type %} return self._{{ CHILD.name }} {% elif TYPES.GROUP in CHILD.type %} - if type(self._{{ CHILD.name }}) is type(_AbsentGroup): + if self._{{ CHILD.name }} is _AbsentGroup: return None return self._{{ CHILD.name }} {% else %} - if type(self._{{ CHILD.name }}) is type(_AbsentDataset): + if self._{{ CHILD.name }} is _AbsentDataset: return None if type(self._{{ CHILD.name }}) is type(_PresentDataset): {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %} @@ -118,7 +118,8 @@ else: raise ValueError("Only a Group of type {{ sentencecase(CHILD.name) }} can be assigned to {{ CHILD.name }}.") {% elif TYPES.ARRAY_1D in CHILD.type or TYPES.ARRAY_2D in CHILD.type %} - self._{{ CHILD.name }} = np.array(value) + if value is not None and any([v is not None for v in value]): + self._{{ CHILD.name }} = np.array(value) {% else %} self._{{ CHILD.name }} = value {% endif %} @@ -157,7 +158,7 @@ {% if TYPES.INDEXED_GROUP in CHILD.type %} self.{{ CHILD.name }}._save(*args) {% elif TYPES.GROUP in CHILD.type %} - if type(self._{{ CHILD.name }}) is type(_AbsentGroup) or self._{{ CHILD.name }}.is_empty(): + if self._{{ CHILD.name }} is _AbsentGroup or self._{{ CHILD.name }}.is_empty(): if '{{ CHILD.name }}' in file: del file['{{ CHILD.name }}'] self._cfg.logger.info('Deleted Group %s/{{ CHILD.name }} from %s', self.location, file) @@ -165,7 +166,7 @@ self.{{ CHILD.name }}._save(*args) {% else %} name = self.location + '/{{ CHILD.name }}' - if type(self._{{ CHILD.name }}) not in [type(_AbsentDataset), type(None)]: + if not self._{{ CHILD.name }} is _AbsentDataset: data = self.{{ CHILD.name }} # Use loader function via getter if name in file: del file[name] @@ -239,7 +240,7 @@ self.{{ CHILD.name }}._validate(result) {% elif TYPES.GROUP in CHILD.type %} # If Group is not present in file and empty in the wrapper, it is missing - if type(self._{{ CHILD.name }}) in [type(_AbsentGroup), type(None)] or ('{{ CHILD.name }}' not in self._h and self._{{ CHILD.name }}.is_empty()): + if self._{{ CHILD.name }} is _AbsentGroup or ('{{ CHILD.name }}' not in self._h and self._{{ CHILD.name }}.is_empty()): {% if TYPES.REQUIRED in CHILD.type %} result._add(name, 'REQUIRED_GROUP_MISSING') {% else %} @@ -248,7 +249,7 @@ else: self._{{ CHILD.name }}._validate(result) {% else %} - if type(self._{{ CHILD.name }}) in [type(_AbsentDataset), type(None)]: + if self._{{ CHILD.name }} is _AbsentDataset: {% if TYPES.REQUIRED in CHILD.type %} result._add(name, 'REQUIRED_DATASET_MISSING') {% else %} diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 7ad5c62..ee21685 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -192,6 +192,8 @@ def _create_dataset(file: h5py.File, name: str, data): Raises: TypeError: The data could not be mapped to a SNIRF compliant h5py format. """ + if data is None: # Don't create dataset from None + return data = np.array(data) # Cast to numpy type to identify if data.size > 1: dtype = data[0].dtype @@ -227,6 +229,8 @@ def _create_dataset_string(file: h5py.File, name: str, data: str): Returns: An h5py.Dataset instance created """ + if data is None: + return None return file.create_dataset(name, dtype=_varlen_str_type, data=str(data)) @@ -241,6 +245,8 @@ def _create_dataset_int(file: h5py.File, name: str, data: int): Returns: An h5py.Dataset instance created """ + if data is None: + return None return file.create_dataset(name, dtype=_DTYPE_INT32, data=int(data)) @@ -255,6 +261,8 @@ def _create_dataset_float(file: h5py.File, name: str, data: float): Returns: An h5py.Dataset instance created """ + if data is None: + return None return file.create_dataset(name, dtype=_DTYPE_FLOAT64, data=float(data)) @@ -272,7 +280,11 @@ def _create_dataset_string_array(file: h5py.File, Returns: An h5py.Dataset instance created """ - array = np.array(data).astype('O') + try: + array = np.array(data).astype('O') + except TypeError as e: + warn('Could not cast {} array to numpy "O": {}'.format(name, e)) + return shape = _get_padded_shape(name, array, ndim) return file.create_dataset(name, dtype=_varlen_str_type, data=array) @@ -291,7 +303,11 @@ def _create_dataset_int_array(file: h5py.File, Returns: An h5py.Dataset instance created """ - array = np.array(data).astype(int) + try: + array = np.array(data).astype(int) + except TypeError as e: + warn('Could not cast {} array to int: {}'.format(name, e)) + return shape = _get_padded_shape(name, array, ndim) return file.create_dataset(name, dtype=_DTYPE_INT32, data=array) @@ -310,7 +326,11 @@ def _create_dataset_float_array(file: h5py.File, Returns: An h5py.Dataset instance created """ - array = np.array(data).astype(float) + try: + array = np.array(data).astype(float) + except TypeError as e: + warn('Could not cast {} array to float: {}'.format(name, e)) + return shape = _get_padded_shape(name, array, ndim) return file.create_dataset(name, dtype=_DTYPE_FLOAT64, @@ -748,6 +768,8 @@ def _validate_string(dataset: h5py.Dataset) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.size > 1 or dataset.ndim > 0: @@ -769,6 +791,8 @@ def _validate_int(dataset: h5py.Dataset) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.size > 1 or dataset.ndim > 0: @@ -794,6 +818,8 @@ def _validate_float(dataset: h5py.Dataset) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.size > 1 or dataset.ndim > 0: @@ -813,6 +839,8 @@ def _validate_string_array(dataset: h5py.Dataset, ndims=[1]) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.ndim not in ndims: @@ -834,6 +862,8 @@ def _validate_int_array(dataset: h5py.Dataset, ndims=[1]) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.ndim not in ndims: @@ -855,6 +885,8 @@ def _validate_float_array(dataset: h5py.Dataset, ndims=[1]) -> str: Returns: An issue code describing the validity of the dataset based on its format and shape """ + if dataset is None: + return 'REQUIRED_DATASET_MISSING' if type(dataset) is not h5py.Dataset: raise TypeError("'dataset' must be type h5py.Dataset") if dataset.ndim != ndims[0]: @@ -1508,7 +1540,7 @@ def SubjectID(self): This record stores the string-valued ID of the study subject or experiment. """ - if type(self._SubjectID) is type(_AbsentDataset): + if self._SubjectID is _AbsentDataset: return None if type(self._SubjectID) is type(_PresentDataset): return _read_string(self._h['SubjectID']) @@ -1541,7 +1573,7 @@ def MeasurementDate(self): - `DD` is the 2-digit date (padding zero if a single digit) """ - if type(self._MeasurementDate) is type(_AbsentDataset): + if self._MeasurementDate is _AbsentDataset: return None if type(self._MeasurementDate) is type(_PresentDataset): return _read_string(self._h['MeasurementDate']) @@ -1577,7 +1609,7 @@ def MeasurementTime(self): - `TZD` is the time zone designator (`Z` or `+hh:mm` or `-hh:mm`) """ - if type(self._MeasurementTime) is type(_AbsentDataset): + if self._MeasurementTime is _AbsentDataset: return None if type(self._MeasurementTime) is type(_PresentDataset): return _read_string(self._h['MeasurementTime']) @@ -1609,7 +1641,7 @@ def LengthUnit(self): "um" is the same as "mm", i.e. micrometer. """ - if type(self._LengthUnit) is type(_AbsentDataset): + if self._LengthUnit is _AbsentDataset: return None if type(self._LengthUnit) is type(_PresentDataset): return _read_string(self._h['LengthUnit']) @@ -1640,7 +1672,7 @@ def TimeUnit(self): is the same as "ms", i.e. microsecond. """ - if type(self._TimeUnit) is type(_AbsentDataset): + if self._TimeUnit is _AbsentDataset: return None if type(self._TimeUnit) is type(_PresentDataset): return _read_string(self._h['TimeUnit']) @@ -1706,7 +1738,7 @@ def FrequencyUnit(self): time in seconds since 1970-01-01T00:00:00Z (UTC) minus the leap seconds. """ - if type(self._FrequencyUnit) is type(_AbsentDataset): + if self._FrequencyUnit is _AbsentDataset: return None if type(self._FrequencyUnit) is type(_PresentDataset): return _read_string(self._h['FrequencyUnit']) @@ -1744,7 +1776,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/SubjectID' - if type(self._SubjectID) not in [type(_AbsentDataset), type(None)]: + if not self._SubjectID is _AbsentDataset: data = self.SubjectID # Use loader function via getter if name in file: del file[name] @@ -1755,9 +1787,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/MeasurementDate' - if type(self._MeasurementDate) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._MeasurementDate is _AbsentDataset: data = self.MeasurementDate # Use loader function via getter if name in file: del file[name] @@ -1768,9 +1798,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/MeasurementTime' - if type(self._MeasurementTime) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._MeasurementTime is _AbsentDataset: data = self.MeasurementTime # Use loader function via getter if name in file: del file[name] @@ -1781,7 +1809,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/LengthUnit' - if type(self._LengthUnit) not in [type(_AbsentDataset), type(None)]: + if not self._LengthUnit is _AbsentDataset: data = self.LengthUnit # Use loader function via getter if name in file: del file[name] @@ -1792,7 +1820,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/TimeUnit' - if type(self._TimeUnit) not in [type(_AbsentDataset), type(None)]: + if not self._TimeUnit is _AbsentDataset: data = self.TimeUnit # Use loader function via getter if name in file: del file[name] @@ -1803,7 +1831,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/FrequencyUnit' - if type(self._FrequencyUnit) not in [type(_AbsentDataset), type(None)]: + if not self._FrequencyUnit is _AbsentDataset: data = self.FrequencyUnit # Use loader function via getter if name in file: del file[name] @@ -1831,7 +1859,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/SubjectID' - if type(self._SubjectID) in [type(_AbsentDataset), type(None)]: + if self._SubjectID is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -1845,9 +1873,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/MeasurementDate' - if type(self._MeasurementDate) in [ - type(_AbsentDataset), type(None) - ]: + if self._MeasurementDate is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -1861,9 +1887,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/MeasurementTime' - if type(self._MeasurementTime) in [ - type(_AbsentDataset), type(None) - ]: + if self._MeasurementTime is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -1877,7 +1901,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/LengthUnit' - if type(self._LengthUnit) in [type(_AbsentDataset), type(None)]: + if self._LengthUnit is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -1891,7 +1915,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/TimeUnit' - if type(self._TimeUnit) in [type(_AbsentDataset), type(None)]: + if self._TimeUnit is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -1905,7 +1929,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/FrequencyUnit' - if type(self._FrequencyUnit) in [type(_AbsentDataset), type(None)]: + if self._FrequencyUnit is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2051,7 +2075,7 @@ def sourceIndex(self): Source indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._sourceIndex) is type(_AbsentDataset): + if self._sourceIndex is _AbsentDataset: return None if type(self._sourceIndex) is type(_PresentDataset): return _read_int_array(self._h['sourceIndex']) @@ -2061,7 +2085,8 @@ def sourceIndex(self): @sourceIndex.setter def sourceIndex(self, value): - self._sourceIndex = np.array(value) + if value is not None and any([v is not None for v in value]): + self._sourceIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/sourceIndex in %s', self.location, self.filename) @sourceIndex.deleter @@ -2080,7 +2105,7 @@ def detectorIndex(self): Detector indices for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._detectorIndex) is type(_AbsentDataset): + if self._detectorIndex is _AbsentDataset: return None if type(self._detectorIndex) is type(_PresentDataset): return _read_int_array(self._h['detectorIndex']) @@ -2091,7 +2116,8 @@ def detectorIndex(self): @detectorIndex.setter def detectorIndex(self, value): - self._detectorIndex = np.array(value) + if value is not None and any([v is not None for v in value]): + self._detectorIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorIndex in %s', self.location, self.filename) @detectorIndex.deleter @@ -2110,7 +2136,7 @@ def wavelengthIndex(self): Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._wavelengthIndex) is type(_AbsentDataset): + if self._wavelengthIndex is _AbsentDataset: return None if type(self._wavelengthIndex) is type(_PresentDataset): return _read_int_array(self._h['wavelengthIndex']) @@ -2121,7 +2147,8 @@ def wavelengthIndex(self): @wavelengthIndex.setter def wavelengthIndex(self, value): - self._wavelengthIndex = np.array(value) + if value is not None and any([v is not None for v in value]): + self._wavelengthIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthIndex in %s', self.location, self.filename) @wavelengthIndex.deleter @@ -2140,7 +2167,7 @@ def wavelengthActual(self): Actual (measured) wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._wavelengthActual) is type(_AbsentDataset): + if self._wavelengthActual is _AbsentDataset: return None if type(self._wavelengthActual) is type(_PresentDataset): return _read_float_array(self._h['wavelengthActual']) @@ -2151,7 +2178,8 @@ def wavelengthActual(self): @wavelengthActual.setter def wavelengthActual(self, value): - self._wavelengthActual = np.array(value) + if value is not None and any([v is not None for v in value]): + self._wavelengthActual = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthActual in %s', self.location, self.filename) @wavelengthActual.deleter @@ -2170,7 +2198,7 @@ def wavelengthEmissionActual(self): Actual (measured) emission wavelength in nm, if available, for the source in each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._wavelengthEmissionActual) is type(_AbsentDataset): + if self._wavelengthEmissionActual is _AbsentDataset: return None if type(self._wavelengthEmissionActual) is type(_PresentDataset): return _read_float_array(self._h['wavelengthEmissionActual']) @@ -2181,7 +2209,8 @@ def wavelengthEmissionActual(self): @wavelengthEmissionActual.setter def wavelengthEmissionActual(self, value): - self._wavelengthEmissionActual = np.array(value) + if value is not None and any([v is not None for v in value]): + self._wavelengthEmissionActual = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthEmissionActual in %s', self.location, self.filename) @wavelengthEmissionActual.deleter @@ -2200,7 +2229,7 @@ def dataType(self): A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. See Appendix for list of possible values. """ - if type(self._dataType) is type(_AbsentDataset): + if self._dataType is _AbsentDataset: return None if type(self._dataType) is type(_PresentDataset): return _read_int_array(self._h['dataType']) @@ -2210,7 +2239,8 @@ def dataType(self): @dataType.setter def dataType(self, value): - self._dataType = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataType = np.array(value) # self._cfg.logger.info('Assignment to %s/dataType in %s', self.location, self.filename) @dataType.deleter @@ -2229,7 +2259,7 @@ def dataUnit(self): International System of Units (SI units) identifier for each channel. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._dataUnit) is type(_AbsentDataset): + if self._dataUnit is _AbsentDataset: return None if type(self._dataUnit) is type(_PresentDataset): return _read_string_array(self._h['dataUnit']) @@ -2239,7 +2269,8 @@ def dataUnit(self): @dataUnit.setter def dataUnit(self, value): - self._dataUnit = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataUnit = np.array(value) # self._cfg.logger.info('Assignment to %s/dataUnit in %s', self.location, self.filename) @dataUnit.deleter @@ -2258,7 +2289,7 @@ def dataTypeLabel(self): Data-type label. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. """ - if type(self._dataTypeLabel) is type(_AbsentDataset): + if self._dataTypeLabel is _AbsentDataset: return None if type(self._dataTypeLabel) is type(_PresentDataset): return _read_string_array(self._h['dataTypeLabel']) @@ -2269,7 +2300,8 @@ def dataTypeLabel(self): @dataTypeLabel.setter def dataTypeLabel(self, value): - self._dataTypeLabel = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataTypeLabel = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTypeLabel in %s', self.location, self.filename) @dataTypeLabel.deleter @@ -2288,7 +2320,7 @@ def dataTypeIndex(self): Data-type specific parameter indices. A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Note that the Time Domain and Diffuse Correlation Spectroscopy data types have two additional parameters and so `dataTimeIndex` must be a 2-D array with 2 columns that index the additional parameters. """ - if type(self._dataTypeIndex) is type(_AbsentDataset): + if self._dataTypeIndex is _AbsentDataset: return None if type(self._dataTypeIndex) is type(_PresentDataset): return _read_int_array(self._h['dataTypeIndex']) @@ -2299,7 +2331,8 @@ def dataTypeIndex(self): @dataTypeIndex.setter def dataTypeIndex(self, value): - self._dataTypeIndex = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataTypeIndex = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTypeIndex in %s', self.location, self.filename) @dataTypeIndex.deleter @@ -2318,7 +2351,7 @@ def sourcePower(self): A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. """ - if type(self._sourcePower) is type(_AbsentDataset): + if self._sourcePower is _AbsentDataset: return None if type(self._sourcePower) is type(_PresentDataset): return _read_float_array(self._h['sourcePower']) @@ -2328,7 +2361,8 @@ def sourcePower(self): @sourcePower.setter def sourcePower(self, value): - self._sourcePower = np.array(value) + if value is not None and any([v is not None for v in value]): + self._sourcePower = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePower in %s', self.location, self.filename) @sourcePower.deleter @@ -2347,7 +2381,7 @@ def detectorGain(self): A 1-D array with length equal to the size of the second dimension of `/nirs(i)/data(j)/dataTimeSeries`. Units are optionally defined in `metaDataTags`. """ - if type(self._detectorGain) is type(_AbsentDataset): + if self._detectorGain is _AbsentDataset: return None if type(self._detectorGain) is type(_PresentDataset): return _read_float_array(self._h['detectorGain']) @@ -2357,7 +2391,8 @@ def detectorGain(self): @detectorGain.setter def detectorGain(self, value): - self._detectorGain = np.array(value) + if value is not None and any([v is not None for v in value]): + self._detectorGain = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorGain in %s', self.location, self.filename) @detectorGain.deleter @@ -2384,7 +2419,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/sourceIndex' - if type(self._sourceIndex) not in [type(_AbsentDataset), type(None)]: + if not self._sourceIndex is _AbsentDataset: data = self.sourceIndex # Use loader function via getter if name in file: del file[name] @@ -2395,7 +2430,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorIndex' - if type(self._detectorIndex) not in [type(_AbsentDataset), type(None)]: + if not self._detectorIndex is _AbsentDataset: data = self.detectorIndex # Use loader function via getter if name in file: del file[name] @@ -2406,9 +2441,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthIndex' - if type(self._wavelengthIndex) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthIndex is _AbsentDataset: data = self.wavelengthIndex # Use loader function via getter if name in file: del file[name] @@ -2419,9 +2452,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthActual' - if type(self._wavelengthActual) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthActual is _AbsentDataset: data = self.wavelengthActual # Use loader function via getter if name in file: del file[name] @@ -2432,9 +2463,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthEmissionActual' - if type(self._wavelengthEmissionActual) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthEmissionActual is _AbsentDataset: data = self.wavelengthEmissionActual # Use loader function via getter if name in file: del file[name] @@ -2445,7 +2474,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataType' - if type(self._dataType) not in [type(_AbsentDataset), type(None)]: + if not self._dataType is _AbsentDataset: data = self.dataType # Use loader function via getter if name in file: del file[name] @@ -2456,7 +2485,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataUnit' - if type(self._dataUnit) not in [type(_AbsentDataset), type(None)]: + if not self._dataUnit is _AbsentDataset: data = self.dataUnit # Use loader function via getter if name in file: del file[name] @@ -2467,7 +2496,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataTypeLabel' - if type(self._dataTypeLabel) not in [type(_AbsentDataset), type(None)]: + if not self._dataTypeLabel is _AbsentDataset: data = self.dataTypeLabel # Use loader function via getter if name in file: del file[name] @@ -2478,7 +2507,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataTypeIndex' - if type(self._dataTypeIndex) not in [type(_AbsentDataset), type(None)]: + if not self._dataTypeIndex is _AbsentDataset: data = self.dataTypeIndex # Use loader function via getter if name in file: del file[name] @@ -2489,7 +2518,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/sourcePower' - if type(self._sourcePower) not in [type(_AbsentDataset), type(None)]: + if not self._sourcePower is _AbsentDataset: data = self.sourcePower # Use loader function via getter if name in file: del file[name] @@ -2500,7 +2529,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorGain' - if type(self._detectorGain) not in [type(_AbsentDataset), type(None)]: + if not self._detectorGain is _AbsentDataset: data = self.detectorGain # Use loader function via getter if name in file: del file[name] @@ -2518,7 +2547,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/sourceIndex' - if type(self._sourceIndex) in [type(_AbsentDataset), type(None)]: + if self._sourceIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2532,7 +2561,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorIndex' - if type(self._detectorIndex) in [type(_AbsentDataset), type(None)]: + if self._detectorIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2546,9 +2575,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthIndex' - if type(self._wavelengthIndex) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2562,9 +2589,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthActual' - if type(self._wavelengthActual) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthActual is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2579,9 +2604,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthEmissionActual' - if type(self._wavelengthEmissionActual) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthEmissionActual is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2598,7 +2621,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataType' - if type(self._dataType) in [type(_AbsentDataset), type(None)]: + if self._dataType is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2612,7 +2635,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataUnit' - if type(self._dataUnit) in [type(_AbsentDataset), type(None)]: + if self._dataUnit is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2627,7 +2650,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataTypeLabel' - if type(self._dataTypeLabel) in [type(_AbsentDataset), type(None)]: + if self._dataTypeLabel is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2642,7 +2665,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataTypeIndex' - if type(self._dataTypeIndex) in [type(_AbsentDataset), type(None)]: + if self._dataTypeIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -2656,7 +2679,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/sourcePower' - if type(self._sourcePower) in [type(_AbsentDataset), type(None)]: + if self._sourcePower is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2671,7 +2694,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorGain' - if type(self._detectorGain) in [type(_AbsentDataset), type(None)]: + if self._detectorGain is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -2923,7 +2946,7 @@ def wavelengths(self): """ - if type(self._wavelengths) is type(_AbsentDataset): + if self._wavelengths is _AbsentDataset: return None if type(self._wavelengths) is type(_PresentDataset): return _read_float_array(self._h['wavelengths']) @@ -2933,7 +2956,8 @@ def wavelengths(self): @wavelengths.setter def wavelengths(self, value): - self._wavelengths = np.array(value) + if value is not None and any([v is not None for v in value]): + self._wavelengths = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengths in %s', self.location, self.filename) @wavelengths.deleter @@ -2960,7 +2984,7 @@ def wavelengthsEmission(self): """ - if type(self._wavelengthsEmission) is type(_AbsentDataset): + if self._wavelengthsEmission is _AbsentDataset: return None if type(self._wavelengthsEmission) is type(_PresentDataset): return _read_float_array(self._h['wavelengthsEmission']) @@ -2971,7 +2995,8 @@ def wavelengthsEmission(self): @wavelengthsEmission.setter def wavelengthsEmission(self, value): - self._wavelengthsEmission = np.array(value) + if value is not None and any([v is not None for v in value]): + self._wavelengthsEmission = np.array(value) # self._cfg.logger.info('Assignment to %s/wavelengthsEmission in %s', self.location, self.filename) @wavelengthsEmission.deleter @@ -2995,7 +3020,7 @@ def sourcePos2D(self): """ - if type(self._sourcePos2D) is type(_AbsentDataset): + if self._sourcePos2D is _AbsentDataset: return None if type(self._sourcePos2D) is type(_PresentDataset): return _read_float_array(self._h['sourcePos2D']) @@ -3005,7 +3030,8 @@ def sourcePos2D(self): @sourcePos2D.setter def sourcePos2D(self, value): - self._sourcePos2D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._sourcePos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePos2D in %s', self.location, self.filename) @sourcePos2D.deleter @@ -3026,7 +3052,7 @@ def sourcePos3D(self): """ - if type(self._sourcePos3D) is type(_AbsentDataset): + if self._sourcePos3D is _AbsentDataset: return None if type(self._sourcePos3D) is type(_PresentDataset): return _read_float_array(self._h['sourcePos3D']) @@ -3036,7 +3062,8 @@ def sourcePos3D(self): @sourcePos3D.setter def sourcePos3D(self, value): - self._sourcePos3D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._sourcePos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/sourcePos3D in %s', self.location, self.filename) @sourcePos3D.deleter @@ -3057,7 +3084,7 @@ def detectorPos2D(self): """ - if type(self._detectorPos2D) is type(_AbsentDataset): + if self._detectorPos2D is _AbsentDataset: return None if type(self._detectorPos2D) is type(_PresentDataset): return _read_float_array(self._h['detectorPos2D']) @@ -3068,7 +3095,8 @@ def detectorPos2D(self): @detectorPos2D.setter def detectorPos2D(self, value): - self._detectorPos2D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._detectorPos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorPos2D in %s', self.location, self.filename) @detectorPos2D.deleter @@ -3089,7 +3117,7 @@ def detectorPos3D(self): """ - if type(self._detectorPos3D) is type(_AbsentDataset): + if self._detectorPos3D is _AbsentDataset: return None if type(self._detectorPos3D) is type(_PresentDataset): return _read_float_array(self._h['detectorPos3D']) @@ -3100,7 +3128,8 @@ def detectorPos3D(self): @detectorPos3D.setter def detectorPos3D(self, value): - self._detectorPos3D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._detectorPos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorPos3D in %s', self.location, self.filename) @detectorPos3D.deleter @@ -3122,7 +3151,7 @@ def frequencies(self): """ - if type(self._frequencies) is type(_AbsentDataset): + if self._frequencies is _AbsentDataset: return None if type(self._frequencies) is type(_PresentDataset): return _read_float_array(self._h['frequencies']) @@ -3132,7 +3161,8 @@ def frequencies(self): @frequencies.setter def frequencies(self, value): - self._frequencies = np.array(value) + if value is not None and any([v is not None for v in value]): + self._frequencies = np.array(value) # self._cfg.logger.info('Assignment to %s/frequencies in %s', self.location, self.filename) @frequencies.deleter @@ -3155,7 +3185,7 @@ def timeDelays(self): """ - if type(self._timeDelays) is type(_AbsentDataset): + if self._timeDelays is _AbsentDataset: return None if type(self._timeDelays) is type(_PresentDataset): return _read_float_array(self._h['timeDelays']) @@ -3165,7 +3195,8 @@ def timeDelays(self): @timeDelays.setter def timeDelays(self, value): - self._timeDelays = np.array(value) + if value is not None and any([v is not None for v in value]): + self._timeDelays = np.array(value) # self._cfg.logger.info('Assignment to %s/timeDelays in %s', self.location, self.filename) @timeDelays.deleter @@ -3188,7 +3219,7 @@ def timeDelayWidths(self): """ - if type(self._timeDelayWidths) is type(_AbsentDataset): + if self._timeDelayWidths is _AbsentDataset: return None if type(self._timeDelayWidths) is type(_PresentDataset): return _read_float_array(self._h['timeDelayWidths']) @@ -3199,7 +3230,8 @@ def timeDelayWidths(self): @timeDelayWidths.setter def timeDelayWidths(self, value): - self._timeDelayWidths = np.array(value) + if value is not None and any([v is not None for v in value]): + self._timeDelayWidths = np.array(value) # self._cfg.logger.info('Assignment to %s/timeDelayWidths in %s', self.location, self.filename) @timeDelayWidths.deleter @@ -3227,7 +3259,7 @@ def momentOrders(self): """ - if type(self._momentOrders) is type(_AbsentDataset): + if self._momentOrders is _AbsentDataset: return None if type(self._momentOrders) is type(_PresentDataset): return _read_float_array(self._h['momentOrders']) @@ -3237,7 +3269,8 @@ def momentOrders(self): @momentOrders.setter def momentOrders(self, value): - self._momentOrders = np.array(value) + if value is not None and any([v is not None for v in value]): + self._momentOrders = np.array(value) # self._cfg.logger.info('Assignment to %s/momentOrders in %s', self.location, self.filename) @momentOrders.deleter @@ -3260,7 +3293,7 @@ def correlationTimeDelays(self): """ - if type(self._correlationTimeDelays) is type(_AbsentDataset): + if self._correlationTimeDelays is _AbsentDataset: return None if type(self._correlationTimeDelays) is type(_PresentDataset): return _read_float_array(self._h['correlationTimeDelays']) @@ -3271,7 +3304,8 @@ def correlationTimeDelays(self): @correlationTimeDelays.setter def correlationTimeDelays(self, value): - self._correlationTimeDelays = np.array(value) + if value is not None and any([v is not None for v in value]): + self._correlationTimeDelays = np.array(value) # self._cfg.logger.info('Assignment to %s/correlationTimeDelays in %s', self.location, self.filename) @correlationTimeDelays.deleter @@ -3294,7 +3328,7 @@ def correlationTimeDelayWidths(self): """ - if type(self._correlationTimeDelayWidths) is type(_AbsentDataset): + if self._correlationTimeDelayWidths is _AbsentDataset: return None if type(self._correlationTimeDelayWidths) is type(_PresentDataset): return _read_float_array(self._h['correlationTimeDelayWidths']) @@ -3305,7 +3339,8 @@ def correlationTimeDelayWidths(self): @correlationTimeDelayWidths.setter def correlationTimeDelayWidths(self, value): - self._correlationTimeDelayWidths = np.array(value) + if value is not None and any([v is not None for v in value]): + self._correlationTimeDelayWidths = np.array(value) # self._cfg.logger.info('Assignment to %s/correlationTimeDelayWidths in %s', self.location, self.filename) @correlationTimeDelayWidths.deleter @@ -3330,7 +3365,7 @@ def sourceLabels(self): """ - if type(self._sourceLabels) is type(_AbsentDataset): + if self._sourceLabels is _AbsentDataset: return None if type(self._sourceLabels) is type(_PresentDataset): return _read_string_array(self._h['sourceLabels']) @@ -3340,7 +3375,8 @@ def sourceLabels(self): @sourceLabels.setter def sourceLabels(self, value): - self._sourceLabels = np.array(value) + if value is not None and any([v is not None for v in value]): + self._sourceLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/sourceLabels in %s', self.location, self.filename) @sourceLabels.deleter @@ -3363,7 +3399,7 @@ def detectorLabels(self): """ - if type(self._detectorLabels) is type(_AbsentDataset): + if self._detectorLabels is _AbsentDataset: return None if type(self._detectorLabels) is type(_PresentDataset): return _read_string_array(self._h['detectorLabels']) @@ -3374,7 +3410,8 @@ def detectorLabels(self): @detectorLabels.setter def detectorLabels(self, value): - self._detectorLabels = np.array(value) + if value is not None and any([v is not None for v in value]): + self._detectorLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/detectorLabels in %s', self.location, self.filename) @detectorLabels.deleter @@ -3400,7 +3437,7 @@ def landmarkPos2D(self): """ - if type(self._landmarkPos2D) is type(_AbsentDataset): + if self._landmarkPos2D is _AbsentDataset: return None if type(self._landmarkPos2D) is type(_PresentDataset): return _read_float_array(self._h['landmarkPos2D']) @@ -3411,7 +3448,8 @@ def landmarkPos2D(self): @landmarkPos2D.setter def landmarkPos2D(self, value): - self._landmarkPos2D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._landmarkPos2D = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkPos2D in %s', self.location, self.filename) @landmarkPos2D.deleter @@ -3437,7 +3475,7 @@ def landmarkPos3D(self): """ - if type(self._landmarkPos3D) is type(_AbsentDataset): + if self._landmarkPos3D is _AbsentDataset: return None if type(self._landmarkPos3D) is type(_PresentDataset): return _read_float_array(self._h['landmarkPos3D']) @@ -3448,7 +3486,8 @@ def landmarkPos3D(self): @landmarkPos3D.setter def landmarkPos3D(self, value): - self._landmarkPos3D = np.array(value) + if value is not None and any([v is not None for v in value]): + self._landmarkPos3D = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkPos3D in %s', self.location, self.filename) @landmarkPos3D.deleter @@ -3475,7 +3514,7 @@ def landmarkLabels(self): """ - if type(self._landmarkLabels) is type(_AbsentDataset): + if self._landmarkLabels is _AbsentDataset: return None if type(self._landmarkLabels) is type(_PresentDataset): return _read_string_array(self._h['landmarkLabels']) @@ -3486,7 +3525,8 @@ def landmarkLabels(self): @landmarkLabels.setter def landmarkLabels(self, value): - self._landmarkLabels = np.array(value) + if value is not None and any([v is not None for v in value]): + self._landmarkLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/landmarkLabels in %s', self.location, self.filename) @landmarkLabels.deleter @@ -3513,7 +3553,7 @@ def coordinateSystem(self): """ - if type(self._coordinateSystem) is type(_AbsentDataset): + if self._coordinateSystem is _AbsentDataset: return None if type(self._coordinateSystem) is type(_PresentDataset): return _read_string(self._h['coordinateSystem']) @@ -3547,7 +3587,7 @@ def coordinateSystemDescription(self): """ - if type(self._coordinateSystemDescription) is type(_AbsentDataset): + if self._coordinateSystemDescription is _AbsentDataset: return None if type(self._coordinateSystemDescription) is type(_PresentDataset): return _read_string(self._h['coordinateSystemDescription']) @@ -3585,7 +3625,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/wavelengths' - if type(self._wavelengths) not in [type(_AbsentDataset), type(None)]: + if not self._wavelengths is _AbsentDataset: data = self.wavelengths # Use loader function via getter if name in file: del file[name] @@ -3596,9 +3636,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthsEmission' - if type(self._wavelengthsEmission) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthsEmission is _AbsentDataset: data = self.wavelengthsEmission # Use loader function via getter if name in file: del file[name] @@ -3609,7 +3647,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/sourcePos2D' - if type(self._sourcePos2D) not in [type(_AbsentDataset), type(None)]: + if not self._sourcePos2D is _AbsentDataset: data = self.sourcePos2D # Use loader function via getter if name in file: del file[name] @@ -3620,7 +3658,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/sourcePos3D' - if type(self._sourcePos3D) not in [type(_AbsentDataset), type(None)]: + if not self._sourcePos3D is _AbsentDataset: data = self.sourcePos3D # Use loader function via getter if name in file: del file[name] @@ -3631,7 +3669,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorPos2D' - if type(self._detectorPos2D) not in [type(_AbsentDataset), type(None)]: + if not self._detectorPos2D is _AbsentDataset: data = self.detectorPos2D # Use loader function via getter if name in file: del file[name] @@ -3642,7 +3680,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorPos3D' - if type(self._detectorPos3D) not in [type(_AbsentDataset), type(None)]: + if not self._detectorPos3D is _AbsentDataset: data = self.detectorPos3D # Use loader function via getter if name in file: del file[name] @@ -3653,7 +3691,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/frequencies' - if type(self._frequencies) not in [type(_AbsentDataset), type(None)]: + if not self._frequencies is _AbsentDataset: data = self.frequencies # Use loader function via getter if name in file: del file[name] @@ -3664,7 +3702,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/timeDelays' - if type(self._timeDelays) not in [type(_AbsentDataset), type(None)]: + if not self._timeDelays is _AbsentDataset: data = self.timeDelays # Use loader function via getter if name in file: del file[name] @@ -3675,9 +3713,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/timeDelayWidths' - if type(self._timeDelayWidths) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._timeDelayWidths is _AbsentDataset: data = self.timeDelayWidths # Use loader function via getter if name in file: del file[name] @@ -3688,7 +3724,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/momentOrders' - if type(self._momentOrders) not in [type(_AbsentDataset), type(None)]: + if not self._momentOrders is _AbsentDataset: data = self.momentOrders # Use loader function via getter if name in file: del file[name] @@ -3699,9 +3735,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/correlationTimeDelays' - if type(self._correlationTimeDelays) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._correlationTimeDelays is _AbsentDataset: data = self.correlationTimeDelays # Use loader function via getter if name in file: del file[name] @@ -3712,9 +3746,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/correlationTimeDelayWidths' - if type(self._correlationTimeDelayWidths) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._correlationTimeDelayWidths is _AbsentDataset: data = self.correlationTimeDelayWidths # Use loader function via getter if name in file: del file[name] @@ -3725,7 +3757,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/sourceLabels' - if type(self._sourceLabels) not in [type(_AbsentDataset), type(None)]: + if not self._sourceLabels is _AbsentDataset: data = self.sourceLabels # Use loader function via getter if name in file: del file[name] @@ -3736,9 +3768,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorLabels' - if type(self._detectorLabels) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._detectorLabels is _AbsentDataset: data = self.detectorLabels # Use loader function via getter if name in file: del file[name] @@ -3749,7 +3779,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/landmarkPos2D' - if type(self._landmarkPos2D) not in [type(_AbsentDataset), type(None)]: + if not self._landmarkPos2D is _AbsentDataset: data = self.landmarkPos2D # Use loader function via getter if name in file: del file[name] @@ -3760,7 +3790,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/landmarkPos3D' - if type(self._landmarkPos3D) not in [type(_AbsentDataset), type(None)]: + if not self._landmarkPos3D is _AbsentDataset: data = self.landmarkPos3D # Use loader function via getter if name in file: del file[name] @@ -3771,9 +3801,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/landmarkLabels' - if type(self._landmarkLabels) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._landmarkLabels is _AbsentDataset: data = self.landmarkLabels # Use loader function via getter if name in file: del file[name] @@ -3784,9 +3812,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/coordinateSystem' - if type(self._coordinateSystem) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._coordinateSystem is _AbsentDataset: data = self.coordinateSystem # Use loader function via getter if name in file: del file[name] @@ -3797,9 +3823,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/coordinateSystemDescription' - if type(self._coordinateSystemDescription) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._coordinateSystemDescription is _AbsentDataset: data = self.coordinateSystemDescription # Use loader function via getter if name in file: del file[name] @@ -3817,7 +3841,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/wavelengths' - if type(self._wavelengths) in [type(_AbsentDataset), type(None)]: + if self._wavelengths is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -3832,9 +3856,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthsEmission' - if type(self._wavelengthsEmission) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthsEmission is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3851,7 +3873,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/sourcePos2D' - if type(self._sourcePos2D) in [type(_AbsentDataset), type(None)]: + if self._sourcePos2D is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -3866,7 +3888,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/sourcePos3D' - if type(self._sourcePos3D) in [type(_AbsentDataset), type(None)]: + if self._sourcePos3D is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -3881,7 +3903,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorPos2D' - if type(self._detectorPos2D) in [type(_AbsentDataset), type(None)]: + if self._detectorPos2D is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -3896,7 +3918,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorPos3D' - if type(self._detectorPos3D) in [type(_AbsentDataset), type(None)]: + if self._detectorPos3D is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -3911,7 +3933,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/frequencies' - if type(self._frequencies) in [type(_AbsentDataset), type(None)]: + if self._frequencies is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3926,7 +3948,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/timeDelays' - if type(self._timeDelays) in [type(_AbsentDataset), type(None)]: + if self._timeDelays is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3941,9 +3963,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/timeDelayWidths' - if type(self._timeDelayWidths) in [ - type(_AbsentDataset), type(None) - ]: + if self._timeDelayWidths is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3958,7 +3978,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/momentOrders' - if type(self._momentOrders) in [type(_AbsentDataset), type(None)]: + if self._momentOrders is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3973,9 +3993,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/correlationTimeDelays' - if type(self._correlationTimeDelays) in [ - type(_AbsentDataset), type(None) - ]: + if self._correlationTimeDelays is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -3992,9 +4010,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/correlationTimeDelayWidths' - if type(self._correlationTimeDelayWidths) in [ - type(_AbsentDataset), type(None) - ]: + if self._correlationTimeDelayWidths is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4011,7 +4027,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/sourceLabels' - if type(self._sourceLabels) in [type(_AbsentDataset), type(None)]: + if self._sourceLabels is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4026,9 +4042,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorLabels' - if type(self._detectorLabels) in [ - type(_AbsentDataset), type(None) - ]: + if self._detectorLabels is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4043,7 +4057,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/landmarkPos2D' - if type(self._landmarkPos2D) in [type(_AbsentDataset), type(None)]: + if self._landmarkPos2D is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4058,7 +4072,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/landmarkPos3D' - if type(self._landmarkPos3D) in [type(_AbsentDataset), type(None)]: + if self._landmarkPos3D is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4073,9 +4087,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/landmarkLabels' - if type(self._landmarkLabels) in [ - type(_AbsentDataset), type(None) - ]: + if self._landmarkLabels is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4090,9 +4102,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/coordinateSystem' - if type(self._coordinateSystem) in [ - type(_AbsentDataset), type(None) - ]: + if self._coordinateSystem is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4106,9 +4116,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/coordinateSystemDescription' - if type(self._coordinateSystemDescription) in [ - type(_AbsentDataset), type(None) - ]: + if self._coordinateSystemDescription is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4188,7 +4196,7 @@ def metaDataTags(self): The below five metadata records are minimally required in a SNIRF file """ - if type(self._metaDataTags) is type(_AbsentGroup): + if self._metaDataTags is _AbsentGroup: return None return self._metaDataTags @@ -4276,7 +4284,7 @@ def probe(self): geometry. This variable has a number of required fields. """ - if type(self._probe) is type(_AbsentGroup): + if self._probe is _AbsentGroup: return None return self._probe @@ -4337,8 +4345,7 @@ def _save(self, *args): raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance without a filename') - if type(self._metaDataTags) is type( - _AbsentGroup) or self._metaDataTags.is_empty(): + if self._metaDataTags is _AbsentGroup or self._metaDataTags.is_empty(): if 'metaDataTags' in file: del file['metaDataTags'] self._cfg.logger.info('Deleted Group %s/metaDataTags from %s', @@ -4347,7 +4354,7 @@ def _save(self, *args): self.metaDataTags._save(*args) self.data._save(*args) self.stim._save(*args) - if type(self._probe) is type(_AbsentGroup) or self._probe.is_empty(): + if self._probe is _AbsentGroup or self._probe.is_empty(): if 'probe' in file: del file['probe'] self._cfg.logger.info('Deleted Group %s/probe from %s', @@ -4364,10 +4371,9 @@ def _validate(self, result: ValidationResult): backing_store=False) as tmp: name = self.location + '/metaDataTags' # If Group is not present in file and empty in the wrapper, it is missing - if type(self._metaDataTags) in [ - type(_AbsentGroup), type(None) - ] or ('metaDataTags' not in self._h - and self._metaDataTags.is_empty()): + if self._metaDataTags is _AbsentGroup or ( + 'metaDataTags' not in self._h + and self._metaDataTags.is_empty()): result._add(name, 'REQUIRED_GROUP_MISSING') else: self._metaDataTags._validate(result) @@ -4383,9 +4389,8 @@ def _validate(self, result: ValidationResult): self.stim._validate(result) name = self.location + '/probe' # If Group is not present in file and empty in the wrapper, it is missing - if type(self._probe) in [ - type(_AbsentGroup), type(None) - ] or ('probe' not in self._h and self._probe.is_empty()): + if self._probe is _AbsentGroup or ('probe' not in self._h + and self._probe.is_empty()): result._add(name, 'REQUIRED_GROUP_MISSING') else: self._probe._validate(result) @@ -4502,7 +4507,7 @@ def dataTimeSeries(self): """ - if type(self._dataTimeSeries) is type(_AbsentDataset): + if self._dataTimeSeries is _AbsentDataset: return None if type(self._dataTimeSeries) is type(_PresentDataset): return _read_float_array(self._h['dataTimeSeries']) @@ -4513,7 +4518,8 @@ def dataTimeSeries(self): @dataTimeSeries.setter def dataTimeSeries(self, value): - self._dataTimeSeries = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataTimeSeries = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename) @dataTimeSeries.deleter @@ -4537,7 +4543,7 @@ def dataOffset(self): """ - if type(self._dataOffset) is type(_AbsentDataset): + if self._dataOffset is _AbsentDataset: return None if type(self._dataOffset) is type(_PresentDataset): return _read_float_array(self._h['dataOffset']) @@ -4547,7 +4553,8 @@ def dataOffset(self): @dataOffset.setter def dataOffset(self, value): - self._dataOffset = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataOffset = np.array(value) # self._cfg.logger.info('Assignment to %s/dataOffset in %s', self.location, self.filename) @dataOffset.deleter @@ -4580,7 +4587,7 @@ def time(self): Chunked data is allowed to support real-time streaming of data in this array. """ - if type(self._time) is type(_AbsentDataset): + if self._time is _AbsentDataset: return None if type(self._time) is type(_PresentDataset): return _read_float_array(self._h['time']) @@ -4590,7 +4597,8 @@ def time(self): @time.setter def time(self, value): - self._time = np.array(value) + if value is not None and any([v is not None for v in value]): + self._time = np.array(value) # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename) @time.deleter @@ -4647,7 +4655,7 @@ def measurementLists(self): The arrays of `measurementLists` are: """ - if type(self._measurementLists) is type(_AbsentGroup): + if self._measurementLists is _AbsentGroup: return None return self._measurementLists @@ -4686,9 +4694,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/dataTimeSeries' - if type(self._dataTimeSeries) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._dataTimeSeries is _AbsentDataset: data = self.dataTimeSeries # Use loader function via getter if name in file: del file[name] @@ -4699,7 +4705,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataOffset' - if type(self._dataOffset) not in [type(_AbsentDataset), type(None)]: + if not self._dataOffset is _AbsentDataset: data = self.dataOffset # Use loader function via getter if name in file: del file[name] @@ -4710,7 +4716,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/time' - if type(self._time) not in [type(_AbsentDataset), type(None)]: + if not self._time is _AbsentDataset: data = self.time # Use loader function via getter if name in file: del file[name] @@ -4721,8 +4727,8 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) self.measurementList._save(*args) - if type(self._measurementLists) is type( - _AbsentGroup) or self._measurementLists.is_empty(): + if self._measurementLists is _AbsentGroup or self._measurementLists.is_empty( + ): if 'measurementLists' in file: del file['measurementLists'] self._cfg.logger.info( @@ -4738,9 +4744,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/dataTimeSeries' - if type(self._dataTimeSeries) in [ - type(_AbsentDataset), type(None) - ]: + if self._dataTimeSeries is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -4755,7 +4759,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataOffset' - if type(self._dataOffset) in [type(_AbsentDataset), type(None)]: + if self._dataOffset is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -4770,7 +4774,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/time' - if type(self._time) in [type(_AbsentDataset), type(None)]: + if self._time is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -4791,10 +4795,9 @@ def _validate(self, result: ValidationResult): self.measurementList._validate(result) name = self.location + '/measurementLists' # If Group is not present in file and empty in the wrapper, it is missing - if type(self._measurementLists) in [ - type(_AbsentGroup), type(None) - ] or ('measurementLists' not in self._h - and self._measurementLists.is_empty()): + if self._measurementLists is _AbsentGroup or ( + 'measurementLists' not in self._h + and self._measurementLists.is_empty()): result._add(name, 'REQUIRED_GROUP_MISSING') else: self._measurementLists._validate(result) @@ -4953,7 +4956,7 @@ def sourceIndex(self): Index of the source. """ - if type(self._sourceIndex) is type(_AbsentDataset): + if self._sourceIndex is _AbsentDataset: return None if type(self._sourceIndex) is type(_PresentDataset): return _read_int(self._h['sourceIndex']) @@ -4982,7 +4985,7 @@ def detectorIndex(self): Index of the detector. """ - if type(self._detectorIndex) is type(_AbsentDataset): + if self._detectorIndex is _AbsentDataset: return None if type(self._detectorIndex) is type(_PresentDataset): return _read_int(self._h['detectorIndex']) @@ -5012,7 +5015,7 @@ def wavelengthIndex(self): Index of the "nominal" wavelength (in `probe.wavelengths`). """ - if type(self._wavelengthIndex) is type(_AbsentDataset): + if self._wavelengthIndex is _AbsentDataset: return None if type(self._wavelengthIndex) is type(_PresentDataset): return _read_int(self._h['wavelengthIndex']) @@ -5042,7 +5045,7 @@ def wavelengthActual(self): Actual (measured) wavelength in nm, if available, for the source in a given channel. """ - if type(self._wavelengthActual) is type(_AbsentDataset): + if self._wavelengthActual is _AbsentDataset: return None if type(self._wavelengthActual) is type(_PresentDataset): return _read_float(self._h['wavelengthActual']) @@ -5072,7 +5075,7 @@ def wavelengthEmissionActual(self): Actual (measured) emission wavelength in nm, if available, for the source in a given channel. """ - if type(self._wavelengthEmissionActual) is type(_AbsentDataset): + if self._wavelengthEmissionActual is _AbsentDataset: return None if type(self._wavelengthEmissionActual) is type(_PresentDataset): return _read_float(self._h['wavelengthEmissionActual']) @@ -5102,7 +5105,7 @@ def dataType(self): Data-type identifier. See Appendix for list possible values. """ - if type(self._dataType) is type(_AbsentDataset): + if self._dataType is _AbsentDataset: return None if type(self._dataType) is type(_PresentDataset): return _read_int(self._h['dataType']) @@ -5131,7 +5134,7 @@ def dataUnit(self): International System of Units (SI units) identifier for the given channel. Encoding should follow the [CMIXF-12 standard](https://people.csail.mit.edu/jaffer/MIXF/CMIXF-12), avoiding special unicode symbols like U+03BC (m) or U+00B5 (u) and using '/' rather than 'per' for units such as `V/us`. The recommended export format is in unscaled units such as V, s, Mole. """ - if type(self._dataUnit) is type(_AbsentDataset): + if self._dataUnit is _AbsentDataset: return None if type(self._dataUnit) is type(_PresentDataset): return _read_string(self._h['dataUnit']) @@ -5161,7 +5164,7 @@ def dataTypeLabel(self): for list of possible values. """ - if type(self._dataTypeLabel) is type(_AbsentDataset): + if self._dataTypeLabel is _AbsentDataset: return None if type(self._dataTypeLabel) is type(_PresentDataset): return _read_string(self._h['dataTypeLabel']) @@ -5191,7 +5194,7 @@ def dataTypeIndex(self): Data-type specific parameter index. The data type index specifies additional data type specific parameters that are further elaborated by other fields in the probe structure, as detailed below. Note that where multiple parameters are required, the same index must be used into each (examples include data types such as Time Domain and Diffuse Correlation Spectroscopy). One use of this parameter is as a stimulus condition index when `measurementList(k).dataType = 99999` (i.e, `processed` and `measurementList(k).dataTypeLabel = 'HRF ...'` . """ - if type(self._dataTypeIndex) is type(_AbsentDataset): + if self._dataTypeIndex is _AbsentDataset: return None if type(self._dataTypeIndex) is type(_PresentDataset): return _read_int(self._h['dataTypeIndex']) @@ -5221,7 +5224,7 @@ def sourcePower(self): The units are not defined, unless the user takes the option of using a `metaDataTag` as described below. """ - if type(self._sourcePower) is type(_AbsentDataset): + if self._sourcePower is _AbsentDataset: return None if type(self._sourcePower) is type(_PresentDataset): return _read_float(self._h['sourcePower']) @@ -5283,7 +5286,7 @@ def detectorGain(self): label for sources and detectors. """ - if type(self._detectorGain) is type(_AbsentDataset): + if self._detectorGain is _AbsentDataset: return None if type(self._detectorGain) is type(_PresentDataset): return _read_float(self._h['detectorGain']) @@ -5320,7 +5323,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/sourceIndex' - if type(self._sourceIndex) not in [type(_AbsentDataset), type(None)]: + if not self._sourceIndex is _AbsentDataset: data = self.sourceIndex # Use loader function via getter if name in file: del file[name] @@ -5331,7 +5334,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorIndex' - if type(self._detectorIndex) not in [type(_AbsentDataset), type(None)]: + if not self._detectorIndex is _AbsentDataset: data = self.detectorIndex # Use loader function via getter if name in file: del file[name] @@ -5342,9 +5345,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthIndex' - if type(self._wavelengthIndex) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthIndex is _AbsentDataset: data = self.wavelengthIndex # Use loader function via getter if name in file: del file[name] @@ -5355,9 +5356,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthActual' - if type(self._wavelengthActual) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthActual is _AbsentDataset: data = self.wavelengthActual # Use loader function via getter if name in file: del file[name] @@ -5368,9 +5367,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/wavelengthEmissionActual' - if type(self._wavelengthEmissionActual) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._wavelengthEmissionActual is _AbsentDataset: data = self.wavelengthEmissionActual # Use loader function via getter if name in file: del file[name] @@ -5381,7 +5378,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataType' - if type(self._dataType) not in [type(_AbsentDataset), type(None)]: + if not self._dataType is _AbsentDataset: data = self.dataType # Use loader function via getter if name in file: del file[name] @@ -5392,7 +5389,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataUnit' - if type(self._dataUnit) not in [type(_AbsentDataset), type(None)]: + if not self._dataUnit is _AbsentDataset: data = self.dataUnit # Use loader function via getter if name in file: del file[name] @@ -5403,7 +5400,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataTypeLabel' - if type(self._dataTypeLabel) not in [type(_AbsentDataset), type(None)]: + if not self._dataTypeLabel is _AbsentDataset: data = self.dataTypeLabel # Use loader function via getter if name in file: del file[name] @@ -5414,7 +5411,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataTypeIndex' - if type(self._dataTypeIndex) not in [type(_AbsentDataset), type(None)]: + if not self._dataTypeIndex is _AbsentDataset: data = self.dataTypeIndex # Use loader function via getter if name in file: del file[name] @@ -5425,7 +5422,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/sourcePower' - if type(self._sourcePower) not in [type(_AbsentDataset), type(None)]: + if not self._sourcePower is _AbsentDataset: data = self.sourcePower # Use loader function via getter if name in file: del file[name] @@ -5436,7 +5433,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/detectorGain' - if type(self._detectorGain) not in [type(_AbsentDataset), type(None)]: + if not self._detectorGain is _AbsentDataset: data = self.detectorGain # Use loader function via getter if name in file: del file[name] @@ -5454,7 +5451,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/sourceIndex' - if type(self._sourceIndex) in [type(_AbsentDataset), type(None)]: + if self._sourceIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -5474,7 +5471,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorIndex' - if type(self._detectorIndex) in [type(_AbsentDataset), type(None)]: + if self._detectorIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -5494,9 +5491,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthIndex' - if type(self._wavelengthIndex) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -5516,9 +5511,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthActual' - if type(self._wavelengthActual) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthActual is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5532,9 +5525,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/wavelengthEmissionActual' - if type(self._wavelengthEmissionActual) in [ - type(_AbsentDataset), type(None) - ]: + if self._wavelengthEmissionActual is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5550,7 +5541,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataType' - if type(self._dataType) in [type(_AbsentDataset), type(None)]: + if self._dataType is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -5564,7 +5555,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataUnit' - if type(self._dataUnit) in [type(_AbsentDataset), type(None)]: + if self._dataUnit is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5578,7 +5569,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataTypeLabel' - if type(self._dataTypeLabel) in [type(_AbsentDataset), type(None)]: + if self._dataTypeLabel is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5592,7 +5583,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataTypeIndex' - if type(self._dataTypeIndex) in [type(_AbsentDataset), type(None)]: + if self._dataTypeIndex is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -5612,7 +5603,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/sourcePower' - if type(self._sourcePower) in [type(_AbsentDataset), type(None)]: + if self._sourcePower is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5626,7 +5617,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/detectorGain' - if type(self._detectorGain) in [type(_AbsentDataset), type(None)]: + if self._detectorGain is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5724,7 +5715,7 @@ def name(self): """ - if type(self._name) is type(_AbsentDataset): + if self._name is _AbsentDataset: return None if type(self._name) is type(_PresentDataset): return _read_string(self._h['name']) @@ -5765,7 +5756,7 @@ def data(self): used to annotate the meanings of each data column. """ - if type(self._data) is type(_AbsentDataset): + if self._data is _AbsentDataset: return None if type(self._data) is type(_PresentDataset): return _read_float_array(self._h['data']) @@ -5775,7 +5766,8 @@ def data(self): @data.setter def data(self, value): - self._data = np.array(value) + if value is not None and any([v is not None for v in value]): + self._data = np.array(value) # self._cfg.logger.info('Assignment to %s/data in %s', self.location, self.filename) @data.deleter @@ -5797,7 +5789,7 @@ def dataLabels(self): of `/nirs(i)/stim(j)/data`, including the first 3 required columns. """ - if type(self._dataLabels) is type(_AbsentDataset): + if self._dataLabels is _AbsentDataset: return None if type(self._dataLabels) is type(_PresentDataset): return _read_string_array(self._h['dataLabels']) @@ -5807,7 +5799,8 @@ def dataLabels(self): @dataLabels.setter def dataLabels(self, value): - self._dataLabels = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataLabels = np.array(value) # self._cfg.logger.info('Assignment to %s/dataLabels in %s', self.location, self.filename) @dataLabels.deleter @@ -5834,7 +5827,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/name' - if type(self._name) not in [type(_AbsentDataset), type(None)]: + if not self._name is _AbsentDataset: data = self.name # Use loader function via getter if name in file: del file[name] @@ -5845,7 +5838,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/data' - if type(self._data) not in [type(_AbsentDataset), type(None)]: + if not self._data is _AbsentDataset: data = self.data # Use loader function via getter if name in file: del file[name] @@ -5856,7 +5849,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataLabels' - if type(self._dataLabels) not in [type(_AbsentDataset), type(None)]: + if not self._dataLabels is _AbsentDataset: data = self.dataLabels # Use loader function via getter if name in file: del file[name] @@ -5874,7 +5867,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/name' - if type(self._name) in [type(_AbsentDataset), type(None)]: + if self._name is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5888,7 +5881,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/data' - if type(self._data) in [type(_AbsentDataset), type(None)]: + if self._data is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -5903,7 +5896,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataLabels' - if type(self._dataLabels) in [type(_AbsentDataset), type(None)]: + if self._dataLabels is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6013,7 +6006,7 @@ def name(self): This is string describing the jth auxiliary data timecourse. While auxiliary data can be given any title, standard names for commonly used auxiliary channels (i.e. accelerometer data) are specified in the appendix. """ - if type(self._name) is type(_AbsentDataset): + if self._name is _AbsentDataset: return None if type(self._name) is type(_PresentDataset): return _read_string(self._h['name']) @@ -6043,7 +6036,7 @@ def dataTimeSeries(self): time points> x `. If multiple channels of related data are generated by a system, they may be encoded in the multiple columns of the time series (i.e. complex numbers). For example, a system containing more than one accelerometer may output this data as a set of `ACCEL_X`/`ACCEL_Y`/`ACCEL_Z` auxiliary time series, where each has the dimension of ` x `. Note that it is NOT recommended to encode the various accelerometer dimensions as multiple channels of the same `aux` Group: instead follow the `"ACCEL_X"`, `"ACCEL_Y"`, `"ACCEL_Z"` naming conventions described in the appendix. Chunked data is allowed to support real-time data streaming. """ - if type(self._dataTimeSeries) is type(_AbsentDataset): + if self._dataTimeSeries is _AbsentDataset: return None if type(self._dataTimeSeries) is type(_PresentDataset): return _read_float_array(self._h['dataTimeSeries']) @@ -6054,7 +6047,8 @@ def dataTimeSeries(self): @dataTimeSeries.setter def dataTimeSeries(self, value): - self._dataTimeSeries = np.array(value) + if value is not None and any([v is not None for v in value]): + self._dataTimeSeries = np.array(value) # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename) @dataTimeSeries.deleter @@ -6073,7 +6067,7 @@ def dataUnit(self): International System of Units (SI units) identifier for the given channel. Encoding should follow the [CMIXF-12 standard](https://people.csail.mit.edu/jaffer/MIXF/CMIXF-12), avoiding special unicode symbols like U+03BC (m) or U+00B5 (u) and using '/' rather than 'per' for units such as `V/us`. The recommended export format is in unscaled units such as V, s, Mole. """ - if type(self._dataUnit) is type(_AbsentDataset): + if self._dataUnit is _AbsentDataset: return None if type(self._dataUnit) is type(_PresentDataset): return _read_string(self._h['dataUnit']) @@ -6109,7 +6103,7 @@ def time(self): Chunked data is allowed to support real-time data streaming """ - if type(self._time) is type(_AbsentDataset): + if self._time is _AbsentDataset: return None if type(self._time) is type(_PresentDataset): return _read_float_array(self._h['time']) @@ -6119,7 +6113,8 @@ def time(self): @time.setter def time(self, value): - self._time = np.array(value) + if value is not None and any([v is not None for v in value]): + self._time = np.array(value) # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename) @time.deleter @@ -6141,7 +6136,7 @@ def timeOffset(self): """ - if type(self._timeOffset) is type(_AbsentDataset): + if self._timeOffset is _AbsentDataset: return None if type(self._timeOffset) is type(_PresentDataset): return _read_float_array(self._h['timeOffset']) @@ -6151,7 +6146,8 @@ def timeOffset(self): @timeOffset.setter def timeOffset(self, value): - self._timeOffset = np.array(value) + if value is not None and any([v is not None for v in value]): + self._timeOffset = np.array(value) # self._cfg.logger.info('Assignment to %s/timeOffset in %s', self.location, self.filename) @timeOffset.deleter @@ -6178,7 +6174,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/name' - if type(self._name) not in [type(_AbsentDataset), type(None)]: + if not self._name is _AbsentDataset: data = self.name # Use loader function via getter if name in file: del file[name] @@ -6189,9 +6185,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataTimeSeries' - if type(self._dataTimeSeries) not in [ - type(_AbsentDataset), type(None) - ]: + if not self._dataTimeSeries is _AbsentDataset: data = self.dataTimeSeries # Use loader function via getter if name in file: del file[name] @@ -6202,7 +6196,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/dataUnit' - if type(self._dataUnit) not in [type(_AbsentDataset), type(None)]: + if not self._dataUnit is _AbsentDataset: data = self.dataUnit # Use loader function via getter if name in file: del file[name] @@ -6213,7 +6207,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/time' - if type(self._time) not in [type(_AbsentDataset), type(None)]: + if not self._time is _AbsentDataset: data = self.time # Use loader function via getter if name in file: del file[name] @@ -6224,7 +6218,7 @@ def _save(self, *args): del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) name = self.location + '/timeOffset' - if type(self._timeOffset) not in [type(_AbsentDataset), type(None)]: + if not self._timeOffset is _AbsentDataset: data = self.timeOffset # Use loader function via getter if name in file: del file[name] @@ -6242,7 +6236,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/name' - if type(self._name) in [type(_AbsentDataset), type(None)]: + if self._name is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6256,9 +6250,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataTimeSeries' - if type(self._dataTimeSeries) in [ - type(_AbsentDataset), type(None) - ]: + if self._dataTimeSeries is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6273,7 +6265,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/dataUnit' - if type(self._dataUnit) in [type(_AbsentDataset), type(None)]: + if self._dataUnit is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6287,7 +6279,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/time' - if type(self._time) in [type(_AbsentDataset), type(None)]: + if self._time is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6302,7 +6294,7 @@ def _validate(self, result: ValidationResult): except ValueError: # If the _create_dataset function can't convert the data result._add(name, 'INVALID_DATASET_TYPE') name = self.location + '/timeOffset' - if type(self._timeOffset) in [type(_AbsentDataset), type(None)]: + if self._timeOffset is _AbsentDataset: result._add(name, 'OPTIONAL_DATASET_MISSING') else: try: @@ -6449,7 +6441,7 @@ def formatVersion(self): describes format version "1.0" """ - if type(self._formatVersion) is type(_AbsentDataset): + if self._formatVersion is _AbsentDataset: return None if type(self._formatVersion) is type(_PresentDataset): return _read_string(self._h['formatVersion']) @@ -6519,7 +6511,7 @@ def _save(self, *args): self.__class__.__name__ + ' instance without a filename') name = self.location + '/formatVersion' - if type(self._formatVersion) not in [type(_AbsentDataset), type(None)]: + if not self._formatVersion is _AbsentDataset: data = self.formatVersion # Use loader function via getter if name in file: del file[name] @@ -6538,7 +6530,7 @@ def _validate(self, result: ValidationResult): driver='core', backing_store=False) as tmp: name = self.location + '/formatVersion' - if type(self._formatVersion) in [type(_AbsentDataset), type(None)]: + if self._formatVersion is _AbsentDataset: result._add(name, 'REQUIRED_DATASET_MISSING') else: try: @@ -6677,7 +6669,7 @@ def measurementList_to_measurementLists(self): vals = [ getattr(ml, dataset_name) for ml in self.measurementList ] - if any(val is not None for val in vals): + if all(val is not None for val in vals): setattr(self.measurementLists, dataset_name, vals) def _validate(self, result: ValidationResult): From 98f4b96eaaf2961057752149b96aeb73c3d28780 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Dec 2024 22:09:23 +0000 Subject: [PATCH 27/37] CI: Automated docs update --- docs/pysnirf2.md | 216 +++++++++++++++++++++++------------------------ 1 file changed, 108 insertions(+), 108 deletions(-) diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index ffdd956..e06b30c 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -109,14 +109,14 @@ Raised when SNIRF-specific error prevents file from loading or saving properly. --- - + ## class `ValidationIssue` Information about the validity of a given SNIRF file location. Properties: location: A relative HDF5 name corresponding to the location of the issue name: A string describing the issue. Must be predefined in `_CODES` id: An integer corresponding to the predefined error type severity: An integer ranking the serverity level of the issue. 0 OK, Nothing remarkable 1 Potentially useful `INFO` 2 `WARNING`, the file is valid but exhibits undefined behavior or features marked deprecation 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue - + ### method `__init__` @@ -133,7 +133,7 @@ __init__(name: str, location: str) --- - + ### method `dictize` @@ -146,7 +146,7 @@ Return dictionary representation of Issue. --- - + ## class `ValidationResult` The result of Snirf file validation routines. @@ -158,7 +158,7 @@ Validation results in a list of issues. Each issue records information about the = validateSnirf() ``` - + ### method `__init__` @@ -209,7 +209,7 @@ A list of the `WARNING` issues catalogued during validation. --- - + ### method `display` @@ -227,7 +227,7 @@ Reads the contents of an `h5py.Dataset` to an array of `dtype=str`. --- - + ### method `is_valid` @@ -239,7 +239,7 @@ Returns True if no `FATAL` issues were catalogued during validation. --- - + ### method `serialize` @@ -252,14 +252,14 @@ Render serialized JSON ValidationResult. --- - + ## class `SnirfConfig` Structure containing Snirf-wide data and settings. Properties: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property - + ### method `__init__` @@ -277,14 +277,14 @@ __init__() --- - + ## class `Group` - + ### method `__init__` @@ -322,7 +322,7 @@ The HDF5 relative location indentifier. --- - + ### method `is_empty` @@ -340,7 +340,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -368,14 +368,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -409,7 +409,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -427,7 +427,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -441,7 +441,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -460,7 +460,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -480,7 +480,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -498,7 +498,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -528,14 +528,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -646,7 +646,7 @@ The HDF5 relative location indentifier. --- - + ### method `add` @@ -665,7 +665,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -683,7 +683,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -701,7 +701,7 @@ Remove a tag from the list. You cannot remove a required tag. --- - + ### method `save` @@ -729,14 +729,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementLists` - + ### method `__init__` @@ -881,7 +881,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1 --- - + ### method `is_empty` @@ -899,7 +899,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -927,14 +927,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Probe` - + ### method `__init__` @@ -1165,7 +1165,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1183,7 +1183,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1211,12 +1211,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1303,7 +1303,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1321,7 +1321,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1349,7 +1349,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1360,7 +1360,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1383,7 +1383,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1401,7 +1401,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1415,7 +1415,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1434,7 +1434,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1454,7 +1454,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1472,7 +1472,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1502,14 +1502,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1606,7 +1606,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1624,7 +1624,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -1640,7 +1640,7 @@ The `measurementList` indexedGroup is not be removed. --- - + ### method `save` @@ -1668,14 +1668,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1698,7 +1698,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1716,7 +1716,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1730,7 +1730,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1749,7 +1749,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1769,7 +1769,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1787,7 +1787,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1817,12 +1817,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1973,7 +1973,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -1991,7 +1991,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2019,7 +2019,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -2032,7 +2032,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -2055,7 +2055,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2073,7 +2073,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2087,7 +2087,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2106,7 +2106,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2126,7 +2126,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2144,7 +2144,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2174,14 +2174,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2246,7 +2246,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2264,7 +2264,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2292,14 +2292,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2322,7 +2322,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2340,7 +2340,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2354,7 +2354,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2373,7 +2373,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2393,7 +2393,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2411,7 +2411,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2441,14 +2441,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2531,7 +2531,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2549,7 +2549,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2577,14 +2577,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2607,7 +2607,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2625,7 +2625,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2639,7 +2639,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2658,7 +2658,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2678,7 +2678,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2696,7 +2696,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2726,14 +2726,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2784,7 +2784,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2800,7 +2800,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2814,7 +2814,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2832,7 +2832,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -2846,7 +2846,7 @@ Does not delete the measurementList Dataset. --- - + ### method `save` @@ -2876,7 +2876,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From 54b0842c152226f7ee8672bb3dcf5d8c7a971776 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 00:41:54 -0500 Subject: [PATCH 28/37] Conversion interface --- snirf/pysnirf2.py | 105 +++++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index ee21685..c536b6f 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -1440,7 +1440,7 @@ def _recursive_hdf5_copy(g_dst: Group, g_src: Group): # ================================================================================ # <<< BEGIN TEMPLATE INSERT >>> -# generated by sstucker on 2024-12-30 +# generated by sstucker on 2024-12-31 # version 1.2-development SNIRF specification parsed from https://raw.githubusercontent.com/sstucker/snirf/refs/heads/master/snirf_specification.md @@ -6660,7 +6660,7 @@ class DataElement(DataElement): def measurementList_to_measurementLists(self): """Converts `measurementList` to a `measurementLists` structure if it is present. - This method will create a new `measurementLists` Group structure and populate it with the contents of the `measurementList` indexed Group. + This method will populate the `measurementLists` Group structure with the contents of the `measurementList` indexed Group. The `measurementList` indexedGroup is not be removed. """ @@ -6672,6 +6672,29 @@ def measurementList_to_measurementLists(self): if all(val is not None for val in vals): setattr(self.measurementLists, dataset_name, vals) + def measurementLists_to_measurementList(self): + """Converts `measurementLists` to a `measurementList` indexed Group structure if it is present. + + This method will create new `measurementList` indexed Group entries populated with the contents + of the `measurementLists` Group. + + The `measurementList` Group is not removed. + """ + values = {} + for dataset_name in self.measurementLists._snirf_names: + val = getattr(self.measurementLists, dataset_name) + if val is not None: + values[dataset_name] = val + if len(self.measurementList) > 0: + del self.measurementList[:] + n = max(len(v) + for v in values.values()) # Number of measurementList entries + [self.measurementList.appendGroup() for i in range(n)] + for i in range(n): + row = {k: v[i] for k, v in values.items()} + for k, v in row.items(): + setattr(self.measurementList[i], k, v) + def _validate(self, result: ValidationResult): # Override measurementList/measurementLists validation, only one is required @@ -6695,8 +6718,9 @@ def _validate(self, result: ValidationResult): if self.time.size != np.shape(self.dataTimeSeries)[0]: result._add(self.location + '/time', 'INVALID_TIME') - if len(self.measurementList) != np.shape(self.dataTimeSeries)[1]: - result._add(self.location, 'INVALID_MEASUREMENTLIST') + # todo validate length of measurementList/measurementLists jointly + # if len(self.measurementList) != np.shape(self.dataTimeSeries)[1] and not np.all([len(getattr(self.measurementLists, dataset)) for dataset in self.measurementLists._snirf_names] == np.shape(self.dataTimeSeries)[1]): + # result._add(self.location, 'INVALID_MEASUREMENTLIST') super()._validate(result) @@ -6850,6 +6874,18 @@ def measurementList_to_measurementLists(self): for data in nirs.data: data.measurementList_to_measurementLists() + def measurementLists_to_measurementList(self): + """Converts `measurementLists` to a `measurementList` indexed Group structure if it is present. + + This method will create new `measurementList` indexed Group entries populated with the contents + of the `measurementLists` Group. + + The `measurementList` Group is not removed. + """ + for nirs in self.nirs: + for data in nirs.data: + data.measurementLists_to_measurementList() + # overload @property def filename(self): @@ -6919,37 +6955,38 @@ def _validate(self, result: ValidationResult): lenDetectors = nirs.probe.detectorPos2D.shape[0] elif nirs.probe.detectorPos3D is not None: lenDetectors = nirs.probe.detectorPos3D.shape[0] - for data in nirs.data: - if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and np.max( - data.measurementLists.sourceIndex - ) > lenSourceLabels: - result._add( - data.measurementLists.location + '/sourceIndex', - 'INVALID_SOURCE_INDEX') - if lenSources is not None and data.measurementLists.sourceIndex is not None and np.max( - data.measurementLists.sourceIndex) > lenSources: - result._add( - data.measurementLists.location + '/sourceIndex', - 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and np.max( - data.measurementLists.detectorIndex - ) > lenDetectorLabels: - result._add( - data.measurementLists.location + '/detectorIndex', - 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and data.measurementLists.detectorIndex is not None and np.max( - data.measurementLists.detectorIndex - ) > lenDetectors: - result._add( - data.measurementLists.location + '/detectorIndex', - 'INVALID_DETECTOR_INDEX') - if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and np.max( - data.measurementLists.wavelengthIndex - ) > lenWavelengths: # No wavelengths should raise a missing issue - result._add( - data.measurementLists.location + - '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') + if data.measurementLists is not None: + if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and np.max( + data.measurementLists.sourceIndex + ) > lenSourceLabels: + result._add( + data.measurementLists.location + + '/sourceIndex', 'INVALID_SOURCE_INDEX') + if lenSources is not None and data.measurementLists.sourceIndex is not None and np.max( + data.measurementLists.sourceIndex + ) > lenSources: + result._add( + data.measurementLists.location + + '/sourceIndex', 'INVALID_SOURCE_INDEX') + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and np.max( + data.measurementLists.detectorIndex + ) > lenDetectorLabels: + result._add( + data.measurementLists.location + + '/detectorIndex', 'INVALID_DETECTOR_INDEX') + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and np.max( + data.measurementLists.detectorIndex + ) > lenDetectors: + result._add( + data.measurementLists.location + + '/detectorIndex', 'INVALID_DETECTOR_INDEX') + if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and np.max( + data.measurementLists.wavelengthIndex + ) > lenWavelengths: # No wavelengths should raise a missing issue + result._add( + data.measurementLists.location + + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') for ml in data.measurementList: if ml.sourceIndex is not None and lenSourceLabels is not None: if ml.sourceIndex > lenSourceLabels: From 295417b0a8f1c88422636cef80d732f79e28db35 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 11:25:52 -0500 Subject: [PATCH 29/37] Groups are now deleted from disk files with save; validation of measurementList(s) fixes --- gen/pysnirf2.jinja | 6 +-- snirf/pysnirf2.py | 106 +++++++++++++++++++++++++++------------------ 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja index fcb3d39..862acc5 100644 --- a/gen/pysnirf2.jinja +++ b/gen/pysnirf2.jinja @@ -155,17 +155,17 @@ else: raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance without a filename') {% for CHILD in NODE.children %} + name = self.location + '/{{ CHILD.name }}' {% if TYPES.INDEXED_GROUP in CHILD.type %} self.{{ CHILD.name }}._save(*args) {% elif TYPES.GROUP in CHILD.type %} if self._{{ CHILD.name }} is _AbsentGroup or self._{{ CHILD.name }}.is_empty(): - if '{{ CHILD.name }}' in file: - del file['{{ CHILD.name }}'] + if name in file: + del file[name] self._cfg.logger.info('Deleted Group %s/{{ CHILD.name }} from %s', self.location, file) else: self.{{ CHILD.name }}._save(*args) {% else %} - name = self.location + '/{{ CHILD.name }}' if not self._{{ CHILD.name }} is _AbsentDataset: data = self.{{ CHILD.name }} # Use loader function via getter if name in file: diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index c536b6f..27ee6ad 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -512,66 +512,69 @@ def _read_float_array(dataset: h5py.Dataset) -> np.ndarray: 'INVALID_MEASUREMENTLIST': (8, 3, 'The number of measurementList elements does not match the second dimension of dataTimeSeries' + ), 'INVALID_MEASUREMENTLISTS': + (9, 3, + 'The length of at least one measurementLists element does not match the second dimension of dataTimeSeries' ), 'INVALID_TIME': - (9, 3, + (10, 3, 'The length of the data/time vector does not match the first dimension of data/dataTimeSeries' ), 'INVALID_STIM_DATALABELS': - (10, 3, + (11, 3, 'The length of stim/dataLabels exceeds the second dimension of stim/data' ), 'INVALID_SOURCE_INDEX': - (11, 3, + (12, 3, 'measurementList(s)/sourceIndex exceeds length of probe/sourceLabels or the first axis of source position data' ), 'INVALID_DETECTOR_INDEX': - (12, 3, + (13, 3, 'measurementList(s)/detectorIndex exceeds length of probe/detectorLabels or the first axis of source position data' ), 'INVALID_WAVELENGTH_INDEX': - (13, 3, + (14, 3, 'measurementList(s)/waveLengthIndex exceeds length of probe/wavelengths'), - 'NEGATIVE_INDEX': (14, 3, 'An index is negative'), + 'NEGATIVE_INDEX': (15, 3, 'An index is negative'), # Warnings (Severity 2) - 'INDEX_OF_ZERO': (15, 2, 'An index of zero is usually undefined'), - 'UNRECOGNIZED_GROUP': (16, 2, + 'INDEX_OF_ZERO': (16, 2, 'An index of zero is usually undefined'), + 'UNRECOGNIZED_GROUP': (17, 2, 'An unspecified Group is a part of the file'), 'UNRECOGNIZED_DATASET': - (17, 2, + (18, 2, 'An unspecified Dataset is a part of the file in an unexpected place'), 'UNRECOGNIZED_DATATYPELABEL': - (18, 2, + (19, 2, 'measurementList/dataTypeLabel is not one of the recognized values listed in the Appendix' ), 'UNRECOGNIZED_DATATYPE': - (19, 2, + (20, 2, 'measurementList/dataType is not one of the recognized values listed in the Appendix' ), 'INT_64': - (25, 2, + (21, 2, 'The SNIRF specification limits users to the use of 32 bit native integer types' ), 'UNRECOGNIZED_COORDINATE_SYSTEM': - (26, 2, + (22, 2, 'The identifying string of the coordinate system was not recognized.'), 'NO_COORDINATE_SYSTEM_DESCRIPTION': - (27, 2, + (23, 2, "The coordinate system was unrecognized or 'Other' but lacks a probe/coordinateSystemDescription" ), 'FIXED_LENGTH_STRING': - (20, 2, + (24, 2, 'The use of fixed-length strings is discouraged and may be banned by a future spec version. Rewrite this file with pysnirf2 to use variable length strings' ), # Info (Severity 1) - 'OPTIONAL_GROUP_MISSING': (21, 1, + 'OPTIONAL_GROUP_MISSING': (25, 1, 'Missing an optional Group in this location'), - 'OPTIONAL_DATASET_MISSING': (22, 1, + 'OPTIONAL_DATASET_MISSING': (26, 1, 'Missing optional Dataset in this location'), 'OPTIONAL_INDEXED_GROUP_EMPTY': - (23, 1, 'The optional indexed group has no elements'), + (27, 1, 'The optional indexed group has no elements'), # OK (Severity 0) - 'OK': (24, 0, 'No issues detected'), + 'OK': (28, 0, 'No issues detected'), } @@ -4345,22 +4348,27 @@ def _save(self, *args): raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance without a filename') + name = self.location + '/metaDataTags' if self._metaDataTags is _AbsentGroup or self._metaDataTags.is_empty(): - if 'metaDataTags' in file: - del file['metaDataTags'] + if name in file: + del file[name] self._cfg.logger.info('Deleted Group %s/metaDataTags from %s', self.location, file) else: self.metaDataTags._save(*args) + name = self.location + '/data' self.data._save(*args) + name = self.location + '/stim' self.stim._save(*args) + name = self.location + '/probe' if self._probe is _AbsentGroup or self._probe.is_empty(): - if 'probe' in file: - del file['probe'] + if name in file: + del file[name] self._cfg.logger.info('Deleted Group %s/probe from %s', self.location, file) else: self.probe._save(*args) + name = self.location + '/aux' self.aux._save(*args) def _validate(self, result: ValidationResult): @@ -4726,11 +4734,13 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/measurementList' self.measurementList._save(*args) + name = self.location + '/measurementLists' if self._measurementLists is _AbsentGroup or self._measurementLists.is_empty( ): - if 'measurementLists' in file: - del file['measurementLists'] + if name in file: + del file[name] self._cfg.logger.info( 'Deleted Group %s/measurementLists from %s', self.location, file) @@ -6521,6 +6531,7 @@ def _save(self, *args): if name in file: del file[name] self._cfg.logger.info('Deleted Dataset %s from %s', name, file) + name = self.location + '/nirs' self.nirs._save(*args) def _validate(self, result: ValidationResult): @@ -6718,9 +6729,17 @@ def _validate(self, result: ValidationResult): if self.time.size != np.shape(self.dataTimeSeries)[0]: result._add(self.location + '/time', 'INVALID_TIME') - # todo validate length of measurementList/measurementLists jointly - # if len(self.measurementList) != np.shape(self.dataTimeSeries)[1] and not np.all([len(getattr(self.measurementLists, dataset)) for dataset in self.measurementLists._snirf_names] == np.shape(self.dataTimeSeries)[1]): - # result._add(self.location, 'INVALID_MEASUREMENTLIST') + # Check measurementList(s) length depending on which exist + n = np.shape(self.dataTimeSeries)[1] + ml_valid = (len(self.measurementList) == n) + if self.measurementLists is not None and not self.measurementLists.is_empty(): # if measurementLists exists + mls_valid = self.measurementLists is not None and (not self.measurementLists.is_empty() and not any([len(getattr(self.measurementLists, k)) != n for k in self.measurementLists._snirf_names if getattr(self.measurementLists, k) is not None])) + if not mls_valid: + result._add(self.location, 'INVALID_MEASUREMENTLISTS') + if not ml_valid: + result._add(self.location, 'INVALID_MEASUREMENTLIST') + elif not ml_valid: + result._add(self.location, 'INVALID_MEASUREMENTLIST') super()._validate(result) @@ -6941,6 +6960,7 @@ def _validate(self, result: ValidationResult): lenWavelengths = None lenSources = None lenDetectors = None + # todo label validation of length against probe if nirs.probe.sourceLabels is not None: lenSourceLabels = len(nirs.probe.sourceLabels) if nirs.probe.detectorLabels is not None: @@ -6957,47 +6977,47 @@ def _validate(self, result: ValidationResult): lenDetectors = nirs.probe.detectorPos3D.shape[0] for data in nirs.data: if data.measurementLists is not None: - if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and np.max( + if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and not 0 < np.max( data.measurementLists.sourceIndex - ) > lenSourceLabels: + ) <= lenSourceLabels: result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenSources is not None and data.measurementLists.sourceIndex is not None and np.max( + if lenSources is not None and data.measurementLists.sourceIndex is not None and not 0 < np.max( data.measurementLists.sourceIndex - ) > lenSources: + ) <= lenSources: result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and np.max( + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not 0 < np.max( data.measurementLists.detectorIndex - ) > lenDetectorLabels: + ) <= lenDetectorLabels: result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and data.measurementLists.detectorIndex is not None and np.max( + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not 0 < np.max( data.measurementLists.detectorIndex - ) > lenDetectors: + ) <= lenDetectors: result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and np.max( + if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and not 0 < np.max( data.measurementLists.wavelengthIndex - ) > lenWavelengths: # No wavelengths should raise a missing issue + ) <= lenWavelengths: # No wavelengths should raise a missing issue result._add( data.measurementLists.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') for ml in data.measurementList: - if ml.sourceIndex is not None and lenSourceLabels is not None: - if ml.sourceIndex > lenSourceLabels: + if ml.sourceIndex is not None and lenSources is not None: + if not 0 < ml.sourceIndex <= lenSources: result._add(ml.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if ml.detectorIndex is not None and lenDetectorLabels is not None: - if ml.detectorIndex > lenDetectorLabels: + if ml.detectorIndex is not None and lenDetectors is not None: + if not 0 < ml.detectorIndex <= lenDetectors: result._add(ml.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') if ml.wavelengthIndex is not None and lenWavelengths is not None: - if ml.wavelengthIndex > lenWavelengths: + if not 0 < ml.wavelengthIndex <= lenWavelengths: result._add(ml.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') From feef6ac3e6a2bd5464771cb97be38f7780db6da4 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 11:35:59 -0500 Subject: [PATCH 30/37] Fix index > 0 validation --- snirf/pysnirf2.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 27ee6ad..900d886 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -6977,33 +6977,23 @@ def _validate(self, result: ValidationResult): lenDetectors = nirs.probe.detectorPos3D.shape[0] for data in nirs.data: if data.measurementLists is not None: - if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and not 0 < np.max( - data.measurementLists.sourceIndex - ) <= lenSourceLabels: + if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and not np.all([0 < x <= lenSourceLabels for x in data.measurementLists.sourceIndex]): result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenSources is not None and data.measurementLists.sourceIndex is not None and not 0 < np.max( - data.measurementLists.sourceIndex - ) <= lenSources: + if lenSources is not None and data.measurementLists.sourceIndex is not None and not np.all([0 < x <= lenSources for x in data.measurementLists.sourceIndex]): result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not 0 < np.max( - data.measurementLists.detectorIndex - ) <= lenDetectorLabels: + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectorLabels for x in data.measurementLists.detectorIndex]) result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not 0 < np.max( - data.measurementLists.detectorIndex - ) <= lenDetectors: + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectors for x in data.measurementLists.detectorIndex]) result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and not 0 < np.max( - data.measurementLists.wavelengthIndex - ) <= lenWavelengths: # No wavelengths should raise a missing issue + if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and not np.all([0 < x <= lenWavelengths for x in data.measurementLists.wavelengthIndex]): # No wavelengths should raise a missing issue result._add( data.measurementLists.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') From e0db231cc1fc836f8290fb5a4e6956461be0e557 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 11:36:35 -0500 Subject: [PATCH 31/37] Accidentally a : --- snirf/pysnirf2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 900d886..7bfe086 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -6985,11 +6985,11 @@ def _validate(self, result: ValidationResult): result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectorLabels for x in data.measurementLists.detectorIndex]) + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectorLabels for x in data.measurementLists.detectorIndex]): result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectors for x in data.measurementLists.detectorIndex]) + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectors for x in data.measurementLists.detectorIndex]): result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') From 2dc3e0c356f4e06502b139558aaa36a32ed436ea Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 13:13:53 -0500 Subject: [PATCH 32/37] tests of measurementList(s) validation and conversion utility functions --- tests/test.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test.py b/tests/test.py index d3331f7..3768fdf 100644 --- a/tests/test.py +++ b/tests/test.py @@ -152,6 +152,87 @@ def _print_keys(group): class PySnirf2_Test(unittest.TestCase): + def test_validate_measurementList_dimensions(self): + """ + Validate that measurementList dimensions are consistent with dataTimeSeries + """ + for i, mode in enumerate([False, True]): + for file in self._test_files: + # Test measurementList(s) length validation + with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data) == 1 and len(s.nirs[0].data[0].measurementList) > 1: + self.assertTrue(s.validate(), msg="Failed to validate SNIRF object") + s.nirs[0].data[0].measurementList.appendGroup() + if VERBOSE: + s.validate().display() + self.assertTrue('INVALID_MEASUREMENTLIST' in [err.name for err in s.validate().errors], msg='Failed to raise measurementList length error') + new_path = file.split('.')[0] + '_invalid_ml.snirf' + s.save(new_path) + self.assertTrue('INVALID_MEASUREMENTLIST' in [err.name for err in validateSnirf(new_path).errors], msg='Failed to raise measurementList length error') + with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data) >= 1 and len(s.nirs[0].data[0].measurementList) > 0: + s.measurementList_to_measurementLists() + wli = s.nirs[0].data[0].measurementLists.wavelengthIndex + s.nirs[0].data[0].measurementLists.wavelengthIndex = np.concatenate([wli, [0]]) + self.assertTrue('INVALID_MEASUREMENTLISTS' in [err.name for err in s.validate().errors], msg='Failed to raise measurementList length error') + # Test measurementList(s) value validation + with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data) >= 1 and len(s.nirs[0].data[0].measurementList) > 0: + s.nirs[0].data[0].measurementList[0].wavelengthIndex = 999_999_999_999 # Unreasonable values + s.nirs[0].data[0].measurementList[0].sourceIndex = -1 + s.nirs[0].data[0].measurementList[0].detectorIndex = 999_999_999_999 + if VERBOSE: + s.validate().display() + errs = [err.name for err in s.validate().errors] + self.assertTrue('INVALID_WAVELENGTH_INDEX' in errs, msg='Failed to raise wavelengthIndex error') + self.assertTrue('INVALID_SOURCE_INDEX' in errs, msg='Failed to raise sourceIndex error') + self.assertTrue('INVALID_DETECTOR_INDEX' in errs, msg='Failed to raise detectorIndex error') + with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data) >= 1 and len(s.nirs[0].data[0].measurementList) > 0: + s.measurementList_to_measurementLists() + s.nirs[0].data[0].measurementLists.wavelengthIndex[0] = 999_999_999_999 + s.nirs[0].data[0].measurementLists.sourceIndex[0] = -1 + s.nirs[0].data[0].measurementLists.detectorIndex[0] = 999_999_999_999 + if VERBOSE: + s.validate().display() + errs = [err.name for err in s.validate().errors] + self.assertTrue('INVALID_WAVELENGTH_INDEX' in errs, msg='Failed to raise wavelengthIndex error') + self.assertTrue('INVALID_SOURCE_INDEX' in errs, msg='Failed to raise sourceIndex error') + self.assertTrue('INVALID_DETECTOR_INDEX' in errs, msg='Failed to raise detectorIndex error') + + + def test_validate_measurementList_conversion(self): + """ + Validate that measurementList can be converted to measurementLists and back + + Also tests that Groups can be deleted from files on disk + """ + for i, mode in enumerate([False, True]): + for file in self._test_files: + with Snirf(file, dynamic_loading=mode) as s: + if len(s.nirs[0].data) >= 1 and len(s.nirs[0].data[0].measurementList) > 0: # Subset of test data? + if VERBOSE: + print('Converting measurementList', file, 'to measurementLists') + s.nirs[0].data[0].measurementList_to_measurementLists() + del s.nirs[0].data[0].measurementList[:] + new_path = file.split('.')[0] + '_converted_to_measurementLists.snirf' + if VERBOSE: + s.validate().display() + self.assertTrue(s.validate(), msg="Failed to validate SNIRF object after conversion to measurementLists") + if VERBOSE: + print('Writing file to', new_path) + s.save(new_path) + self.assertTrue(validateSnirf(new_path), msg="Failed to validate file on disk after conversion to measurementLists") + with Snirf(new_path, dynamic_loading=mode) as s: + if VERBOSE: + print('Converting measurementLists in', new_path, 'back to measurementList') + s.nirs[0].data[0].measurementLists_to_measurementList() + del s.nirs[0].data[0].measurementLists + self.assertTrue(s.validate(), msg="Failed to validate file after conversion back to measurementList") + s.save() + print('Checking to see if conversion is reversible...') + dataset_equal_test(self, file, new_path) + def test_multidimensional_aux(self): """ Test to ensure the validator permits multidimensional aux From 86b928eaf8ec9673e351cb86d96ac4ac67b3990a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Dec 2024 18:14:21 +0000 Subject: [PATCH 33/37] CI: Automated docs update --- docs/pysnirf2.md | 250 ++++++++++++++++++++++++++--------------------- 1 file changed, 141 insertions(+), 109 deletions(-) diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index e06b30c..8e1091e 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -109,14 +109,14 @@ Raised when SNIRF-specific error prevents file from loading or saving properly. --- - + ## class `ValidationIssue` Information about the validity of a given SNIRF file location. Properties: location: A relative HDF5 name corresponding to the location of the issue name: A string describing the issue. Must be predefined in `_CODES` id: An integer corresponding to the predefined error type severity: An integer ranking the serverity level of the issue. 0 OK, Nothing remarkable 1 Potentially useful `INFO` 2 `WARNING`, the file is valid but exhibits undefined behavior or features marked deprecation 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue - + ### method `__init__` @@ -133,7 +133,7 @@ __init__(name: str, location: str) --- - + ### method `dictize` @@ -146,7 +146,7 @@ Return dictionary representation of Issue. --- - + ## class `ValidationResult` The result of Snirf file validation routines. @@ -158,7 +158,7 @@ Validation results in a list of issues. Each issue records information about the = validateSnirf() ``` - + ### method `__init__` @@ -209,7 +209,7 @@ A list of the `WARNING` issues catalogued during validation. --- - + ### method `display` @@ -227,7 +227,7 @@ Reads the contents of an `h5py.Dataset` to an array of `dtype=str`. --- - + ### method `is_valid` @@ -239,7 +239,7 @@ Returns True if no `FATAL` issues were catalogued during validation. --- - + ### method `serialize` @@ -252,14 +252,14 @@ Render serialized JSON ValidationResult. --- - + ## class `SnirfConfig` Structure containing Snirf-wide data and settings. Properties: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property - + ### method `__init__` @@ -277,14 +277,14 @@ __init__() --- - + ## class `Group` - + ### method `__init__` @@ -322,7 +322,7 @@ The HDF5 relative location indentifier. --- - + ### method `is_empty` @@ -340,7 +340,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -368,14 +368,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -409,7 +409,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -427,7 +427,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -441,7 +441,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -460,7 +460,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -480,7 +480,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -498,7 +498,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -528,14 +528,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -646,7 +646,7 @@ The HDF5 relative location indentifier. --- - + ### method `add` @@ -665,7 +665,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -683,7 +683,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -701,7 +701,7 @@ Remove a tag from the list. You cannot remove a required tag. --- - + ### method `save` @@ -729,14 +729,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementLists` - + ### method `__init__` @@ -881,7 +881,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1 --- - + ### method `is_empty` @@ -899,7 +899,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -927,14 +927,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Probe` - + ### method `__init__` @@ -1165,7 +1165,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1183,7 +1183,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1211,12 +1211,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1303,7 +1303,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1321,7 +1321,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1349,7 +1349,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1360,7 +1360,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1383,7 +1383,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1401,7 +1401,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1415,7 +1415,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1434,7 +1434,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1454,7 +1454,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1472,7 +1472,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1502,14 +1502,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1606,7 +1606,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1624,7 +1624,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -1634,13 +1634,29 @@ measurementList_to_measurementLists() Converts `measurementList` to a `measurementLists` structure if it is present. -This method will create a new `measurementLists` Group structure and populate it with the contents of the `measurementList` indexed Group. +This method will populate the `measurementLists` Group structure with the contents of the `measurementList` indexed Group. The `measurementList` indexedGroup is not be removed. --- - + + +### method `measurementLists_to_measurementList` + +```python +measurementLists_to_measurementList() +``` + +Converts `measurementLists` to a `measurementList` indexed Group structure if it is present. + +This method will create new `measurementList` indexed Group entries populated with the contents of the `measurementLists` Group. + +The `measurementList` Group is not removed. + +--- + + ### method `save` @@ -1668,14 +1684,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1698,7 +1714,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1716,7 +1732,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1730,7 +1746,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1749,7 +1765,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1769,7 +1785,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1787,7 +1803,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1817,12 +1833,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1973,7 +1989,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -1991,7 +2007,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2019,7 +2035,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -2032,7 +2048,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -2055,7 +2071,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2073,7 +2089,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2087,7 +2103,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2106,7 +2122,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2126,7 +2142,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2144,7 +2160,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2174,14 +2190,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2246,7 +2262,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2264,7 +2280,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2292,14 +2308,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2322,7 +2338,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2340,7 +2356,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2354,7 +2370,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2373,7 +2389,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2393,7 +2409,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2411,7 +2427,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2441,14 +2457,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2531,7 +2547,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2549,7 +2565,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2577,14 +2593,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2607,7 +2623,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2625,7 +2641,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2639,7 +2655,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2658,7 +2674,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2678,7 +2694,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2696,7 +2712,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2726,14 +2742,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2784,7 +2800,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2800,7 +2816,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2814,7 +2830,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2832,7 +2848,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -2846,7 +2862,23 @@ Does not delete the measurementList Dataset. --- - + + +### method `measurementLists_to_measurementList` + +```python +measurementLists_to_measurementList() +``` + +Converts `measurementLists` to a `measurementList` indexed Group structure if it is present. + +This method will create new `measurementList` indexed Group entries populated with the contents of the `measurementLists` Group. + +The `measurementList` Group is not removed. + +--- + + ### method `save` @@ -2876,7 +2908,7 @@ Save a SNIRF file to disk. --- - + ### method `validate` From 751c489e859e6869ec7d75f845471e80bd736476 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 14:28:21 -0500 Subject: [PATCH 34/37] Generator support for collecting recognized aux names, datatypes and datatype labels from spec --- gen/data.py | 9 ++++++++ gen/gen.py | 52 ++++++++++++++++++++++++++++++++-------------- gen/pysnirf2.jinja | 6 ++++++ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/gen/data.py b/gen/data.py index f8b92b6..17e6f8f 100644 --- a/gen/data.py +++ b/gen/data.py @@ -38,6 +38,15 @@ DEFINITIONS_DELIM_START = '### SNIRF data container definitions' DEFINITIONS_DELIM_END = '## Appendix' +DATA_TYPE_DELIM_START = '### Supported `measurementList(k).dataType` values in `dataTimeSeries`' +DATA_TYPE_DELIM_END = '### Supported `measurementList(k).dataTypeLabel` values in `dataTimeSeries`' + +DATA_TYPE_LABEL_TABLE_START = '### Supported `measurementList(k).dataTypeLabel` values in `dataTimeSeries`' +DATA_TYPE_LABEL_TABLE_END = '### Supported `/nirs(i)/aux(j)/name` values' + +AUX_NAME_TABLE_START = '### Supported `/nirs(i)/aux(j)/name` values' +AUX_NAME_TABLE_END = '### Examples of stimulus waveforms' + # -- BIDS Probe name identifiers --------------------------------------------- BIDS_PROBE_NAMES = ['ICBM452AirSpace', diff --git a/gen/gen.py b/gen/gen.py index 50891d7..b1c7e70 100644 --- a/gen/gen.py +++ b/gen/gen.py @@ -7,7 +7,7 @@ import getpass import os import sys -import warnings +import re from pylint import lint """ @@ -15,7 +15,7 @@ hosted at SPEC_SRC. """ -LIB_VERSION = '0.8.2' # Version for this script +LIB_VERSION = '0.9.2' # Version for this script if __name__ == '__main__': @@ -171,18 +171,38 @@ 'required': required }) - ans = input('Proceed? y/n\n') - if ans not in ['y', 'Y']: + if input('Proceed? y/n\n') not in ['y', 'Y']: sys.exit('pysnirf2 generation aborted.') print('Loading BIDS-specified Probe names from gen/data.py...') for name in BIDS_PROBE_NAMES: print('Found', name) - - ans = input('Proceed? y/n\n') - if ans not in ['y', 'Y']: + + print('\nParsing specification for supported data type integer values...') + data_type_table = unidecode(text).split(DATA_TYPE_DELIM_START)[1].split(DATA_TYPE_DELIM_END)[0] + data_types = re.findall(r'(?<=\s)-\s(\d+)\s-', data_type_table) + data_types = [int(i) for i in data_types] + for i in data_types: + print('Found', i) + if input('Proceed? y/n\n') not in ['y', 'Y']: sys.exit('pysnirf2 generation aborted.') - + + print('\nParsing specification for supported aux names...') + aux_name_table = unidecode(text).split(AUX_NAME_TABLE_START)[1].split(AUX_NAME_TABLE_END)[0] + aux_names = re.findall(r'"(.*?)"', aux_name_table) + for name in aux_names: + print('Found', name) + if input('Proceed? y/n\n') not in ['y', 'Y']: + sys.exit('pysnirf2 generation aborted.') + + print('\nParsing specification for supported data type labels...') + data_type_label_table = unidecode(text).split(DATA_TYPE_LABEL_TABLE_START)[1].split(DATA_TYPE_LABEL_TABLE_END)[0] + data_type_labels = re.findall(r'"(.*?)"', data_type_label_table) + for name in data_type_labels: + print('Found', name) + if input('Proceed? y/n\n') not in ['y', 'Y']: + sys.exit('pysnirf2 generation aborted.') + # Generate data for template SNIRF = { 'VERSION': SPEC_VERSION, @@ -196,7 +216,10 @@ 'INDEXED_GROUPS': [], 'GROUPS': [], 'UNSPECIFIED_DATASETS_OK': UNSPECIFIED_DATASETS_OK, - 'BIDS_COORDINATE_SYSTEM_NAMES': BIDS_PROBE_NAMES + 'BIDS_COORDINATE_SYSTEM_NAMES': BIDS_PROBE_NAMES, + 'AUX_NAMES': aux_names, + 'DATA_TYPES': data_types, + 'DATA_TYPE_LABELS': data_type_labels } # Build list of groups and indexed groups @@ -231,8 +254,7 @@ SNIRF['FOOTER'] = TEMPLATE_INSERT_END_STR + b.split(TEMPLATE_INSERT_END_STR, 1)[1] print('Loaded footer code, {} lines'.format(len(SNIRF['FOOTER'].split('\n')))) - ans = input('Proceed? LOCAL CHANGES MAY BE OVERWRITTEN OR LOST! y/n\n') - if ans not in ['y', 'Y']: + if input('Proceed? LOCAL CHANGES MAY BE OVERWRITTEN OR LOST! y/n\n') not in ['y', 'Y']: sys.exit('pysnirf2 generation aborted.') try: os.remove(library_path) @@ -256,12 +278,10 @@ if errors == 0: print('pysnirf2.py generated with', errors, 'errors.') - ans = input('Format the generated code? y/n\n') - if ans in ['y', 'Y']: + if input('Format the generated code? y/n\n') in ['y', 'Y']: FormatFile(library_path, in_place=True)[:2] - - ans = input('Lint the generated code? y/n\n') - if ans in ['y', 'Y']: + + if input('Lint the generated code? y/n\n') in ['y', 'Y']: lint.Run(['--errors-only', library_path]) print('\npysnirf2 generation complete.') diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja index 862acc5..4579f59 100644 --- a/gen/pysnirf2.jinja +++ b/gen/pysnirf2.jinja @@ -441,4 +441,10 @@ class Snirf(Group): _RECOGNIZED_COORDINATE_SYSTEM_NAMES = [{% for NAME in BIDS_COORDINATE_SYSTEM_NAMES %}'{{ NAME }}', {% endfor -%}] +_RECOGNIZED_AUX_NAMES = [{% for NAME in AUX_NAMES %}'{{ NAME }}', {% endfor -%}] + +_RECOGNIZED_DATA_TYPES = [{% for VALUE in DATA_TYPES %}{{ VALUE }}, {% endfor -%}] + +_RECOGNIZED_DATA_TYPE_LABELS = [{% for NAME in DATA_TYPE_LABELS %}'{{ NAME }}', {% endfor -%}] + {{ FOOTER }} \ No newline at end of file From 09562f1ca1dfd969310ba88c0a6afe94ba0d9cab Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 14:28:50 -0500 Subject: [PATCH 35/37] dataType and dataTypeLabel validation --- snirf/pysnirf2.py | 133 +++++++++++++++++++++++++++++++++++++++++----- tests/test.py | 28 ++++++++-- 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 7bfe086..27822c8 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -512,7 +512,8 @@ def _read_float_array(dataset: h5py.Dataset) -> np.ndarray: 'INVALID_MEASUREMENTLIST': (8, 3, 'The number of measurementList elements does not match the second dimension of dataTimeSeries' - ), 'INVALID_MEASUREMENTLISTS': + ), + 'INVALID_MEASUREMENTLISTS': (9, 3, 'The length of at least one measurementLists element does not match the second dimension of dataTimeSeries' ), @@ -543,13 +544,13 @@ def _read_float_array(dataset: h5py.Dataset) -> np.ndarray: 'UNRECOGNIZED_DATASET': (18, 2, 'An unspecified Dataset is a part of the file in an unexpected place'), - 'UNRECOGNIZED_DATATYPELABEL': - (19, 2, - 'measurementList/dataTypeLabel is not one of the recognized values listed in the Appendix' + 'UNRECOGNIZED_DATA_TYPE_LABEL': + (19, 3, + 'measurementList(s)/dataTypeLabel is not one of the recognized values listed in the Appendix' ), - 'UNRECOGNIZED_DATATYPE': - (20, 2, - 'measurementList/dataType is not one of the recognized values listed in the Appendix' + 'UNRECOGNIZED_DATA_TYPE': + (20, 3, + 'measurementList(s)/dataType is not one of the recognized values listed in the Appendix' ), 'INT_64': (21, 2, @@ -6590,6 +6591,58 @@ def _validate(self, result: ValidationResult): 'UNCInfant', ] +_RECOGNIZED_AUX_NAMES = [ + 'ACCEL_X', + 'ACCEL_Y', + 'ACCEL_Z', + 'GYRO_X', + 'GYRO_Y', + 'GYRO_Z', + 'MAGN_X', + 'MAGN_Y', + 'MAGN_Z', +] + +_RECOGNIZED_DATA_TYPES = [ + 1, + 51, + 101, + 102, + 151, + 152, + 201, + 251, + 301, + 351, + 401, + 410, + 99999, +] + +_RECOGNIZED_DATA_TYPE_LABELS = [ + 'dOD', + 'dMean', + 'dVar', + 'dSkew', + 'mua', + 'musp', + 'HbO', + 'HbR', + 'HbT', + 'H2O', + 'Lipid', + 'StO2', + 'BFi', + 'HRF dOD', + 'HRF dMean', + 'HRF dVar', + 'HRF dSkew', + 'HRF HbO', + 'HRF HbR', + 'HRF HbT', + 'HRF BFi', +] + # <<< END TEMPLATE INSERT >>> # ================================================================================ # DO NOT EDIT THE ABOVE CODE! IT IS GENERATED VIA TEMPLATE. SEE README FOR DETAILS @@ -6725,6 +6778,7 @@ def _validate(self, result: ValidationResult): result._add(self.location + '/measurementLists', ['REQUIRED_DATASET_MISSING', 'OK'][int(mls)]) + # Check time/dataTimeSeries length agreement if all(attr is not None for attr in [self.time, self.dataTimeSeries]): if self.time.size != np.shape(self.dataTimeSeries)[0]: result._add(self.location + '/time', 'INVALID_TIME') @@ -6732,8 +6786,14 @@ def _validate(self, result: ValidationResult): # Check measurementList(s) length depending on which exist n = np.shape(self.dataTimeSeries)[1] ml_valid = (len(self.measurementList) == n) - if self.measurementLists is not None and not self.measurementLists.is_empty(): # if measurementLists exists - mls_valid = self.measurementLists is not None and (not self.measurementLists.is_empty() and not any([len(getattr(self.measurementLists, k)) != n for k in self.measurementLists._snirf_names if getattr(self.measurementLists, k) is not None])) + if self.measurementLists is not None and not self.measurementLists.is_empty( + ): # if measurementLists exists + mls_valid = self.measurementLists is not None and ( + not self.measurementLists.is_empty() and not any([ + len(getattr(self.measurementLists, k)) != n + for k in self.measurementLists._snirf_names + if getattr(self.measurementLists, k) is not None + ])) if not mls_valid: result._add(self.location, 'INVALID_MEASUREMENTLISTS') if not ml_valid: @@ -6741,6 +6801,31 @@ def _validate(self, result: ValidationResult): elif not ml_valid: result._add(self.location, 'INVALID_MEASUREMENTLIST') + # Validate dataType and dataTypeLabel + if self.measurementLists is not None and not self.measurementLists.is_empty( + ): + if self.measurementLists.dataType is not None: + for value in self.measurementLists.dataType: + if value not in _RECOGNIZED_DATA_TYPES: + result._add( + self.location + '/measurementLists/dataType', + 'UNRECOGNIZED_DATA_TYPE') + elif value == 99999: + for label in self.measurementLists.dataTypeLabel: + if label not in _RECOGNIZED_DATA_TYPE_LABELS: + result._add( + self.location + + '/measurementLists/dataTypeLabel', + 'UNRECOGNIZED_DATA_TYPE_LABEL') + for ml in self.measurementList: + if ml.dataType is not None and ml.dataType not in _RECOGNIZED_DATA_TYPES: + result._add(ml.location + '/dataType', + 'UNRECOGNIZED_DATA_TYPE') + elif ml.dataType == 99999: + if ml.dataTypeLabel is not None and ml.dataTypeLabel not in _RECOGNIZED_DATA_TYPE_LABELS: + result._add(ml.location + '/dataTypeLabel', + 'UNRECOGNIZED_DATA_TYPE_LABEL') + super()._validate(result) @@ -6977,23 +7062,43 @@ def _validate(self, result: ValidationResult): lenDetectors = nirs.probe.detectorPos3D.shape[0] for data in nirs.data: if data.measurementLists is not None: - if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and not np.all([0 < x <= lenSourceLabels for x in data.measurementLists.sourceIndex]): + if lenSourceLabels is not None and data.measurementLists.sourceIndex is not None and not np.all( + [ + 0 < x <= lenSourceLabels + for x in data.measurementLists.sourceIndex + ]): result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenSources is not None and data.measurementLists.sourceIndex is not None and not np.all([0 < x <= lenSources for x in data.measurementLists.sourceIndex]): + if lenSources is not None and data.measurementLists.sourceIndex is not None and not np.all( + [ + 0 < x <= lenSources + for x in data.measurementLists.sourceIndex + ]): result._add( data.measurementLists.location + '/sourceIndex', 'INVALID_SOURCE_INDEX') - if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectorLabels for x in data.measurementLists.detectorIndex]): + if lenDetectorLabels is not None and data.measurementLists.detectorIndex is not None and not np.all( + [ + 0 < x <= lenDetectorLabels + for x in data.measurementLists.detectorIndex + ]): result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not np.all([0 < x <= lenDetectors for x in data.measurementLists.detectorIndex]): + if lenDetectors is not None and data.measurementLists.detectorIndex is not None and not np.all( + [ + 0 < x <= lenDetectors + for x in data.measurementLists.detectorIndex + ]): result._add( data.measurementLists.location + '/detectorIndex', 'INVALID_DETECTOR_INDEX') - if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and not np.all([0 < x <= lenWavelengths for x in data.measurementLists.wavelengthIndex]): # No wavelengths should raise a missing issue + if lenWavelengths is not None and data.measurementLists.wavelengthIndex is not None and not np.all( + [ + 0 < x <= lenWavelengths + for x in data.measurementLists.wavelengthIndex + ]): # No wavelengths should raise a missing issue result._add( data.measurementLists.location + '/wavelengthIndex', 'INVALID_WAVELENGTH_INDEX') diff --git a/tests/test.py b/tests/test.py index 3768fdf..7d0dbfa 100644 --- a/tests/test.py +++ b/tests/test.py @@ -152,9 +152,27 @@ def _print_keys(group): class PySnirf2_Test(unittest.TestCase): + def test_validate_datatypes(self): + """ + Test validation methods for dataType and dataType labels + """ + for i, mode in enumerate([False, True]): + for file in self._test_files: + with Snirf(file, 'r+', dynamic_loading=mode) as s: + if len(s.nirs[0].data) == 1 and len(s.nirs[0].data[0].measurementList) > 1: + s.nirs[0].data[0].measurementList[0].dataType = -100 + if VERBOSE: + s.validate().display(severity=3) + self.assertTrue('UNRECOGNIZED_DATA_TYPE' in [err.name for err in s.validate().errors], msg='Failed to raise dataType error') + s.nirs[0].data[0].measurementList[0].dataType = 99999 + s.nirs[0].data[0].measurementList[0].dataTypeLabel = 'bar' + if VERBOSE: + s.validate().display(severity=3) + self.assertTrue('UNRECOGNIZED_DATA_TYPE_LABEL' in [err.name for err in s.validate().errors], msg='Failed to raise dataTypeLabel error') + def test_validate_measurementList_dimensions(self): """ - Validate that measurementList dimensions are consistent with dataTimeSeries + Test validation that measurementList dimensions are consistent with dataTimeSeries """ for i, mode in enumerate([False, True]): for file in self._test_files: @@ -164,7 +182,7 @@ def test_validate_measurementList_dimensions(self): self.assertTrue(s.validate(), msg="Failed to validate SNIRF object") s.nirs[0].data[0].measurementList.appendGroup() if VERBOSE: - s.validate().display() + s.validate().display(severity=3) self.assertTrue('INVALID_MEASUREMENTLIST' in [err.name for err in s.validate().errors], msg='Failed to raise measurementList length error') new_path = file.split('.')[0] + '_invalid_ml.snirf' s.save(new_path) @@ -182,7 +200,7 @@ def test_validate_measurementList_dimensions(self): s.nirs[0].data[0].measurementList[0].sourceIndex = -1 s.nirs[0].data[0].measurementList[0].detectorIndex = 999_999_999_999 if VERBOSE: - s.validate().display() + s.validate().display(severity=3) errs = [err.name for err in s.validate().errors] self.assertTrue('INVALID_WAVELENGTH_INDEX' in errs, msg='Failed to raise wavelengthIndex error') self.assertTrue('INVALID_SOURCE_INDEX' in errs, msg='Failed to raise sourceIndex error') @@ -194,7 +212,7 @@ def test_validate_measurementList_dimensions(self): s.nirs[0].data[0].measurementLists.sourceIndex[0] = -1 s.nirs[0].data[0].measurementLists.detectorIndex[0] = 999_999_999_999 if VERBOSE: - s.validate().display() + s.validate().display(severity=3) errs = [err.name for err in s.validate().errors] self.assertTrue('INVALID_WAVELENGTH_INDEX' in errs, msg='Failed to raise wavelengthIndex error') self.assertTrue('INVALID_SOURCE_INDEX' in errs, msg='Failed to raise sourceIndex error') @@ -217,7 +235,7 @@ def test_validate_measurementList_conversion(self): del s.nirs[0].data[0].measurementList[:] new_path = file.split('.')[0] + '_converted_to_measurementLists.snirf' if VERBOSE: - s.validate().display() + s.validate().display(severity=3) self.assertTrue(s.validate(), msg="Failed to validate SNIRF object after conversion to measurementLists") if VERBOSE: print('Writing file to', new_path) From d5ac9a60d2f7052381766dab9769083486542c41 Mon Sep 17 00:00:00 2001 From: sstucker Date: Tue, 31 Dec 2024 14:35:00 -0500 Subject: [PATCH 36/37] Fix to type/typeLabel validation for measurementLists --- snirf/pysnirf2.py | 13 +++++++------ tests/test.py | 8 +++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/snirf/pysnirf2.py b/snirf/pysnirf2.py index 27822c8..18023b0 100644 --- a/snirf/pysnirf2.py +++ b/snirf/pysnirf2.py @@ -6811,12 +6811,13 @@ def _validate(self, result: ValidationResult): self.location + '/measurementLists/dataType', 'UNRECOGNIZED_DATA_TYPE') elif value == 99999: - for label in self.measurementLists.dataTypeLabel: - if label not in _RECOGNIZED_DATA_TYPE_LABELS: - result._add( - self.location + - '/measurementLists/dataTypeLabel', - 'UNRECOGNIZED_DATA_TYPE_LABEL') + if self.measurementLists.dataTypeLabel is not None: + for label in self.measurementLists.dataTypeLabel: + if label not in _RECOGNIZED_DATA_TYPE_LABELS: + result._add( + self.location + + '/measurementLists/dataTypeLabel', + 'UNRECOGNIZED_DATA_TYPE_LABEL') for ml in self.measurementList: if ml.dataType is not None and ml.dataType not in _RECOGNIZED_DATA_TYPES: result._add(ml.location + '/dataType', diff --git a/tests/test.py b/tests/test.py index 7d0dbfa..7f3c0e8 100644 --- a/tests/test.py +++ b/tests/test.py @@ -169,7 +169,13 @@ def test_validate_datatypes(self): if VERBOSE: s.validate().display(severity=3) self.assertTrue('UNRECOGNIZED_DATA_TYPE_LABEL' in [err.name for err in s.validate().errors], msg='Failed to raise dataTypeLabel error') - + s.measurementList_to_measurementLists() + if VERBOSE: + s.validate().display(severity=3) + self.assertTrue('UNRECOGNIZED_DATA_TYPE_LABEL' in [err.name for err in s.validate().errors], msg='Failed to raise dataTypeLabel error after converting to measurementLists') + s.nirs[0].data[0].measurementLists.dataType[-1] = -100 + self.assertTrue('UNRECOGNIZED_DATA_TYPE' in [err.name for err in s.validate().errors], msg='Failed to raise dataType error after converting to measurementLists') + def test_validate_measurementList_dimensions(self): """ Test validation that measurementList dimensions are consistent with dataTimeSeries From 7dd14f8f88f116f9ebdb0217ff9e139b497827fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Dec 2024 19:35:34 +0000 Subject: [PATCH 37/37] CI: Automated docs update --- docs/pysnirf2.md | 220 +++++++++++++++++++++++------------------------ 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/docs/pysnirf2.md b/docs/pysnirf2.md index 8e1091e..2042caa 100644 --- a/docs/pysnirf2.md +++ b/docs/pysnirf2.md @@ -24,7 +24,7 @@ Maintained by the Boston University Neurophotonics Center --- - + ## function `loadSnirf` @@ -63,7 +63,7 @@ Returns a `Snirf` object loaded from path if a SNIRF file exists there. Takes th --- - + ## function `saveSnirf` @@ -83,7 +83,7 @@ Saves a SNIRF file to disk. --- - + ## function `validateSnirf` @@ -109,14 +109,14 @@ Raised when SNIRF-specific error prevents file from loading or saving properly. --- - + ## class `ValidationIssue` Information about the validity of a given SNIRF file location. Properties: location: A relative HDF5 name corresponding to the location of the issue name: A string describing the issue. Must be predefined in `_CODES` id: An integer corresponding to the predefined error type severity: An integer ranking the serverity level of the issue. 0 OK, Nothing remarkable 1 Potentially useful `INFO` 2 `WARNING`, the file is valid but exhibits undefined behavior or features marked deprecation 3 `FATAL`, The file is invalid. message: A string containing a more verbose description of the issue - + ### method `__init__` @@ -133,7 +133,7 @@ __init__(name: str, location: str) --- - + ### method `dictize` @@ -146,7 +146,7 @@ Return dictionary representation of Issue. --- - + ## class `ValidationResult` The result of Snirf file validation routines. @@ -158,7 +158,7 @@ Validation results in a list of issues. Each issue records information about the = validateSnirf() ``` - + ### method `__init__` @@ -209,7 +209,7 @@ A list of the `WARNING` issues catalogued during validation. --- - + ### method `display` @@ -227,7 +227,7 @@ Reads the contents of an `h5py.Dataset` to an array of `dtype=str`. --- - + ### method `is_valid` @@ -239,7 +239,7 @@ Returns True if no `FATAL` issues were catalogued during validation. --- - + ### method `serialize` @@ -252,14 +252,14 @@ Render serialized JSON ValidationResult. --- - + ## class `SnirfConfig` Structure containing Snirf-wide data and settings. Properties: logger (logging.Logger): The logger that the Snirf instance writes to dynamic_loading (bool): If True, data is loaded from the HDF5 file only on access via property - + ### method `__init__` @@ -277,14 +277,14 @@ __init__() --- - + ## class `Group` - + ### method `__init__` @@ -322,7 +322,7 @@ The HDF5 relative location indentifier. --- - + ### method `is_empty` @@ -340,7 +340,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -368,14 +368,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `IndexedGroup` - + ### method `__init__` @@ -409,7 +409,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -427,7 +427,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -441,7 +441,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -460,7 +460,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -480,7 +480,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -498,7 +498,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -528,14 +528,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MetaDataTags` - + ### method `__init__` @@ -646,7 +646,7 @@ The HDF5 relative location indentifier. --- - + ### method `add` @@ -665,7 +665,7 @@ Add a new tag to the list. --- - + ### method `is_empty` @@ -683,7 +683,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `remove` @@ -701,7 +701,7 @@ Remove a tag from the list. You cannot remove a required tag. --- - + ### method `save` @@ -729,14 +729,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementLists` - + ### method `__init__` @@ -881,7 +881,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`) for each channel. A 1 --- - + ### method `is_empty` @@ -899,7 +899,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -927,14 +927,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Probe` - + ### method `__init__` @@ -1165,7 +1165,7 @@ Please note that this field stores the "nominal" emission wavelengths. If the pr --- - + ### method `is_empty` @@ -1183,7 +1183,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1211,12 +1211,12 @@ Group level save to a SNIRF file on disk. --- - + ## class `NirsElement` Wrapper for an element of indexed group `Nirs`. - + ### method `__init__` @@ -1303,7 +1303,7 @@ This is an array describing any stimulus conditions. Each element of the array --- - + ### method `is_empty` @@ -1321,7 +1321,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -1349,7 +1349,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `Nirs` Interface for indexed group `Nirs`. @@ -1360,7 +1360,7 @@ To add or remove an element from the list, use the `appendGroup` method and the This group stores one set of NIRS data. This can be extended by adding the count number (e.g. `/nirs1`, `/nirs2`,...) to the group name. This is intended to allow the storage of 1 or more complete NIRS datasets inside a single SNIRF document. For example, a two-subject hyperscanning can be stored using the notation * `/nirs1` = first subject's data * `/nirs2` = second subject's data The use of a non-indexed (e.g. `/nirs`) entry is allowed when only one entry is present and is assumed to be entry 1. - + ### method `__init__` @@ -1383,7 +1383,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1401,7 +1401,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1415,7 +1415,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1434,7 +1434,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1454,7 +1454,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1472,7 +1472,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1502,14 +1502,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `DataElement` - + ### method `__init__` @@ -1606,7 +1606,7 @@ Chunked data is allowed to support real-time streaming of data in this array. --- - + ### method `is_empty` @@ -1624,7 +1624,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -1640,7 +1640,7 @@ The `measurementList` indexedGroup is not be removed. --- - + ### method `measurementLists_to_measurementList` @@ -1656,7 +1656,7 @@ The `measurementList` Group is not removed. --- - + ### method `save` @@ -1684,14 +1684,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Data` - + ### method `__init__` @@ -1714,7 +1714,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -1732,7 +1732,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -1746,7 +1746,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -1765,7 +1765,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -1785,7 +1785,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -1803,7 +1803,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -1833,12 +1833,12 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `MeasurementListElement` Wrapper for an element of indexed group `MeasurementList`. - + ### method `__init__` @@ -1989,7 +1989,7 @@ Index of the "nominal" wavelength (in `probe.wavelengths`). --- - + ### method `is_empty` @@ -2007,7 +2007,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2035,7 +2035,7 @@ Group level save to a SNIRF file on disk. --- - + ## class `MeasurementList` Interface for indexed group `MeasurementList`. @@ -2048,7 +2048,7 @@ The measurement list. This variable serves to map the data array onto the probe Each element of the array is a structure which describes the measurement conditions for this data with the following fields: - + ### method `__init__` @@ -2071,7 +2071,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2089,7 +2089,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2103,7 +2103,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2122,7 +2122,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2142,7 +2142,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2160,7 +2160,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2190,14 +2190,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `StimElement` - + ### method `__init__` @@ -2262,7 +2262,7 @@ This is a string describing the jth stimulus condition. --- - + ### method `is_empty` @@ -2280,7 +2280,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2308,14 +2308,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Stim` - + ### method `__init__` @@ -2338,7 +2338,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2356,7 +2356,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2370,7 +2370,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2389,7 +2389,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2409,7 +2409,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2427,7 +2427,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2457,14 +2457,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `AuxElement` - + ### method `__init__` @@ -2547,7 +2547,7 @@ This variable specifies the offset of the file time origin relative to absolute --- - + ### method `is_empty` @@ -2565,7 +2565,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `save` @@ -2593,14 +2593,14 @@ Group level save to a SNIRF file on disk. --- - + ## class `Aux` - + ### method `__init__` @@ -2623,7 +2623,7 @@ The filename the Snirf object was loaded from and will save to. --- - + ### method `append` @@ -2641,7 +2641,7 @@ Append a new Group to the IndexedGroup. --- - + ### method `appendGroup` @@ -2655,7 +2655,7 @@ Creates an empty Group with the appropriate name at the end of the list of Group --- - + ### method `insert` @@ -2674,7 +2674,7 @@ Insert a new Group into the IndexedGroup. --- - + ### method `insertGroup` @@ -2694,7 +2694,7 @@ Creates an empty Group with a placeholder name within the list of Groups managed --- - + ### method `is_empty` @@ -2712,7 +2712,7 @@ Returns True if the Indexed Group has no member Groups with contents. --- - + ### method `save` @@ -2742,14 +2742,14 @@ When saving, the naming convention defined by the SNIRF spec is enforced: groups --- - + ## class `Snirf` - + ### method `__init__` @@ -2800,7 +2800,7 @@ This group stores one set of NIRS data. This can be extended by adding the coun --- - + ### method `close` @@ -2816,7 +2816,7 @@ After closing, the underlying SNIRF file cannot be accessed from this interface --- - + ### method `copy` @@ -2830,7 +2830,7 @@ A copy of a Snirf instance is a brand new HDF5 file in memory. This can be expe --- - + ### method `is_empty` @@ -2848,7 +2848,7 @@ If the Group has no member Groups or Datasets. --- - + ### method `measurementList_to_measurementLists` @@ -2862,7 +2862,7 @@ Does not delete the measurementList Dataset. --- - + ### method `measurementLists_to_measurementList` @@ -2878,7 +2878,7 @@ The `measurementList` Group is not removed. --- - + ### method `save` @@ -2908,7 +2908,7 @@ Save a SNIRF file to disk. --- - + ### method `validate`