Repository: BUNPC/pysnirf2 (language: python)

Issue:
Refactored everything to remove "Class" is this is not good style
Also reworked a bit of logic in the varg validate function


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index b807d32..f6275ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
 **/venv
-**/venv_gen
 **/__pycache__
 .idea
-*.snirf
\ No newline at end of file
+*.snirf
+*.log
\ No newline at end of file
diff --git a/gen/base.py b/gen/base.py
deleted file mode 100644
index 70277c6..0000000
--- a/gen/base.py
+++ /dev/null
@@ -1,186 +0,0 @@
-from abc import ABC, abstractmethod
-import h5py
-import os
-import sys
-import numpy as np
-from warnings import warn
-from collections import MutableSequence
-from tempfile import TemporaryFile
-
-if sys.version_info[0] < 3:
-    raise ImportError('pysnirf2 requires Python > 3')
-
-# -- methods for writing and reading ------
-
-    
-def _read_string(dataset):
-    # Because many SNIRF files are saved with string values in length 1 arrays
-    if dataset.ndim > 0:
-        return dataset[0].decode('ascii')
-    else:
-        return dataset[()].decode('ascii')
-
-
-# -----------------------------------------
-
-
-class SnirfFormatError(Exception):
-    pass
-
-
-class SnirfConfig:
-    dynamic_loading: bool = False  # If False, data is loaded in the constructor, if True, data is loaded on access
-
-
-class Group(ABC):
-
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        self._id = gid
-        if not isinstance(gid, h5py.h5g.GroupID):
-            raise TypeError('must initialize with a Group ID, not ' + str(type(gid)))
-        self._h = h5py.Group(self._id)
-        self._cfg = cfg
-
-    def save(self, *args):
-        if len(args) > 0:
-            if type(args[0]) is h5py.File:
-                self._save(args[0])
-            elif type(args[0]) is str:
-                path = args[0]
-                if not path.endswith('.snirf'):
-                    path += '.snirf'
-                if os.path.exists(path):
-                    file = h5py.File(path, 'r+')
-                else:
-                    file = h5py.File(path, 'w')
-                self._save(file)
-                file.close()
-        else:
-            self._save()
-
-    @property
-    def filename(self):
-        return self._h.file.filename
-    
-    @abstractmethod
-    def _save(self, *args):
-        raise NotImplementedError('_save is an abstract method')
-        
-    def __repr__(self):
-        props = [p for p in dir(self) if ('_' not in p and not callable(getattr(self, p)))]
-        out = str(self.__class__.__name__) + ' at ' + str(self._h.name) + '\n'
-        for prop in props:
-            attr = getattr(self, prop)
-            out += prop + ': '
-            if type(attr) is np.ndarray or type(attr) is list:
-                if np.size(attr) > 32:
-                    out += '<' + str(np.shape(attr)) + ' array of ' + str(attr.dtype) + '>'
-                else:
-                    out += str(attr)
-            else:
-                prepr = str(attr)
-                if len(prepr) < 64:
-                    out += prepr
-                else:
-                    out += '\n' + prepr
-            out += '\n'
-        return out[:-1]
-
-
-class IndexedGroup(MutableSequence, ABC):
-    """
-    Represents the "indexed group" which is defined by v1.0 of the SNIRF
-    specification as:
-        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.
-    """
-    
-    _name: str = ''
-    _element: Group = None
-
-    def __init__(self, parent: (h5py.Group, h5py.File), cfg: SnirfConfig):
-        if isinstance(parent, (h5py.Group, h5py.File)):
-            # Because the indexed group is not a true HDF5 group but rather an
-            # iterable list of HDF5 groups, it takes a base group or file and
-            # searches its keys, appending the appropriate elements to itself
-            # in order
-            self._parent = parent
-            self._cfg = cfg
-            self._list = list()
-            unordered = []
-            indices = []
-            for key in self._parent.keys():
-                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(self._element(self._parent[key].id, self._cfg))
-                        indices.append(int(numsplit[1]))
-                elif key.endswith(self._name):
-                    unordered.append(self._element(self._parent[key].id, self._cfg))
-                    indices.append(0)
-            ordered = np.argsort(indices)
-            for i, j in enumerate(ordered):
-                self._list.append(unordered[j])
-        else:
-            raise TypeError('must initialize IndexedGroup with a Group or File')
-    
-    
-    def __len__(self): return len(self._list)
-
-    def __getitem__(self, i): return self._list[i]
-
-    def __delitem__(self, i): del self._list[i]
-    
-    def __setitem__(self, i, item):
-        self._check_type(item)
-        self._list[i] = item
-        self._order_names
-
-    def __getattr__(self, name):
-        # If user tries to access an element's properties, raise informative exception
-        if name in [p for p in dir(self._element) if ('_' not in p and not callable(getattr(self._element, p)))]:
-            raise AttributeError(self.__class__.__name__ + ' is an interable list of '
-                                + str(len(self)) + ' ' + str(self._element)
-                                + ', access these with an index i.e. '
-                                + str(self._name) + '[0].' + name
-                                )
-
-    def __repr__(self):
-        return str('<' + 'iterable of ' + str(len(self._list)) + ' ' + str(self._element) + '>')
-
-    def insert(self, i, item):
-        self._check_type(item)
-        self._list.insert(i, item)
-
-    def append(self, item):
-        self._check_type(item)
-        self._list.append(item)
-    
-    def save(self, *args):
-        self._save(*args)
-    
-    def appendGroup(self):
-        'Adds a group to the end of the list'
-        g = self._parent.create_group(self._name + str(len(self._list) + 1))
-        gid = g.id
-        self._list.append(self._element(gid, self._cfg))
-    
-    def _check_type(self, item):
-        if type(item) is not self._element:
-            raise TypeError('elements of ' + str(self.__class__.__name__) +
-                            ' must be ' + str(self._element) + ', not ' +
-                            str(type(item))
-                            )
-        
-    def _order_names(self):
-        for i, element in enumerate(self._list):
-            self._parent.move(element._h.name.split('/')[-1], self._name + str(i + 1))
-#            print(element._h.name.split('/')[-1], '--->', self._name + str(i + 1))
-        
-    def _save(self, *args):
-        self._order_names()
-        [element._save(*args) for element in self._list]
diff --git a/gen/data.py b/gen/data.py
index 3e55d52..a843ddc 100644
--- a/gen/data.py
+++ b/gen/data.py
@@ -11,9 +11,9 @@
         'VARLEN_STRING': '"s"'
         }
 TEMPLATE = 'pysnirf2.jinja'
-HEADER = 'base.py'
-FOOTER = None
+HEADER = 'header.py'
+FOOTER = 'footer.py'
 TABLE_DELIM_START = '### SNIRF data format summary'
 TABLE_DELIM_END = 'In the above table, the used notations are explained below'
 DEFINITIONS_DELIM_START = '### SNIRF data container definitions'
-DEFINITIONS_DELIM_END = '## Appendix'
\ No newline at end of file
+DEFINITIONS_DELIM_END = '## Appendix'
diff --git a/gen/footer.py b/gen/footer.py
new file mode 100644
index 0000000..a2e68b8
--- /dev/null
+++ b/gen/footer.py
@@ -0,0 +1,24 @@
+# Extend metaDataTags to support loading and saving of unspecified datasets
+
+class MetaDataTags(MetaDataTags):
+    
+    def __init__(self, varg, cfg):
+        super().__init__(varg, cfg)
+        self.__other = []
+        for key in self._h:
+            if key not in self.__snirfnames:
+                data = np.array(self._h[key])
+                self.__other.append(key)
+                setattr(self, key, data)
+                
+    def _save(self, *args):
+        super()._save(*args)
+        if len(args) > 0 and type(args[0]) is h5py.File:
+            file = args[0]
+        else:
+            file = self._h.file
+        for key in self.__other:
+            if key in file:
+                del file[key]
+            file.create_dataset(self.location + '/' + key, data=getattr(self, key))
+                
\ No newline at end of file
diff --git a/gen/gen.py b/gen/gen.py
index 095d19f..a399002 100644
--- a/gen/gen.py
+++ b/gen/gen.py
@@ -2,7 +2,7 @@
 import requests
 from unidecode import unidecode
 from pathlib import Path
-from datetime import date
+from datetime import date, datetime
 import getpass
 import os
 import sys
@@ -16,17 +16,33 @@
 
 if __name__ == '__main__':
     
-
-    print('Attempting to retrieve spec from', SPEC_SRC, '...')
-    
-    # Retrieve the SNIRF specification from GitHub and parse it for the summary table
-    spec = requests.get(SPEC_SRC)
+    local_spec = SPEC_SRC.split('/')[-1].split('.')[0] + '_retrieved_'+ datetime.now().strftime('%d_%m_%y') + '.txt'
     
-    if spec.text == '404: Not Found':
-        print(spec.text)
-        sys.exit('The Snirf specification could not be found at ' + SPEC_SRC)
+    if os.path.exists(local_spec):
+        print('Loading specification from local document', local_spec, '...')
+        with open(local_spec, 'r') as f:
+            text = f.read()
+    else:
+        print('Attempting to retrieve spec from', SPEC_SRC, '...')
     
-    table = unidecode(spec.text).split(TABLE_DELIM_START)[1].split(TABLE_DELIM_END)[0]
+        # Retrieve the SNIRF specification from GitHub and parse it for the summary table
+        spec = requests.get(SPEC_SRC)
+        
+        if spec.text == '404: Not Found':
+            print(spec.text)
+            sys.exit('The Snirf specification could not be found at ' + SPEC_SRC)
+        
+        text = unidecode(spec.text)
+        
+        for file in os.listdir():
+            if file.startswith(SPEC_SRC.split('/')[-1].split('.')[0] + '_retrieved_') and file.endswith('.txt'):
+                os.remove(file)
+        
+        with open(local_spec, 'w') as f:
+            f.write(text)
+            
+        
+    table = text.split(TABLE_DELIM_START)[1].split(TABLE_DELIM_END)[0]
     
     rows = table.split('\n')
     while '' in rows:
@@ -52,7 +68,7 @@
     print('Found', len(type_codes), 'types in the table...')
     
     # Parse headings in spec for complete HDF style locations with which to build tree
-    definitions = unidecode(spec.text).split(DEFINITIONS_DELIM_START)[1].split(DEFINITIONS_DELIM_END)[0]
+    definitions = unidecode(text).split(DEFINITIONS_DELIM_START)[1].split(DEFINITIONS_DELIM_END)[0]
     lines = definitions.split('\n')
     while '' in lines:
         lines.remove('')
@@ -69,6 +85,15 @@
 
     print('Found', len(locations), 'locations...')
     
+    # Write locations to file
+    if os.path.exists('locations.txt'):
+        os.remove('locations.txt')
+    with open('locations.txt', 'w') as f:
+        for location in locations:
+            f.write(location.replace('(i)', '').replace('(j)', '').replace('(k)', '') + '\n')
+    print('Wrote to locations.txt')
+    
+    
     if len(locations) != len(type_codes):
         sys.exit('Parsed ' + str(len(type_codes)) + ' type codes from the summary table but '
                  + str(len(locations)) + ' names from the definitions: the specification hosted at '
@@ -107,7 +132,8 @@
     SNIRF = {
             'VERSION': VERSION,
             'SPEC_SRC': SPEC_SRC,
-            'BASE': '',
+            'HEADER': '',
+            'FOOTER': '',
             'ROOT': flat[0],  # Snirf root '/'
             'TYPES': TYPELUT, 
             'USER': getpass.getuser(),
@@ -143,10 +169,13 @@
     # Generate the complete Snirf interface from base.py and the template + data
     dst = os.path.abspath(os.path.join(os.getcwd(), os.pardir))
     output_path = dst + '/src/' + 'pysnirf2.py'
-    with open(HEADER, 'r') as f_base:
-        print('Loading base class definitions and file header from', HEADER)
-        with open(output_path, "w") as f_out:
-            SNIRF['BASE'] = f_base.read()
-            f_out.write(template.render(SNIRF))
+    with open(HEADER, 'r') as f_header:
+        with open(FOOTER, 'r') as f_footer:
+            print('Loading base class definitions and file header from', HEADER)
+            print('Loading file footer from', FOOTER)
+            with open(output_path, "w") as f_out:
+                SNIRF['HEADER'] = f_header.read()
+                SNIRF['FOOTER'] = f_footer.read()
+                f_out.write(template.render(SNIRF))
             
     print('\nWrote script to ' + output_path + '.')
diff --git a/gen/header.py b/gen/header.py
new file mode 100644
index 0000000..8f148d9
--- /dev/null
+++ b/gen/header.py
@@ -0,0 +1,478 @@
+from abc import ABC, abstractmethod
+import h5py
+import os
+import sys
+import numpy as np
+from warnings import warn
+from collections.abc import MutableSequence
+from tempfile import TemporaryFile
+import logging
+
+_loggers = {}
+def _create_logger(name, log_file, level=logging.INFO):
+    if name in _loggers.keys():
+        return _loggers[name]
+    handler = logging.FileHandler(log_file)
+    handler.setFormatter(logging.Formatter('%(asctime)s | %(name)s | %(message)s'))
+    logger = logging.getLogger(name)
+    logger.setLevel(level)
+    logger.addHandler(handler)
+    _loggers[name] = logger
+    return logger
+
+# Package-wide logger
+_logger = _create_logger('pysnirf2', 'pysnirf2.log')
+_logger.info('Opened pysnirf2.log')
+
+if sys.version_info[0] < 3:
+    raise ImportError('pysnirf2 requires Python > 3')
+
+# -- methods to cast data prior to writing to and after reading from h5py interfaces------
+
+_varlen_str_type = h5py.string_dtype(encoding='ascii', length=None)  # Length=None creates HDF5 variable length string
+_DTYPE_FLOAT = 'f8'
+_DTYPE_INT = 'i4'
+_DTYPE_STR = 'S'  # Not sure how robust this is, but fixed length strings will always at least contain S
+
+
+def _create_dataset(file, name, data):
+    """
+    Formats the input to satisfy the SNIRF specification as well as the HDF5 format. Determines appropriate write
+    function from the type of data. The returned value should be passed to h5py.create_dataset() as kwarg "data" to be
+    saved to an HDF5 file.
+
+    Returns None if input is invalid and an h5py.Dataset instance if successful.
+
+    Supported dtype str values: 'O', _DTYPE_FLOAT, _DTYPE_INT
+    """
+    try:
+        if len(data) > 1:
+            data = np.array(data)  # Cast to array
+            dtype = data[0].dtype
+            if any([dtype is t for t in [int, np.int32, np.int64]]):  # int
+                return _create_dataset_int_array(file, name, data)
+            elif any([dtype is t for t in [float, np.float, np.float64]]):  # float
+                return _create_dataset_float_array(file, name, data)
+            elif any([dtype is t for t in [str, np.string_]]):  # string
+                return _create_dataset_string_array(file, name, data)
+    except TypeError:  # data has no len()
+        dtype = type(data)
+    if any([dtype is t for t in [int, np.int32, np.int64]]):  # int
+        return _create_dataset_int(file, name, data)
+    elif any([dtype is t for t in [float, np.float, np.float64]]):  # float
+        return _create_dataset_float(file, name, data)
+    elif any([dtype is t for t in [str, np.string_]]):  # string
+        return _create_dataset_string(file, name, data)
+    raise TypeError('Unrecognized data type' + str(dtype)
+                    + '. Please provide an int, float, or str, or an iterable of these.')
+
+
+def _create_dataset_string(file: h5py.File, name: str, data: str):
+    return file.create_dataset(name, dtype=_varlen_str_type, data=np.string_(data))
+
+
+def _create_dataset_int(file: h5py.File, name: str, data: int):
+    return file.create_dataset(name, dtype=_DTYPE_INT, data=int(data))
+
+
+def _create_dataset_float(file: h5py.File, name: str, data: float):
+    return file.create_dataset(name, dtype=_DTYPE_FLOAT, data=float(data))
+
+
+def _create_dataset_string_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype('O')
+    if data.size is 0:
+        array = AbsentDataset()  # Do not save empty or "None" NumPy arrays
+    return file.create_dataset(name, dtype=_varlen_str_type, data=array)
+
+
+def _create_dataset_int_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype(int)
+    if data.size is 0:
+        array = AbsentDataset()
+    return file.create_dataset(name, dtype=_DTYPE_INT, data=array)
+
+
+def _create_dataset_float_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype(float)
+    if data.size is 0:
+        array = AbsentDataset()
+    return file.create_dataset(name, dtype=_DTYPE_FLOAT, data=array)
+
+
+# -----------------------------------------
+
+
+def _read_dataset(h_dataset: h5py.Dataset):
+    """
+    Reads data from a SNIRF file using the HDF5 interface and casts it to a Pythonic type or numpy array. Determines
+    the appropriate read function from the properties of h_dataset
+    """
+    if type(h_dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if h_dataset.size > 1:
+        if _DTYPE_STR in h_dataset.dtype:
+            return _read_string_array(h_dataset)
+        elif _DTYPE_INT in h_dataset.dtype:
+            return _read_int_array(h_dataset)
+        elif _DTYPE_FLOAT in h_dataset.dtype:
+            return _read_float_array(h_dataset)
+    else:
+        if _DTYPE_STR in h_dataset.dtype:
+            return _read_string(h_dataset)
+        elif _DTYPE_INT in h_dataset.dtype:
+            return _read_int(h_dataset)
+        elif _DTYPE_FLOAT in h_dataset.dtype:
+            return _read_float(h_dataset)
+    raise TypeError("Dataset dtype='" + str(h_dataset.dtype)
+    + "' not recognized. Expecting dtype to contain one of these: " + str([_DTYPE_STR, _DTYPE_INT, _DTYPE_FLOAT]))
+
+
+def _read_string(dataset: h5py.Dataset):
+    # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if dataset.ndim > 0:
+        return str(dataset[0].decode('ascii'))
+    else:
+        return str(dataset[()].decode('ascii'))
+
+
+def _read_int(dataset: h5py.Dataset):
+    # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if dataset.ndim > 0:
+        return int(dataset[0])
+    else:
+        return int(dataset[()])
+
+
+def _read_float(dataset: h5py.Dataset):
+    # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if dataset.ndim > 0:
+        return float(dataset[0])
+    else:
+        return float(dataset[()])
+
+
+def _read_string_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(str)
+
+
+def _read_int_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(int)
+
+
+def _read_float_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(float)
+
+
+# -----------------------------------------
+
+
+class SnirfFormatError(Exception):
+    pass
+
+
+class SnirfConfig:
+    logger: logging.Logger = _logger
+    dynamic_loading: bool = False  # If False, data is loaded in the constructor, if True, data is loaded on access
+
+
+class AbsentDataset():    
+    def __repr__(self):
+        return str(None)
+
+
+class AbsentGroup():
+    def __repr__(self):
+        return str(None)
+
+
+class Group(ABC):
+
+    def __init__(self, varg, cfg: SnirfConfig):
+        """
+        Wrapper for an HDF5 Group element defined by SNIRF. Must be created with a
+        Group ID or string specifying a complete path relative to file root--in
+        the latter case, the wrapper will not correspond to a real HDF5 group on
+        disk until _save() (with no arguments) is executed for the first time
+        """
+        self._cfg = cfg
+        if type(varg) is str:  # If a Group wrapper is created prior to a save to HDF Group object
+            self._h = {}
+            self._location = varg
+        elif isinstance(varg, h5py.h5g.GroupID):  # If Group is created based on an HDF Group object
+            self._h = h5py.Group(varg)
+            self._location = self._h.name
+        else:
+            raise TypeError('must initialize ' + self.__class__.__name__ + ' with a Group ID or string, not ' + str(type(varg)))
+
+    def save(self, *args):
+        """
+        Entry to Group-level save
+        """
+        if len(args) > 0:
+            if type(args[0]) is h5py.File:
+                self._cfg.logger.info('Group-level save of %s in %s', self.location, self.filename)
+                self._save(args[0])
+            elif type(args[0]) is str:
+                path = args[0]
+                if not path.endswith('.snirf'):
+                    path += '.snirf'
+                if os.path.exists(path):
+                    file = h5py.File(path, 'w')
+                else:
+                    raise FileNotFoundError("No such SNIRF file '" + path + "'. Create a SNIRF file before attempting to save a Group to it.")
+                self._cfg.logger.info('Group-level save of %s in %s to new file %s', self.location, self.filename, file)
+                self._save(file)
+                file.close()
+        else:
+            if self._h != {}:
+                file = self._h.file
+            self._cfg.logger.info('Group-level save of %s in %s', self.location, file)
+            self._save(file)
+            
+    @property
+    def filename(self):
+        """
+        Returns None if the wrapper is not associated with a Group on disk        
+        """
+        if self._h != {}:
+            return self._h.file.filename
+        else:
+            return None
+
+    @property
+    def location(self):
+        if self._h != {}:
+            return self._h.name
+        else:
+            return self._location
+    
+    @abstractmethod
+    def _save(self, *args):
+        """
+        args is path or empty
+        """
+        raise NotImplementedError('_save is an abstract method')
+        
+    def __repr__(self):
+        props = [p for p in dir(self) if ('_' not in p and not callable(getattr(self, p)))]
+        out = str(self.__class__.__name__) + ' at ' + str(self.location) + '\n'
+        for prop in props:
+            attr = getattr(self, prop)
+            out += prop + ': '
+            if type(attr) is np.ndarray or type(attr) is list:
+                if np.size(attr) > 32:
+                    out += '<' + str(np.shape(attr)) + ' array of ' + str(attr.dtype) + '>'
+                else:
+                    out += str(attr)
+            else:
+                prepr = str(attr)
+                if len(prepr) < 64:
+                    out += prepr
+                else:
+                    out += '\n' + prepr
+            out += '\n'
+        return out[:-1]
+
+    def __getitem__(self, key):
+        if self._h != {}:
+            if key in self._h:
+                return self._h[key]
+        else:
+            return None
+
+    def __contains__(self, key):
+        return key in self._h;
+
+
+class IndexedGroup(MutableSequence, ABC):
+    """
+    Represents the "indexed group" which is defined by v1.0 of the SNIRF
+    specification as:
+        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.
+    """
+    
+    _name: str = ''  # The specified prefix to this indexed group's members, i.e. nirs, data, stim, aux, measurementList
+    _element: Group = None  # The type of Group which belongs to this IndexedGroup
+
+    def __init__(self, parent: Group, cfg: SnirfConfig):
+        # Because the indexed group is not a true HDF5 group but rather an
+        # iterable list of HDF5 groups, it takes a base group or file and
+        # searches its keys, appending the appropriate elements to itself
+        # in order
+        self._parent = parent
+        self._cfg = cfg
+        self._populate_list()
+        self._cfg.logger.info('IndexedGroup %s at %s in %s initalized with %i instances of %s', self.__class__.__name__,
+                              self._parent.location, self.filename, len(self._list), self._element)
+    
+    @property
+    def filename(self):
+        return self._parent.filename
+
+    def __len__(self): return len(self._list)
+
+    def __getitem__(self, i): return self._list[i]
+
+    def __delitem__(self, i): del self._list[i]
+    
+    def __setitem__(self, i, item):
+        self._check_type(item)
+        if not item.location in [e.location for e in self._list]:
+            self._list[i] = item
+        else:
+            raise SnirfFormatError(item.location + ' already an element of ' + self.__class__.__name__)
+
+    def __getattr__(self, name):
+        # If user tries to access an element's properties, raise informative exception
+        if name in [p for p in dir(self._element) if ('_' not in p and not callable(getattr(self._element, p)))]:
+            raise AttributeError(self.__class__.__name__ + ' is an interable list of '
+                                + str(len(self)) + ' ' + str(self._element)
+                                + ', access these with an index i.e. '
+                                + str(self._name) + '[0].' + name
+                                )
+
+    def __repr__(self):
+        return str('<' + 'iterable of ' + str(len(self._list)) + ' ' + str(self._element) + '>')
+
+    def insert(self, i, item):
+        self._check_type(item)
+        self._list.insert(i, item)
+        self._cfg.logger.info('%i th element inserted into IndexedGroup %s at %s in %s at %i', len(self._list),
+                              self.__class__.__name__, self._parent.location, self.filename, i)
+
+    def append(self, item):
+        self._check_type(item)
+        self._list.append(item)
+        self._cfg.logger.info('%i th element appended to IndexedGroup %s at %s in %s', len(self._list),
+                              self.__class__.__name__, self._parent.location, self.filename)
+
+    def save(self, *args):
+        if len(args) > 0:
+            if type(args[0]) is h5py.File:
+                self._save(args[0])
+                self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s', self.__class__.__name__,
+                      self._parent.location, self.filename)
+            elif type(args[0]) is str:
+                path = args[0]
+                if not path.endswith('.snirf'):
+                    path += '.snirf'
+                if os.path.exists(path):
+                    file = h5py.File(path, 'w')
+                else:
+                    raise FileNotFoundError("No such SNIRF file '" + path + "'. Create a SNIRF file before attempting to save an IndexedGroup to it.")
+                self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s to %s', self.__class__.__name__,
+                                      self._parent.location, self.filename, file)
+                self._save(file)
+                file.close()
+        else:
+            if self._parent._h != {}:
+                file = self._parent._h.file
+            self._save(file)
+            self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s', self.__class__.__name__,
+                      self._parent.location, self.filename)
+
+    def appendGroup(self):
+        'Adds a group to the end of the list'
+        location = self._parent.location + '/' + self._name + str(len(self._list) + 1)
+        self._list.append(self._element(location, self._cfg))
+        self._cfg.logger.info('%i th %s appended to IndexedGroup %s at %s in %s', len(self._list),
+                          self._element, self.__class__.__name__, self._parent.location, self.filename)
+    
+    def _populate_list(self):
+        """
+        Add all the appropriate groups in parent to the list
+        """
+        self._list = list()
+        names = self._get_matching_keys()
+        for name in names:
+            if name in self._parent._h:
+                self._list.append(self._element(self._parent[name].id, self._cfg))
+    
+    def _check_type(self, item):
+        if type(item) is not self._element:
+            raise TypeError('elements of ' + str(self.__class__.__name__) +
+                            ' must be ' + str(self._element) + ', not ' +
+                            str(type(item))
+                            )
+        
+    def _order_names(self, h=None):
+        '''
+        Enforce the format of the names of HDF5 groups within a group or file on disk. i.e. IndexedGroup stim's elements
+        will be renamed, in order, /stim1, /stim2, /stim3. This is expensive but can be avoided by save()ing individual groups
+        within the IndexedGroup
+        '''
+        if h is None:
+            h = self._parent._h
+        if all([len(e.location.split('/' + self._name)[-1]) > 0 for e in self._list]):
+            if not [int(e.location.split('/' + self._name)[-1]) for e in self._list] == list(range(1, len(self._list) + 1)):
+                self._cfg.logger.info('renaming elements of IndexedGroup ' + self.__class__.__name__ + ' at '
+                                      + self._parent.location + ' in ' + self.filename + ' to agree with naming format')
+                # if list is not already ordered propertly
+                for i, e in enumerate(self._list):
+                    # To avoid assignment to an existing name, move all
+                    h.move(e.location,
+                           '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp')
+                    self._cfg.logger.info(e.location, '--->',
+                                          '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp')
+                for i, e in enumerate(self._list):
+                    h.move('/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp',
+                           '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1))
+                    self._cfg.logger.info('/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp',
+                                          '--->', '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1))
+
+    def _get_matching_keys(self, h=None):
+        '''
+        Return sorted list of a group or file's keys which match this IndexedList's _name format
+        '''
+        if h is None:
+            h = self._parent._h
+        unordered = []
+        indices = []
+        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]))
+            elif key.endswith(self._name):
+                unordered.append(key)
+                indices.append(0)
+        order = np.argsort(indices)
+        return [unordered[i] for i in order]
+        
+    def _save(self, *args):
+        if len(args) > 0 and type(args[0]) is h5py.File:
+            h = args[0]
+        else:
+            if self._parent._h != {}:
+                h = self._parent._h.file
+            else:
+                raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance')
+        names_in_file = self._get_matching_keys(h=h)  # List of all names in the file on disk
+        names_to_save = [e.location.split('/')[-1] for e in self._list]  # List of names in the wrapper
+        # Remove groups which remain on disk after being removed from the wrapper
+        for name in names_in_file:
+            if name not in names_to_save:
+                del h[self._parent.name + '/' + name]  # Remove the actual data from the hdf5 file.
+        for e in self._list:
+            e._save(*args)  # Group save functions handle the write to disk
+        self._order_names(h=h)  # Enforce order in the group names
+
diff --git a/gen/locations.txt b/gen/locations.txt
new file mode 100644
index 0000000..dd81141
--- /dev/null
+++ b/gen/locations.txt
@@ -0,0 +1,54 @@
+/formatVersion
+/nirs
+/nirs/metaDataTags
+/nirs/metaDataTags/SubjectID
+/nirs/metaDataTags/MeasurementDate
+/nirs/metaDataTags/MeasurementTime
+/nirs/metaDataTags/LengthUnit
+/nirs/metaDataTags/TimeUnit
+/nirs/metaDataTags/FrequencyUnit
+/nirs/data
+/nirs/data/dataTimeSeries
+/nirs/data/time
+/nirs/data/measurementList
+/nirs/data/measurementList/sourceIndex
+/nirs/data/measurementList/detectorIndex
+/nirs/data/measurementList/wavelengthIndex
+/nirs/data/measurementList/wavelengthActual
+/nirs/data/measurementList/wavelengthEmissionActual
+/nirs/data/measurementList/dataType
+/nirs/data/measurementList/dataTypeLabel
+/nirs/data/measurementList/dataTypeIndex
+/nirs/data/measurementList/sourcePower
+/nirs/data/measurementList/detectorGain
+/nirs/data/measurementList/moduleIndex
+/nirs/data/measurementList/sourceModuleIndex
+/nirs/data/measurementList/detectorModuleIndex
+/nirs/stim
+/nirs/stim/name
+/nirs/stim/data
+/nirs/stim/dataLabels
+/nirs/probe
+/nirs/probe/wavelengths
+/nirs/probe/wavelengthsEmission
+/nirs/probe/sourcePos2D
+/nirs/probe/sourcePos3D
+/nirs/probe/detectorPos2D
+/nirs/probe/detectorPos3D
+/nirs/probe/frequencies
+/nirs/probe/timeDelays
+/nirs/probe/timeDelayWidths
+/nirs/probe/momentOrders
+/nirs/probe/correlationTimeDelays
+/nirs/probe/correlationTimeDelayWidths
+/nirs/probe/sourceLabels
+/nirs/probe/detectorLabels
+/nirs/probe/landmarkPos2D
+/nirs/probe/landmarkPos3D
+/nirs/probe/landmarkLabels
+/nirs/probe/useLocalIndex
+/nirs/aux
+/nirs/aux/name
+/nirs/aux/dataTimeSeries
+/nirs/aux/time
+/nirs/aux/timeOffset
diff --git a/gen/pysnirf2.jinja b/gen/pysnirf2.jinja
index 0af0479..8b8042b 100644
--- a/gen/pysnirf2.jinja
+++ b/gen/pysnirf2.jinja
@@ -1,81 +1,109 @@
 {% macro sentencecase(text) %}
     {{- text[0]|upper}}{{text[1:] -}}
 {% endmacro %}
+{% macro declare_members(NODE) %}
+    {%- for CHILD in NODE.children %}
+    {%- if TYPES.GROUP in CHILD.name %}
+    _{{ CHILD.name }} = AbsentGroup()  # {{ CHILD.type }}
+    {% else  %}
+    _{{ CHILD.name }} = AbsentDataset()  # {{ CHILD.type }}
+    {% endif -%}    
+    {% endfor -%}
+{% endmacro %}
 {% macro init_members(NODE) %}
+    self.__snirfnames = [{% for CHILD in NODE.children %}'{{ CHILD.name }}', {% endfor %}]
     {% for CHILD in NODE.children %}
-    {% if TYPES.INT_VALUE in CHILD.type %}
-        {% set python_type = 'int' %}
-    {% elif TYPES.FLOAT_VALUE in CHILD.type %}
-        {% set python_type = 'float' %}
-    {% elif TYPES.VARLEN_STRING in CHILD.type %}
-        {% set python_type = 'str' %}
-    {% else %}
-        {% set python_type = 'float' %}
-    {% endif %}
     {% if TYPES.INDEXED_GROUP in CHILD.type %}
-    self.{{- CHILD.name }} = {{ sentencecase(CHILD.name) }}(self._h, self._cfg)  # Indexed group
+    self.{{- CHILD.name }} = {{ sentencecase(CHILD.name) }}(self, self._cfg)  # Indexed group
     {% elif TYPES.GROUP in CHILD.type %}
-    if '{{ CHILD.name }}' in self._h.keys():
+    if '{{ CHILD.name }}' in self._h:
         self._{{- CHILD.name }} = {{ sentencecase(CHILD.name) }}(self._h['{{ CHILD.name }}'].id, self._cfg)  # Group
-        {% if TYPES.REQUIRED in CHILD.type %}
-    else:  # Create an empty group
-        self._h.create_group('{{ CHILD.name }}')
-        self._{{ CHILD.name }} = {{ sentencecase(CHILD.name) }}(self._h['{{ CHILD.name }}'].id, self._cfg)  # Group
-        {% endif %}
+    else:
+        self._{{ CHILD.name }} = {{ sentencecase(CHILD.name) }}(self.location + '/' + '{{ CHILD.name }}', self._cfg)  # Anonymous group (wrapper only)
     {% else %}
-    if '{{ CHILD.name }}' in self._h.keys():
-        {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
-        if not self._cfg.dynamic_loading:
-            self._{{- CHILD.name }} = np.array(self._h['{{ CHILD.name }}']).astype({{ python_type }})  # Array
-        {% elif TYPES.VARLEN_STRING in CHILD.type %}
+    if '{{ CHILD.name }}' in self._h:
         if not self._cfg.dynamic_loading:
-            self._{{- CHILD.name }} = _read_string(self._h['{{ CHILD.name }}'])
+        {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
+            {% if TYPES.VARLEN_STRING in CHILD.type %}
+            self._{{- CHILD.name }} = _read_string_array(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+            self._{{- CHILD.name }} = _read_int_array(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+            self._{{- CHILD.name }} = _read_float_array(self._h['{{ CHILD.name }}'])
+            {% else %}
+            self._{{- CHILD.name }} = _read_dataset(self._h['{{ CHILD.name }}'])
+            {% endif %}
         {% else %}
-        if not self._cfg.dynamic_loading:
-            self._{{- CHILD.name }} = {{ python_type }}(self._h['{{ CHILD.name }}'][()])
+            {% if TYPES.VARLEN_STRING in CHILD.type %}
+            self._{{- CHILD.name }} = _read_string(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+            self._{{- CHILD.name }} = _read_int(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+            self._{{- CHILD.name }} = _read_float(self._h['{{ CHILD.name }}'])
+            {% else %}
+            self._{{- CHILD.name }} = _read_dataset(self._h['{{ CHILD.name }}'])
+            {% endif %}
         {% endif %}
         {% if (TYPES.REQUIRED in CHILD.type) and not ((TYPES.REQUIRED + '1' in CHILD.type) or (TYPES.REQUIRED + '2' in CHILD.type)) %}
-    else:
-        warn(str(self.__class__.__name__) + ' missing required key ' + '"{{ CHILD.name }}"')
         {% endif %}
     {% endif %}
-
     {% endfor %}
 {% endmacro %}
 {% macro gen_properties(NODE) %}
 {% for CHILD in NODE.children %}
-    {% if TYPES.INT_VALUE in CHILD.type %}
-        {% set python_type = 'int' %}
-    {% elif TYPES.FLOAT_VALUE in CHILD.type %}
-        {% set python_type = 'float' %}
-    {% elif TYPES.VARLEN_STRING in CHILD.type %}
-        {% set python_type = 'str' %}
-    {% else %}
-        {% set python_type = 'float' %}
-    {% endif %}
     @property
     def {{ CHILD.name }}(self):
     {% if TYPES.INDEXED_GROUP in CHILD.type %}
         return self._{{ CHILD.name }}
     {% elif TYPES.GROUP in CHILD.type %}
-        if '{{ CHILD.name }}' in self._h.keys():
-            return self._{{ CHILD.name }}
-    {% else %}
-        if self._cfg.dynamic_loading and self._{{ CHILD.name }} is None:
-            if '{{ CHILD.name }}' in self._h.keys():
-    {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
-                return np.array(self._h['{{ CHILD.name }}']).astype({{ python_type }})  # Array
-    {% elif TYPES.VARLEN_STRING in CHILD.type %}
-                return _read_string(self._h['{{ CHILD.name }}'])
-    {% else %}
-                return {{ python_type }}(self._h['{{ CHILD.name }}'][()])
-    {% endif %}
         return self._{{ CHILD.name }}
+    {% else %}
+        if type(self._{{ CHILD.name }}) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if '{{ CHILD.name }}' in self._h:
+        {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
+            {% if TYPES.VARLEN_STRING in CHILD.type %}
+                    return _read_string_array(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+                    return _read_int_array(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+                    return _read_float_array(self._h['{{ CHILD.name }}'])
+            {% else %}
+                    return _read_dataset(self._h['{{ CHILD.name }}'])
+            {% endif %}
+        {% else %}
+            {% if TYPES.VARLEN_STRING in CHILD.type %}
+                    return _read_string(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+                    return _read_int(self._h['{{ CHILD.name }}'])
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+                    return _read_float(self._h['{{ CHILD.name }}'])
+            {% else %}
+                    return _read_dataset(self._h['{{ CHILD.name }}'])
+            {% endif %}
+        {% endif %}
+                    self._cfg.logger.info('Dynamically loaded %s/{{ CHILD.name }} from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._{{ CHILD.name }}
     {% endif %}
 
     @{{ CHILD.name }}.setter
     def {{ CHILD.name }}(self, value):
         self._{{ CHILD.name }} = value
+        # self._cfg.logger.info('Assignment to %s/{{ CHILD.name }} in %s', self.location, self.filename)
+
+    @{{ CHILD.name }}.deleter
+    def {{ CHILD.name }}(self):
+        {% if TYPES.INDEXED_GROUP in CHILD.type %}
+        raise AttributeError('IndexedGroup ' + str(type(self._{{ CHILD.name }})) + ' cannot be deleted')
+        {% elif TYPES.GROUP in CHILD.type %}
+        self._{{ CHILD.name }} = AbsentGroup()
+        {% else %}
+        self._{{ CHILD.name }} = AbsentDataset()
+        {% endif %}
+        self._cfg.logger.info('Deleted %s/{{ CHILD.name }} from %s', self.location, self.filename)
 
     {% endfor %}
 {% endmacro %}
@@ -83,41 +111,61 @@
     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:
-            file = self._h.file
+            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')
 {% for CHILD in NODE.children %}
-    {% if TYPES.INT_VALUE in CHILD.type %}
-        {% set h5_type = "'i4'" %}
-    {% elif TYPES.FLOAT_VALUE in CHILD.type %}
-        {% set h5_type = "'f8'" %}
-    {% elif TYPES.VARLEN_STRING in CHILD.type %}
-        {% set h5_type = "h5py.string_dtype(encoding='ascii', length=None)" %}
-    {% else %}
-        {% set h5_type = "'f8'" %}
-    {% endif %}
-    {% if (TYPES.GROUP in CHILD.type) or (TYPES.INDEXED_GROUP in CHILD.type) %}
+    {% if TYPES.INDEXED_GROUP in CHILD.type %}
         self.{{ CHILD.name }}._save(*args)
+    {% elif TYPES.GROUP in CHILD.type %}
+        if type(self._{{ CHILD.name }}) is AbsentGroup:
+            if '{{ CHILD.name }}' in file:
+                del file['{{ CHILD.name }}']
+                self._cfg.logger.info('Deleted Group %s/{{ CHILD.name }} from %s', self.location, file)
+        else:
+            self.{{ CHILD.name }}._save(*args)
     {% else %}
-        if '{{ CHILD.name }}' in self._h:
-            name = self._h['{{ CHILD.name }}'].name
+        name = self.location + '/{{ CHILD.name }}'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.{{ CHILD.name }}
+        if type(data) is not AbsentDataset and data is not None:
         {% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
             {% if TYPES.VARLEN_STRING in CHILD.type %}
-            data = np.array(self.{{ CHILD.name }}).astype('O')
+            _create_dataset_string_array(file, name, data)
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+            _create_dataset_int_array(file, name, data)
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+            _create_dataset_float_array(file, name, data)
             {% else %}
-            data = np.array(self.{{ CHILD.name }})
+            _create_dataset(file, name, data)
             {% endif %}
-        {% elif TYPES.VARLEN_STRING in CHILD.type %}
-            data = self.{{ CHILD.name }}
         {% else %}
-            data = self.{{ CHILD.name }}
+            {% if TYPES.VARLEN_STRING in CHILD.type %}
+            _create_dataset_string(file, name, data)
+            {% elif TYPES.INT_VALUE in CHILD.type %}
+            _create_dataset_int(file, name, data)
+            {% elif TYPES.FLOAT_VALUE in CHILD.type %}
+            _create_dataset_float(file, name, data)
+            {% else %}
+            _create_dataset(file, name, data)
+            {% endif %}
         {% endif %}
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype={{ h5_type }}, data=data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
     {% endif %}
     {% endfor %}
 {% endmacro %}
-{{ BASE }}
+{{ HEADER }}
 
 # generated by {{ USER }} on {{ DATE }}
 # version {{ VERSION }} SNIRF specification parsed from {{ SPEC_SRC }}
@@ -126,12 +174,10 @@
 {% for GROUP in GROUPS %}
 class {{ sentencecase(GROUP.name) -}}(Group):
 
-    {% for CHILD in GROUP.children %}
-    _{{ CHILD.name }} = None  # {{ CHILD.type }}
-    {% endfor %}
+{{ declare_members(GROUP) }}
 
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        super().__init__(gid, cfg)
+    def __init__(self, var, cfg: SnirfConfig):
+        super().__init__(var, cfg)
     {{ init_members(GROUP) | indent }}
 {{ gen_properties(GROUP) }}
 {{ gen_writer(GROUP) }}
@@ -141,9 +187,7 @@ class {{ sentencecase(GROUP.name) -}}(Group):
 {% for INDEXED_GROUP in INDEXED_GROUPS %}
 class {{ sentencecase(INDEXED_GROUP.name) -}}Element(Group):
 
-    {% for CHILD in INDEXED_GROUP.children %}
-    _{{ CHILD.name }} = None  # {{ CHILD.type }}
-    {% endfor %}
+{{ declare_members(INDEXED_GROUP) }}
 
     def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
         super().__init__(gid, cfg)
@@ -164,26 +208,33 @@ class {{ sentencecase(INDEXED_GROUP.name) -}}(IndexedGroup):
 class Snirf():
     
     _name = '/'
-    {% for CHILD in ROOT.children %}
-    _{{ CHILD.name }} = None  # {{ CHILD.type }}
-    {% endfor %}
+{{ declare_members(ROOT) }}
 
-    def __init__(self, *args, dynamic_loading: bool = False):
+    def __init__(self, *args, dynamic_loading: bool = False, logfile: bool = False):
+        self._cfg = SnirfConfig()
+        self._cfg.dynamic_loading = dynamic_loading
         if len(args) > 0:
             path = args[0]
             if type(path) is str:
                 if not path.endswith('.snirf'):
-                    path = path.join('.snirf')
+                    path = path + '.snirf'
+                if logfile:
+                    self._cfg.logger = _create_logger(path, path.split('.')[0] + '.log')
+                else:
+                    self._cfg.logger = _logger  # Use global logger
                 if os.path.exists(path):
+                    self._cfg.logger.info('Loading from file %s', path)
                     self._h = h5py.File(path, 'r+')
                 else:
+                    self._cfg.logger.info('Creating new file at %s', path)
                     self._h = h5py.File(path, 'w')
             else:
                 raise TypeError(str(path) + ' is not a valid filename')
         else:
+            self._cfg.logger = _logger
+            self._cfg.logger.info('Created Snirf object based on tempfile')
+            path = None
             self._h = h5py.File(TemporaryFile(), 'w')
-        self._cfg = SnirfConfig()
-        self._cfg.dynamic_loading = dynamic_loading
     {{ init_members(ROOT) | indent }}
 {{ gen_properties(ROOT) }}
 {{ gen_writer(ROOT) }}
@@ -203,19 +254,41 @@ class Snirf():
                 path += '.snirf'
             new_file = h5py.File(path, 'w')
             self._save(new_file)
+            self._cfg.logger.info('Saved Snirf file at %s to copy at %s', self.filename, path)
             new_file.close()
         else:
-            self._save()
+            self._save(self._h.file)
 
     @property
     def filename(self):
         return self._h.filename
 
+    @property
+    def location(self):
+        return self._h.name
+
     def close(self):
+        self._cfg.logger.info('Closing Snirf file %s', self.filename)
         self._h.close()
 
-    def __del__(self):
-        self._h.close()
+    def __enter__(self):
+        return self
+
+#    def __exit__(self):
+#        self.close()
+
+#    def __del__(self):
+#        self.close()
+
+    def __getitem__(self, key):
+        if self._h != {}:
+            if key in self._h:
+                return self._h[key]
+        else:
+            return None
+
+    def __contains__(self, key):
+        return key in self._h;
 
     def __repr__(self):
         props = [p for p in dir(self) if ('_' not in p and not callable(getattr(self, p)))]
@@ -236,3 +309,5 @@ class Snirf():
                     out += '\n' + prepr
             out += '\n'
         return out[:-1]
+
+{{ FOOTER }}
\ No newline at end of file
diff --git a/gen/snirf_specification_retrieved_01_12_21.txt b/gen/snirf_specification_retrieved_01_12_21.txt
new file mode 100644
index 0000000..9140dfd
--- /dev/null
+++ b/gen/snirf_specification_retrieved_01_12_21.txt
@@ -0,0 +1,988 @@
+Shared Near Infrared Spectroscopy Format (SNIRF) Specification
+==============================================================
+
+* **Document Version**: v1.0
+* **License**: This document is in the public domain.
+
+## Table of Content
+
+- [Introduction](#introduction)
+- [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.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.dataTypeLabel](#nirsidatajmeasurementlistkdatatypelabel)
+       * [data.measurementList.dataTypeIndex](#nirsidatajmeasurementlistkdatatypeindex)
+       * [data.measurementList.sourcePower](#nirsidatajmeasurementlistksourcepower)
+       * [data.measurementList.detectorGain](#nirsidatajmeasurementlistkdetectorgain)
+       * [data.measurementList.moduleIndex](#nirsidatajmeasurementlistkmoduleindex)
+       * [data.measurementList.sourceModuleIndex](#nirsidatajmeasurementlistksourcemoduleindex)
+       * [data.measurementList.detectorModuleIndex](#nirsidatajmeasurementlistkdetectormoduleindex)
+       * [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.useLocalIndex](#nirsiprobeuselocalindex)
+       * [aux](#nirsiauxj)
+       * [aux.name](#nirsiauxjname)
+       * [aux.dataTimeSeries](#nirsiauxjdatatimeseries)
+       * [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 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`: either a `H5T.C_S1` (null terminated string) type or as ASCII 
+  encoded 8-bit `char` array or UNICODE UTF-16 array.
+  Defined by the `H5T.NATIVE_CHAR` or
+  `H5T.H5T_NATIVE_B16` datatypes in `H5T`.  (note, 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).
+- `integer`: the native integer types `H5T_NATIVE_INT` `H5T` datatype (alias of 
+  `H5T_STD_I32BE` or `H5T_STD_I32LE`)
+- `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")
+
+For `integer` and `numeric` data fields, users should use HDF5's Datatype 
+Interface to query the byte-length stored in the file.
+
+The array dimensions in this Specification refer to the **HDF5 dataset 
+dimensions** and are independent to programming languages.
+
+Note, native datatypes are defined by the build of the software (e.g. 
+little/big endian) and are automatically converted by the HDF5 backend for 
+consistent read/write between OS platforms.
+
+## 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
+
+|  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     | `[[<f>,...]]`* |
+|        `time`                         | * Time (in `TimeUnit` defined in metaDataTag)|  `[<f>,...]` * |
+|        `measurementList{i}`           | * Per-channel source-detector information    |   `{i}`      * |
+|            `sourceIndex`              | * Source index for a given channel           |   `<i>`      * |
+|            `detectorIndex`            | * Detector index for a given channel         |   `<i>`      * |
+|            `wavelengthIndex`          | * Wavelength index for a given channel       |   `<i>`      * |
+|            `wavelengthActual`         | * Actual wavelength for a given channel      |   `<f>`        |
+|            `wavelengthEmissionActual` | * Actual emission wavelength for a channel   |   `<f>`        |
+|            `dataType`                 | * Data type for a given channel              |   `<i>`      * |
+|            `dataTypeLabel`            | * Data type name for a given channel         |   `"s"`        |
+|            `dataTypeIndex`            | * Data type index for a given channel        |   `<i>`      * |
+|            `sourcePower`              | * Source power for a given channel           |   `<f>`        |
+|            `detectorGain`             | * Detector gain for a given channel          |   `<f>`        |
+|            `moduleIndex`              | * Index of the parent module (if modular)    |   `<i>`        |
+|            `sourceModuleIndex`        | * Index of the source's parent module        |   `<i>`        |
+|            `detectorModuleIndex`      | * Index of the detector's parent module      |   `<i>`        |
+|     `stim{i}`                         | * Root-group for stimulus measurements       |   `{i}`        |
+|         `name`                        | * Name of the stimulus data                  |   `"s"`      + |
+|         `data`                        | * Data stream of the stimulus channel        |  `[<f>,...]` + |
+|         `dataLabels`                  | * Names of additional columns of stim data   |  `["s",...]`   |
+|     `probe`                           | * Root group for NIRS probe information      |   `{.}`      * |
+|         `wavelengths`                 | * List of wavelengths (in nm)                |  `[<f>,...]` * |
+|         `wavelengthsEmission`         | * List of emission wavelengths (in nm)       |  `[<f>,...]`   |
+|         `sourcePos2D`                 | * Source 2-D positions in `LengthUnit`       | `[[<f>,...]]`*1|
+|         `sourcePos3D`                 | * Source 3-D positions in `LengthUnit`       | `[[<f>,...]]`*1|
+|         `detectorPos2D`               | * Detector 2-D positions in `LengthUnit`     | `[[<f>,...]]`*2|
+|         `detectorPos3D`               | * Detector 3-D positions in `LengthUnit`     | `[[<f>,...]]`*2|
+|         `frequencies`                 | * Modulation frequency list                  |  `[<f>,...]`   |
+|         `timeDelays`                  | * Time delays for gated time-domain data     |  `[<f>,...]`   |
+|         `timeDelayWidths`             | * Time delay width for gated time-domain data|  `[<f>,...]`   |
+|         `momentOrders`                | * Moment orders of the moment TD data        |  `[<f>,...]`   |
+|         `correlationTimeDelays`       | * Time delays for DCS measurements           |  `[<f>,...]`   |
+|         `correlationTimeDelayWidths`  | * Time delay width for DCS measurements      |  `[<f>,...]`   |
+|         `sourceLabels`                | * String arrays specifying source names      |  `["s",...]`   |
+|         `detectorLabels`              | * String arrays specifying detector names    |  `["s",...]`   |
+|         `landmarkPos2D`               | * Anatomical landmark 2-D positions          | `[[<f>,...]]`  |
+|         `landmarkPos3D`               | * Anatomical landmark 3-D positions          | `[[<f>,...]]`  |
+|         `landmarkLabels`              | * String arrays specifying landmark names    |  `["s",...]`   |
+|         `useLocalIndex`               | * If source/detector index is within a module|   `<i>`        |
+|     `aux{i}`                          | * Root-group for auxiliary measurements      |   `{i}`        |
+|         `name`                        | * Name of the auxiliary channel              |   `"s"`      + |
+|         `dataTimeSeries`              | * Data acquired from the auxiliary channel   | `[[<f>,...]]`+ |
+|         `time`                        | * Time (in `TimeUnit`) for auxiliary data    |  `[<f>,...]` + |
+|         `timeOffset`                  | * Time offset of auxiliary channel data      |  `[<f>,...]`   |
+
+In the above 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)
+* `<i>` represents an integer value
+* `<f>` represents an numerical 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` 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.
+
+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**:  numerical 2-D array
+* **Location**: `/nirs(i)/data(j)/dataTimeSeries`
+
+This is the actual raw or processed data variable. This variable has dimensions 
+of `<number of time points> x <number of channels>`. 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)/time 
+* **Presence**: required
+* **Type**:  numerical 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 a shorthand `<2x1>` array 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 `<number of time points x 1>` and 
+             corresponds to the sample time of every data point
+* **Option 2**-  The size of this variable is `<2x1>` 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
+* **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 `<number of channels>` 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)/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 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 ...'` .
+
+#### /nirs(i)/data(j)/measurementList(k)/sourcePower 
+* **Presence**: optional
+* **Type**:  numeric
+* **Location**: `/nirs(i)/data(j)/measurementList(k)/sourcePower`
+
+Source power in milliwatt (mW). 
+
+#### /nirs(i)/data(j)/measurementList(k)/detectorGain 
+* **Presence**: optional
+* **Type**:  numeric
+* **Location**: `/nirs(i)/data(j)/measurementList(k)/detectorGain`
+
+Detector gain
+
+#### /nirs(i)/data(j)/measurementList(k)/moduleIndex 
+* **Presence**: optional
+* **Type**:  integer
+* **Location**: `/nirs(i)/data(j)/measurementList(k)/moduleIndex`
+
+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.
+
+
+#### /nirs(i)/data(j)/measurementList(k)/sourceModuleIndex 
+* **Presence**: optional
+* **Type**:  integer
+* **Location**: `/nirs(i)/data(j)/measurementList(k)/sourceModuleIndex`
+
+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.
+
+#### /nirs(i)/data(j)/measurementList(k)/detectorModuleIndex 
+* **Presence**: optional
+* **Type**:  integer
+* **Location**: `/nirs(i)/data(j)/measurementList(k)/detectorModuleIndex`
+
+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.
+
+#### /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 j<sup>th</sup> stimulus condition.
+
+
+#### /nirs(i)/stim(j)/data 
+* **Presence**: required  as part of `stim(i)` 
+* **Type**:  numerical 2-D array
+* **Location**: `/nirs(i)/stim(j)/data`
+* **Allowed attribute**: `names`
+
+This is a numerical 2-D array with at least 3 columns, specifying the stimulus 
+time course for the j<sup>th</sup> 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 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**:  numerical 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 k<sup>th</sup> 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**:  numerical 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 `<number of sources> 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 `<number of sources> x 3`.
+
+
+#### /nirs(i)/probe/detectorPos2D
+* **Presence**: at least one of `detectorPos2D` or `detectorPos3D` is required
+* **Type**:  numeric
+* **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 = <t> = (1/N_total) \integral t N(t) dt` \
+momentOrder = 2: variance/second central moment: `V = (1/N_total) \integral (t - <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 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 `<number 
+of sources>x 1` or `<number of sources> x <number of 
+wavelengths>`. This is indexed by `measurementList(k).sourceIndex` and 
+`measurementList(k).wavelengthIndex`.
+
+
+#### /nirs(i)/probe/detectorLabels 
+* **Presence**: optional 
+* **Type**:  string 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 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/useLocalIndex 
+* **Presence**: optional 
+* **Type**:  integer
+* **Location**: `/nirs(i)/probe/useLocalIndex`
+
+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.
+
+
+#### /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 j<sup>th</sup> auxiliary data timecourse.
+
+#### /nirs(i)/aux(j)/dataTimeSeries 
+* **Presence**: optional; required if `aux` is used
+* **Type**:  numeric
+* **Location**: `/nirs(i)/aux(j)/dataTimeSeries`
+
+This is the aux data variable. This variable has dimensions of `<number of 
+time points> x 1`.
+
+Chunked data is allowed to support real-time data streaming
+
+#### /nirs(i)/aux(j)/time 
+* **Presence**: optional; required if `aux` is used
+* **Type**:  numeric
+* **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 
+`<number of time points> x 1` or `<2x1>` 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                                              |
+|"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]
+```
+
+## 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
+
+### Hardware
+- Hirokazu Asaka, Hitachi
+- Rob Cooper, Gower Labs Inc
+- Mathieu Coursolle, Rogue Research
+- Rueben Hill, Gower Labs Inc
+- Jorn Horschig, Artinis Inc
+- Takumi Inakazu, Hitachi
+- Lamija Pasalic, NIRx
+- Davood Tashayyod, fNIR Devices and Biopac Inc
+- Hanseok Yun, OdeLab Inc
diff --git a/requirements.txt b/requirements.txt
index dd8ff90..23e1966 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,4 @@
 cached-property==1.5.2
 h5py==3.6.0
 numpy==1.19.5
-pip==21.3.1
 setuptools==40.8.0
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index da5751b..064eee8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,8 +5,7 @@ description = Dynamically generated SNIRF file format interface and validator
 long_description = file: README.md
 long_description_content_type = text/markdown
 url = https://github.com/BUNPC/pysnirf2
-project_urls =
-    Bug Tracker = https://github.com/BUNPC/pysnirf2/issues
+project_urls = Bug Tracker = https://github.com/BUNPC/pysnirf2/issues
 classifiers =
     Programming Language :: Python :: 3
     License :: OSI Approved :: GNU General Public License v3 (GPLv3)
diff --git a/setup.py b/setup.py
index 9c58c73..6c9b838 100644
--- a/setup.py
+++ b/setup.py
@@ -1,21 +1,7 @@
-from setuptools import setup
+from setuptools import setup, find_packages
 
 setup(
-    name='pysnirf2',
-    # version='0.0.1',
-    description='Interface and validator for the SNIRF file format dynamically generated from the specification document.',
-    author_email='sstucker@bu.edu',
-    url='https://github.com/BUNPC/pysnirf2',
-    packages=['src'],
-    install_requires=[
-        'cached-property==1.5.2',
-        'h5py==3.6.0',
-        'numpy==1.19.5',
-        'pip==21.3.1',
-        'setuptools==40.8.0'
-    ],
+    packages=find_packages('src'),
+    package_dir={'': 'src'},
     include_package_data=True,
-    classifiers=[
-        'Programming Language :: Python :: 3.6',
-    ],
 )
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..d002639
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,1 @@
+from pysnirf2 import *
\ No newline at end of file
diff --git a/src/pysnirf2.py b/src/pysnirf2.py
index 1cbe38b..59d61ee 100644
--- a/src/pysnirf2.py
+++ b/src/pysnirf2.py
@@ -4,21 +4,176 @@
 import sys
 import numpy as np
 from warnings import warn
-from collections import MutableSequence
+from collections.abc import MutableSequence
 from tempfile import TemporaryFile
+import logging
+
+_loggers = {}
+def _create_logger(name, log_file, level=logging.INFO):
+    if name in _loggers.keys():
+        return _loggers[name]
+    handler = logging.FileHandler(log_file)
+    handler.setFormatter(logging.Formatter('%(asctime)s | %(name)s | %(message)s'))
+    logger = logging.getLogger(name)
+    logger.setLevel(level)
+    logger.addHandler(handler)
+    _loggers[name] = logger
+    return logger
+
+# Package-wide logger
+_logger = _create_logger('pysnirf2', 'pysnirf2.log')
+_logger.info('Opened pysnirf2.log')
 
 if sys.version_info[0] < 3:
     raise ImportError('pysnirf2 requires Python > 3')
 
-# -- methods for writing and reading ------
+# -- methods to cast data prior to writing to and after reading from h5py interfaces------
 
-    
-def _read_string(dataset):
+_varlen_str_type = h5py.string_dtype(encoding='ascii', length=None)  # Length=None creates HDF5 variable length string
+_DTYPE_FLOAT = 'f8'
+_DTYPE_INT = 'i4'
+_DTYPE_STR = 'S'  # Not sure how robust this is, but fixed length strings will always at least contain S
+
+
+def _create_dataset(file, name, data):
+    """
+    Formats the input to satisfy the SNIRF specification as well as the HDF5 format. Determines appropriate write
+    function from the type of data. The returned value should be passed to h5py.create_dataset() as kwarg "data" to be
+    saved to an HDF5 file.
+
+    Returns None if input is invalid and an h5py.Dataset instance if successful.
+
+    Supported dtype str values: 'O', _DTYPE_FLOAT, _DTYPE_INT
+    """
+    try:
+        if len(data) > 1:
+            data = np.array(data)  # Cast to array
+            dtype = data[0].dtype
+            if any([dtype is t for t in [int, np.int32, np.int64]]):  # int
+                return _create_dataset_int_array(file, name, data)
+            elif any([dtype is t for t in [float, np.float, np.float64]]):  # float
+                return _create_dataset_float_array(file, name, data)
+            elif any([dtype is t for t in [str, np.string_]]):  # string
+                return _create_dataset_string_array(file, name, data)
+    except TypeError:  # data has no len()
+        dtype = type(data)
+    if any([dtype is t for t in [int, np.int32, np.int64]]):  # int
+        return _create_dataset_int(file, name, data)
+    elif any([dtype is t for t in [float, np.float, np.float64]]):  # float
+        return _create_dataset_float(file, name, data)
+    elif any([dtype is t for t in [str, np.string_]]):  # string
+        return _create_dataset_string(file, name, data)
+    raise TypeError('Unrecognized data type' + str(dtype)
+                    + '. Please provide an int, float, or str, or an iterable of these.')
+
+
+def _create_dataset_string(file: h5py.File, name: str, data: str):
+    return file.create_dataset(name, dtype=_varlen_str_type, data=np.string_(data))
+
+
+def _create_dataset_int(file: h5py.File, name: str, data: int):
+    return file.create_dataset(name, dtype=_DTYPE_INT, data=int(data))
+
+
+def _create_dataset_float(file: h5py.File, name: str, data: float):
+    return file.create_dataset(name, dtype=_DTYPE_FLOAT, data=float(data))
+
+
+def _create_dataset_string_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype('O')
+    if data.size is 0:
+        array = AbsentDataset()  # Do not save empty or "None" NumPy arrays
+    return file.create_dataset(name, dtype=_varlen_str_type, data=array)
+
+
+def _create_dataset_int_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype(int)
+    if data.size is 0:
+        array = AbsentDataset()
+    return file.create_dataset(name, dtype=_DTYPE_INT, data=array)
+
+
+def _create_dataset_float_array(file: h5py.File, name: str, data: np.ndarray):
+    array = np.array(data).astype(float)
+    if data.size is 0:
+        array = AbsentDataset()
+    return file.create_dataset(name, dtype=_DTYPE_FLOAT, data=array)
+
+
+# -----------------------------------------
+
+
+def _read_dataset(h_dataset: h5py.Dataset):
+    """
+    Reads data from a SNIRF file using the HDF5 interface and casts it to a Pythonic type or numpy array. Determines
+    the appropriate read function from the properties of h_dataset
+    """
+    if type(h_dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if h_dataset.size > 1:
+        if _DTYPE_STR in h_dataset.dtype:
+            return _read_string_array(h_dataset)
+        elif _DTYPE_INT in h_dataset.dtype:
+            return _read_int_array(h_dataset)
+        elif _DTYPE_FLOAT in h_dataset.dtype:
+            return _read_float_array(h_dataset)
+    else:
+        if _DTYPE_STR in h_dataset.dtype:
+            return _read_string(h_dataset)
+        elif _DTYPE_INT in h_dataset.dtype:
+            return _read_int(h_dataset)
+        elif _DTYPE_FLOAT in h_dataset.dtype:
+            return _read_float(h_dataset)
+    raise TypeError("Dataset dtype='" + str(h_dataset.dtype)
+    + "' not recognized. Expecting dtype to contain one of these: " + str([_DTYPE_STR, _DTYPE_INT, _DTYPE_FLOAT]))
+
+
+def _read_string(dataset: h5py.Dataset):
+    # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if dataset.ndim > 0:
+        return str(dataset[0].decode('ascii'))
+    else:
+        return str(dataset[()].decode('ascii'))
+
+
+def _read_int(dataset: h5py.Dataset):
+    # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    if dataset.ndim > 0:
+        return int(dataset[0])
+    else:
+        return int(dataset[()])
+
+
+def _read_float(dataset: h5py.Dataset):
     # Because many SNIRF files are saved with string values in length 1 arrays
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
     if dataset.ndim > 0:
-        return dataset[0].decode('ascii')
+        return float(dataset[0])
     else:
-        return dataset[()].decode('ascii')
+        return float(dataset[()])
+
+
+def _read_string_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(str)
+
+
+def _read_int_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(int)
+
+
+def _read_float_array(dataset: h5py.Dataset):
+    if type(dataset) is not h5py.Dataset:
+        raise TypeError("'dataset' must be type h5py.Dataset")
+    return np.array(dataset).astype(float)
 
 
 # -----------------------------------------
@@ -29,46 +184,91 @@ class SnirfFormatError(Exception):
 
 
 class SnirfConfig:
+    logger: logging.Logger = _logger
     dynamic_loading: bool = False  # If False, data is loaded in the constructor, if True, data is loaded on access
 
 
+class AbsentDataset():    
+    def __repr__(self):
+        return str(None)
+
+
+class AbsentGroup():
+    def __repr__(self):
+        return str(None)
+
+
 class Group(ABC):
 
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        self._id = gid
-        if not isinstance(gid, h5py.h5g.GroupID):
-            raise TypeError('must initialize with a Group ID, not ' + str(type(gid)))
-        self._h = h5py.Group(self._id)
+    def __init__(self, varg, cfg: SnirfConfig):
+        """
+        Wrapper for an HDF5 Group element defined by SNIRF. Must be created with a
+        Group ID or string specifying a complete path relative to file root--in
+        the latter case, the wrapper will not correspond to a real HDF5 group on
+        disk until _save() (with no arguments) is executed for the first time
+        """
         self._cfg = cfg
+        if type(varg) is str:  # If a Group wrapper is created prior to a save to HDF Group object
+            self._h = {}
+            self._location = varg
+        elif isinstance(varg, h5py.h5g.GroupID):  # If Group is created based on an HDF Group object
+            self._h = h5py.Group(varg)
+            self._location = self._h.name
+        else:
+            raise TypeError('must initialize ' + self.__class__.__name__ + ' with a Group ID or string, not ' + str(type(varg)))
 
     def save(self, *args):
+        """
+        Entry to Group-level save
+        """
         if len(args) > 0:
             if type(args[0]) is h5py.File:
+                self._cfg.logger.info('Group-level save of %s in %s', self.location, self.filename)
                 self._save(args[0])
             elif type(args[0]) is str:
                 path = args[0]
                 if not path.endswith('.snirf'):
                     path += '.snirf'
                 if os.path.exists(path):
-                    file = h5py.File(path, 'r+')
-                else:
                     file = h5py.File(path, 'w')
+                else:
+                    raise FileNotFoundError("No such SNIRF file '" + path + "'. Create a SNIRF file before attempting to save a Group to it.")
+                self._cfg.logger.info('Group-level save of %s in %s to new file %s', self.location, self.filename, file)
                 self._save(file)
                 file.close()
         else:
-            self._save()
-
+            if self._h != {}:
+                file = self._h.file
+            self._cfg.logger.info('Group-level save of %s in %s', self.location, file)
+            self._save(file)
+            
     @property
     def filename(self):
-        return self._h.file.filename
+        """
+        Returns None if the wrapper is not associated with a Group on disk        
+        """
+        if self._h != {}:
+            return self._h.file.filename
+        else:
+            return None
+
+    @property
+    def location(self):
+        if self._h != {}:
+            return self._h.name
+        else:
+            return self._location
     
     @abstractmethod
     def _save(self, *args):
+        """
+        args is path or empty
+        """
         raise NotImplementedError('_save is an abstract method')
         
     def __repr__(self):
         props = [p for p in dir(self) if ('_' not in p and not callable(getattr(self, p)))]
-        out = str(self.__class__.__name__) + ' at ' + str(self._h.name) + '\n'
+        out = str(self.__class__.__name__) + ' at ' + str(self.location) + '\n'
         for prop in props:
             attr = getattr(self, prop)
             out += prop + ': '
@@ -86,6 +286,16 @@ def __repr__(self):
             out += '\n'
         return out[:-1]
 
+    def __getitem__(self, key):
+        if self._h != {}:
+            if key in self._h:
+                return self._h[key]
+        else:
+            return None
+
+    def __contains__(self, key):
+        return key in self._h;
+
 
 class IndexedGroup(MutableSequence, ABC):
     """
@@ -99,36 +309,24 @@ class IndexedGroup(MutableSequence, ABC):
         denotes the 2nd element, and so on.
     """
     
-    _name: str = ''
-    _element: Group = None
-
-    def __init__(self, parent: (h5py.Group, h5py.File), cfg: SnirfConfig):
-        if isinstance(parent, (h5py.Group, h5py.File)):
-            # Because the indexed group is not a true HDF5 group but rather an
-            # iterable list of HDF5 groups, it takes a base group or file and
-            # searches its keys, appending the appropriate elements to itself
-            # in order
-            self._parent = parent
-            self._cfg = cfg
-            self._list = list()
-            unordered = []
-            indices = []
-            for key in self._parent.keys():
-                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(self._element(self._parent[key].id, self._cfg))
-                        indices.append(int(numsplit[1]))
-                elif key.endswith(self._name):
-                    unordered.append(self._element(self._parent[key].id, self._cfg))
-                    indices.append(0)
-            ordered = np.argsort(indices)
-            for i, j in enumerate(ordered):
-                self._list.append(unordered[j])
-        else:
-            raise TypeError('must initialize IndexedGroup with a Group or File')
-    
+    _name: str = ''  # The specified prefix to this indexed group's members, i.e. nirs, data, stim, aux, measurementList
+    _element: Group = None  # The type of Group which belongs to this IndexedGroup
+
+    def __init__(self, parent: Group, cfg: SnirfConfig):
+        # Because the indexed group is not a true HDF5 group but rather an
+        # iterable list of HDF5 groups, it takes a base group or file and
+        # searches its keys, appending the appropriate elements to itself
+        # in order
+        self._parent = parent
+        self._cfg = cfg
+        self._populate_list()
+        self._cfg.logger.info('IndexedGroup %s at %s in %s initalized with %i instances of %s', self.__class__.__name__,
+                              self._parent.location, self.filename, len(self._list), self._element)
     
+    @property
+    def filename(self):
+        return self._parent.filename
+
     def __len__(self): return len(self._list)
 
     def __getitem__(self, i): return self._list[i]
@@ -137,8 +335,10 @@ def __delitem__(self, i): del self._list[i]
     
     def __setitem__(self, i, item):
         self._check_type(item)
-        self._list[i] = item
-        self._order_names
+        if not item.location in [e.location for e in self._list]:
+            self._list[i] = item
+        else:
+            raise SnirfFormatError(item.location + ' already an element of ' + self.__class__.__name__)
 
     def __getattr__(self, name):
         # If user tries to access an element's properties, raise informative exception
@@ -155,19 +355,56 @@ def __repr__(self):
     def insert(self, i, item):
         self._check_type(item)
         self._list.insert(i, item)
+        self._cfg.logger.info('%i th element inserted into IndexedGroup %s at %s in %s at %i', len(self._list),
+                              self.__class__.__name__, self._parent.location, self.filename, i)
 
     def append(self, item):
         self._check_type(item)
         self._list.append(item)
-    
+        self._cfg.logger.info('%i th element appended to IndexedGroup %s at %s in %s', len(self._list),
+                              self.__class__.__name__, self._parent.location, self.filename)
+
     def save(self, *args):
-        self._save(*args)
-    
+        if len(args) > 0:
+            if type(args[0]) is h5py.File:
+                self._save(args[0])
+                self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s', self.__class__.__name__,
+                      self._parent.location, self.filename)
+            elif type(args[0]) is str:
+                path = args[0]
+                if not path.endswith('.snirf'):
+                    path += '.snirf'
+                if os.path.exists(path):
+                    file = h5py.File(path, 'w')
+                else:
+                    raise FileNotFoundError("No such SNIRF file '" + path + "'. Create a SNIRF file before attempting to save an IndexedGroup to it.")
+                self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s to %s', self.__class__.__name__,
+                                      self._parent.location, self.filename, file)
+                self._save(file)
+                file.close()
+        else:
+            if self._parent._h != {}:
+                file = self._parent._h.file
+            self._save(file)
+            self._cfg.logger.info('IndexedGroup-level save of %s at %s in %s', self.__class__.__name__,
+                      self._parent.location, self.filename)
+
     def appendGroup(self):
         'Adds a group to the end of the list'
-        g = self._parent.createGroup(self._name + str(len(self._list) + 1))
-        gid = g.id
-        self._list.append(self._element(gid, self._cfg))
+        location = self._parent.location + '/' + self._name + str(len(self._list) + 1)
+        self._list.append(self._element(location, self._cfg))
+        self._cfg.logger.info('%i th %s appended to IndexedGroup %s at %s in %s', len(self._list),
+                          self._element, self.__class__.__name__, self._parent.location, self.filename)
+    
+    def _populate_list(self):
+        """
+        Add all the appropriate groups in parent to the list
+        """
+        self._list = list()
+        names = self._get_matching_keys()
+        for name in names:
+            if name in self._parent._h:
+                self._list.append(self._element(self._parent[name].id, self._cfg))
     
     def _check_type(self, item):
         if type(item) is not self._element:
@@ -176,629 +413,982 @@ def _check_type(self, item):
                             str(type(item))
                             )
         
-    def _order_names(self):
-        for i, element in enumerate(self._list):
-            self._parent.move(element._h.name.split('/')[-1], self._name + str(i + 1))
-#            print(element._h.name.split('/')[-1], '--->', self._name + str(i + 1))
+    def _order_names(self, h=None):
+        '''
+        Enforce the format of the names of HDF5 groups within a group or file on disk. i.e. IndexedGroup stim's elements
+        will be renamed, in order, /stim1, /stim2, /stim3. This is expensive but can be avoided by save()ing individual groups
+        within the IndexedGroup
+        '''
+        if h is None:
+            h = self._parent._h
+        if all([len(e.location.split('/' + self._name)[-1]) > 0 for e in self._list]):
+            if not [int(e.location.split('/' + self._name)[-1]) for e in self._list] == list(range(1, len(self._list) + 1)):
+                self._cfg.logger.info('renaming elements of IndexedGroup ' + self.__class__.__name__ + ' at '
+                                      + self._parent.location + ' in ' + self.filename + ' to agree with naming format')
+                # if list is not already ordered propertly
+                for i, e in enumerate(self._list):
+                    # To avoid assignment to an existing name, move all
+                    h.move(e.location,
+                           '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp')
+                    self._cfg.logger.info(e.location, '--->',
+                                          '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp')
+                for i, e in enumerate(self._list):
+                    h.move('/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp',
+                           '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1))
+                    self._cfg.logger.info('/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1) + '_tmp',
+                                          '--->', '/'.join(e.location.split('/')[:-1]) + '/' + self._name + str(i + 1))
+
+    def _get_matching_keys(self, h=None):
+        '''
+        Return sorted list of a group or file's keys which match this IndexedList's _name format
+        '''
+        if h is None:
+            h = self._parent._h
+        unordered = []
+        indices = []
+        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]))
+            elif key.endswith(self._name):
+                unordered.append(key)
+                indices.append(0)
+        order = np.argsort(indices)
+        return [unordered[i] for i in order]
         
     def _save(self, *args):
-        self._order_names()
-        [element._save(*args) for element in self._list]
+        if len(args) > 0 and type(args[0]) is h5py.File:
+            h = args[0]
+        else:
+            if self._parent._h != {}:
+                h = self._parent._h.file
+            else:
+                raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance')
+        names_in_file = self._get_matching_keys(h=h)  # List of all names in the file on disk
+        names_to_save = [e.location.split('/')[-1] for e in self._list]  # List of names in the wrapper
+        # Remove groups which remain on disk after being removed from the wrapper
+        for name in names_in_file:
+            if name not in names_to_save:
+                del h[self._parent.name + '/' + name]  # Remove the actual data from the hdf5 file.
+        for e in self._list:
+            e._save(*args)  # Group save functions handle the write to disk
+        self._order_names(h=h)  # Enforce order in the group names
+
 
 
-# generated by sstucker on 2021-11-21
+# generated by sstucker on 2021-12-01
 # version 1.0 SNIRF specification parsed from https://raw.githubusercontent.com/fNIRS/snirf/v1.0/snirf_specification.md
 
 
 class MetaDataTags(Group):
 
-    _SubjectID = None  # "s"*
-    _MeasurementDate = None  # "s"*
-    _MeasurementTime = None  # "s"*
-    _LengthUnit = None  # "s"*
-    _TimeUnit = None  # "s"*
-    _FrequencyUnit = None  # "s"*
+    _SubjectID = AbsentDataset()  # "s"*
+    _MeasurementDate = AbsentDataset()  # "s"*
+    _MeasurementTime = AbsentDataset()  # "s"*
+    _LengthUnit = AbsentDataset()  # "s"*
+    _TimeUnit = AbsentDataset()  # "s"*
+    _FrequencyUnit = AbsentDataset()  # "s"*
 
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        super().__init__(gid, cfg)
-        if 'SubjectID' in self._h.keys():
+
+    def __init__(self, var, cfg: SnirfConfig):
+        super().__init__(var, cfg)
+        self.__snirfnames = ['SubjectID', 'MeasurementDate', 'MeasurementTime', 'LengthUnit', 'TimeUnit', 'FrequencyUnit', ]
+        if 'SubjectID' in self._h:
             if not self._cfg.dynamic_loading:
                 self._SubjectID = _read_string(self._h['SubjectID'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"SubjectID"')
-
-        if 'MeasurementDate' in self._h.keys():
+        if 'MeasurementDate' in self._h:
             if not self._cfg.dynamic_loading:
                 self._MeasurementDate = _read_string(self._h['MeasurementDate'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"MeasurementDate"')
-
-        if 'MeasurementTime' in self._h.keys():
+        if 'MeasurementTime' in self._h:
             if not self._cfg.dynamic_loading:
                 self._MeasurementTime = _read_string(self._h['MeasurementTime'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"MeasurementTime"')
-
-        if 'LengthUnit' in self._h.keys():
+        if 'LengthUnit' in self._h:
             if not self._cfg.dynamic_loading:
                 self._LengthUnit = _read_string(self._h['LengthUnit'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"LengthUnit"')
-
-        if 'TimeUnit' in self._h.keys():
+        if 'TimeUnit' in self._h:
             if not self._cfg.dynamic_loading:
                 self._TimeUnit = _read_string(self._h['TimeUnit'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"TimeUnit"')
-
-        if 'FrequencyUnit' in self._h.keys():
+        if 'FrequencyUnit' in self._h:
             if not self._cfg.dynamic_loading:
                 self._FrequencyUnit = _read_string(self._h['FrequencyUnit'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"FrequencyUnit"')
-
 
     @property
     def SubjectID(self):
-        if self._cfg.dynamic_loading and self._SubjectID is None:
-            if 'SubjectID' in self._h.keys():
-                return _read_string(self._h['SubjectID'])
-        return self._SubjectID
+        if type(self._SubjectID) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'SubjectID' in self._h:
+                    return _read_string(self._h['SubjectID'])
+                    self._cfg.logger.info('Dynamically loaded %s/SubjectID from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._SubjectID
 
     @SubjectID.setter
     def SubjectID(self, value):
         self._SubjectID = value
+        # self._cfg.logger.info('Assignment to %s/SubjectID in %s', self.location, self.filename)
+
+    @SubjectID.deleter
+    def SubjectID(self):
+        self._SubjectID = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/SubjectID from %s', self.location, self.filename)
 
     @property
     def MeasurementDate(self):
-        if self._cfg.dynamic_loading and self._MeasurementDate is None:
-            if 'MeasurementDate' in self._h.keys():
-                return _read_string(self._h['MeasurementDate'])
-        return self._MeasurementDate
+        if type(self._MeasurementDate) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'MeasurementDate' in self._h:
+                    return _read_string(self._h['MeasurementDate'])
+                    self._cfg.logger.info('Dynamically loaded %s/MeasurementDate from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._MeasurementDate
 
     @MeasurementDate.setter
     def MeasurementDate(self, value):
         self._MeasurementDate = value
+        # self._cfg.logger.info('Assignment to %s/MeasurementDate in %s', self.location, self.filename)
+
+    @MeasurementDate.deleter
+    def MeasurementDate(self):
+        self._MeasurementDate = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/MeasurementDate from %s', self.location, self.filename)
 
     @property
     def MeasurementTime(self):
-        if self._cfg.dynamic_loading and self._MeasurementTime is None:
-            if 'MeasurementTime' in self._h.keys():
-                return _read_string(self._h['MeasurementTime'])
-        return self._MeasurementTime
+        if type(self._MeasurementTime) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'MeasurementTime' in self._h:
+                    return _read_string(self._h['MeasurementTime'])
+                    self._cfg.logger.info('Dynamically loaded %s/MeasurementTime from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._MeasurementTime
 
     @MeasurementTime.setter
     def MeasurementTime(self, value):
         self._MeasurementTime = value
+        # self._cfg.logger.info('Assignment to %s/MeasurementTime in %s', self.location, self.filename)
+
+    @MeasurementTime.deleter
+    def MeasurementTime(self):
+        self._MeasurementTime = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/MeasurementTime from %s', self.location, self.filename)
 
     @property
     def LengthUnit(self):
-        if self._cfg.dynamic_loading and self._LengthUnit is None:
-            if 'LengthUnit' in self._h.keys():
-                return _read_string(self._h['LengthUnit'])
-        return self._LengthUnit
+        if type(self._LengthUnit) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'LengthUnit' in self._h:
+                    return _read_string(self._h['LengthUnit'])
+                    self._cfg.logger.info('Dynamically loaded %s/LengthUnit from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._LengthUnit
 
     @LengthUnit.setter
     def LengthUnit(self, value):
         self._LengthUnit = value
+        # self._cfg.logger.info('Assignment to %s/LengthUnit in %s', self.location, self.filename)
+
+    @LengthUnit.deleter
+    def LengthUnit(self):
+        self._LengthUnit = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/LengthUnit from %s', self.location, self.filename)
 
     @property
     def TimeUnit(self):
-        if self._cfg.dynamic_loading and self._TimeUnit is None:
-            if 'TimeUnit' in self._h.keys():
-                return _read_string(self._h['TimeUnit'])
-        return self._TimeUnit
+        if type(self._TimeUnit) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'TimeUnit' in self._h:
+                    return _read_string(self._h['TimeUnit'])
+                    self._cfg.logger.info('Dynamically loaded %s/TimeUnit from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._TimeUnit
 
     @TimeUnit.setter
     def TimeUnit(self, value):
         self._TimeUnit = value
+        # self._cfg.logger.info('Assignment to %s/TimeUnit in %s', self.location, self.filename)
+
+    @TimeUnit.deleter
+    def TimeUnit(self):
+        self._TimeUnit = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/TimeUnit from %s', self.location, self.filename)
 
     @property
     def FrequencyUnit(self):
-        if self._cfg.dynamic_loading and self._FrequencyUnit is None:
-            if 'FrequencyUnit' in self._h.keys():
-                return _read_string(self._h['FrequencyUnit'])
-        return self._FrequencyUnit
+        if type(self._FrequencyUnit) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'FrequencyUnit' in self._h:
+                    return _read_string(self._h['FrequencyUnit'])
+                    self._cfg.logger.info('Dynamically loaded %s/FrequencyUnit from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._FrequencyUnit
 
     @FrequencyUnit.setter
     def FrequencyUnit(self, value):
         self._FrequencyUnit = value
+        # self._cfg.logger.info('Assignment to %s/FrequencyUnit in %s', self.location, self.filename)
+
+    @FrequencyUnit.deleter
+    def FrequencyUnit(self):
+        self._FrequencyUnit = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/FrequencyUnit 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:
-            file = self._h.file
-        if 'SubjectID' in self._h:
-            name = self._h['SubjectID'].name
-            data = self.SubjectID
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'MeasurementDate' in self._h:
-            name = self._h['MeasurementDate'].name
-            data = self.MeasurementDate
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'MeasurementTime' in self._h:
-            name = self._h['MeasurementTime'].name
-            data = self.MeasurementTime
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'LengthUnit' in self._h:
-            name = self._h['LengthUnit'].name
-            data = self.LengthUnit
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'TimeUnit' in self._h:
-            name = self._h['TimeUnit'].name
-            data = self.TimeUnit
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'FrequencyUnit' in self._h:
-            name = self._h['FrequencyUnit'].name
-            data = self.FrequencyUnit
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
+            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 + '/SubjectID'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.SubjectID
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/MeasurementDate'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.MeasurementDate
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/MeasurementTime'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.MeasurementTime
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/LengthUnit'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.LengthUnit
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/TimeUnit'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.TimeUnit
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/FrequencyUnit'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.FrequencyUnit
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
 
 
 
 class Probe(Group):
 
-    _wavelengths = None  # [<f>,...]*
-    _wavelengthsEmission = None  # [<f>,...]
-    _sourcePos2D = None  # [[<f>,...]]*1
-    _sourcePos3D = None  # [[<f>,...]]*1
-    _detectorPos2D = None  # [[<f>,...]]*2
-    _detectorPos3D = None  # [[<f>,...]]*2
-    _frequencies = None  # [<f>,...]
-    _timeDelays = None  # [<f>,...]
-    _timeDelayWidths = None  # [<f>,...]
-    _momentOrders = None  # [<f>,...]
-    _correlationTimeDelays = None  # [<f>,...]
-    _correlationTimeDelayWidths = None  # [<f>,...]
-    _sourceLabels = None  # ["s",...]
-    _detectorLabels = None  # ["s",...]
-    _landmarkPos2D = None  # [[<f>,...]]
-    _landmarkPos3D = None  # [[<f>,...]]
-    _landmarkLabels = None  # ["s",...]
-    _useLocalIndex = None  # <i>
-
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        super().__init__(gid, cfg)
-        if 'wavelengths' in self._h.keys():
+    _wavelengths = AbsentDataset()  # [<f>,...]*
+    _wavelengthsEmission = AbsentDataset()  # [<f>,...]
+    _sourcePos2D = AbsentDataset()  # [[<f>,...]]*1
+    _sourcePos3D = AbsentDataset()  # [[<f>,...]]*1
+    _detectorPos2D = AbsentDataset()  # [[<f>,...]]*2
+    _detectorPos3D = AbsentDataset()  # [[<f>,...]]*2
+    _frequencies = AbsentDataset()  # [<f>,...]
+    _timeDelays = AbsentDataset()  # [<f>,...]
+    _timeDelayWidths = AbsentDataset()  # [<f>,...]
+    _momentOrders = AbsentDataset()  # [<f>,...]
+    _correlationTimeDelays = AbsentDataset()  # [<f>,...]
+    _correlationTimeDelayWidths = AbsentDataset()  # [<f>,...]
+    _sourceLabels = AbsentDataset()  # ["s",...]
+    _detectorLabels = AbsentDataset()  # ["s",...]
+    _landmarkPos2D = AbsentDataset()  # [[<f>,...]]
+    _landmarkPos3D = AbsentDataset()  # [[<f>,...]]
+    _landmarkLabels = AbsentDataset()  # ["s",...]
+    _useLocalIndex = AbsentDataset()  # <i>
+
+
+    def __init__(self, var, cfg: SnirfConfig):
+        super().__init__(var, cfg)
+        self.__snirfnames = ['wavelengths', 'wavelengthsEmission', 'sourcePos2D', 'sourcePos3D', 'detectorPos2D', 'detectorPos3D', 'frequencies', 'timeDelays', 'timeDelayWidths', 'momentOrders', 'correlationTimeDelays', 'correlationTimeDelayWidths', 'sourceLabels', 'detectorLabels', 'landmarkPos2D', 'landmarkPos3D', 'landmarkLabels', 'useLocalIndex', ]
+        if 'wavelengths' in self._h:
             if not self._cfg.dynamic_loading:
-                self._wavelengths = np.array(self._h['wavelengths']).astype(float)  # Array
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"wavelengths"')
-
-        if 'wavelengthsEmission' in self._h.keys():
+                self._wavelengths = _read_float_array(self._h['wavelengths'])
+        if 'wavelengthsEmission' in self._h:
             if not self._cfg.dynamic_loading:
-                self._wavelengthsEmission = np.array(self._h['wavelengthsEmission']).astype(float)  # Array
-
-        if 'sourcePos2D' in self._h.keys():
+                self._wavelengthsEmission = _read_float_array(self._h['wavelengthsEmission'])
+        if 'sourcePos2D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourcePos2D = np.array(self._h['sourcePos2D']).astype(float)  # Array
-
-        if 'sourcePos3D' in self._h.keys():
+                self._sourcePos2D = _read_float_array(self._h['sourcePos2D'])
+        if 'sourcePos3D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourcePos3D = np.array(self._h['sourcePos3D']).astype(float)  # Array
-
-        if 'detectorPos2D' in self._h.keys():
+                self._sourcePos3D = _read_float_array(self._h['sourcePos3D'])
+        if 'detectorPos2D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorPos2D = np.array(self._h['detectorPos2D']).astype(float)  # Array
-
-        if 'detectorPos3D' in self._h.keys():
+                self._detectorPos2D = _read_float_array(self._h['detectorPos2D'])
+        if 'detectorPos3D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorPos3D = np.array(self._h['detectorPos3D']).astype(float)  # Array
-
-        if 'frequencies' in self._h.keys():
+                self._detectorPos3D = _read_float_array(self._h['detectorPos3D'])
+        if 'frequencies' in self._h:
             if not self._cfg.dynamic_loading:
-                self._frequencies = np.array(self._h['frequencies']).astype(float)  # Array
-
-        if 'timeDelays' in self._h.keys():
+                self._frequencies = _read_float_array(self._h['frequencies'])
+        if 'timeDelays' in self._h:
             if not self._cfg.dynamic_loading:
-                self._timeDelays = np.array(self._h['timeDelays']).astype(float)  # Array
-
-        if 'timeDelayWidths' in self._h.keys():
+                self._timeDelays = _read_float_array(self._h['timeDelays'])
+        if 'timeDelayWidths' in self._h:
             if not self._cfg.dynamic_loading:
-                self._timeDelayWidths = np.array(self._h['timeDelayWidths']).astype(float)  # Array
-
-        if 'momentOrders' in self._h.keys():
+                self._timeDelayWidths = _read_float_array(self._h['timeDelayWidths'])
+        if 'momentOrders' in self._h:
             if not self._cfg.dynamic_loading:
-                self._momentOrders = np.array(self._h['momentOrders']).astype(float)  # Array
-
-        if 'correlationTimeDelays' in self._h.keys():
+                self._momentOrders = _read_float_array(self._h['momentOrders'])
+        if 'correlationTimeDelays' in self._h:
             if not self._cfg.dynamic_loading:
-                self._correlationTimeDelays = np.array(self._h['correlationTimeDelays']).astype(float)  # Array
-
-        if 'correlationTimeDelayWidths' in self._h.keys():
+                self._correlationTimeDelays = _read_float_array(self._h['correlationTimeDelays'])
+        if 'correlationTimeDelayWidths' in self._h:
             if not self._cfg.dynamic_loading:
-                self._correlationTimeDelayWidths = np.array(self._h['correlationTimeDelayWidths']).astype(float)  # Array
-
-        if 'sourceLabels' in self._h.keys():
+                self._correlationTimeDelayWidths = _read_float_array(self._h['correlationTimeDelayWidths'])
+        if 'sourceLabels' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourceLabels = np.array(self._h['sourceLabels']).astype(str)  # Array
-
-        if 'detectorLabels' in self._h.keys():
+                self._sourceLabels = _read_string_array(self._h['sourceLabels'])
+        if 'detectorLabels' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorLabels = np.array(self._h['detectorLabels']).astype(str)  # Array
-
-        if 'landmarkPos2D' in self._h.keys():
+                self._detectorLabels = _read_string_array(self._h['detectorLabels'])
+        if 'landmarkPos2D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._landmarkPos2D = np.array(self._h['landmarkPos2D']).astype(float)  # Array
-
-        if 'landmarkPos3D' in self._h.keys():
+                self._landmarkPos2D = _read_float_array(self._h['landmarkPos2D'])
+        if 'landmarkPos3D' in self._h:
             if not self._cfg.dynamic_loading:
-                self._landmarkPos3D = np.array(self._h['landmarkPos3D']).astype(float)  # Array
-
-        if 'landmarkLabels' in self._h.keys():
+                self._landmarkPos3D = _read_float_array(self._h['landmarkPos3D'])
+        if 'landmarkLabels' in self._h:
             if not self._cfg.dynamic_loading:
-                self._landmarkLabels = np.array(self._h['landmarkLabels']).astype(str)  # Array
-
-        if 'useLocalIndex' in self._h.keys():
+                self._landmarkLabels = _read_string_array(self._h['landmarkLabels'])
+        if 'useLocalIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._useLocalIndex = int(self._h['useLocalIndex'][()])
-
+                self._useLocalIndex = _read_int(self._h['useLocalIndex'])
 
     @property
     def wavelengths(self):
-        if self._cfg.dynamic_loading and self._wavelengths is None:
-            if 'wavelengths' in self._h.keys():
-                return np.array(self._h['wavelengths']).astype(float)  # Array
-        return self._wavelengths
+        if type(self._wavelengths) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'wavelengths' in self._h:
+                    return _read_float_array(self._h['wavelengths'])
+                    self._cfg.logger.info('Dynamically loaded %s/wavelengths from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._wavelengths
 
     @wavelengths.setter
     def wavelengths(self, value):
         self._wavelengths = value
+        # self._cfg.logger.info('Assignment to %s/wavelengths in %s', self.location, self.filename)
+
+    @wavelengths.deleter
+    def wavelengths(self):
+        self._wavelengths = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/wavelengths from %s', self.location, self.filename)
 
     @property
     def wavelengthsEmission(self):
-        if self._cfg.dynamic_loading and self._wavelengthsEmission is None:
-            if 'wavelengthsEmission' in self._h.keys():
-                return np.array(self._h['wavelengthsEmission']).astype(float)  # Array
-        return self._wavelengthsEmission
+        if type(self._wavelengthsEmission) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'wavelengthsEmission' in self._h:
+                    return _read_float_array(self._h['wavelengthsEmission'])
+                    self._cfg.logger.info('Dynamically loaded %s/wavelengthsEmission from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._wavelengthsEmission
 
     @wavelengthsEmission.setter
     def wavelengthsEmission(self, value):
         self._wavelengthsEmission = value
+        # self._cfg.logger.info('Assignment to %s/wavelengthsEmission in %s', self.location, self.filename)
+
+    @wavelengthsEmission.deleter
+    def wavelengthsEmission(self):
+        self._wavelengthsEmission = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/wavelengthsEmission from %s', self.location, self.filename)
 
     @property
     def sourcePos2D(self):
-        if self._cfg.dynamic_loading and self._sourcePos2D is None:
-            if 'sourcePos2D' in self._h.keys():
-                return np.array(self._h['sourcePos2D']).astype(float)  # Array
-        return self._sourcePos2D
+        if type(self._sourcePos2D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourcePos2D' in self._h:
+                    return _read_float_array(self._h['sourcePos2D'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourcePos2D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._sourcePos2D
 
     @sourcePos2D.setter
     def sourcePos2D(self, value):
         self._sourcePos2D = value
+        # self._cfg.logger.info('Assignment to %s/sourcePos2D in %s', self.location, self.filename)
+
+    @sourcePos2D.deleter
+    def sourcePos2D(self):
+        self._sourcePos2D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/sourcePos2D from %s', self.location, self.filename)
 
     @property
     def sourcePos3D(self):
-        if self._cfg.dynamic_loading and self._sourcePos3D is None:
-            if 'sourcePos3D' in self._h.keys():
-                return np.array(self._h['sourcePos3D']).astype(float)  # Array
-        return self._sourcePos3D
+        if type(self._sourcePos3D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourcePos3D' in self._h:
+                    return _read_float_array(self._h['sourcePos3D'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourcePos3D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._sourcePos3D
 
     @sourcePos3D.setter
     def sourcePos3D(self, value):
         self._sourcePos3D = value
+        # self._cfg.logger.info('Assignment to %s/sourcePos3D in %s', self.location, self.filename)
+
+    @sourcePos3D.deleter
+    def sourcePos3D(self):
+        self._sourcePos3D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/sourcePos3D from %s', self.location, self.filename)
 
     @property
     def detectorPos2D(self):
-        if self._cfg.dynamic_loading and self._detectorPos2D is None:
-            if 'detectorPos2D' in self._h.keys():
-                return np.array(self._h['detectorPos2D']).astype(float)  # Array
-        return self._detectorPos2D
+        if type(self._detectorPos2D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorPos2D' in self._h:
+                    return _read_float_array(self._h['detectorPos2D'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorPos2D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._detectorPos2D
 
     @detectorPos2D.setter
     def detectorPos2D(self, value):
         self._detectorPos2D = value
+        # self._cfg.logger.info('Assignment to %s/detectorPos2D in %s', self.location, self.filename)
+
+    @detectorPos2D.deleter
+    def detectorPos2D(self):
+        self._detectorPos2D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/detectorPos2D from %s', self.location, self.filename)
 
     @property
     def detectorPos3D(self):
-        if self._cfg.dynamic_loading and self._detectorPos3D is None:
-            if 'detectorPos3D' in self._h.keys():
-                return np.array(self._h['detectorPos3D']).astype(float)  # Array
-        return self._detectorPos3D
+        if type(self._detectorPos3D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorPos3D' in self._h:
+                    return _read_float_array(self._h['detectorPos3D'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorPos3D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._detectorPos3D
 
     @detectorPos3D.setter
     def detectorPos3D(self, value):
         self._detectorPos3D = value
+        # self._cfg.logger.info('Assignment to %s/detectorPos3D in %s', self.location, self.filename)
+
+    @detectorPos3D.deleter
+    def detectorPos3D(self):
+        self._detectorPos3D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/detectorPos3D from %s', self.location, self.filename)
 
     @property
     def frequencies(self):
-        if self._cfg.dynamic_loading and self._frequencies is None:
-            if 'frequencies' in self._h.keys():
-                return np.array(self._h['frequencies']).astype(float)  # Array
-        return self._frequencies
+        if type(self._frequencies) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'frequencies' in self._h:
+                    return _read_float_array(self._h['frequencies'])
+                    self._cfg.logger.info('Dynamically loaded %s/frequencies from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._frequencies
 
     @frequencies.setter
     def frequencies(self, value):
         self._frequencies = value
+        # self._cfg.logger.info('Assignment to %s/frequencies in %s', self.location, self.filename)
+
+    @frequencies.deleter
+    def frequencies(self):
+        self._frequencies = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/frequencies from %s', self.location, self.filename)
 
     @property
     def timeDelays(self):
-        if self._cfg.dynamic_loading and self._timeDelays is None:
-            if 'timeDelays' in self._h.keys():
-                return np.array(self._h['timeDelays']).astype(float)  # Array
-        return self._timeDelays
+        if type(self._timeDelays) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'timeDelays' in self._h:
+                    return _read_float_array(self._h['timeDelays'])
+                    self._cfg.logger.info('Dynamically loaded %s/timeDelays from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._timeDelays
 
     @timeDelays.setter
     def timeDelays(self, value):
         self._timeDelays = value
+        # self._cfg.logger.info('Assignment to %s/timeDelays in %s', self.location, self.filename)
+
+    @timeDelays.deleter
+    def timeDelays(self):
+        self._timeDelays = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/timeDelays from %s', self.location, self.filename)
 
     @property
     def timeDelayWidths(self):
-        if self._cfg.dynamic_loading and self._timeDelayWidths is None:
-            if 'timeDelayWidths' in self._h.keys():
-                return np.array(self._h['timeDelayWidths']).astype(float)  # Array
-        return self._timeDelayWidths
+        if type(self._timeDelayWidths) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'timeDelayWidths' in self._h:
+                    return _read_float_array(self._h['timeDelayWidths'])
+                    self._cfg.logger.info('Dynamically loaded %s/timeDelayWidths from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._timeDelayWidths
 
     @timeDelayWidths.setter
     def timeDelayWidths(self, value):
         self._timeDelayWidths = value
+        # self._cfg.logger.info('Assignment to %s/timeDelayWidths in %s', self.location, self.filename)
+
+    @timeDelayWidths.deleter
+    def timeDelayWidths(self):
+        self._timeDelayWidths = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/timeDelayWidths from %s', self.location, self.filename)
 
     @property
     def momentOrders(self):
-        if self._cfg.dynamic_loading and self._momentOrders is None:
-            if 'momentOrders' in self._h.keys():
-                return np.array(self._h['momentOrders']).astype(float)  # Array
-        return self._momentOrders
+        if type(self._momentOrders) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'momentOrders' in self._h:
+                    return _read_float_array(self._h['momentOrders'])
+                    self._cfg.logger.info('Dynamically loaded %s/momentOrders from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._momentOrders
 
     @momentOrders.setter
     def momentOrders(self, value):
         self._momentOrders = value
+        # self._cfg.logger.info('Assignment to %s/momentOrders in %s', self.location, self.filename)
+
+    @momentOrders.deleter
+    def momentOrders(self):
+        self._momentOrders = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/momentOrders from %s', self.location, self.filename)
 
     @property
     def correlationTimeDelays(self):
-        if self._cfg.dynamic_loading and self._correlationTimeDelays is None:
-            if 'correlationTimeDelays' in self._h.keys():
-                return np.array(self._h['correlationTimeDelays']).astype(float)  # Array
-        return self._correlationTimeDelays
+        if type(self._correlationTimeDelays) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'correlationTimeDelays' in self._h:
+                    return _read_float_array(self._h['correlationTimeDelays'])
+                    self._cfg.logger.info('Dynamically loaded %s/correlationTimeDelays from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._correlationTimeDelays
 
     @correlationTimeDelays.setter
     def correlationTimeDelays(self, value):
         self._correlationTimeDelays = value
+        # self._cfg.logger.info('Assignment to %s/correlationTimeDelays in %s', self.location, self.filename)
+
+    @correlationTimeDelays.deleter
+    def correlationTimeDelays(self):
+        self._correlationTimeDelays = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/correlationTimeDelays from %s', self.location, self.filename)
 
     @property
     def correlationTimeDelayWidths(self):
-        if self._cfg.dynamic_loading and self._correlationTimeDelayWidths is None:
-            if 'correlationTimeDelayWidths' in self._h.keys():
-                return np.array(self._h['correlationTimeDelayWidths']).astype(float)  # Array
-        return self._correlationTimeDelayWidths
+        if type(self._correlationTimeDelayWidths) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'correlationTimeDelayWidths' in self._h:
+                    return _read_float_array(self._h['correlationTimeDelayWidths'])
+                    self._cfg.logger.info('Dynamically loaded %s/correlationTimeDelayWidths from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._correlationTimeDelayWidths
 
     @correlationTimeDelayWidths.setter
     def correlationTimeDelayWidths(self, value):
         self._correlationTimeDelayWidths = value
+        # self._cfg.logger.info('Assignment to %s/correlationTimeDelayWidths in %s', self.location, self.filename)
+
+    @correlationTimeDelayWidths.deleter
+    def correlationTimeDelayWidths(self):
+        self._correlationTimeDelayWidths = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/correlationTimeDelayWidths from %s', self.location, self.filename)
 
     @property
     def sourceLabels(self):
-        if self._cfg.dynamic_loading and self._sourceLabels is None:
-            if 'sourceLabels' in self._h.keys():
-                return np.array(self._h['sourceLabels']).astype(str)  # Array
-        return self._sourceLabels
+        if type(self._sourceLabels) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourceLabels' in self._h:
+                    return _read_string_array(self._h['sourceLabels'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourceLabels from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._sourceLabels
 
     @sourceLabels.setter
     def sourceLabels(self, value):
         self._sourceLabels = value
+        # self._cfg.logger.info('Assignment to %s/sourceLabels in %s', self.location, self.filename)
+
+    @sourceLabels.deleter
+    def sourceLabels(self):
+        self._sourceLabels = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/sourceLabels from %s', self.location, self.filename)
 
     @property
     def detectorLabels(self):
-        if self._cfg.dynamic_loading and self._detectorLabels is None:
-            if 'detectorLabels' in self._h.keys():
-                return np.array(self._h['detectorLabels']).astype(str)  # Array
-        return self._detectorLabels
+        if type(self._detectorLabels) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorLabels' in self._h:
+                    return _read_string_array(self._h['detectorLabels'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorLabels from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._detectorLabels
 
     @detectorLabels.setter
     def detectorLabels(self, value):
         self._detectorLabels = value
+        # self._cfg.logger.info('Assignment to %s/detectorLabels in %s', self.location, self.filename)
+
+    @detectorLabels.deleter
+    def detectorLabels(self):
+        self._detectorLabels = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/detectorLabels from %s', self.location, self.filename)
 
     @property
     def landmarkPos2D(self):
-        if self._cfg.dynamic_loading and self._landmarkPos2D is None:
-            if 'landmarkPos2D' in self._h.keys():
-                return np.array(self._h['landmarkPos2D']).astype(float)  # Array
-        return self._landmarkPos2D
+        if type(self._landmarkPos2D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'landmarkPos2D' in self._h:
+                    return _read_float_array(self._h['landmarkPos2D'])
+                    self._cfg.logger.info('Dynamically loaded %s/landmarkPos2D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._landmarkPos2D
 
     @landmarkPos2D.setter
     def landmarkPos2D(self, value):
         self._landmarkPos2D = value
+        # self._cfg.logger.info('Assignment to %s/landmarkPos2D in %s', self.location, self.filename)
+
+    @landmarkPos2D.deleter
+    def landmarkPos2D(self):
+        self._landmarkPos2D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/landmarkPos2D from %s', self.location, self.filename)
 
     @property
     def landmarkPos3D(self):
-        if self._cfg.dynamic_loading and self._landmarkPos3D is None:
-            if 'landmarkPos3D' in self._h.keys():
-                return np.array(self._h['landmarkPos3D']).astype(float)  # Array
-        return self._landmarkPos3D
+        if type(self._landmarkPos3D) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'landmarkPos3D' in self._h:
+                    return _read_float_array(self._h['landmarkPos3D'])
+                    self._cfg.logger.info('Dynamically loaded %s/landmarkPos3D from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._landmarkPos3D
 
     @landmarkPos3D.setter
     def landmarkPos3D(self, value):
         self._landmarkPos3D = value
+        # self._cfg.logger.info('Assignment to %s/landmarkPos3D in %s', self.location, self.filename)
+
+    @landmarkPos3D.deleter
+    def landmarkPos3D(self):
+        self._landmarkPos3D = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/landmarkPos3D from %s', self.location, self.filename)
 
     @property
     def landmarkLabels(self):
-        if self._cfg.dynamic_loading and self._landmarkLabels is None:
-            if 'landmarkLabels' in self._h.keys():
-                return np.array(self._h['landmarkLabels']).astype(str)  # Array
-        return self._landmarkLabels
+        if type(self._landmarkLabels) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'landmarkLabels' in self._h:
+                    return _read_string_array(self._h['landmarkLabels'])
+                    self._cfg.logger.info('Dynamically loaded %s/landmarkLabels from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._landmarkLabels
 
     @landmarkLabels.setter
     def landmarkLabels(self, value):
         self._landmarkLabels = value
+        # self._cfg.logger.info('Assignment to %s/landmarkLabels in %s', self.location, self.filename)
 
-    @property
-    def useLocalIndex(self):
-        if self._cfg.dynamic_loading and self._useLocalIndex is None:
-            if 'useLocalIndex' in self._h.keys():
-                return int(self._h['useLocalIndex'][()])
-        return self._useLocalIndex
-
-    @useLocalIndex.setter
-    def useLocalIndex(self, value):
-        self._useLocalIndex = value
-
-
-    def _save(self, *args):
-        if len(args) > 0 and type(args[0]) is h5py.File:
-            file = args[0]
-        else:
-            file = self._h.file
-        if 'wavelengths' in self._h:
-            name = self._h['wavelengths'].name
-            data = np.array(self.wavelengths)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'wavelengthsEmission' in self._h:
-            name = self._h['wavelengthsEmission'].name
-            data = np.array(self.wavelengthsEmission)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'sourcePos2D' in self._h:
-            name = self._h['sourcePos2D'].name
-            data = np.array(self.sourcePos2D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'sourcePos3D' in self._h:
-            name = self._h['sourcePos3D'].name
-            data = np.array(self.sourcePos3D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'detectorPos2D' in self._h:
-            name = self._h['detectorPos2D'].name
-            data = np.array(self.detectorPos2D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'detectorPos3D' in self._h:
-            name = self._h['detectorPos3D'].name
-            data = np.array(self.detectorPos3D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'frequencies' in self._h:
-            name = self._h['frequencies'].name
-            data = np.array(self.frequencies)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'timeDelays' in self._h:
-            name = self._h['timeDelays'].name
-            data = np.array(self.timeDelays)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'timeDelayWidths' in self._h:
-            name = self._h['timeDelayWidths'].name
-            data = np.array(self.timeDelayWidths)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'momentOrders' in self._h:
-            name = self._h['momentOrders'].name
-            data = np.array(self.momentOrders)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'correlationTimeDelays' in self._h:
-            name = self._h['correlationTimeDelays'].name
-            data = np.array(self.correlationTimeDelays)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'correlationTimeDelayWidths' in self._h:
-            name = self._h['correlationTimeDelayWidths'].name
-            data = np.array(self.correlationTimeDelayWidths)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'sourceLabels' in self._h:
-            name = self._h['sourceLabels'].name
-            data = np.array(self.sourceLabels).astype('O')
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'detectorLabels' in self._h:
-            name = self._h['detectorLabels'].name
-            data = np.array(self.detectorLabels).astype('O')
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'landmarkPos2D' in self._h:
-            name = self._h['landmarkPos2D'].name
-            data = np.array(self.landmarkPos2D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'landmarkPos3D' in self._h:
-            name = self._h['landmarkPos3D'].name
-            data = np.array(self.landmarkPos3D)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'landmarkLabels' in self._h:
-            name = self._h['landmarkLabels'].name
-            data = np.array(self.landmarkLabels).astype('O')
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'useLocalIndex' in self._h:
-            name = self._h['useLocalIndex'].name
-            data = self.useLocalIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
+    @landmarkLabels.deleter
+    def landmarkLabels(self):
+        self._landmarkLabels = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/landmarkLabels from %s', self.location, self.filename)
 
+    @property
+    def useLocalIndex(self):
+        if type(self._useLocalIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'useLocalIndex' in self._h:
+                    return _read_int(self._h['useLocalIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/useLocalIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._useLocalIndex
 
+    @useLocalIndex.setter
+    def useLocalIndex(self, value):
+        self._useLocalIndex = value
+        # self._cfg.logger.info('Assignment to %s/useLocalIndex in %s', self.location, self.filename)
 
-class NirsElement(Group):
+    @useLocalIndex.deleter
+    def useLocalIndex(self):
+        self._useLocalIndex = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/useLocalIndex from %s', self.location, self.filename)
 
-    _metaDataTags = None  # {.}*
-    _data = None  # {i}*
-    _stim = None  # {i}
-    _probe = None  # {.}*
-    _aux = None  # {i}
 
-    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
-        super().__init__(gid, cfg)
-        if 'metaDataTags' in self._h.keys():
-            self._metaDataTags = MetaDataTags(self._h['metaDataTags'].id, self._cfg)  # Group
-        else:  # Create an empty group
-            self._h.create_group('metaDataTags')
-            self._metaDataTags = MetaDataTags(self._h['metaDataTags'].id, self._cfg)  # Group
+    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 + '/wavelengths'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.wavelengths
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/wavelengthsEmission'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.wavelengthsEmission
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/sourcePos2D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourcePos2D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/sourcePos3D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourcePos3D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorPos2D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorPos2D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorPos3D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorPos3D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/frequencies'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.frequencies
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/timeDelays'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.timeDelays
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/timeDelayWidths'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.timeDelayWidths
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/momentOrders'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.momentOrders
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/correlationTimeDelays'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.correlationTimeDelays
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/correlationTimeDelayWidths'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.correlationTimeDelayWidths
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/sourceLabels'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourceLabels
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorLabels'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorLabels
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/landmarkPos2D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.landmarkPos2D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/landmarkPos3D'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.landmarkPos3D
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/landmarkLabels'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.landmarkLabels
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/useLocalIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.useLocalIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
 
-        self.data = Data(self._h, self._cfg)  # Indexed group
 
-        self.stim = Stim(self._h, self._cfg)  # Indexed group
 
-        if 'probe' in self._h.keys():
-            self._probe = Probe(self._h['probe'].id, self._cfg)  # Group
-        else:  # Create an empty group
-            self._h.create_group('probe')
-            self._probe = Probe(self._h['probe'].id, self._cfg)  # Group
+class NirsElement(Group):
 
-        self.aux = Aux(self._h, self._cfg)  # Indexed group
+    _metaDataTags = AbsentDataset()  # {.}*
+    _data = AbsentDataset()  # {i}*
+    _stim = AbsentDataset()  # {i}
+    _probe = AbsentDataset()  # {.}*
+    _aux = AbsentDataset()  # {i}
 
 
+    def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
+        super().__init__(gid, cfg)
+        self.__snirfnames = ['metaDataTags', 'data', 'stim', 'probe', 'aux', ]
+        if 'metaDataTags' in self._h:
+            self._metaDataTags = MetaDataTags(self._h['metaDataTags'].id, self._cfg)  # Group
+        else:
+            self._metaDataTags = MetaDataTags(self.location + '/' + 'metaDataTags', self._cfg)  # Anonymous group (wrapper only)
+        self.data = Data(self, self._cfg)  # Indexed group
+        self.stim = Stim(self, self._cfg)  # Indexed group
+        if 'probe' in self._h:
+            self._probe = Probe(self._h['probe'].id, self._cfg)  # Group
+        else:
+            self._probe = Probe(self.location + '/' + 'probe', self._cfg)  # Anonymous group (wrapper only)
+        self.aux = Aux(self, self._cfg)  # Indexed group
+
     @property
     def metaDataTags(self):
-        if 'metaDataTags' in self._h.keys():
-            return self._metaDataTags
+        return self._metaDataTags
 
     @metaDataTags.setter
     def metaDataTags(self, value):
         self._metaDataTags = value
+        # self._cfg.logger.info('Assignment to %s/metaDataTags in %s', self.location, self.filename)
+
+    @metaDataTags.deleter
+    def metaDataTags(self):
+        self._metaDataTags = AbsentGroup()
+        self._cfg.logger.info('Deleted %s/metaDataTags from %s', self.location, self.filename)
 
     @property
     def data(self):
@@ -807,6 +1397,12 @@ def data(self):
     @data.setter
     def data(self, value):
         self._data = value
+        # self._cfg.logger.info('Assignment to %s/data in %s', self.location, self.filename)
+
+    @data.deleter
+    def data(self):
+        raise AttributeError('IndexedGroup ' + str(type(self._data)) + ' cannot be deleted')
+        self._cfg.logger.info('Deleted %s/data from %s', self.location, self.filename)
 
     @property
     def stim(self):
@@ -815,15 +1411,26 @@ def stim(self):
     @stim.setter
     def stim(self, value):
         self._stim = value
+        # self._cfg.logger.info('Assignment to %s/stim in %s', self.location, self.filename)
+
+    @stim.deleter
+    def stim(self):
+        raise AttributeError('IndexedGroup ' + str(type(self._stim)) + ' cannot be deleted')
+        self._cfg.logger.info('Deleted %s/stim from %s', self.location, self.filename)
 
     @property
     def probe(self):
-        if 'probe' in self._h.keys():
-            return self._probe
+        return self._probe
 
     @probe.setter
     def probe(self, value):
         self._probe = value
+        # self._cfg.logger.info('Assignment to %s/probe in %s', self.location, self.filename)
+
+    @probe.deleter
+    def probe(self):
+        self._probe = AbsentGroup()
+        self._cfg.logger.info('Deleted %s/probe from %s', self.location, self.filename)
 
     @property
     def aux(self):
@@ -832,17 +1439,43 @@ def aux(self):
     @aux.setter
     def aux(self, value):
         self._aux = value
+        # self._cfg.logger.info('Assignment to %s/aux in %s', self.location, self.filename)
+
+    @aux.deleter
+    def aux(self):
+        raise AttributeError('IndexedGroup ' + str(type(self._aux)) + ' cannot be deleted')
+        self._cfg.logger.info('Deleted %s/aux 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:
-            file = self._h.file
-        self.metaDataTags._save(*args)
+            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')
+        if type(self._metaDataTags) is AbsentGroup:
+            if 'metaDataTags' in file:
+                del file['metaDataTags']
+                self._cfg.logger.info('Deleted Group %s/metaDataTags from %s', self.location, file)
+        else:
+            self.metaDataTags._save(*args)
         self.data._save(*args)
         self.stim._save(*args)
-        self.probe._save(*args)
+        if type(self._probe) is AbsentGroup:
+            if 'probe' in file:
+                del file['probe']
+                self._cfg.logger.info('Deleted Group %s/probe from %s', self.location, file)
+        else:
+            self.probe._save(*args)
         self.aux._save(*args)
 
 
@@ -857,48 +1490,65 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig):
 
 class DataElement(Group):
 
-    _dataTimeSeries = None  # [[<f>,...]]*
-    _time = None  # [<f>,...]*
-    _measurementList = None  # {i}*
+    _dataTimeSeries = AbsentDataset()  # [[<f>,...]]*
+    _time = AbsentDataset()  # [<f>,...]*
+    _measurementList = AbsentDataset()  # {i}*
+
 
     def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
         super().__init__(gid, cfg)
-        if 'dataTimeSeries' in self._h.keys():
+        self.__snirfnames = ['dataTimeSeries', 'time', 'measurementList', ]
+        if 'dataTimeSeries' in self._h:
             if not self._cfg.dynamic_loading:
-                self._dataTimeSeries = np.array(self._h['dataTimeSeries']).astype(float)  # Array
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"dataTimeSeries"')
-
-        if 'time' in self._h.keys():
+                self._dataTimeSeries = _read_float_array(self._h['dataTimeSeries'])
+        if 'time' in self._h:
             if not self._cfg.dynamic_loading:
-                self._time = np.array(self._h['time']).astype(float)  # Array
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"time"')
-
-        self.measurementList = MeasurementList(self._h, self._cfg)  # Indexed group
-
+                self._time = _read_float_array(self._h['time'])
+        self.measurementList = MeasurementList(self, self._cfg)  # Indexed group
 
     @property
     def dataTimeSeries(self):
-        if self._cfg.dynamic_loading and self._dataTimeSeries is None:
-            if 'dataTimeSeries' in self._h.keys():
-                return np.array(self._h['dataTimeSeries']).astype(float)  # Array
-        return self._dataTimeSeries
+        if type(self._dataTimeSeries) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataTimeSeries' in self._h:
+                    return _read_float_array(self._h['dataTimeSeries'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataTimeSeries from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._dataTimeSeries
 
     @dataTimeSeries.setter
     def dataTimeSeries(self, value):
         self._dataTimeSeries = value
+        # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename)
+
+    @dataTimeSeries.deleter
+    def dataTimeSeries(self):
+        self._dataTimeSeries = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/dataTimeSeries from %s', self.location, self.filename)
 
     @property
     def time(self):
-        if self._cfg.dynamic_loading and self._time is None:
-            if 'time' in self._h.keys():
-                return np.array(self._h['time']).astype(float)  # Array
-        return self._time
+        if type(self._time) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'time' in self._h:
+                    return _read_float_array(self._h['time'])
+                    self._cfg.logger.info('Dynamically loaded %s/time from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._time
 
     @time.setter
     def time(self, value):
         self._time = value
+        # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename)
+
+    @time.deleter
+    def time(self):
+        self._time = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/time from %s', self.location, self.filename)
 
     @property
     def measurementList(self):
@@ -907,25 +1557,45 @@ def measurementList(self):
     @measurementList.setter
     def measurementList(self, value):
         self._measurementList = value
+        # self._cfg.logger.info('Assignment to %s/measurementList in %s', self.location, self.filename)
+
+    @measurementList.deleter
+    def measurementList(self):
+        raise AttributeError('IndexedGroup ' + str(type(self._measurementList)) + ' cannot be deleted')
+        self._cfg.logger.info('Deleted %s/measurementList 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:
-            file = self._h.file
-        if 'dataTimeSeries' in self._h:
-            name = self._h['dataTimeSeries'].name
-            data = np.array(self.dataTimeSeries)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'time' in self._h:
-            name = self._h['time'].name
-            data = np.array(self.time)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
+            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 + '/dataTimeSeries'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataTimeSeries
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/time'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.time
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
         self.measurementList._save(*args)
 
 
@@ -940,312 +1610,470 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig):
 
 class MeasurementListElement(Group):
 
-    _sourceIndex = None  # <i>*
-    _detectorIndex = None  # <i>*
-    _wavelengthIndex = None  # <i>*
-    _wavelengthActual = None  # <f>
-    _wavelengthEmissionActual = None  # <f>
-    _dataType = None  # <i>*
-    _dataTypeLabel = None  # "s"
-    _dataTypeIndex = None  # <i>*
-    _sourcePower = None  # <f>
-    _detectorGain = None  # <f>
-    _moduleIndex = None  # <i>
-    _sourceModuleIndex = None  # <i>
-    _detectorModuleIndex = None  # <i>
+    _sourceIndex = AbsentDataset()  # <i>*
+    _detectorIndex = AbsentDataset()  # <i>*
+    _wavelengthIndex = AbsentDataset()  # <i>*
+    _wavelengthActual = AbsentDataset()  # <f>
+    _wavelengthEmissionActual = AbsentDataset()  # <f>
+    _dataType = AbsentDataset()  # <i>*
+    _dataTypeLabel = AbsentDataset()  # "s"
+    _dataTypeIndex = AbsentDataset()  # <i>*
+    _sourcePower = AbsentDataset()  # <f>
+    _detectorGain = AbsentDataset()  # <f>
+    _moduleIndex = AbsentDataset()  # <i>
+    _sourceModuleIndex = AbsentDataset()  # <i>
+    _detectorModuleIndex = AbsentDataset()  # <i>
+
 
     def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
         super().__init__(gid, cfg)
-        if 'sourceIndex' in self._h.keys():
+        self.__snirfnames = ['sourceIndex', 'detectorIndex', 'wavelengthIndex', 'wavelengthActual', 'wavelengthEmissionActual', 'dataType', 'dataTypeLabel', 'dataTypeIndex', 'sourcePower', 'detectorGain', 'moduleIndex', 'sourceModuleIndex', 'detectorModuleIndex', ]
+        if 'sourceIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourceIndex = int(self._h['sourceIndex'][()])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"sourceIndex"')
-
-        if 'detectorIndex' in self._h.keys():
+                self._sourceIndex = _read_int(self._h['sourceIndex'])
+        if 'detectorIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorIndex = int(self._h['detectorIndex'][()])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"detectorIndex"')
-
-        if 'wavelengthIndex' in self._h.keys():
+                self._detectorIndex = _read_int(self._h['detectorIndex'])
+        if 'wavelengthIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._wavelengthIndex = int(self._h['wavelengthIndex'][()])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"wavelengthIndex"')
-
-        if 'wavelengthActual' in self._h.keys():
+                self._wavelengthIndex = _read_int(self._h['wavelengthIndex'])
+        if 'wavelengthActual' in self._h:
             if not self._cfg.dynamic_loading:
-                self._wavelengthActual = float(self._h['wavelengthActual'][()])
-
-        if 'wavelengthEmissionActual' in self._h.keys():
+                self._wavelengthActual = _read_float(self._h['wavelengthActual'])
+        if 'wavelengthEmissionActual' in self._h:
             if not self._cfg.dynamic_loading:
-                self._wavelengthEmissionActual = float(self._h['wavelengthEmissionActual'][()])
-
-        if 'dataType' in self._h.keys():
+                self._wavelengthEmissionActual = _read_float(self._h['wavelengthEmissionActual'])
+        if 'dataType' in self._h:
             if not self._cfg.dynamic_loading:
-                self._dataType = int(self._h['dataType'][()])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"dataType"')
-
-        if 'dataTypeLabel' in self._h.keys():
+                self._dataType = _read_int(self._h['dataType'])
+        if 'dataTypeLabel' in self._h:
             if not self._cfg.dynamic_loading:
                 self._dataTypeLabel = _read_string(self._h['dataTypeLabel'])
-
-        if 'dataTypeIndex' in self._h.keys():
+        if 'dataTypeIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._dataTypeIndex = int(self._h['dataTypeIndex'][()])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"dataTypeIndex"')
-
-        if 'sourcePower' in self._h.keys():
+                self._dataTypeIndex = _read_int(self._h['dataTypeIndex'])
+        if 'sourcePower' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourcePower = float(self._h['sourcePower'][()])
-
-        if 'detectorGain' in self._h.keys():
+                self._sourcePower = _read_float(self._h['sourcePower'])
+        if 'detectorGain' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorGain = float(self._h['detectorGain'][()])
-
-        if 'moduleIndex' in self._h.keys():
+                self._detectorGain = _read_float(self._h['detectorGain'])
+        if 'moduleIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._moduleIndex = int(self._h['moduleIndex'][()])
-
-        if 'sourceModuleIndex' in self._h.keys():
+                self._moduleIndex = _read_int(self._h['moduleIndex'])
+        if 'sourceModuleIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._sourceModuleIndex = int(self._h['sourceModuleIndex'][()])
-
-        if 'detectorModuleIndex' in self._h.keys():
+                self._sourceModuleIndex = _read_int(self._h['sourceModuleIndex'])
+        if 'detectorModuleIndex' in self._h:
             if not self._cfg.dynamic_loading:
-                self._detectorModuleIndex = int(self._h['detectorModuleIndex'][()])
-
+                self._detectorModuleIndex = _read_int(self._h['detectorModuleIndex'])
 
     @property
     def sourceIndex(self):
-        if self._cfg.dynamic_loading and self._sourceIndex is None:
-            if 'sourceIndex' in self._h.keys():
-                return int(self._h['sourceIndex'][()])
-        return self._sourceIndex
+        if type(self._sourceIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourceIndex' in self._h:
+                    return _read_int(self._h['sourceIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourceIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._sourceIndex
 
     @sourceIndex.setter
     def sourceIndex(self, value):
         self._sourceIndex = value
+        # self._cfg.logger.info('Assignment to %s/sourceIndex in %s', self.location, self.filename)
+
+    @sourceIndex.deleter
+    def sourceIndex(self):
+        self._sourceIndex = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/sourceIndex from %s', self.location, self.filename)
 
     @property
     def detectorIndex(self):
-        if self._cfg.dynamic_loading and self._detectorIndex is None:
-            if 'detectorIndex' in self._h.keys():
-                return int(self._h['detectorIndex'][()])
-        return self._detectorIndex
+        if type(self._detectorIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorIndex' in self._h:
+                    return _read_int(self._h['detectorIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._wavelengthIndex is None:
-            if 'wavelengthIndex' in self._h.keys():
-                return int(self._h['wavelengthIndex'][()])
-        return self._wavelengthIndex
+        if type(self._wavelengthIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'wavelengthIndex' in self._h:
+                    return _read_int(self._h['wavelengthIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/wavelengthIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._wavelengthActual is None:
-            if 'wavelengthActual' in self._h.keys():
-                return float(self._h['wavelengthActual'][()])
-        return self._wavelengthActual
+        if type(self._wavelengthActual) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'wavelengthActual' in self._h:
+                    return _read_float(self._h['wavelengthActual'])
+                    self._cfg.logger.info('Dynamically loaded %s/wavelengthActual from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._wavelengthEmissionActual is None:
-            if 'wavelengthEmissionActual' in self._h.keys():
-                return float(self._h['wavelengthEmissionActual'][()])
-        return self._wavelengthEmissionActual
+        if type(self._wavelengthEmissionActual) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'wavelengthEmissionActual' in self._h:
+                    return _read_float(self._h['wavelengthEmissionActual'])
+                    self._cfg.logger.info('Dynamically loaded %s/wavelengthEmissionActual from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._dataType is None:
-            if 'dataType' in self._h.keys():
-                return int(self._h['dataType'][()])
-        return self._dataType
+        if type(self._dataType) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataType' in self._h:
+                    return _read_int(self._h['dataType'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataType from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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 dataTypeLabel(self):
-        if self._cfg.dynamic_loading and self._dataTypeLabel is None:
-            if 'dataTypeLabel' in self._h.keys():
-                return _read_string(self._h['dataTypeLabel'])
-        return self._dataTypeLabel
+        if type(self._dataTypeLabel) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataTypeLabel' in self._h:
+                    return _read_string(self._h['dataTypeLabel'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataTypeLabel from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._dataTypeIndex is None:
-            if 'dataTypeIndex' in self._h.keys():
-                return int(self._h['dataTypeIndex'][()])
-        return self._dataTypeIndex
+        if type(self._dataTypeIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataTypeIndex' in self._h:
+                    return _read_int(self._h['dataTypeIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataTypeIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._sourcePower is None:
-            if 'sourcePower' in self._h.keys():
-                return float(self._h['sourcePower'][()])
-        return self._sourcePower
+        if type(self._sourcePower) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourcePower' in self._h:
+                    return _read_float(self._h['sourcePower'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourcePower from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._detectorGain is None:
-            if 'detectorGain' in self._h.keys():
-                return float(self._h['detectorGain'][()])
-        return self._detectorGain
+        if type(self._detectorGain) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorGain' in self._h:
+                    return _read_float(self._h['detectorGain'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorGain from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._moduleIndex is None:
-            if 'moduleIndex' in self._h.keys():
-                return int(self._h['moduleIndex'][()])
-        return self._moduleIndex
+        if type(self._moduleIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'moduleIndex' in self._h:
+                    return _read_int(self._h['moduleIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/moduleIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._sourceModuleIndex is None:
-            if 'sourceModuleIndex' in self._h.keys():
-                return int(self._h['sourceModuleIndex'][()])
-        return self._sourceModuleIndex
+        if type(self._sourceModuleIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'sourceModuleIndex' in self._h:
+                    return _read_int(self._h['sourceModuleIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/sourceModuleIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            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):
-        if self._cfg.dynamic_loading and self._detectorModuleIndex is None:
-            if 'detectorModuleIndex' in self._h.keys():
-                return int(self._h['detectorModuleIndex'][()])
-        return self._detectorModuleIndex
+        if type(self._detectorModuleIndex) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'detectorModuleIndex' in self._h:
+                    return _read_int(self._h['detectorModuleIndex'])
+                    self._cfg.logger.info('Dynamically loaded %s/detectorModuleIndex from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._detectorModuleIndex
 
     @detectorModuleIndex.setter
     def detectorModuleIndex(self, value):
         self._detectorModuleIndex = value
+        # self._cfg.logger.info('Assignment to %s/detectorModuleIndex 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)
 
 
     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:
-            file = self._h.file
-        if 'sourceIndex' in self._h:
-            name = self._h['sourceIndex'].name
-            data = self.sourceIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'detectorIndex' in self._h:
-            name = self._h['detectorIndex'].name
-            data = self.detectorIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'wavelengthIndex' in self._h:
-            name = self._h['wavelengthIndex'].name
-            data = self.wavelengthIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'wavelengthActual' in self._h:
-            name = self._h['wavelengthActual'].name
-            data = self.wavelengthActual
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'wavelengthEmissionActual' in self._h:
-            name = self._h['wavelengthEmissionActual'].name
-            data = self.wavelengthEmissionActual
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'dataType' in self._h:
-            name = self._h['dataType'].name
-            data = self.dataType
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'dataTypeLabel' in self._h:
-            name = self._h['dataTypeLabel'].name
-            data = self.dataTypeLabel
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'dataTypeIndex' in self._h:
-            name = self._h['dataTypeIndex'].name
-            data = self.dataTypeIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'sourcePower' in self._h:
-            name = self._h['sourcePower'].name
-            data = self.sourcePower
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'detectorGain' in self._h:
-            name = self._h['detectorGain'].name
-            data = self.detectorGain
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'moduleIndex' in self._h:
-            name = self._h['moduleIndex'].name
-            data = self.moduleIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'sourceModuleIndex' in self._h:
-            name = self._h['sourceModuleIndex'].name
-            data = self.sourceModuleIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
-        if 'detectorModuleIndex' in self._h:
-            name = self._h['detectorModuleIndex'].name
-            data = self.detectorModuleIndex
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='i4', data=data)
+            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 name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourceIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/wavelengthIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.wavelengthIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/wavelengthActual'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.wavelengthActual
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/wavelengthEmissionActual'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.wavelengthEmissionActual
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/dataType'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataType
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/dataTypeLabel'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataTypeLabel
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/dataTypeIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataTypeIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/sourcePower'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourcePower
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorGain'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorGain
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/moduleIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.moduleIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/sourceModuleIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.sourceModuleIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/detectorModuleIndex'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.detectorModuleIndex
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_int(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
 
 
 class MeasurementList(IndexedGroup):
@@ -1259,82 +2087,130 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig):
 
 class StimElement(Group):
 
-    _name = None  # "s"+
-    _data = None  # [<f>,...]+
-    _dataLabels = None  # ["s",...]
+    _name = AbsentDataset()  # "s"+
+    _data = AbsentDataset()  # [<f>,...]+
+    _dataLabels = AbsentDataset()  # ["s",...]
+
 
     def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
         super().__init__(gid, cfg)
-        if 'name' in self._h.keys():
+        self.__snirfnames = ['name', 'data', 'dataLabels', ]
+        if 'name' in self._h:
             if not self._cfg.dynamic_loading:
                 self._name = _read_string(self._h['name'])
-
-        if 'data' in self._h.keys():
+        if 'data' in self._h:
             if not self._cfg.dynamic_loading:
-                self._data = np.array(self._h['data']).astype(float)  # Array
-
-        if 'dataLabels' in self._h.keys():
+                self._data = _read_float_array(self._h['data'])
+        if 'dataLabels' in self._h:
             if not self._cfg.dynamic_loading:
-                self._dataLabels = np.array(self._h['dataLabels']).astype(str)  # Array
-
+                self._dataLabels = _read_string_array(self._h['dataLabels'])
 
     @property
     def name(self):
-        if self._cfg.dynamic_loading and self._name is None:
-            if 'name' in self._h.keys():
-                return _read_string(self._h['name'])
-        return self._name
+        if type(self._name) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'name' in self._h:
+                    return _read_string(self._h['name'])
+                    self._cfg.logger.info('Dynamically loaded %s/name from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._name
 
     @name.setter
     def name(self, value):
         self._name = value
+        # self._cfg.logger.info('Assignment to %s/name in %s', self.location, self.filename)
+
+    @name.deleter
+    def name(self):
+        self._name = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/name from %s', self.location, self.filename)
 
     @property
     def data(self):
-        if self._cfg.dynamic_loading and self._data is None:
-            if 'data' in self._h.keys():
-                return np.array(self._h['data']).astype(float)  # Array
-        return self._data
+        if type(self._data) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'data' in self._h:
+                    return _read_float_array(self._h['data'])
+                    self._cfg.logger.info('Dynamically loaded %s/data from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._data
 
     @data.setter
     def data(self, value):
         self._data = value
+        # self._cfg.logger.info('Assignment to %s/data in %s', self.location, self.filename)
+
+    @data.deleter
+    def data(self):
+        self._data = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/data from %s', self.location, self.filename)
 
     @property
     def dataLabels(self):
-        if self._cfg.dynamic_loading and self._dataLabels is None:
-            if 'dataLabels' in self._h.keys():
-                return np.array(self._h['dataLabels']).astype(str)  # Array
-        return self._dataLabels
+        if type(self._dataLabels) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataLabels' in self._h:
+                    return _read_string_array(self._h['dataLabels'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataLabels from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._dataLabels
 
     @dataLabels.setter
     def dataLabels(self, value):
         self._dataLabels = value
+        # self._cfg.logger.info('Assignment to %s/dataLabels in %s', self.location, self.filename)
+
+    @dataLabels.deleter
+    def dataLabels(self):
+        self._dataLabels = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/dataLabels 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:
-            file = self._h.file
-        if 'name' in self._h:
-            name = self._h['name'].name
-            data = self.name
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'data' in self._h:
-            name = self._h['data'].name
-            data = np.array(self.data)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'dataLabels' in self._h:
-            name = self._h['dataLabels'].name
-            data = np.array(self.dataLabels).astype('O')
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
+            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 + '/name'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.name
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/data'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.data
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/dataLabels'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataLabels
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
 
 
 class Stim(IndexedGroup):
@@ -1348,104 +2224,164 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig):
 
 class AuxElement(Group):
 
-    _name = None  # "s"+
-    _dataTimeSeries = None  # [[<f>,...]]+
-    _time = None  # [<f>,...]+
-    _timeOffset = None  # [<f>,...]
+    _name = AbsentDataset()  # "s"+
+    _dataTimeSeries = AbsentDataset()  # [[<f>,...]]+
+    _time = AbsentDataset()  # [<f>,...]+
+    _timeOffset = AbsentDataset()  # [<f>,...]
+
 
     def __init__(self, gid: h5py.h5g.GroupID, cfg: SnirfConfig):
         super().__init__(gid, cfg)
-        if 'name' in self._h.keys():
+        self.__snirfnames = ['name', 'dataTimeSeries', 'time', 'timeOffset', ]
+        if 'name' in self._h:
             if not self._cfg.dynamic_loading:
                 self._name = _read_string(self._h['name'])
-
-        if 'dataTimeSeries' in self._h.keys():
+        if 'dataTimeSeries' in self._h:
             if not self._cfg.dynamic_loading:
-                self._dataTimeSeries = np.array(self._h['dataTimeSeries']).astype(float)  # Array
-
-        if 'time' in self._h.keys():
+                self._dataTimeSeries = _read_float_array(self._h['dataTimeSeries'])
+        if 'time' in self._h:
             if not self._cfg.dynamic_loading:
-                self._time = np.array(self._h['time']).astype(float)  # Array
-
-        if 'timeOffset' in self._h.keys():
+                self._time = _read_float_array(self._h['time'])
+        if 'timeOffset' in self._h:
             if not self._cfg.dynamic_loading:
-                self._timeOffset = np.array(self._h['timeOffset']).astype(float)  # Array
-
+                self._timeOffset = _read_float_array(self._h['timeOffset'])
 
     @property
     def name(self):
-        if self._cfg.dynamic_loading and self._name is None:
-            if 'name' in self._h.keys():
-                return _read_string(self._h['name'])
-        return self._name
+        if type(self._name) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'name' in self._h:
+                    return _read_string(self._h['name'])
+                    self._cfg.logger.info('Dynamically loaded %s/name from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._name
 
     @name.setter
     def name(self, value):
         self._name = value
+        # self._cfg.logger.info('Assignment to %s/name in %s', self.location, self.filename)
+
+    @name.deleter
+    def name(self):
+        self._name = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/name from %s', self.location, self.filename)
 
     @property
     def dataTimeSeries(self):
-        if self._cfg.dynamic_loading and self._dataTimeSeries is None:
-            if 'dataTimeSeries' in self._h.keys():
-                return np.array(self._h['dataTimeSeries']).astype(float)  # Array
-        return self._dataTimeSeries
+        if type(self._dataTimeSeries) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'dataTimeSeries' in self._h:
+                    return _read_float_array(self._h['dataTimeSeries'])
+                    self._cfg.logger.info('Dynamically loaded %s/dataTimeSeries from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._dataTimeSeries
 
     @dataTimeSeries.setter
     def dataTimeSeries(self, value):
         self._dataTimeSeries = value
+        # self._cfg.logger.info('Assignment to %s/dataTimeSeries in %s', self.location, self.filename)
+
+    @dataTimeSeries.deleter
+    def dataTimeSeries(self):
+        self._dataTimeSeries = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/dataTimeSeries from %s', self.location, self.filename)
 
     @property
     def time(self):
-        if self._cfg.dynamic_loading and self._time is None:
-            if 'time' in self._h.keys():
-                return np.array(self._h['time']).astype(float)  # Array
-        return self._time
+        if type(self._time) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'time' in self._h:
+                    return _read_float_array(self._h['time'])
+                    self._cfg.logger.info('Dynamically loaded %s/time from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._time
 
     @time.setter
     def time(self, value):
         self._time = value
+        # self._cfg.logger.info('Assignment to %s/time in %s', self.location, self.filename)
+
+    @time.deleter
+    def time(self):
+        self._time = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/time from %s', self.location, self.filename)
 
     @property
     def timeOffset(self):
-        if self._cfg.dynamic_loading and self._timeOffset is None:
-            if 'timeOffset' in self._h.keys():
-                return np.array(self._h['timeOffset']).astype(float)  # Array
-        return self._timeOffset
+        if type(self._timeOffset) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'timeOffset' in self._h:
+                    return _read_float_array(self._h['timeOffset'])
+                    self._cfg.logger.info('Dynamically loaded %s/timeOffset from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._timeOffset
 
     @timeOffset.setter
     def timeOffset(self, value):
         self._timeOffset = value
+        # self._cfg.logger.info('Assignment to %s/timeOffset in %s', self.location, self.filename)
+
+    @timeOffset.deleter
+    def timeOffset(self):
+        self._timeOffset = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/timeOffset 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:
-            file = self._h.file
-        if 'name' in self._h:
-            name = self._h['name'].name
-            data = self.name
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
-        if 'dataTimeSeries' in self._h:
-            name = self._h['dataTimeSeries'].name
-            data = np.array(self.dataTimeSeries)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'time' in self._h:
-            name = self._h['time'].name
-            data = np.array(self.time)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
-        if 'timeOffset' in self._h:
-            name = self._h['timeOffset'].name
-            data = np.array(self.timeOffset)
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype='f8', data=data)
+            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 + '/name'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.name
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/dataTimeSeries'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.dataTimeSeries
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/time'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.time
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
+        name = self.location + '/timeOffset'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.timeOffset
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_float_array(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
 
 
 class Aux(IndexedGroup):
@@ -1460,44 +2396,62 @@ def __init__(self, h: h5py.File, cfg: SnirfConfig):
 class Snirf():
     
     _name = '/'
-    _formatVersion = None  # "s"*
-    _nirs = None  # {i}*
+    _formatVersion = AbsentDataset()  # "s"*
+    _nirs = AbsentDataset()  # {i}*
 
-    def __init__(self, *args, dynamic_loading: bool = False):
+
+    def __init__(self, *args, dynamic_loading: bool = False, logfile: bool = False):
+        self._cfg = SnirfConfig()
+        self._cfg.dynamic_loading = dynamic_loading
         if len(args) > 0:
             path = args[0]
             if type(path) is str:
                 if not path.endswith('.snirf'):
-                    path = path.join('.snirf')
+                    path = path + '.snirf'
+                if logfile:
+                    self._cfg.logger = _create_logger(path, path.split('.')[0] + '.log')
+                else:
+                    self._cfg.logger = _logger  # Use global logger
                 if os.path.exists(path):
+                    self._cfg.logger.info('Loading from file %s', path)
                     self._h = h5py.File(path, 'r+')
                 else:
+                    self._cfg.logger.info('Creating new file at %s', path)
                     self._h = h5py.File(path, 'w')
             else:
                 raise TypeError(str(path) + ' is not a valid filename')
         else:
+            self._cfg.logger = _logger
+            self._cfg.logger.info('Created Snirf object based on tempfile')
+            path = None
             self._h = h5py.File(TemporaryFile(), 'w')
-        self._cfg = SnirfConfig()
-        self._cfg.dynamic_loading = dynamic_loading
-        if 'formatVersion' in self._h.keys():
+        self.__snirfnames = ['formatVersion', 'nirs', ]
+        if 'formatVersion' in self._h:
             if not self._cfg.dynamic_loading:
                 self._formatVersion = _read_string(self._h['formatVersion'])
-        else:
-            warn(str(self.__class__.__name__) + ' missing required key ' + '"formatVersion"')
-
-        self.nirs = Nirs(self._h, self._cfg)  # Indexed group
-
+        self.nirs = Nirs(self, self._cfg)  # Indexed group
 
     @property
     def formatVersion(self):
-        if self._cfg.dynamic_loading and self._formatVersion is None:
-            if 'formatVersion' in self._h.keys():
-                return _read_string(self._h['formatVersion'])
-        return self._formatVersion
+        if type(self._formatVersion) is AbsentDataset:
+            if self._cfg.dynamic_loading:
+                if 'formatVersion' in self._h:
+                    return _read_string(self._h['formatVersion'])
+                    self._cfg.logger.info('Dynamically loaded %s/formatVersion from %s', self.location, self.filename)
+            else:
+                return None
+        else:
+            return self._formatVersion
 
     @formatVersion.setter
     def formatVersion(self, value):
         self._formatVersion = value
+        # self._cfg.logger.info('Assignment to %s/formatVersion in %s', self.location, self.filename)
+
+    @formatVersion.deleter
+    def formatVersion(self):
+        self._formatVersion = AbsentDataset()
+        self._cfg.logger.info('Deleted %s/formatVersion from %s', self.location, self.filename)
 
     @property
     def nirs(self):
@@ -1506,19 +2460,37 @@ def nirs(self):
     @nirs.setter
     def nirs(self, value):
         self._nirs = value
+        # self._cfg.logger.info('Assignment to %s/nirs in %s', self.location, self.filename)
+
+    @nirs.deleter
+    def nirs(self):
+        raise AttributeError('IndexedGroup ' + str(type(self._nirs)) + ' cannot be deleted')
+        self._cfg.logger.info('Deleted %s/nirs 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:
-            file = self._h.file
-        if 'formatVersion' in self._h:
-            name = self._h['formatVersion'].name
-            data = self.formatVersion
-            if name in file:
-                del file[name]
-            file.create_dataset(name, dtype=h5py.string_dtype(encoding='ascii', length=None), data=data)
+            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 + '/formatVersion'
+        if name in file:
+            del file[name]
+            self._cfg.logger.info('Deleted Dataset %s from %s', name, file)
+        data = self.formatVersion
+        if type(data) is not AbsentDataset and data is not None:
+            _create_dataset_string(file, name, data)
+            # self._cfg.logger.info('Creating Dataset %s in %s', name, file)
         self.nirs._save(*args)
 
 
@@ -1537,19 +2509,41 @@ def save(self, *args):
                 path += '.snirf'
             new_file = h5py.File(path, 'w')
             self._save(new_file)
+            self._cfg.logger.info('Saved Snirf file at %s to copy at %s', self.filename, path)
             new_file.close()
         else:
-            self._save()
+            self._save(self._h.file)
 
     @property
     def filename(self):
         return self._h.filename
 
+    @property
+    def location(self):
+        return self._h.name
+
     def close(self):
+        self._cfg.logger.info('Closing Snirf file %s', self.filename)
         self._h.close()
 
-    def __del__(self):
-        self._h.close()
+    def __enter__(self):
+        return self
+
+#    def __exit__(self):
+#        self.close()
+
+#    def __del__(self):
+#        self.close()
+
+    def __getitem__(self, key):
+        if self._h != {}:
+            if key in self._h:
+                return self._h[key]
+        else:
+            return None
+
+    def __contains__(self, key):
+        return key in self._h;
 
     def __repr__(self):
         props = [p for p in dir(self) if ('_' not in p and not callable(getattr(self, p)))]
@@ -1569,4 +2563,29 @@ def __repr__(self):
                 else:
                     out += '\n' + prepr
             out += '\n'
-        return out[:-1]
\ No newline at end of file
+        return out[:-1]
+
+# Extend metaDataTags to support loading and saving of unspecified datasets
+
+class MetaDataTags(MetaDataTags):
+    
+    def __init__(self, varg, cfg):
+        super().__init__(varg, cfg)
+        self.__other = []
+        for key in self._h:
+            if key not in self.__snirfnames:
+                data = np.array(self._h[key])
+                self.__other.append(key)
+                setattr(self, key, data)
+                
+    def _save(self, *args):
+        super()._save(*args)
+        if len(args) > 0 and type(args[0]) is h5py.File:
+            file = args[0]
+        else:
+            file = self._h.file
+        for key in self.__other:
+            if key in file:
+                del file[key]
+            file.create_dataset(self.location + '/' + key, data=getattr(self, key))
+                
\ No newline at end of file


Test patch:
diff --git a/tests/test.py b/tests/test.py
new file mode 100644
index 0000000..b3d27d5
--- /dev/null
+++ b/tests/test.py
@@ -0,0 +1,271 @@
+import unittest
+import pysnirf2
+from pysnirf2 import Snirf, NirsElement, StimElement, MetaDataTags
+import h5py
+import os
+import sys
+import time
+from numbers import Number
+from collections import deque
+from collections.abc import Set, Mapping
+import numpy as np
+
+VERBOSE = True  # Additional print statements in each test
+SNIRF_DIR = 'data'  # Sample data source
+WD = 'wd'  # Working directory for testing
+
+ZERO_DEPTH_BASES = (str, bytes, Number, range, bytearray)
+def getsize(obj_0):
+    """
+    Recursively calculate size of object & members in bytes.
+    the work of Aaron Hall https://stackoverflow.com/a/30316760
+    """
+    _seen_ids = set()
+    def inner(obj):
+        obj_id = id(obj)
+        if obj_id in _seen_ids:
+            return 0
+        _seen_ids.add(obj_id)
+        size = sys.getsizeof(obj)
+        if isinstance(obj, ZERO_DEPTH_BASES):
+            pass # bypass remaining control flow and return
+        elif isinstance(obj, (tuple, list, Set, deque)):
+            size += sum(inner(i) for i in obj)
+        elif isinstance(obj, Mapping) or hasattr(obj, 'items'):
+            if getattr(obj, 'items') is not None:
+                size += sum(inner(k) + inner(v) for k, v in getattr(obj, 'items')())
+        # Check for custom object instances - may subclass above too
+        if hasattr(obj, '__dict__'):
+            size += inner(vars(obj))
+        if hasattr(obj, '__slots__'): # can have __slots__ with __dict__
+            size += sum(inner(getattr(obj, s)) for s in obj.__slots__ if hasattr(obj, s))
+        return size
+    return inner(obj_0)
+
+
+def compare_snirf(snirf_path_1, snirf_path_2, enforce_length=True):
+    '''
+    Returns a struct pairing the Dataset names appearing in each SNIRF file with
+    the result of comparing these values after a cast to numpy array. If None,
+    the Dataset appears in only one of the two SNIRF files.
+    '''
+    f1 = h5py.File(snirf_path_1, 'r')
+    f2 = h5py.File(snirf_path_2, 'r')
+    loc_f1 = get_all_dataset_locations(f1)
+    loc_f2 = get_all_dataset_locations(f2)
+    
+    if VERBOSE:
+        print('Loaded', f1, 'and', f2, 'for comparison')
+    
+    results = {}
+    
+    locations = list(set(loc_f1 + loc_f2))
+    for location in locations:
+        if not (location in loc_f1 and location in loc_f2):
+            results[location] = None
+        else:
+            arr1 =  np.array(f1[location])
+            arr2 =  np.array(f2[location])
+            if not enforce_length:
+                if arr1.size == 1 or len(arr1) == 1:
+                    try:
+                        arr1 = arr1[0]
+                    except IndexError:
+                        arr1 = arr1[()]
+                if arr2.size == 1 or len(arr2) == 1:
+                    try:
+                        arr2 = arr2[0]
+                    except IndexError:
+                        arr2 = arr2[()]
+            eq = arr1 == arr2
+            if not (type(eq) is np.bool_ or type(eq) is bool):
+                eq = eq.all()
+            if VERBOSE and not eq:
+                print(type(arr1), arr1, '!=', type(arr2), arr2)
+            results[location] = bool(eq)
+    return results
+
+        
+def get_all_dataset_locations(h):
+    '''
+    Recursively create list of all relative names of the Datasets in an HDF5
+    group or file
+    '''
+    locations = []
+    for key in h.keys():
+        if type(h[key]) is h5py.Dataset:
+            locations.append(h[key].name)
+        else:
+            locations += get_all_dataset_locations(h[key])
+    return locations
+        
+
+def dataset_equal_test(test, fname1, fname2):
+    """
+    Asserts that the HDF files at fname1 and fname2 have the same datasets
+    """
+    result = compare_snirf(fname1, fname2)
+    false_keys = []
+    none_keys = []
+    for key in result.keys():
+        if result[key] is None:
+            none_keys.append(key)
+        elif result[key] is False:
+            false_keys.append(key)
+        elif result[key] is True:
+            pass
+        else:
+            raise ValueError('test_loading_saving failed. Invalid comparison between ', fname1 + ' and ' + fname2 + 'resulted in value' + str(result[key]))
+    if len(none_keys) > 0 and VERBOSE:
+        print('The following keys were not found in both SNIRF files:')
+        print(none_keys)
+    if len(false_keys) > 0 and VERBOSE:
+        print('The following keys were not equivalent:')
+        print(false_keys)
+    missing = np.array([('metaDataTags' in key or 'stim0' in key or 'measurementList0' in key or 'data0' in key or 'aux0' in key or 'nirs0' in key) for key in none_keys]).astype(bool)
+    test.assertTrue(missing.all(), msg=fname1 + ' and ' + fname2 + 'not equal: specified datasets are missing from the copied file: ' + str(none_keys))
+    test.assertFalse(len(false_keys) > 0, msg=fname1 + ' and ' + fname2 + 'are not equal: datasets were incorrectly copied: ' + str(false_keys))
+
+def _print_keys(group):
+    for key in group.keys():
+        print(key)
+
+
+# -- Tests --------------------------------------------------------------------
+
+class TestSnirf(unittest.TestCase):
+    
+    def test_dynamic(self):
+        """
+        Confirm that dynamically loaded files have smaller memory footprints
+        and faster load-times than non dynamically loaded files
+        """
+        times = [-1, -1]
+        sizes = [-1, -1]
+        for i, mode in enumerate([True, False]):
+            s = []
+            start = time.time()
+            for file in TEST_FILES:
+                s.append(Snirf(file, dynamic_loading=mode))
+            times[i] = time.time() - start
+            sizes[i] = getsize(s)
+            for snirf in s:
+                snirf.close()
+            if VERBOSE:
+                print('Loaded', len(TEST_FILES), 'SNIRF files of total size', sizes[i],
+                      'in', str(times[i])[0:6], 'seconds with dynamic_loading =', mode)
+        self.assertTrue(times[0] < times[1], msg='Dynamically-loaded files not loaded faster')
+        self.assertTrue(sizes[0] < sizes[1], msg='Dynamically-loaded files not smaller in memory')
+    
+    
+    def test_loading_saving(self):
+        """
+        Loads all files in filenames using Snirf in both dynamic and static mode, 
+        saves them to a new file, compares the results using h5py and a naive cast.
+        If returns True, all specified datasets are equivalent in the resaved files.
+        """
+        for i, mode in enumerate([True, False]):
+            s1_paths = []
+            s2_paths = []
+            start = time.time()
+            for file in TEST_FILES:
+                snirf = Snirf(file, dynamic_loading=mode)
+                s1_paths.append(file)
+                new_path = file.split('.')[0] + '_unedited.snirf'
+                snirf.save(new_path)
+                s2_paths.append(new_path)
+                snirf.close()
+            if VERBOSE:
+                print('Read and rewrote', len(TEST_FILES), 'SNIRF files in',
+                str(time.time() - start)[0:6], 'seconds with dynamic_loading =', mode)
+            
+            for (fname1, fname2) in zip(s1_paths, s2_paths):
+                dataset_equal_test(self, fname1, fname2)
+    
+    
+    # def test_create_a_file(self):
+    #     """
+    #     Create a file from scratch using the data in the first test file. Compare
+    #     the resulting file to a file created using the HDF library. 
+    #     """
+    #     for imode, mode in enumerate([True, False]):
+    #         filename_in = TEST_FILES[0]
+    #         filename_out = filename_in.split('.')[0] + '_' + str(imode)
+    #         src_data = Snirf(filename_in, 'r+', dynamic_loading=mode)
+    #         dst_data = Snirf(filename_out, 'w', dynamic_loading=mode)
+            
+    #         h5snirf = h5py.File(filename_out + '.hdf', 'w')
+    #         h5snirf['nirs1/metaDataTags/SubjectID'] = src_data.nirs[0].metaDataTags.SubjectID 
+    #         h5snirf['nirs1/metaDataTags/MeasurementDate'] = src_data.nirs[0].metaDataTags.MeasurementDate
+    #         h5snirf['nirs1/metaDataTags/MeasurementTime'] = src_data.nirs[0].metaDataTags.MeasurementTime
+    #         h5snirf['nirs1/metaDataTags/LengthUnit'] = src_data.nirs[0].metaDataTags.LengthUnit
+    #         h5snirf['nirs1/metaDataTags/TimeUnit'] = src_data.nirs[0].metaDataTags.TimeUnit
+            
+    #         dst_data.nirs.appendGroup()
+            
+    #         # metaDataTags   
+    #         dst_data.nirs[0].metaDataTags.SubjectID = src_data.nirs[0].metaDataTags.SubjectID
+    #         dst_data.nirs[0].metaDataTags.MeasurementDate = src_data.nirs[0].metaDataTags.MeasurementDate
+    #         dst_data.nirs[0].metaDataTags.MeasurementTime = src_data.nirs[0].metaDataTags.MeasurementTime
+    #         dst_data.nirs[0].metaDataTags.LengthUnit = src_data.nirs[0].metaDataTags.LengthUnit
+    #         dst_data.nirs[0].metaDataTags.TimeUnit = src_data.nirs[0].metaDataTags.TimeUnit
+            
+    #         # data
+    #         h5snirf['nirs1/data1/time'] = src_data.nirs[0].data[0].time 
+    #         h5snirf['nirs1/data1/dataTimeSeries'] = src_data.nirs[0].data[0].dataTimeSeries[:, 0]
+            
+    #         dst_data.nirs[0].data.appendGroup()
+    #         dst_data.nirs[0].data[0].time = src_data.nirs[0].data[0].time 
+    #         dst_data.nirs[0].data[0].dataTimeSeries = src_data.nirs[0].data[0].dataTimeSeries[:, 0]
+            
+    #         # measurementList
+    #         h5snirf['nirs1/data1/measurementList1/sourceIndex'] = src_data.nirs[0].data[0].measurementList[0].sourceIndex
+    #         h5snirf['nirs1/data1/measurementList1/detectorIndex'] = src_data.nirs[0].data[0].measurementList[0].detectorIndex
+    #         h5snirf['nirs1/data1/measurementList1/wavelengthIndex'] = src_data.nirs[0].data[0].measurementList[0].wavelengthIndex
+    #         h5snirf['nirs1/data1/measurementList1/dataType'] = src_data.nirs[0].data[0].measurementList[0].dataType
+    #         h5snirf['nirs1/data1/measurementList1/dataTypeIndex'] = src_data.nirs[0].data[0].measurementList[0].dataTypeIndex
+            
+    #         dst_data.nirs[0].data[0].measurementList.appendGroup()
+    #         dst_data.nirs[0].data[0].measurementList[0].sourceIndex = src_data.nirs[0].data[0].measurementList[0].sourceIndex
+    #         dst_data.nirs[0].data[0].measurementList[0].detectorIndex = src_data.nirs[0].data[0].measurementList[0].detectorIndex
+    #         dst_data.nirs[0].data[0].measurementList[0].wavelengthIndex = src_data.nirs[0].data[0].measurementList[0].wavelengthIndex
+    #         dst_data.nirs[0].data[0].measurementList[0].dataType = src_data.nirs[0].data[0].measurementList[0].dataType
+    #         dst_data.nirs[0].data[0].measurementList[0].dataTypeIndex = src_data.nirs[0].data[0].measurementList[0].dataTypeIndex
+            
+    #         # probe
+    #         h5snirf['nirs1/probe/wavelengths'] = src_data.nirs[0].probe.wavelengths
+    #         h5snirf['nirs1/probe/sourcePos2D'] = src_data.nirs[0].probe.sourcePos2D
+    #         h5snirf['nirs1/probe/detectorPos2D'] = src_data.nirs[0].probe.detectorPos2D
+            
+    #         dst_data.nirs[0].probe.wavelengths = src_data.nirs[0].probe.wavelengths
+    #         dst_data.nirs[0].probe.sourcePos2D = src_data.nirs[0].probe.sourcePos2D
+    #         dst_data.nirs[0].probe.detectorPos2D = src_data.nirs[0].probe.detectorPos2D
+            
+    #         src_data.close()
+    #         fname_dst = dst_data.filename
+    #         fname_src = filename_in
+    #         dst_data.save()
+    #         dst_data.close()
+    #         if VERBOSE:
+    #             print(fname_dst, fname_src)
+    #         h5snirf.close()
+            
+    #         dataset_equal_test(self, fname_dst, fname_src)
+        
+
+# -- Set up test working-directory --------------------------------------------
+
+print('Deleting all files in', WD)
+for file in os.listdir(WD):
+    os.remove(WD + '\\' + file)
+
+'Copying all files to WD'
+for file in os.listdir(SNIRF_DIR):
+    os.popen('copy ' + SNIRF_DIR + '\\' + file + ' ' + WD + '\\' + file)
+time.sleep(1)  # Sleep while os exectues copy operation
+
+TEST_FILES = [WD + '/' + file for file in os.listdir(WD)]
+
+
+if __name__ == '__main__':
+    result = unittest.main()
diff --git a/tests/tests.py b/tests/tests.py
deleted file mode 100644
index 4ee5112..0000000
--- a/tests/tests.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-Created on Tue Nov 16 18:45:12 2021
-
-@author: sstucker
-"""
-import pysnirf2
-from pysnirf2 import Snirf, NirsElement
-import h5py
-import os
-import sys
-import time
-from numbers import Number
-from collections import deque
-from collections.abc import Set, Mapping
-import numpy as np
-#import property
-
-ZERO_DEPTH_BASES = (str, bytes, Number, range, bytearray)
-def getsize(obj_0):
-    """
-    the work of Aaron Hall https://stackoverflow.com/a/30316760
-    Recursively iterate to sum size of object & members."""
-    _seen_ids = set()
-    def inner(obj):
-        obj_id = id(obj)
-        if obj_id in _seen_ids:
-            return 0
-        _seen_ids.add(obj_id)
-        size = sys.getsizeof(obj)
-        if isinstance(obj, ZERO_DEPTH_BASES):
-            pass # bypass remaining control flow and return
-        elif isinstance(obj, (tuple, list, Set, deque)):
-            size += sum(inner(i) for i in obj)
-        elif isinstance(obj, Mapping) or hasattr(obj, 'items'):
-            size += sum(inner(k) + inner(v) for k, v in getattr(obj, 'items')())
-        # Check for custom object instances - may subclass above too
-        if hasattr(obj, '__dict__'):
-            size += inner(vars(obj))
-        if hasattr(obj, '__slots__'): # can have __slots__ with __dict__
-            size += sum(inner(getattr(obj, s)) for s in obj.__slots__ if hasattr(obj, s))
-        return size
-    return inner(obj_0)
-
-# TODO remove
-def _print_keys(group):
-    for key in group.keys():
-        print(key)
-
-
-TESTPATH = r"C:\Users\sstucker\OneDrive\Desktop\pysnirf2\tests\snirf\subjA_run03.snirf"
-
-snirf = Snirf(TESTPATH, dynamic_loading=True)