brimfile.data

  1import numpy as np
  2import asyncio
  3
  4import warnings
  5from typing import Any
  6from numpy.typing import NDArray
  7
  8from .file_abstraction import FileAbstraction, sync, _async_getitem, _gather_sync
  9from .utils import concatenate_paths, list_objects_matching_pattern_async, get_object_name, set_object_name
 10from .utils import np_array_to_smallest_int_type, _determine_chunk_size
 11
 12from .metadata import Metadata
 13from .metadata.types import MetadataItem
 14
 15from numbers import Number
 16
 17from . import units
 18from .analysis_results import AnalysisResults
 19from .calibration import Calibration
 20from .constants import brim_obj_names
 21
 22__docformat__ = "google"
 23
 24
 25class Data:
 26    """
 27    Represents a data group within the brim file.
 28    """
 29    # make AnalysisResults available as an attribute of Data
 30    AnalysisResults = AnalysisResults
 31
 32    def __init__(self, file: FileAbstraction, path: str, *, 
 33                 newly_created: bool = False, _initialize: bool = True):
 34        """
 35        Initialize the Data object. This constructor should not be called directly.
 36
 37        Args:
 38            file (File): The parent File object.
 39            path (str): The path to the data group within the file.
 40            newly_created (bool): Whether this data group is being created as new.
 41                            If True, the constructor will not attempt to load spatial mapping.
 42            _initialize (bool): FOR INTERNAL USE ONLY. Whether to automatically initialize the current data group. 
 43                Set to False if you want to initialize them manually later using the _init_async() method. Default is True.
 44        """
 45        self._file = file
 46        self._path = path
 47        
 48
 49        if _initialize:
 50            sync(self._init_async(newly_created=newly_created))        
 51    
 52    async def _init_async(self, newly_created: bool = False) -> None:
 53        """
 54        See __init__() for the description of the arguments.
 55        """
 56        self._group = await self._file.open_group(self._path)
 57
 58        self._sparse = await self._load_sparse_flag_async()
 59        # the _spatial_map is None for non sparse data but the _spatial_map_px_size should always be valid
 60        self._spatial_map, self._spatial_map_px_size = await self._load_spatial_mapping_async() if not newly_created else (None, None)
 61
 62    def get_name(self):
 63        """
 64        Returns the name of the data group.
 65        """
 66        return sync(get_object_name(self._file, self._path))
 67    
 68    def get_index(self):
 69        """
 70        Returns the index of the data group.
 71        """
 72        return int(self._path.split('/')[-1].split('_')[-1])
 73
 74    async def _load_sparse_flag_async(self) -> bool:
 75        """
 76        Load the 'Sparse' flag for the data group.
 77
 78        Returns:
 79            bool: The value of the 'Sparse' flag, or False if the attribute is not found or invalid.
 80        """
 81        try:
 82            sparse = await self._file.get_attr(self._group, 'Sparse')
 83            if isinstance(sparse, bool):
 84                return sparse
 85            else:
 86                warnings.warn(
 87                    f"Invalid value for 'Sparse' attribute in {self._path}. Expected a boolean, got {type(sparse)}. Defaulting to False.")
 88                return False
 89        except Exception:
 90            # if the attribute is not found, return the default value False
 91            return False
 92
 93    async def _load_spatial_mapping_async(self, load_in_memory: bool=True) -> tuple:
 94        """
 95        Load a spatial mapping in the same format as 'Cartesian visualisation',
 96        irrespectively on whether 'Spatial_map' is defined instead.
 97        -1 is used for "empty" pixels in the image
 98        Args:
 99            load_in_memory (bool): Specify whether the map should be forced to load in memory or just opened as a dataset.
100        Returns:
101            The spatial map and the corresponding pixel size as a tuple of 3 Metadata.Item, both in the order z, y, x.
102            If the spatial mapping is not defined in the file, returns None for the spatial map.
103            The pixel size is read from the data group for non-sparse data.
104        """
105        cv = None
106        px_size = 3*(Metadata.Item(value=1, units=None),)
107
108        cv_path = concatenate_paths(
109            self._path, brim_obj_names.data.cartesian_visualisation)
110        sm_path = concatenate_paths(
111            self._path, brim_obj_names.data.spatial_map)
112        
113        if await self._file.object_exists(cv_path):
114            cv = await self._file.open_dataset(cv_path)
115
116            #read the pixel size from the 'Cartesian visualisation' dataset
117            px_size_val = None
118            px_size_units = None
119            try:
120                px_size_val = await self._file.get_attr(cv, 'element_size')
121                if px_size_val is None or len(px_size_val) != 3:
122                    raise ValueError(
123                        "The 'element_size' attribute of 'Cartesian_visualisation' must be a tuple of 3 elements")
124            except Exception:
125                px_size_val = 3*(1,)
126                warnings.warn(
127                    "No pixel size defined for Cartesian visualisation")            
128            px_size_units = await units.of_attribute(
129                    self._file, cv, 'element_size')
130            px_size = ()
131            for i in range(3):
132                # if px_size_val[i] is not a number, set it to 1 and px_size_units to None
133                if isinstance(px_size_val[i], Number):
134                    px_size += (Metadata.Item(px_size_val[i], px_size_units), )
135                else:
136                    px_size += (Metadata.Item(1, None), )
137                    
138
139            if load_in_memory:
140                cv = await cv.to_np_array()  # load the spatial map in memory as a numpy array
141                cv = np_array_to_smallest_int_type(cv)
142
143        elif await self._file.object_exists(sm_path):
144            async def load_spatial_map_from_file():
145                async def load_coordinate_from_sm(coord: str):
146                    res = np.empty(0)  # empty array
147                    try:
148                        res = await self._file.open_dataset(
149                            concatenate_paths(sm_path, coord))
150                        res = await res.to_np_array()
151                        res = np.squeeze(res)  # remove single-dimensional entries
152                    except Exception as e:
153                        # if the coordinate does not exist, return an empty array
154                        pass
155                    if len(res.shape) > 1:
156                        raise ValueError(
157                            f"The 'Spatial_map/{coord}' dataset is not a 1D array as expected")
158                    return res
159
160                def check_coord_array(arr, size):
161                    if arr.size == 0:
162                        return np.zeros(size)
163                    elif arr.size != size:
164                        raise ValueError(
165                            "The 'Spatial_map' dataset is invalid")
166                    return arr
167
168                x, y, z = await asyncio.gather(
169                    load_coordinate_from_sm('x'),
170                    load_coordinate_from_sm('y'),
171                    load_coordinate_from_sm('z')
172                    )
173                size = max([x.size, y.size, z.size])
174                if size == 0:
175                    raise ValueError("The 'Spatial_map' dataset is empty")
176                x = check_coord_array(x, size)
177                y = check_coord_array(y, size)
178                z = check_coord_array(z, size)
179                return x, y, z
180
181            def calculate_step(x):
182                n = len(np.unique(x))
183                if n == 1:
184                    d = None
185                else:
186                    d = (np.max(x)-np.min(x))/(n-1)
187                return n, d
188
189            x, y, z = await load_spatial_map_from_file()
190
191            # TODO extend the reconstruction to non-cartesian cases
192
193            nX, dX = calculate_step(x)
194            nY, dY = calculate_step(y)
195            nZ, dZ = calculate_step(z)
196
197            indices = np_array_to_smallest_int_type(np.lexsort((x, y, z)))
198            cv = np.reshape(indices, (nZ, nY, nX))
199
200            px_size_units = await units.of_object(self._file, sm_path)
201            px_size = ()
202            for i in range(3):
203                px_sz = (dZ, dY, dX)[i]
204                px_unit = px_size_units
205                if px_sz is None:
206                    px_sz = 1
207                    px_unit = None
208                px_size += (Metadata.Item(px_sz, px_unit),)
209        elif not self._sparse:
210            try:
211                px_sz = await self._file.get_attr(self._group, 'element_size')
212                if len(px_sz) != 3:
213                    raise ValueError(
214                        "The 'element_size' attribute must be a tuple of 3 elements")
215                px_unit = None
216                try:
217                    px_unit = await units.of_attribute(self._file, self._group, 'element_size')
218                except Exception:
219                    warnings.warn("Pixel size unit is not provided for non-sparse data.")
220                px_size = tuple(Metadata.Item(el, px_unit) for el in px_sz)
221            except Exception:
222                warnings.warn("Pixel size is not provided for non-sparse data.")
223
224        return cv, px_size
225
226    def get_PSD(self) -> tuple:
227        """
228        LOW LEVEL FUNCTION
229
230        Retrieve the Power Spectral Density (PSD) and frequency from the current data group.
231        Note: this function exposes the internals of the brim file and thus the interface might change in future versions.
232        Use only if more specialized functions are not working for your application!
233        Returns:
234            tuple: (PSD, frequency, PSD_units, frequency_units)
235                - PSD: A 2D (or more) numpy array containing all the spectra (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
236                - frequency: A numpy array representing the frequency data (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
237                - PSD_units: The units of the PSD.
238                - frequency_units: The units of the frequency.
239        """
240        warnings.warn(
241            "Data.get_PSD is deprecated and will be removed in a future release. "
242            "Use Data.get_PSD_as_spatial_map instead.",
243            DeprecationWarning,
244            stacklevel=2,
245        )
246        PSD, frequency = _gather_sync(
247            self._file.open_dataset(concatenate_paths(
248                self._path, brim_obj_names.data.PSD)),
249            self._file.open_dataset(concatenate_paths(
250                self._path, brim_obj_names.data.frequency))
251        )
252        # retrieve the units of the PSD and frequency
253        PSD_units, frequency_units = _gather_sync(
254            units.of_object(self._file, PSD),
255            units.of_object(self._file, frequency)
256        )
257
258        return PSD, frequency, PSD_units, frequency_units
259    
260    def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
261        """
262        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
263        Arguments:
264            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
265                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
266                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
267        Returns:
268            tuple: (PSD, frequency, PSD_units, frequency_units)
269                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
270                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
271                - PSD_units: The units of the PSD.
272                - frequency_units: The units of the frequency.
273        """
274        PSD, frequency = _gather_sync(
275            self._file.open_dataset(concatenate_paths(
276                self._path, brim_obj_names.data.PSD)),        
277            self._file.open_dataset(concatenate_paths(
278                self._path, brim_obj_names.data.frequency))
279            )        
280        # retrieve the units of the PSD and frequency
281        PSD_units, frequency_units = _gather_sync(
282            units.of_object(self._file, PSD),
283            units.of_object(self._file, frequency)
284        )
285
286        # ensure PSD and frequency are numpy arrays
287        PSD = np.array(PSD)  
288        frequency = np.array(frequency)  # ensure it's a numpy array
289        
290        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
291        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
292        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
293            frequency = np.broadcast_to(frequency, PSD.shape)
294        
295        if self._sparse:
296            if self._spatial_map is None:
297                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
298            sm = np.array(self._spatial_map)
299            # reshape the PSD and frequency to have the spatial dimensions first      
300            PSD = PSD[sm, ...]
301            # reshape the frequency only if it is not the same for all spectra
302            if frequency.ndim > 1:
303                frequency = frequency[sm, ...]
304
305        return PSD, frequency, PSD_units, frequency_units
306
307    def _get_spectrum(self, index: int | tuple[int, int, int]) -> tuple:
308        """
309        Synchronous wrapper for `_get_spectrum_async` (see doc for `brimfile.data.Data._get_spectrum_async`)
310        """
311        return sync(self._get_spectrum_async(index))
312    async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
313        """
314        Retrieve a spectrum from the data group by its index or coordinates.
315
316        Args:
317            index (int | tuple[int, int, int]): The index (for sparse data) or z, y, x coordinates (for non-sparse data) of the spectrum to retrieve.
318
319        Returns:
320            tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. 
321                    PSD can be 1D or more (if there are additional parameters);
322                    frequency has the same size as PSD
323        Raises:
324            IndexError: If the index is out of range for the PSD dataset.
325        """
326        if self._sparse and not isinstance(index, int):
327            raise ValueError("For sparse data, index must be an integer.")
328        elif not self._sparse and not (isinstance(index, tuple) and len(index) == 3):
329            raise ValueError("For non-sparse data, index must be a tuple of (z, y, x) coordinates.")
330            
331        # index = -1 corresponds to no spectrum
332        if self._sparse and index < 0:
333            return None, None, None, None
334        elif not self._sparse and any(i < 0 for i in index):
335            return None, None, None, None
336        PSD, frequency = await asyncio.gather(
337            self._file.open_dataset(concatenate_paths(
338                self._path, brim_obj_names.data.PSD)),                       
339            self._file.open_dataset(concatenate_paths(
340                self._path, brim_obj_names.data.frequency))
341            )
342        if self._sparse and index >= PSD.shape[0]:
343            raise IndexError(
344                f"index {index} out of range for PSD with shape {PSD.shape}")
345        elif not self._sparse and any(i >= PSD.shape[j] for j, i in enumerate(index)):
346            raise IndexError(
347                f"index {index} out of range for PSD with shape {PSD.shape}")
348        # retrieve the units of the PSD and frequency
349        PSD_units, frequency_units = await asyncio.gather(
350            units.of_object(self._file, PSD),
351            units.of_object(self._file, frequency)
352        )
353        # add ellipsis to the index to select the spectrum and the corresponding frequency
354        if self._sparse:
355            index = (index, ...)
356        else:
357            index = index + (..., )
358        # map index to the frequency array, considering the broadcasting rules
359        index_frequency = index
360        if frequency.ndim < PSD.ndim:
361            if self._sparse:
362                # given the definition of the brim file format,
363                # if the frequency has less dimensions that PSD,
364                # it can only be because it is the same for all the spatial position (first dimension)
365                index_frequency = (..., )
366            else:
367                unassigned_indices = PSD.ndim - frequency.ndim
368                if unassigned_indices == 3:
369                    # if the frequency has no spatial dimension, it is the same for all the spatial positions
370                    index_frequency = (..., )
371                else:
372                    # if the frequency has some spatial dimensions but not all, we need to add the corresponding indices to the index of the frequency
373                    index_frequency = index[-unassigned_indices:] + (..., )
374        #get the spectrum and the corresponding frequency at the specified index
375        PSD, frequency = await asyncio.gather(
376            _async_getitem(PSD, index),
377            _async_getitem(frequency, index_frequency)
378        )
379        #broadcast the frequency to match the shape of PSD if needed
380        if frequency.ndim < PSD.ndim:
381            frequency = np.broadcast_to(frequency, PSD.shape)
382        return PSD, frequency, PSD_units, frequency_units
383
384    def get_spectrum_in_image(self, coor: tuple) -> tuple:
385        """
386        Retrieve a spectrum from the data group using spatial coordinates.
387
388        Args:
389            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
390
391        Returns:
392            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
393        """
394        if len(coor) != 3:
395            raise ValueError("coor must contain 3 values for z, y, x")
396
397        if self._sparse:
398            index = int(self._spatial_map[coor])
399            return self._get_spectrum(index)
400        else:
401            return self._get_spectrum(coor)
402          
403    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
404        """
405        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
406
407        Args:
408            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
409            coor (tuple): A tuple containing the z, y, x coordinates in the image.
410            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
411
412        Returns:
413            tuple: A tuple containing:
414                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
415                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
416        """
417        if len(coor) != 3:
418            raise ValueError("coor must contain 3 values for z, y, x")
419        index = coor
420        if self._sparse:
421            index = int(self._spatial_map[coor])
422        spectrum, quantities = await asyncio.gather(
423            self._get_spectrum_async(index),
424            ar._get_all_quantities_at_index(index, index_peak)
425        )
426        return spectrum, quantities
427    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
428        """
429        Synchronous wrapper for `get_spectrum_and_all_quantities_in_image_async` (see doc for `brimfile.data.Data.get_spectrum_and_all_quantities_in_image_async`)
430        """
431        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
432
433    def get_metadata(self):
434        """
435        Returns the metadata associated with the current Data group
436        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
437        and the ones specific for this data group
438        """
439        return Metadata(self._file, self._path)
440
441    def get_num_parameters(self) -> tuple:
442        """
443        Retrieves the number of parameters
444
445        Returns:
446            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
447        """
448        pars, _ = self.get_parameters()
449        return pars.shape if pars is not None else ()
450
451    def get_parameters(self) -> list:
452        """
453        Retrieves the parameters  and their associated names.
454
455        If PSD.ndims > 2, the parameters are stored in a separate dataset.
456
457        Returns:
458            list: A tuple containing the parameters and their names if there are any, otherwise None.
459        """
460        pars_full_path = concatenate_paths(
461            self._path, brim_obj_names.data.parameters)
462        if sync(self._file.object_exists(pars_full_path)):
463            pars = sync(self._file.open_dataset(pars_full_path))
464            pars_names = sync(self._file.get_attr(pars, 'Name'))
465            return (pars, pars_names)
466        return (None, None)
467
468    def create_calibration_group(self, *, index: NDArray[np.integer] | None = None, calibration_data: list[dict[str, Any]] | None = None,
469                                 timestamp: list[NDArray[Any]] | None = None, same_as: int | None = None, attributes: dict[str, MetadataItem] = None,
470                                 compression: FileAbstraction.Compression = FileAbstraction.Compression()) -> Calibration:
471        """
472        Create a new calibration group in the current data group.
473        For more details on the expected format of the calibration data, see https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md.
474
475        Parameters:
476            index (np.array | None, optional): Index array for the calibration spectra. For sparse data,
477                this must be 1D; for non-sparse data, this must be 3D.  
478                It can be omitted if each element in `calibration_data` contains only one spectrum.
479            calibration_data (list[dict[str, Any]] | None, optional): Calibration entries to store.
480                Each dictionary must contain `spectra` and `shift` keys, and may provide `shift_units`.                
481            timestamp (list[NDArray[Any]] | None, optional): Timestamp arrays corresponding to each calibration
482                entry. If provided, its length must match `calibration_data`. Defaults to None.
483            same_as (int | None, optional): If provided, links this calibration group to an existing
484                calibration via the `Same_as` attribute. When set, the other data arguments are ignored.
485                Defaults to None.
486            attributes (dict[str, MetadataItem], optional): Additional attributes to attach to the calibration group.
487                Can be one of ('Datetime', 'Description', 'Temperature', 'FSR') with the relative units (when relevant).
488            compression (FileAbstraction.Compression, optional): Compression settings used for created
489                datasets. Defaults to FileAbstraction.Compression().
490
491        Returns:
492            Calibration: The newly created calibration group.
493
494        Raises:
495            ValueError: If the provided calibration data, index, or timestamp is invalid or inconsistent.
496        """
497        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
498        calibration_group = sync(self._file.create_group(calibration_path))
499
500        # if same_as is provided, create the 'Same_as' attribute to link the calibration group to an existing one
501        if same_as is not None:
502            sync(self._file.create_attr(calibration_group, 'Same_as', same_as))
503        else: # if same_as is provided, the other parameters are ignored
504            # check that calibration_data is provided and valid
505            if calibration_data is None:
506                raise ValueError("'calibration_data' is required when 'same_as' is not provided")
507            if not isinstance(calibration_data, (list, tuple)):
508                calibration_data = [calibration_data,]
509            # check that index is valid if provided
510            if index is not None:
511                # TODO: check of the shape of 'index' is compatible with PSD
512                if self._sparse and index.ndim != 1:
513                    raise ValueError("'index' must be a 1D array for sparse data")
514                if not self._sparse and index.ndim != 3:
515                    raise ValueError("'index' must be a 3D array for non-sparse data")
516            # check that timestamp is valid if provided
517            if timestamp is not None: 
518                if not isinstance(timestamp, (list, tuple)):
519                    timestamp = [timestamp,]
520                if len(timestamp) != len(calibration_data):
521                    raise ValueError("If 'timestamp' is provided, it must have the same length as 'calibration_data'")            
522
523            for m, calib in enumerate(calibration_data):
524                # check that each element in calibration_data is a dictionary containing 'spectra' and 'shift' keys
525                if not isinstance(calib, dict):
526                    raise ValueError("Each element in 'calibration_data' must be a dictionary")
527                if 'spectra' not in calib.keys() or 'shift' not in calib.keys():
528                    raise ValueError("Each calibration data dictionary must contain 'spectra' and 'shift' keys")
529                # retrieve the spectra, shift and shift_units from the calibration data and check that they are valid
530                cal_spectra = np.array(calib['spectra'])
531                if cal_spectra.ndim != 2:
532                    raise ValueError("'spectra' in calibration data must be a 2D array. If only one spectrum is provided, set the first dimension to 1.")
533                cal_shift = calib['shift']
534                cal_shift_units = calib.get('shift_units', None)
535                if cal_shift_units is None:
536                    cal_shift_units = 'GHz'
537                    warnings.warn("No units provided for 'shift' in calibration data, defaulting to GHz")
538                # check that index is compatible with the shape of the spectra
539                if index is None and cal_spectra.shape[0] != 1:
540                    raise ValueError("If 'index' is not provided, each element in 'calibration_data' must contain only one spectrum (i.e. have shape (1, n))")
541                if index is not None and np.max(index) >= cal_spectra.shape[0]:
542                    raise ValueError("If 'index' is provided, its maximum value must be less than the number of spectra in each calibration data element")
543                # add the m arrays together with their attributes to the file
544                spectra_dataset = sync(self._file.create_dataset(calibration_group, f'{m}', cal_spectra, chunk_size=_determine_chunk_size(cal_spectra), compression=compression))
545                sync(self._file.create_attr(spectra_dataset, 'Shift', cal_shift))
546                units.add_to_attribute(self._file, spectra_dataset, 'Shift', cal_shift_units)
547                if timestamp is not None:
548                    timestamp_array = np.array(timestamp[m])
549                    if timestamp_array.ndim != 1 or timestamp_array.shape[0] != cal_spectra.shape[0]:
550                        raise ValueError("Each element in 'timestamp' must be a 1D array with the same length as the number of spectra in each calibration data element")
551                    sync(self._file.create_dataset(calibration_group, f'Timestamp_{m}', timestamp_array, compression=compression))
552            # add the index array to the file
553            if index is not None:
554                sync(self._file.create_dataset(calibration_group, 'Index', index, compression=compression))
555        
556        from .calibration import _STANDARD_ATTRIBUTES
557        # add any additional attributes to the calibration group, checking that they do not overwrite the standard
558        if attributes is not None:
559            for key, value in attributes.items():
560                if key not in _STANDARD_ATTRIBUTES:
561                    warnings.warn(f"Attribute '{key}' is not a standard attribute for calibration groups.\
562                                   Standard attributes are: {', '.join(_STANDARD_ATTRIBUTES)}. \
563                                   Make sure this is intentional!")
564                if not isinstance(value, MetadataItem):
565                    value = MetadataItem(value)
566                sync(self._file.create_attr(calibration_group, key, value.value))
567                if value.units is not None:
568                    units.add_to_attribute(self._file, calibration_group, key, value.units)
569
570        return Calibration(self._file, calibration_path, data_group=self)
571    
572    def get_calibration(self) -> Calibration:
573        """
574        Synchronous wrapper for `get_calibration_async` (see doc for `brimfile.data.Data.get_calibration_async`)
575        """
576        return sync(self.get_calibration_async())
577
578    async def get_calibration_async(self) -> Calibration:
579        """
580        Retrieve the calibration group associated with the current data group.
581
582        Returns:
583            Calibration: The calibration group associated with the current data group.
584
585        Raises:
586            ValueError: If no calibration group is found in the current data group or the referenced calibration group does not exist.
587        """
588        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
589        if not await self._file.object_exists(calibration_path):
590            raise ValueError(f"No calibration group found in {self._path}")
591        same_as = None
592        try:
593            same_as = await self._file.get_attr(calibration_path, 'Same_as')
594        except Exception:
595            pass #  same_as attribute is optional, if it does not exist we just ignore it
596        # if the 'Same_as' attribute exists, find the calibration group with the corresponding index
597        if same_as is not None:
598            try:
599                d_m = await Data.from_existing_async(self._file, same_as)
600                return await d_m.get_calibration_async()
601            except IndexError:
602                raise ValueError(f"Calibration group in {self._path} references non-existing calibration index {same_as} in the file")
603        cal_group = Calibration(self._file, calibration_path, data_group=self, _initialize=False)
604        await cal_group._init_async()
605        return cal_group
606
607    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
608                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
609        """
610        Adds a new AnalysisResults entry to the current data group.
611        Parameters:
612            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
613            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
614            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
615            name (str, optional): The name for the new Analysis group. Defaults to None.
616            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
617        Returns:
618            AnalysisResults: The newly created AnalysisResults object.
619        Raises:
620            IndexError: If the specified index already exists in the dataset.
621            ValueError: If any of the data provided is not valid or consistent
622        """
623        if index is not None:
624            try:
625                self.get_analysis_results(index)
626            except IndexError:
627                pass
628            else:
629                # If the group already exists, raise an error
630                raise IndexError(
631                    f"Analysis {index} already exists in {self._path}")
632        else:
633            ar_groups = self.list_AnalysisResults()
634            indices = [ar['index'] for ar in ar_groups]
635            indices.sort()
636            index = indices[-1] + 1 if indices else 0  # Next available index
637
638        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
639        if name is not None:
640            set_object_name(self._file, ar._path, name)
641        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
642
643        return ar
644
645    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
646        """
647        List all AnalysisResults groups in the current data group. The list is ordered by index.
648
649        Returns:
650            list: A list of dictionaries, each containing:
651                - 'name' (str): The name of the AnalysisResults group.
652                - 'index' (int): The index extracted from the group name.
653                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
654        """
655
656        analysis_results_groups = []
657
658        matched_objs = sync(list_objects_matching_pattern_async(
659            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$"))
660        async def _make_dict_item(matched_obj, retrieve_custom_name):
661            name = matched_obj[0]
662            index = int(matched_obj[1])
663            curr_obj_dict = {'name': name, 'index': index}
664            if retrieve_custom_name:
665                ar_path = concatenate_paths(self._path, name)
666                custom_name = await get_object_name(self._file, ar_path)
667                curr_obj_dict['custom_name'] = custom_name
668            return curr_obj_dict
669        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
670        dicts = _gather_sync(*coros)
671        for dict_item in dicts:
672            analysis_results_groups.append(dict_item)
673        # Sort the data groups by index
674        analysis_results_groups.sort(key=lambda x: x['index'])
675
676        return analysis_results_groups
677
678    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
679        """
680        Returns the AnalysisResults at the specified index
681
682        Args:
683            index (int)                
684
685        Raises:
686            IndexError: If there is no analysis with the corresponding index
687        """
688        name = None
689        ls = self.list_AnalysisResults()
690        for el in ls:
691            if el['index'] == index:
692                name = el['name']
693                break
694        if name is None:
695            raise IndexError(f"Analysis {index} not found")
696        path = concatenate_paths(self._path, name)
697        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
698                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)
699
700    def _add_data(self, PSD: np.ndarray, frequency: np.ndarray, *, scanning: dict = None, freq_units='GHz',
701                  timestamp: np.ndarray = None, compression: FileAbstraction.Compression = FileAbstraction.Compression()):
702        """
703        Add data to the current data group.
704
705        This method adds the provided PSD, frequency, and scanning data to the HDF5 group 
706        associated with this `Data` object. It validates the inputs to ensure they meet 
707        the required specifications before adding them.
708
709        Args:
710            PSD (np.ndarray): A 2D numpy array representing the Power Spectral Density (PSD) data. The last dimension contains the spectra.
711            frequency (np.ndarray): A 1D or 2D numpy array representing the frequency data. 
712                It must be broadcastable to the shape of the PSD array.
713            scanning (dict, optional): A dictionary containing scanning-related data. 
714                Required for sparse data (sparse=True), optional for non-sparse data.
715                For sparse data, must include at least one of 'Spatial_map' or 'Cartesian_visualisation'.
716                It may include the following keys:
717                - 'Spatial_map' (optional): A dictionary containing coordinate arrays:
718                    - 'x', 'y', 'z' (optional): 1D numpy arrays of same length with coordinate values
719                    - 'units' (optional): string with the unit (e.g., 'um')
720                - 'Cartesian_visualisation' (optional): A 3D numpy array (z, y, x) with integer values 
721                   mapping spatial positions to spectra indices. Values must be -1 (invalid/empty pixel) 
722                   or between 0 and PSD.shape[0]-1.
723                - 'Cartesian_visualisation_pixel' (recommended with Cartesian_visualisation): 
724                   Tuple/list of 3 float values (z, y, x) representing pixel size. Unused dimensions can be None.
725                - 'Cartesian_visualisation_pixel_unit' (optional): String for pixel size unit (default: 'um').
726            timestamp (np.ndarray, optional): Timestamps in milliseconds for each spectrum.
727                Must be a 1D array with length equal to PSD.shape[0].
728
729
730        Raises:
731            ValueError: If any of the data provided is not valid or consistent
732        """
733
734        # Check if frequency is broadcastable to PSD
735        try:
736            np.broadcast_shapes(tuple(frequency.shape), tuple(PSD.shape))
737        except ValueError as e:
738            raise ValueError(f"frequency (shape: {frequency.shape}) is not broadcastable to PSD (shape: {PSD.shape}): {e}")
739
740        # Check if at least one of 'Spatial_map' or 'Cartesian_visualisation' is present in the scanning dictionary
741        # This is required for sparse data to establish the spatial mapping
742        has_spatial_mapping = False
743        if scanning is not None:
744            if 'Spatial_map' in scanning:
745                sm = scanning['Spatial_map']
746                size = 0
747
748                def check_coor(coor: str):
749                    if coor in sm:
750                        sm[coor] = np.array(sm[coor])
751                        size1 = sm[coor].size
752                        if size1 != size and size != 0:
753                            raise ValueError(
754                                f"'{coor}' in 'Spatial_map' is invalid!")
755                        return size1
756                    return size
757                size = check_coor('x')
758                size = check_coor('y')
759                size = check_coor('z')
760                if size == 0:
761                    raise ValueError(
762                        "'Spatial_map' should contain at least one x, y or z")
763                has_spatial_mapping = True
764            if 'Cartesian_visualisation' in scanning:
765                cv = scanning['Cartesian_visualisation']
766                if not isinstance(cv, np.ndarray) or cv.ndim != 3:
767                    raise ValueError(
768                        "Cartesian_visualisation must be a 3D numpy array")
769                if not np.issubdtype(cv.dtype, np.integer) or np.min(cv) < -1 or np.max(cv) >= PSD.shape[0]:
770                    raise ValueError(
771                        "Cartesian_visualisation values must be integers between -1 and PSD.shape[0]-1")
772                if 'Cartesian_visualisation_pixel' in scanning:
773                    if len(scanning['Cartesian_visualisation_pixel']) != 3:
774                        raise ValueError(
775                            "Cartesian_visualisation_pixel must always contain 3 values for z, y, x (set to None if not used)")
776                else:
777                    warnings.warn(
778                        "It is recommended to include 'Cartesian_visualisation_pixel' in the scanning dictionary to define pixel size for proper spatial calibration")
779                has_spatial_mapping = True
780        if not has_spatial_mapping and self._sparse:
781            raise ValueError("For sparse data, 'scanning' must be provided and must contain at least one of 'Spatial_map' or 'Cartesian_visualisation'")
782
783        if timestamp is not None:
784            if not isinstance(timestamp, np.ndarray) or timestamp.ndim != 1 or len(timestamp) != PSD.shape[0]:
785                raise ValueError("timestamp is not compatible with PSD")
786
787        # TODO: add and validate additional datasets (i.e. 'Parameters', 'Calibration_index', etc.)
788
789        # Add datasets to the group
790        sync(self._file.create_dataset(
791            self._group, brim_obj_names.data.PSD, data=PSD,
792            chunk_size=_determine_chunk_size(PSD), compression=compression))
793        freq_ds = sync(self._file.create_dataset(
794            self._group,  brim_obj_names.data.frequency, data=frequency,
795            chunk_size=_determine_chunk_size(frequency), compression=compression))
796        units.add_to_object(self._file, freq_ds, freq_units)
797
798        if scanning is not None:
799            if 'Spatial_map' in scanning:
800                sm = scanning['Spatial_map']
801                sm_group = sync(self._file.create_group(concatenate_paths(
802                    self._path, brim_obj_names.data.spatial_map)))
803                if 'units' in sm:
804                    units.add_to_object(self._file, sm_group, sm['units'])
805
806                def add_sm_dataset(coord: str):
807                    if coord in sm:
808                        sync(self._file.create_dataset(
809                            sm_group, coord, data=sm[coord], compression=compression))
810
811                add_sm_dataset('x')
812                add_sm_dataset('y')
813                add_sm_dataset('z')
814            if 'Cartesian_visualisation' in scanning:
815                # convert the Cartesian_visualisation to the smallest integer type
816                cv_arr = np_array_to_smallest_int_type(scanning['Cartesian_visualisation'])
817                cv = sync(self._file.create_dataset(self._group, brim_obj_names.data.cartesian_visualisation,
818                                            data=cv_arr, compression=compression))
819                if 'Cartesian_visualisation_pixel' in scanning:
820                    sync(self._file.create_attr(
821                        cv, 'element_size', scanning['Cartesian_visualisation_pixel']))
822                    if 'Cartesian_visualisation_pixel_unit' in scanning:
823                        px_unit = scanning['Cartesian_visualisation_pixel_unit']
824                    else:
825                        warnings.warn(
826                            "No unit provided for Cartesian_visualisation_pixel, defaulting to 'um'")
827                        px_unit = 'um'
828                    units.add_to_attribute(self._file, cv, 'element_size', px_unit)
829
830        self._spatial_map, self._spatial_map_px_size = sync(self._load_spatial_mapping_async())
831
832        if timestamp is not None:
833            sync(self._file.create_dataset(
834                self._group, 'Timestamp', data=timestamp, compression=compression))
835
836    @staticmethod
837    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
838        """
839        Synchronous wrapper for `list_data_groups_async` (see doc for `brimfile.data.Data.list_data_groups_async`)
840        """
841        return sync(Data.list_data_groups_async(file, retrieve_custom_name))
842
843    @staticmethod
844    async def list_data_groups_async(file: FileAbstraction, retrieve_custom_name=False) -> list:
845        """
846        List all data groups in the brim file. The list is ordered by index.
847
848        Returns:
849            list: A list of dictionaries, each containing:
850                - 'name' (str): The name of the data group in the file.
851                - 'index' (int): The index extracted from the group name.
852                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
853        """
854
855        data_groups = []
856
857        matched_objs = await list_objects_matching_pattern_async(
858            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
859        
860        async def _make_dict_item(matched_obj, retrieve_custom_name):
861            name = matched_obj[0]
862            index = int(matched_obj[1])
863            curr_obj_dict = {'name': name, 'index': index}
864            if retrieve_custom_name:
865                path = concatenate_paths(
866                    brim_obj_names.Brillouin_base_path, name)
867                custom_name = await get_object_name(file, path)
868                curr_obj_dict['custom_name'] = custom_name
869            return curr_obj_dict
870        
871        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
872        dicts = await asyncio.gather(*coros)
873        for dict_item in dicts:
874            data_groups.append(dict_item)        
875        # Sort the data groups by index
876        data_groups.sort(key=lambda x: x['index'])
877
878        return data_groups
879
880    @staticmethod
881    async def _get_existing_group_name_async(file: FileAbstraction, index: int) -> str:
882        """
883        Get the name of an existing data group by index.
884
885        Args:
886            file (File): The parent File object.
887            index (int): The index of the data group.
888
889        Returns:
890            str: The name of the data group, or None if not found.
891        """
892        group_name: str = None
893        data_groups = await Data.list_data_groups_async(file)
894        for dg in data_groups:
895            if dg['index'] == index:
896                group_name = dg['name']
897                break
898        return group_name
899    
900    @classmethod
901    async def from_existing_async(cls, file: FileAbstraction, index: int) -> 'Data':
902        """ 
903        Create a Data object from an existing data group in the file.
904        Args:
905            file (File): The parent File object.
906            index (int): The index of the existing data group.      
907        Returns:
908            Data: A Data object corresponding to the existing data group.   
909        Raises:
910            IndexError: If no data group with the specified index is found in the file.
911        """
912        group_name: str = await cls._get_existing_group_name_async(file, index)
913        if group_name is None:
914            raise IndexError(f"No data group with index {index} found in the file")
915        dg = cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), _initialize=False) 
916        await dg._init_async()
917        return dg
918    
919    @classmethod
920    def _create_new(cls, file: FileAbstraction, index: int, sparse: bool = False, name: str = None) -> 'Data':
921        """
922        Create a new data group with the specified index.
923
924        Args:
925            file (File): The parent File object.
926            index (int): The index for the new data group.
927            sparse (bool): Whether the data is sparse. See https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md for details. Defaults to False.
928            name (str, optional): The name for the new data group. Defaults to None.
929
930        Returns:
931            Data: The newly created Data object.
932        """
933        group_name = Data._generate_group_name(index)
934        group = sync(file.create_group(concatenate_paths(
935            brim_obj_names.Brillouin_base_path, group_name)))
936        sync(file.create_attr(group, 'Sparse', sparse))
937        if name is not None:
938            set_object_name(file, group, name)
939        return cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), newly_created=True)
940
941    @staticmethod
942    def _generate_group_name(index: int, n_digits: int = None) -> str:
943        """
944        Generate a name for a data group based on the index.
945
946        Args:
947            index (int): The index for the data group.
948            n_digits (int, optional): The number of digits to pad the index with. If None no padding is applied. Defaults to None.
949
950        Returns:
951            str: The generated group name.
952
953        Raises:
954            ValueError: If the index is negative.
955        """
956        if index < 0:
957            raise ValueError("index must be positive")
958        num = str(index)
959        if n_digits is not None:
960            num = num.zfill(n_digits)
961        return f"{brim_obj_names.data.base_group}_{num}"
class Data:
 26class Data:
 27    """
 28    Represents a data group within the brim file.
 29    """
 30    # make AnalysisResults available as an attribute of Data
 31    AnalysisResults = AnalysisResults
 32
 33    def __init__(self, file: FileAbstraction, path: str, *, 
 34                 newly_created: bool = False, _initialize: bool = True):
 35        """
 36        Initialize the Data object. This constructor should not be called directly.
 37
 38        Args:
 39            file (File): The parent File object.
 40            path (str): The path to the data group within the file.
 41            newly_created (bool): Whether this data group is being created as new.
 42                            If True, the constructor will not attempt to load spatial mapping.
 43            _initialize (bool): FOR INTERNAL USE ONLY. Whether to automatically initialize the current data group. 
 44                Set to False if you want to initialize them manually later using the _init_async() method. Default is True.
 45        """
 46        self._file = file
 47        self._path = path
 48        
 49
 50        if _initialize:
 51            sync(self._init_async(newly_created=newly_created))        
 52    
 53    async def _init_async(self, newly_created: bool = False) -> None:
 54        """
 55        See __init__() for the description of the arguments.
 56        """
 57        self._group = await self._file.open_group(self._path)
 58
 59        self._sparse = await self._load_sparse_flag_async()
 60        # the _spatial_map is None for non sparse data but the _spatial_map_px_size should always be valid
 61        self._spatial_map, self._spatial_map_px_size = await self._load_spatial_mapping_async() if not newly_created else (None, None)
 62
 63    def get_name(self):
 64        """
 65        Returns the name of the data group.
 66        """
 67        return sync(get_object_name(self._file, self._path))
 68    
 69    def get_index(self):
 70        """
 71        Returns the index of the data group.
 72        """
 73        return int(self._path.split('/')[-1].split('_')[-1])
 74
 75    async def _load_sparse_flag_async(self) -> bool:
 76        """
 77        Load the 'Sparse' flag for the data group.
 78
 79        Returns:
 80            bool: The value of the 'Sparse' flag, or False if the attribute is not found or invalid.
 81        """
 82        try:
 83            sparse = await self._file.get_attr(self._group, 'Sparse')
 84            if isinstance(sparse, bool):
 85                return sparse
 86            else:
 87                warnings.warn(
 88                    f"Invalid value for 'Sparse' attribute in {self._path}. Expected a boolean, got {type(sparse)}. Defaulting to False.")
 89                return False
 90        except Exception:
 91            # if the attribute is not found, return the default value False
 92            return False
 93
 94    async def _load_spatial_mapping_async(self, load_in_memory: bool=True) -> tuple:
 95        """
 96        Load a spatial mapping in the same format as 'Cartesian visualisation',
 97        irrespectively on whether 'Spatial_map' is defined instead.
 98        -1 is used for "empty" pixels in the image
 99        Args:
100            load_in_memory (bool): Specify whether the map should be forced to load in memory or just opened as a dataset.
101        Returns:
102            The spatial map and the corresponding pixel size as a tuple of 3 Metadata.Item, both in the order z, y, x.
103            If the spatial mapping is not defined in the file, returns None for the spatial map.
104            The pixel size is read from the data group for non-sparse data.
105        """
106        cv = None
107        px_size = 3*(Metadata.Item(value=1, units=None),)
108
109        cv_path = concatenate_paths(
110            self._path, brim_obj_names.data.cartesian_visualisation)
111        sm_path = concatenate_paths(
112            self._path, brim_obj_names.data.spatial_map)
113        
114        if await self._file.object_exists(cv_path):
115            cv = await self._file.open_dataset(cv_path)
116
117            #read the pixel size from the 'Cartesian visualisation' dataset
118            px_size_val = None
119            px_size_units = None
120            try:
121                px_size_val = await self._file.get_attr(cv, 'element_size')
122                if px_size_val is None or len(px_size_val) != 3:
123                    raise ValueError(
124                        "The 'element_size' attribute of 'Cartesian_visualisation' must be a tuple of 3 elements")
125            except Exception:
126                px_size_val = 3*(1,)
127                warnings.warn(
128                    "No pixel size defined for Cartesian visualisation")            
129            px_size_units = await units.of_attribute(
130                    self._file, cv, 'element_size')
131            px_size = ()
132            for i in range(3):
133                # if px_size_val[i] is not a number, set it to 1 and px_size_units to None
134                if isinstance(px_size_val[i], Number):
135                    px_size += (Metadata.Item(px_size_val[i], px_size_units), )
136                else:
137                    px_size += (Metadata.Item(1, None), )
138                    
139
140            if load_in_memory:
141                cv = await cv.to_np_array()  # load the spatial map in memory as a numpy array
142                cv = np_array_to_smallest_int_type(cv)
143
144        elif await self._file.object_exists(sm_path):
145            async def load_spatial_map_from_file():
146                async def load_coordinate_from_sm(coord: str):
147                    res = np.empty(0)  # empty array
148                    try:
149                        res = await self._file.open_dataset(
150                            concatenate_paths(sm_path, coord))
151                        res = await res.to_np_array()
152                        res = np.squeeze(res)  # remove single-dimensional entries
153                    except Exception as e:
154                        # if the coordinate does not exist, return an empty array
155                        pass
156                    if len(res.shape) > 1:
157                        raise ValueError(
158                            f"The 'Spatial_map/{coord}' dataset is not a 1D array as expected")
159                    return res
160
161                def check_coord_array(arr, size):
162                    if arr.size == 0:
163                        return np.zeros(size)
164                    elif arr.size != size:
165                        raise ValueError(
166                            "The 'Spatial_map' dataset is invalid")
167                    return arr
168
169                x, y, z = await asyncio.gather(
170                    load_coordinate_from_sm('x'),
171                    load_coordinate_from_sm('y'),
172                    load_coordinate_from_sm('z')
173                    )
174                size = max([x.size, y.size, z.size])
175                if size == 0:
176                    raise ValueError("The 'Spatial_map' dataset is empty")
177                x = check_coord_array(x, size)
178                y = check_coord_array(y, size)
179                z = check_coord_array(z, size)
180                return x, y, z
181
182            def calculate_step(x):
183                n = len(np.unique(x))
184                if n == 1:
185                    d = None
186                else:
187                    d = (np.max(x)-np.min(x))/(n-1)
188                return n, d
189
190            x, y, z = await load_spatial_map_from_file()
191
192            # TODO extend the reconstruction to non-cartesian cases
193
194            nX, dX = calculate_step(x)
195            nY, dY = calculate_step(y)
196            nZ, dZ = calculate_step(z)
197
198            indices = np_array_to_smallest_int_type(np.lexsort((x, y, z)))
199            cv = np.reshape(indices, (nZ, nY, nX))
200
201            px_size_units = await units.of_object(self._file, sm_path)
202            px_size = ()
203            for i in range(3):
204                px_sz = (dZ, dY, dX)[i]
205                px_unit = px_size_units
206                if px_sz is None:
207                    px_sz = 1
208                    px_unit = None
209                px_size += (Metadata.Item(px_sz, px_unit),)
210        elif not self._sparse:
211            try:
212                px_sz = await self._file.get_attr(self._group, 'element_size')
213                if len(px_sz) != 3:
214                    raise ValueError(
215                        "The 'element_size' attribute must be a tuple of 3 elements")
216                px_unit = None
217                try:
218                    px_unit = await units.of_attribute(self._file, self._group, 'element_size')
219                except Exception:
220                    warnings.warn("Pixel size unit is not provided for non-sparse data.")
221                px_size = tuple(Metadata.Item(el, px_unit) for el in px_sz)
222            except Exception:
223                warnings.warn("Pixel size is not provided for non-sparse data.")
224
225        return cv, px_size
226
227    def get_PSD(self) -> tuple:
228        """
229        LOW LEVEL FUNCTION
230
231        Retrieve the Power Spectral Density (PSD) and frequency from the current data group.
232        Note: this function exposes the internals of the brim file and thus the interface might change in future versions.
233        Use only if more specialized functions are not working for your application!
234        Returns:
235            tuple: (PSD, frequency, PSD_units, frequency_units)
236                - PSD: A 2D (or more) numpy array containing all the spectra (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
237                - frequency: A numpy array representing the frequency data (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
238                - PSD_units: The units of the PSD.
239                - frequency_units: The units of the frequency.
240        """
241        warnings.warn(
242            "Data.get_PSD is deprecated and will be removed in a future release. "
243            "Use Data.get_PSD_as_spatial_map instead.",
244            DeprecationWarning,
245            stacklevel=2,
246        )
247        PSD, frequency = _gather_sync(
248            self._file.open_dataset(concatenate_paths(
249                self._path, brim_obj_names.data.PSD)),
250            self._file.open_dataset(concatenate_paths(
251                self._path, brim_obj_names.data.frequency))
252        )
253        # retrieve the units of the PSD and frequency
254        PSD_units, frequency_units = _gather_sync(
255            units.of_object(self._file, PSD),
256            units.of_object(self._file, frequency)
257        )
258
259        return PSD, frequency, PSD_units, frequency_units
260    
261    def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
262        """
263        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
264        Arguments:
265            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
266                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
267                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
268        Returns:
269            tuple: (PSD, frequency, PSD_units, frequency_units)
270                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
271                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
272                - PSD_units: The units of the PSD.
273                - frequency_units: The units of the frequency.
274        """
275        PSD, frequency = _gather_sync(
276            self._file.open_dataset(concatenate_paths(
277                self._path, brim_obj_names.data.PSD)),        
278            self._file.open_dataset(concatenate_paths(
279                self._path, brim_obj_names.data.frequency))
280            )        
281        # retrieve the units of the PSD and frequency
282        PSD_units, frequency_units = _gather_sync(
283            units.of_object(self._file, PSD),
284            units.of_object(self._file, frequency)
285        )
286
287        # ensure PSD and frequency are numpy arrays
288        PSD = np.array(PSD)  
289        frequency = np.array(frequency)  # ensure it's a numpy array
290        
291        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
292        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
293        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
294            frequency = np.broadcast_to(frequency, PSD.shape)
295        
296        if self._sparse:
297            if self._spatial_map is None:
298                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
299            sm = np.array(self._spatial_map)
300            # reshape the PSD and frequency to have the spatial dimensions first      
301            PSD = PSD[sm, ...]
302            # reshape the frequency only if it is not the same for all spectra
303            if frequency.ndim > 1:
304                frequency = frequency[sm, ...]
305
306        return PSD, frequency, PSD_units, frequency_units
307
308    def _get_spectrum(self, index: int | tuple[int, int, int]) -> tuple:
309        """
310        Synchronous wrapper for `_get_spectrum_async` (see doc for `brimfile.data.Data._get_spectrum_async`)
311        """
312        return sync(self._get_spectrum_async(index))
313    async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
314        """
315        Retrieve a spectrum from the data group by its index or coordinates.
316
317        Args:
318            index (int | tuple[int, int, int]): The index (for sparse data) or z, y, x coordinates (for non-sparse data) of the spectrum to retrieve.
319
320        Returns:
321            tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. 
322                    PSD can be 1D or more (if there are additional parameters);
323                    frequency has the same size as PSD
324        Raises:
325            IndexError: If the index is out of range for the PSD dataset.
326        """
327        if self._sparse and not isinstance(index, int):
328            raise ValueError("For sparse data, index must be an integer.")
329        elif not self._sparse and not (isinstance(index, tuple) and len(index) == 3):
330            raise ValueError("For non-sparse data, index must be a tuple of (z, y, x) coordinates.")
331            
332        # index = -1 corresponds to no spectrum
333        if self._sparse and index < 0:
334            return None, None, None, None
335        elif not self._sparse and any(i < 0 for i in index):
336            return None, None, None, None
337        PSD, frequency = await asyncio.gather(
338            self._file.open_dataset(concatenate_paths(
339                self._path, brim_obj_names.data.PSD)),                       
340            self._file.open_dataset(concatenate_paths(
341                self._path, brim_obj_names.data.frequency))
342            )
343        if self._sparse and index >= PSD.shape[0]:
344            raise IndexError(
345                f"index {index} out of range for PSD with shape {PSD.shape}")
346        elif not self._sparse and any(i >= PSD.shape[j] for j, i in enumerate(index)):
347            raise IndexError(
348                f"index {index} out of range for PSD with shape {PSD.shape}")
349        # retrieve the units of the PSD and frequency
350        PSD_units, frequency_units = await asyncio.gather(
351            units.of_object(self._file, PSD),
352            units.of_object(self._file, frequency)
353        )
354        # add ellipsis to the index to select the spectrum and the corresponding frequency
355        if self._sparse:
356            index = (index, ...)
357        else:
358            index = index + (..., )
359        # map index to the frequency array, considering the broadcasting rules
360        index_frequency = index
361        if frequency.ndim < PSD.ndim:
362            if self._sparse:
363                # given the definition of the brim file format,
364                # if the frequency has less dimensions that PSD,
365                # it can only be because it is the same for all the spatial position (first dimension)
366                index_frequency = (..., )
367            else:
368                unassigned_indices = PSD.ndim - frequency.ndim
369                if unassigned_indices == 3:
370                    # if the frequency has no spatial dimension, it is the same for all the spatial positions
371                    index_frequency = (..., )
372                else:
373                    # if the frequency has some spatial dimensions but not all, we need to add the corresponding indices to the index of the frequency
374                    index_frequency = index[-unassigned_indices:] + (..., )
375        #get the spectrum and the corresponding frequency at the specified index
376        PSD, frequency = await asyncio.gather(
377            _async_getitem(PSD, index),
378            _async_getitem(frequency, index_frequency)
379        )
380        #broadcast the frequency to match the shape of PSD if needed
381        if frequency.ndim < PSD.ndim:
382            frequency = np.broadcast_to(frequency, PSD.shape)
383        return PSD, frequency, PSD_units, frequency_units
384
385    def get_spectrum_in_image(self, coor: tuple) -> tuple:
386        """
387        Retrieve a spectrum from the data group using spatial coordinates.
388
389        Args:
390            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
391
392        Returns:
393            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
394        """
395        if len(coor) != 3:
396            raise ValueError("coor must contain 3 values for z, y, x")
397
398        if self._sparse:
399            index = int(self._spatial_map[coor])
400            return self._get_spectrum(index)
401        else:
402            return self._get_spectrum(coor)
403          
404    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
405        """
406        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
407
408        Args:
409            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
410            coor (tuple): A tuple containing the z, y, x coordinates in the image.
411            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
412
413        Returns:
414            tuple: A tuple containing:
415                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
416                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
417        """
418        if len(coor) != 3:
419            raise ValueError("coor must contain 3 values for z, y, x")
420        index = coor
421        if self._sparse:
422            index = int(self._spatial_map[coor])
423        spectrum, quantities = await asyncio.gather(
424            self._get_spectrum_async(index),
425            ar._get_all_quantities_at_index(index, index_peak)
426        )
427        return spectrum, quantities
428    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
429        """
430        Synchronous wrapper for `get_spectrum_and_all_quantities_in_image_async` (see doc for `brimfile.data.Data.get_spectrum_and_all_quantities_in_image_async`)
431        """
432        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
433
434    def get_metadata(self):
435        """
436        Returns the metadata associated with the current Data group
437        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
438        and the ones specific for this data group
439        """
440        return Metadata(self._file, self._path)
441
442    def get_num_parameters(self) -> tuple:
443        """
444        Retrieves the number of parameters
445
446        Returns:
447            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
448        """
449        pars, _ = self.get_parameters()
450        return pars.shape if pars is not None else ()
451
452    def get_parameters(self) -> list:
453        """
454        Retrieves the parameters  and their associated names.
455
456        If PSD.ndims > 2, the parameters are stored in a separate dataset.
457
458        Returns:
459            list: A tuple containing the parameters and their names if there are any, otherwise None.
460        """
461        pars_full_path = concatenate_paths(
462            self._path, brim_obj_names.data.parameters)
463        if sync(self._file.object_exists(pars_full_path)):
464            pars = sync(self._file.open_dataset(pars_full_path))
465            pars_names = sync(self._file.get_attr(pars, 'Name'))
466            return (pars, pars_names)
467        return (None, None)
468
469    def create_calibration_group(self, *, index: NDArray[np.integer] | None = None, calibration_data: list[dict[str, Any]] | None = None,
470                                 timestamp: list[NDArray[Any]] | None = None, same_as: int | None = None, attributes: dict[str, MetadataItem] = None,
471                                 compression: FileAbstraction.Compression = FileAbstraction.Compression()) -> Calibration:
472        """
473        Create a new calibration group in the current data group.
474        For more details on the expected format of the calibration data, see https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md.
475
476        Parameters:
477            index (np.array | None, optional): Index array for the calibration spectra. For sparse data,
478                this must be 1D; for non-sparse data, this must be 3D.  
479                It can be omitted if each element in `calibration_data` contains only one spectrum.
480            calibration_data (list[dict[str, Any]] | None, optional): Calibration entries to store.
481                Each dictionary must contain `spectra` and `shift` keys, and may provide `shift_units`.                
482            timestamp (list[NDArray[Any]] | None, optional): Timestamp arrays corresponding to each calibration
483                entry. If provided, its length must match `calibration_data`. Defaults to None.
484            same_as (int | None, optional): If provided, links this calibration group to an existing
485                calibration via the `Same_as` attribute. When set, the other data arguments are ignored.
486                Defaults to None.
487            attributes (dict[str, MetadataItem], optional): Additional attributes to attach to the calibration group.
488                Can be one of ('Datetime', 'Description', 'Temperature', 'FSR') with the relative units (when relevant).
489            compression (FileAbstraction.Compression, optional): Compression settings used for created
490                datasets. Defaults to FileAbstraction.Compression().
491
492        Returns:
493            Calibration: The newly created calibration group.
494
495        Raises:
496            ValueError: If the provided calibration data, index, or timestamp is invalid or inconsistent.
497        """
498        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
499        calibration_group = sync(self._file.create_group(calibration_path))
500
501        # if same_as is provided, create the 'Same_as' attribute to link the calibration group to an existing one
502        if same_as is not None:
503            sync(self._file.create_attr(calibration_group, 'Same_as', same_as))
504        else: # if same_as is provided, the other parameters are ignored
505            # check that calibration_data is provided and valid
506            if calibration_data is None:
507                raise ValueError("'calibration_data' is required when 'same_as' is not provided")
508            if not isinstance(calibration_data, (list, tuple)):
509                calibration_data = [calibration_data,]
510            # check that index is valid if provided
511            if index is not None:
512                # TODO: check of the shape of 'index' is compatible with PSD
513                if self._sparse and index.ndim != 1:
514                    raise ValueError("'index' must be a 1D array for sparse data")
515                if not self._sparse and index.ndim != 3:
516                    raise ValueError("'index' must be a 3D array for non-sparse data")
517            # check that timestamp is valid if provided
518            if timestamp is not None: 
519                if not isinstance(timestamp, (list, tuple)):
520                    timestamp = [timestamp,]
521                if len(timestamp) != len(calibration_data):
522                    raise ValueError("If 'timestamp' is provided, it must have the same length as 'calibration_data'")            
523
524            for m, calib in enumerate(calibration_data):
525                # check that each element in calibration_data is a dictionary containing 'spectra' and 'shift' keys
526                if not isinstance(calib, dict):
527                    raise ValueError("Each element in 'calibration_data' must be a dictionary")
528                if 'spectra' not in calib.keys() or 'shift' not in calib.keys():
529                    raise ValueError("Each calibration data dictionary must contain 'spectra' and 'shift' keys")
530                # retrieve the spectra, shift and shift_units from the calibration data and check that they are valid
531                cal_spectra = np.array(calib['spectra'])
532                if cal_spectra.ndim != 2:
533                    raise ValueError("'spectra' in calibration data must be a 2D array. If only one spectrum is provided, set the first dimension to 1.")
534                cal_shift = calib['shift']
535                cal_shift_units = calib.get('shift_units', None)
536                if cal_shift_units is None:
537                    cal_shift_units = 'GHz'
538                    warnings.warn("No units provided for 'shift' in calibration data, defaulting to GHz")
539                # check that index is compatible with the shape of the spectra
540                if index is None and cal_spectra.shape[0] != 1:
541                    raise ValueError("If 'index' is not provided, each element in 'calibration_data' must contain only one spectrum (i.e. have shape (1, n))")
542                if index is not None and np.max(index) >= cal_spectra.shape[0]:
543                    raise ValueError("If 'index' is provided, its maximum value must be less than the number of spectra in each calibration data element")
544                # add the m arrays together with their attributes to the file
545                spectra_dataset = sync(self._file.create_dataset(calibration_group, f'{m}', cal_spectra, chunk_size=_determine_chunk_size(cal_spectra), compression=compression))
546                sync(self._file.create_attr(spectra_dataset, 'Shift', cal_shift))
547                units.add_to_attribute(self._file, spectra_dataset, 'Shift', cal_shift_units)
548                if timestamp is not None:
549                    timestamp_array = np.array(timestamp[m])
550                    if timestamp_array.ndim != 1 or timestamp_array.shape[0] != cal_spectra.shape[0]:
551                        raise ValueError("Each element in 'timestamp' must be a 1D array with the same length as the number of spectra in each calibration data element")
552                    sync(self._file.create_dataset(calibration_group, f'Timestamp_{m}', timestamp_array, compression=compression))
553            # add the index array to the file
554            if index is not None:
555                sync(self._file.create_dataset(calibration_group, 'Index', index, compression=compression))
556        
557        from .calibration import _STANDARD_ATTRIBUTES
558        # add any additional attributes to the calibration group, checking that they do not overwrite the standard
559        if attributes is not None:
560            for key, value in attributes.items():
561                if key not in _STANDARD_ATTRIBUTES:
562                    warnings.warn(f"Attribute '{key}' is not a standard attribute for calibration groups.\
563                                   Standard attributes are: {', '.join(_STANDARD_ATTRIBUTES)}. \
564                                   Make sure this is intentional!")
565                if not isinstance(value, MetadataItem):
566                    value = MetadataItem(value)
567                sync(self._file.create_attr(calibration_group, key, value.value))
568                if value.units is not None:
569                    units.add_to_attribute(self._file, calibration_group, key, value.units)
570
571        return Calibration(self._file, calibration_path, data_group=self)
572    
573    def get_calibration(self) -> Calibration:
574        """
575        Synchronous wrapper for `get_calibration_async` (see doc for `brimfile.data.Data.get_calibration_async`)
576        """
577        return sync(self.get_calibration_async())
578
579    async def get_calibration_async(self) -> Calibration:
580        """
581        Retrieve the calibration group associated with the current data group.
582
583        Returns:
584            Calibration: The calibration group associated with the current data group.
585
586        Raises:
587            ValueError: If no calibration group is found in the current data group or the referenced calibration group does not exist.
588        """
589        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
590        if not await self._file.object_exists(calibration_path):
591            raise ValueError(f"No calibration group found in {self._path}")
592        same_as = None
593        try:
594            same_as = await self._file.get_attr(calibration_path, 'Same_as')
595        except Exception:
596            pass #  same_as attribute is optional, if it does not exist we just ignore it
597        # if the 'Same_as' attribute exists, find the calibration group with the corresponding index
598        if same_as is not None:
599            try:
600                d_m = await Data.from_existing_async(self._file, same_as)
601                return await d_m.get_calibration_async()
602            except IndexError:
603                raise ValueError(f"Calibration group in {self._path} references non-existing calibration index {same_as} in the file")
604        cal_group = Calibration(self._file, calibration_path, data_group=self, _initialize=False)
605        await cal_group._init_async()
606        return cal_group
607
608    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
609                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
610        """
611        Adds a new AnalysisResults entry to the current data group.
612        Parameters:
613            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
614            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
615            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
616            name (str, optional): The name for the new Analysis group. Defaults to None.
617            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
618        Returns:
619            AnalysisResults: The newly created AnalysisResults object.
620        Raises:
621            IndexError: If the specified index already exists in the dataset.
622            ValueError: If any of the data provided is not valid or consistent
623        """
624        if index is not None:
625            try:
626                self.get_analysis_results(index)
627            except IndexError:
628                pass
629            else:
630                # If the group already exists, raise an error
631                raise IndexError(
632                    f"Analysis {index} already exists in {self._path}")
633        else:
634            ar_groups = self.list_AnalysisResults()
635            indices = [ar['index'] for ar in ar_groups]
636            indices.sort()
637            index = indices[-1] + 1 if indices else 0  # Next available index
638
639        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
640        if name is not None:
641            set_object_name(self._file, ar._path, name)
642        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
643
644        return ar
645
646    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
647        """
648        List all AnalysisResults groups in the current data group. The list is ordered by index.
649
650        Returns:
651            list: A list of dictionaries, each containing:
652                - 'name' (str): The name of the AnalysisResults group.
653                - 'index' (int): The index extracted from the group name.
654                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
655        """
656
657        analysis_results_groups = []
658
659        matched_objs = sync(list_objects_matching_pattern_async(
660            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$"))
661        async def _make_dict_item(matched_obj, retrieve_custom_name):
662            name = matched_obj[0]
663            index = int(matched_obj[1])
664            curr_obj_dict = {'name': name, 'index': index}
665            if retrieve_custom_name:
666                ar_path = concatenate_paths(self._path, name)
667                custom_name = await get_object_name(self._file, ar_path)
668                curr_obj_dict['custom_name'] = custom_name
669            return curr_obj_dict
670        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
671        dicts = _gather_sync(*coros)
672        for dict_item in dicts:
673            analysis_results_groups.append(dict_item)
674        # Sort the data groups by index
675        analysis_results_groups.sort(key=lambda x: x['index'])
676
677        return analysis_results_groups
678
679    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
680        """
681        Returns the AnalysisResults at the specified index
682
683        Args:
684            index (int)                
685
686        Raises:
687            IndexError: If there is no analysis with the corresponding index
688        """
689        name = None
690        ls = self.list_AnalysisResults()
691        for el in ls:
692            if el['index'] == index:
693                name = el['name']
694                break
695        if name is None:
696            raise IndexError(f"Analysis {index} not found")
697        path = concatenate_paths(self._path, name)
698        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
699                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)
700
701    def _add_data(self, PSD: np.ndarray, frequency: np.ndarray, *, scanning: dict = None, freq_units='GHz',
702                  timestamp: np.ndarray = None, compression: FileAbstraction.Compression = FileAbstraction.Compression()):
703        """
704        Add data to the current data group.
705
706        This method adds the provided PSD, frequency, and scanning data to the HDF5 group 
707        associated with this `Data` object. It validates the inputs to ensure they meet 
708        the required specifications before adding them.
709
710        Args:
711            PSD (np.ndarray): A 2D numpy array representing the Power Spectral Density (PSD) data. The last dimension contains the spectra.
712            frequency (np.ndarray): A 1D or 2D numpy array representing the frequency data. 
713                It must be broadcastable to the shape of the PSD array.
714            scanning (dict, optional): A dictionary containing scanning-related data. 
715                Required for sparse data (sparse=True), optional for non-sparse data.
716                For sparse data, must include at least one of 'Spatial_map' or 'Cartesian_visualisation'.
717                It may include the following keys:
718                - 'Spatial_map' (optional): A dictionary containing coordinate arrays:
719                    - 'x', 'y', 'z' (optional): 1D numpy arrays of same length with coordinate values
720                    - 'units' (optional): string with the unit (e.g., 'um')
721                - 'Cartesian_visualisation' (optional): A 3D numpy array (z, y, x) with integer values 
722                   mapping spatial positions to spectra indices. Values must be -1 (invalid/empty pixel) 
723                   or between 0 and PSD.shape[0]-1.
724                - 'Cartesian_visualisation_pixel' (recommended with Cartesian_visualisation): 
725                   Tuple/list of 3 float values (z, y, x) representing pixel size. Unused dimensions can be None.
726                - 'Cartesian_visualisation_pixel_unit' (optional): String for pixel size unit (default: 'um').
727            timestamp (np.ndarray, optional): Timestamps in milliseconds for each spectrum.
728                Must be a 1D array with length equal to PSD.shape[0].
729
730
731        Raises:
732            ValueError: If any of the data provided is not valid or consistent
733        """
734
735        # Check if frequency is broadcastable to PSD
736        try:
737            np.broadcast_shapes(tuple(frequency.shape), tuple(PSD.shape))
738        except ValueError as e:
739            raise ValueError(f"frequency (shape: {frequency.shape}) is not broadcastable to PSD (shape: {PSD.shape}): {e}")
740
741        # Check if at least one of 'Spatial_map' or 'Cartesian_visualisation' is present in the scanning dictionary
742        # This is required for sparse data to establish the spatial mapping
743        has_spatial_mapping = False
744        if scanning is not None:
745            if 'Spatial_map' in scanning:
746                sm = scanning['Spatial_map']
747                size = 0
748
749                def check_coor(coor: str):
750                    if coor in sm:
751                        sm[coor] = np.array(sm[coor])
752                        size1 = sm[coor].size
753                        if size1 != size and size != 0:
754                            raise ValueError(
755                                f"'{coor}' in 'Spatial_map' is invalid!")
756                        return size1
757                    return size
758                size = check_coor('x')
759                size = check_coor('y')
760                size = check_coor('z')
761                if size == 0:
762                    raise ValueError(
763                        "'Spatial_map' should contain at least one x, y or z")
764                has_spatial_mapping = True
765            if 'Cartesian_visualisation' in scanning:
766                cv = scanning['Cartesian_visualisation']
767                if not isinstance(cv, np.ndarray) or cv.ndim != 3:
768                    raise ValueError(
769                        "Cartesian_visualisation must be a 3D numpy array")
770                if not np.issubdtype(cv.dtype, np.integer) or np.min(cv) < -1 or np.max(cv) >= PSD.shape[0]:
771                    raise ValueError(
772                        "Cartesian_visualisation values must be integers between -1 and PSD.shape[0]-1")
773                if 'Cartesian_visualisation_pixel' in scanning:
774                    if len(scanning['Cartesian_visualisation_pixel']) != 3:
775                        raise ValueError(
776                            "Cartesian_visualisation_pixel must always contain 3 values for z, y, x (set to None if not used)")
777                else:
778                    warnings.warn(
779                        "It is recommended to include 'Cartesian_visualisation_pixel' in the scanning dictionary to define pixel size for proper spatial calibration")
780                has_spatial_mapping = True
781        if not has_spatial_mapping and self._sparse:
782            raise ValueError("For sparse data, 'scanning' must be provided and must contain at least one of 'Spatial_map' or 'Cartesian_visualisation'")
783
784        if timestamp is not None:
785            if not isinstance(timestamp, np.ndarray) or timestamp.ndim != 1 or len(timestamp) != PSD.shape[0]:
786                raise ValueError("timestamp is not compatible with PSD")
787
788        # TODO: add and validate additional datasets (i.e. 'Parameters', 'Calibration_index', etc.)
789
790        # Add datasets to the group
791        sync(self._file.create_dataset(
792            self._group, brim_obj_names.data.PSD, data=PSD,
793            chunk_size=_determine_chunk_size(PSD), compression=compression))
794        freq_ds = sync(self._file.create_dataset(
795            self._group,  brim_obj_names.data.frequency, data=frequency,
796            chunk_size=_determine_chunk_size(frequency), compression=compression))
797        units.add_to_object(self._file, freq_ds, freq_units)
798
799        if scanning is not None:
800            if 'Spatial_map' in scanning:
801                sm = scanning['Spatial_map']
802                sm_group = sync(self._file.create_group(concatenate_paths(
803                    self._path, brim_obj_names.data.spatial_map)))
804                if 'units' in sm:
805                    units.add_to_object(self._file, sm_group, sm['units'])
806
807                def add_sm_dataset(coord: str):
808                    if coord in sm:
809                        sync(self._file.create_dataset(
810                            sm_group, coord, data=sm[coord], compression=compression))
811
812                add_sm_dataset('x')
813                add_sm_dataset('y')
814                add_sm_dataset('z')
815            if 'Cartesian_visualisation' in scanning:
816                # convert the Cartesian_visualisation to the smallest integer type
817                cv_arr = np_array_to_smallest_int_type(scanning['Cartesian_visualisation'])
818                cv = sync(self._file.create_dataset(self._group, brim_obj_names.data.cartesian_visualisation,
819                                            data=cv_arr, compression=compression))
820                if 'Cartesian_visualisation_pixel' in scanning:
821                    sync(self._file.create_attr(
822                        cv, 'element_size', scanning['Cartesian_visualisation_pixel']))
823                    if 'Cartesian_visualisation_pixel_unit' in scanning:
824                        px_unit = scanning['Cartesian_visualisation_pixel_unit']
825                    else:
826                        warnings.warn(
827                            "No unit provided for Cartesian_visualisation_pixel, defaulting to 'um'")
828                        px_unit = 'um'
829                    units.add_to_attribute(self._file, cv, 'element_size', px_unit)
830
831        self._spatial_map, self._spatial_map_px_size = sync(self._load_spatial_mapping_async())
832
833        if timestamp is not None:
834            sync(self._file.create_dataset(
835                self._group, 'Timestamp', data=timestamp, compression=compression))
836
837    @staticmethod
838    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
839        """
840        Synchronous wrapper for `list_data_groups_async` (see doc for `brimfile.data.Data.list_data_groups_async`)
841        """
842        return sync(Data.list_data_groups_async(file, retrieve_custom_name))
843
844    @staticmethod
845    async def list_data_groups_async(file: FileAbstraction, retrieve_custom_name=False) -> list:
846        """
847        List all data groups in the brim file. The list is ordered by index.
848
849        Returns:
850            list: A list of dictionaries, each containing:
851                - 'name' (str): The name of the data group in the file.
852                - 'index' (int): The index extracted from the group name.
853                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
854        """
855
856        data_groups = []
857
858        matched_objs = await list_objects_matching_pattern_async(
859            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
860        
861        async def _make_dict_item(matched_obj, retrieve_custom_name):
862            name = matched_obj[0]
863            index = int(matched_obj[1])
864            curr_obj_dict = {'name': name, 'index': index}
865            if retrieve_custom_name:
866                path = concatenate_paths(
867                    brim_obj_names.Brillouin_base_path, name)
868                custom_name = await get_object_name(file, path)
869                curr_obj_dict['custom_name'] = custom_name
870            return curr_obj_dict
871        
872        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
873        dicts = await asyncio.gather(*coros)
874        for dict_item in dicts:
875            data_groups.append(dict_item)        
876        # Sort the data groups by index
877        data_groups.sort(key=lambda x: x['index'])
878
879        return data_groups
880
881    @staticmethod
882    async def _get_existing_group_name_async(file: FileAbstraction, index: int) -> str:
883        """
884        Get the name of an existing data group by index.
885
886        Args:
887            file (File): The parent File object.
888            index (int): The index of the data group.
889
890        Returns:
891            str: The name of the data group, or None if not found.
892        """
893        group_name: str = None
894        data_groups = await Data.list_data_groups_async(file)
895        for dg in data_groups:
896            if dg['index'] == index:
897                group_name = dg['name']
898                break
899        return group_name
900    
901    @classmethod
902    async def from_existing_async(cls, file: FileAbstraction, index: int) -> 'Data':
903        """ 
904        Create a Data object from an existing data group in the file.
905        Args:
906            file (File): The parent File object.
907            index (int): The index of the existing data group.      
908        Returns:
909            Data: A Data object corresponding to the existing data group.   
910        Raises:
911            IndexError: If no data group with the specified index is found in the file.
912        """
913        group_name: str = await cls._get_existing_group_name_async(file, index)
914        if group_name is None:
915            raise IndexError(f"No data group with index {index} found in the file")
916        dg = cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), _initialize=False) 
917        await dg._init_async()
918        return dg
919    
920    @classmethod
921    def _create_new(cls, file: FileAbstraction, index: int, sparse: bool = False, name: str = None) -> 'Data':
922        """
923        Create a new data group with the specified index.
924
925        Args:
926            file (File): The parent File object.
927            index (int): The index for the new data group.
928            sparse (bool): Whether the data is sparse. See https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md for details. Defaults to False.
929            name (str, optional): The name for the new data group. Defaults to None.
930
931        Returns:
932            Data: The newly created Data object.
933        """
934        group_name = Data._generate_group_name(index)
935        group = sync(file.create_group(concatenate_paths(
936            brim_obj_names.Brillouin_base_path, group_name)))
937        sync(file.create_attr(group, 'Sparse', sparse))
938        if name is not None:
939            set_object_name(file, group, name)
940        return cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), newly_created=True)
941
942    @staticmethod
943    def _generate_group_name(index: int, n_digits: int = None) -> str:
944        """
945        Generate a name for a data group based on the index.
946
947        Args:
948            index (int): The index for the data group.
949            n_digits (int, optional): The number of digits to pad the index with. If None no padding is applied. Defaults to None.
950
951        Returns:
952            str: The generated group name.
953
954        Raises:
955            ValueError: If the index is negative.
956        """
957        if index < 0:
958            raise ValueError("index must be positive")
959        num = str(index)
960        if n_digits is not None:
961            num = num.zfill(n_digits)
962        return f"{brim_obj_names.data.base_group}_{num}"

Represents a data group within the brim file.

Data( file: brimfile.file_abstraction.FileAbstraction, path: str, *, newly_created: bool = False, _initialize: bool = True)
33    def __init__(self, file: FileAbstraction, path: str, *, 
34                 newly_created: bool = False, _initialize: bool = True):
35        """
36        Initialize the Data object. This constructor should not be called directly.
37
38        Args:
39            file (File): The parent File object.
40            path (str): The path to the data group within the file.
41            newly_created (bool): Whether this data group is being created as new.
42                            If True, the constructor will not attempt to load spatial mapping.
43            _initialize (bool): FOR INTERNAL USE ONLY. Whether to automatically initialize the current data group. 
44                Set to False if you want to initialize them manually later using the _init_async() method. Default is True.
45        """
46        self._file = file
47        self._path = path
48        
49
50        if _initialize:
51            sync(self._init_async(newly_created=newly_created))        

Initialize the Data object. This constructor should not be called directly.

Arguments:
  • file (File): The parent File object.
  • path (str): The path to the data group within the file.
  • newly_created (bool): Whether this data group is being created as new. If True, the constructor will not attempt to load spatial mapping.
  • _initialize (bool): FOR INTERNAL USE ONLY. Whether to automatically initialize the current data group. Set to False if you want to initialize them manually later using the _init_async() method. Default is True.
def get_name(self):
63    def get_name(self):
64        """
65        Returns the name of the data group.
66        """
67        return sync(get_object_name(self._file, self._path))

Returns the name of the data group.

def get_index(self):
69    def get_index(self):
70        """
71        Returns the index of the data group.
72        """
73        return int(self._path.split('/')[-1].split('_')[-1])

Returns the index of the data group.

def get_PSD(self) -> tuple:
227    def get_PSD(self) -> tuple:
228        """
229        LOW LEVEL FUNCTION
230
231        Retrieve the Power Spectral Density (PSD) and frequency from the current data group.
232        Note: this function exposes the internals of the brim file and thus the interface might change in future versions.
233        Use only if more specialized functions are not working for your application!
234        Returns:
235            tuple: (PSD, frequency, PSD_units, frequency_units)
236                - PSD: A 2D (or more) numpy array containing all the spectra (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
237                - frequency: A numpy array representing the frequency data (see [specs](https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
238                - PSD_units: The units of the PSD.
239                - frequency_units: The units of the frequency.
240        """
241        warnings.warn(
242            "Data.get_PSD is deprecated and will be removed in a future release. "
243            "Use Data.get_PSD_as_spatial_map instead.",
244            DeprecationWarning,
245            stacklevel=2,
246        )
247        PSD, frequency = _gather_sync(
248            self._file.open_dataset(concatenate_paths(
249                self._path, brim_obj_names.data.PSD)),
250            self._file.open_dataset(concatenate_paths(
251                self._path, brim_obj_names.data.frequency))
252        )
253        # retrieve the units of the PSD and frequency
254        PSD_units, frequency_units = _gather_sync(
255            units.of_object(self._file, PSD),
256            units.of_object(self._file, frequency)
257        )
258
259        return PSD, frequency, PSD_units, frequency_units

LOW LEVEL FUNCTION

Retrieve the Power Spectral Density (PSD) and frequency from the current data group. Note: this function exposes the internals of the brim file and thus the interface might change in future versions. Use only if more specialized functions are not working for your application!

Returns:

tuple: (PSD, frequency, PSD_units, frequency_units) - PSD: A 2D (or more) numpy array containing all the spectra (see specs for more details). - frequency: A numpy array representing the frequency data (see specs for more details). - PSD_units: The units of the PSD. - frequency_units: The units of the frequency.

def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
261    def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
262        """
263        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
264        Arguments:
265            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
266                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
267                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
268        Returns:
269            tuple: (PSD, frequency, PSD_units, frequency_units)
270                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
271                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
272                - PSD_units: The units of the PSD.
273                - frequency_units: The units of the frequency.
274        """
275        PSD, frequency = _gather_sync(
276            self._file.open_dataset(concatenate_paths(
277                self._path, brim_obj_names.data.PSD)),        
278            self._file.open_dataset(concatenate_paths(
279                self._path, brim_obj_names.data.frequency))
280            )        
281        # retrieve the units of the PSD and frequency
282        PSD_units, frequency_units = _gather_sync(
283            units.of_object(self._file, PSD),
284            units.of_object(self._file, frequency)
285        )
286
287        # ensure PSD and frequency are numpy arrays
288        PSD = np.array(PSD)  
289        frequency = np.array(frequency)  # ensure it's a numpy array
290        
291        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
292        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
293        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
294            frequency = np.broadcast_to(frequency, PSD.shape)
295        
296        if self._sparse:
297            if self._spatial_map is None:
298                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
299            sm = np.array(self._spatial_map)
300            # reshape the PSD and frequency to have the spatial dimensions first      
301            PSD = PSD[sm, ...]
302            # reshape the frequency only if it is not the same for all spectra
303            if frequency.ndim > 1:
304                frequency = frequency[sm, ...]
305
306        return PSD, frequency, PSD_units, frequency_units

Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.

Arguments:
  • broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
Returns:

tuple: (PSD, frequency, PSD_units, frequency_units) - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum. - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see broadcast_frequency). - PSD_units: The units of the PSD. - frequency_units: The units of the frequency.

def get_spectrum_in_image(self, coor: tuple) -> tuple:
385    def get_spectrum_in_image(self, coor: tuple) -> tuple:
386        """
387        Retrieve a spectrum from the data group using spatial coordinates.
388
389        Args:
390            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
391
392        Returns:
393            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
394        """
395        if len(coor) != 3:
396            raise ValueError("coor must contain 3 values for z, y, x")
397
398        if self._sparse:
399            index = int(self._spatial_map[coor])
400            return self._get_spectrum(index)
401        else:
402            return self._get_spectrum(coor)

Retrieve a spectrum from the data group using spatial coordinates.

Arguments:
  • coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
Returns:

tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See Data._get_spectrum_async for details.

async def get_spectrum_and_all_quantities_in_image_async( self, ar: brimfile.analysis_results.AnalysisResults, coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
404    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
405        """
406        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
407
408        Args:
409            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
410            coor (tuple): A tuple containing the z, y, x coordinates in the image.
411            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
412
413        Returns:
414            tuple: A tuple containing:
415                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
416                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
417        """
418        if len(coor) != 3:
419            raise ValueError("coor must contain 3 values for z, y, x")
420        index = coor
421        if self._sparse:
422            index = int(self._spatial_map[coor])
423        spectrum, quantities = await asyncio.gather(
424            self._get_spectrum_async(index),
425            ar._get_all_quantities_at_index(index, index_peak)
426        )
427        return spectrum, quantities

Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.

Arguments:
  • ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
  • coor (tuple): A tuple containing the z, y, x coordinates in the image.
  • index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
Returns:

tuple: A tuple containing: - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]

def get_spectrum_and_all_quantities_in_image( self, ar: brimfile.analysis_results.AnalysisResults, coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
428    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
429        """
430        Synchronous wrapper for `get_spectrum_and_all_quantities_in_image_async` (see doc for `brimfile.data.Data.get_spectrum_and_all_quantities_in_image_async`)
431        """
432        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
def get_metadata(self):
434    def get_metadata(self):
435        """
436        Returns the metadata associated with the current Data group
437        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
438        and the ones specific for this data group
439        """
440        return Metadata(self._file, self._path)

Returns the metadata associated with the current Data group Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group) and the ones specific for this data group

def get_num_parameters(self) -> tuple:
442    def get_num_parameters(self) -> tuple:
443        """
444        Retrieves the number of parameters
445
446        Returns:
447            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
448        """
449        pars, _ = self.get_parameters()
450        return pars.shape if pars is not None else ()

Retrieves the number of parameters

Returns:

tuple: The shape of the parameters if they exist, otherwise an empty tuple.

def get_parameters(self) -> list:
452    def get_parameters(self) -> list:
453        """
454        Retrieves the parameters  and their associated names.
455
456        If PSD.ndims > 2, the parameters are stored in a separate dataset.
457
458        Returns:
459            list: A tuple containing the parameters and their names if there are any, otherwise None.
460        """
461        pars_full_path = concatenate_paths(
462            self._path, brim_obj_names.data.parameters)
463        if sync(self._file.object_exists(pars_full_path)):
464            pars = sync(self._file.open_dataset(pars_full_path))
465            pars_names = sync(self._file.get_attr(pars, 'Name'))
466            return (pars, pars_names)
467        return (None, None)

Retrieves the parameters and their associated names.

If PSD.ndims > 2, the parameters are stored in a separate dataset.

Returns:

list: A tuple containing the parameters and their names if there are any, otherwise None.

def create_calibration_group( self, *, index: numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[numpy.integer]] | None = None, calibration_data: list[dict[str, typing.Any]] | None = None, timestamp: list[numpy.ndarray[tuple[typing.Any, ...], numpy.dtype[typing.Any]]] | None = None, same_as: int | None = None, attributes: dict[str, brimfile.metadata.types.MetadataItem] = None, compression: brimfile.file_abstraction.FileAbstraction.Compression = <brimfile.file_abstraction.FileAbstraction.Compression object>) -> brimfile.calibration.Calibration:
469    def create_calibration_group(self, *, index: NDArray[np.integer] | None = None, calibration_data: list[dict[str, Any]] | None = None,
470                                 timestamp: list[NDArray[Any]] | None = None, same_as: int | None = None, attributes: dict[str, MetadataItem] = None,
471                                 compression: FileAbstraction.Compression = FileAbstraction.Compression()) -> Calibration:
472        """
473        Create a new calibration group in the current data group.
474        For more details on the expected format of the calibration data, see https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md.
475
476        Parameters:
477            index (np.array | None, optional): Index array for the calibration spectra. For sparse data,
478                this must be 1D; for non-sparse data, this must be 3D.  
479                It can be omitted if each element in `calibration_data` contains only one spectrum.
480            calibration_data (list[dict[str, Any]] | None, optional): Calibration entries to store.
481                Each dictionary must contain `spectra` and `shift` keys, and may provide `shift_units`.                
482            timestamp (list[NDArray[Any]] | None, optional): Timestamp arrays corresponding to each calibration
483                entry. If provided, its length must match `calibration_data`. Defaults to None.
484            same_as (int | None, optional): If provided, links this calibration group to an existing
485                calibration via the `Same_as` attribute. When set, the other data arguments are ignored.
486                Defaults to None.
487            attributes (dict[str, MetadataItem], optional): Additional attributes to attach to the calibration group.
488                Can be one of ('Datetime', 'Description', 'Temperature', 'FSR') with the relative units (when relevant).
489            compression (FileAbstraction.Compression, optional): Compression settings used for created
490                datasets. Defaults to FileAbstraction.Compression().
491
492        Returns:
493            Calibration: The newly created calibration group.
494
495        Raises:
496            ValueError: If the provided calibration data, index, or timestamp is invalid or inconsistent.
497        """
498        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
499        calibration_group = sync(self._file.create_group(calibration_path))
500
501        # if same_as is provided, create the 'Same_as' attribute to link the calibration group to an existing one
502        if same_as is not None:
503            sync(self._file.create_attr(calibration_group, 'Same_as', same_as))
504        else: # if same_as is provided, the other parameters are ignored
505            # check that calibration_data is provided and valid
506            if calibration_data is None:
507                raise ValueError("'calibration_data' is required when 'same_as' is not provided")
508            if not isinstance(calibration_data, (list, tuple)):
509                calibration_data = [calibration_data,]
510            # check that index is valid if provided
511            if index is not None:
512                # TODO: check of the shape of 'index' is compatible with PSD
513                if self._sparse and index.ndim != 1:
514                    raise ValueError("'index' must be a 1D array for sparse data")
515                if not self._sparse and index.ndim != 3:
516                    raise ValueError("'index' must be a 3D array for non-sparse data")
517            # check that timestamp is valid if provided
518            if timestamp is not None: 
519                if not isinstance(timestamp, (list, tuple)):
520                    timestamp = [timestamp,]
521                if len(timestamp) != len(calibration_data):
522                    raise ValueError("If 'timestamp' is provided, it must have the same length as 'calibration_data'")            
523
524            for m, calib in enumerate(calibration_data):
525                # check that each element in calibration_data is a dictionary containing 'spectra' and 'shift' keys
526                if not isinstance(calib, dict):
527                    raise ValueError("Each element in 'calibration_data' must be a dictionary")
528                if 'spectra' not in calib.keys() or 'shift' not in calib.keys():
529                    raise ValueError("Each calibration data dictionary must contain 'spectra' and 'shift' keys")
530                # retrieve the spectra, shift and shift_units from the calibration data and check that they are valid
531                cal_spectra = np.array(calib['spectra'])
532                if cal_spectra.ndim != 2:
533                    raise ValueError("'spectra' in calibration data must be a 2D array. If only one spectrum is provided, set the first dimension to 1.")
534                cal_shift = calib['shift']
535                cal_shift_units = calib.get('shift_units', None)
536                if cal_shift_units is None:
537                    cal_shift_units = 'GHz'
538                    warnings.warn("No units provided for 'shift' in calibration data, defaulting to GHz")
539                # check that index is compatible with the shape of the spectra
540                if index is None and cal_spectra.shape[0] != 1:
541                    raise ValueError("If 'index' is not provided, each element in 'calibration_data' must contain only one spectrum (i.e. have shape (1, n))")
542                if index is not None and np.max(index) >= cal_spectra.shape[0]:
543                    raise ValueError("If 'index' is provided, its maximum value must be less than the number of spectra in each calibration data element")
544                # add the m arrays together with their attributes to the file
545                spectra_dataset = sync(self._file.create_dataset(calibration_group, f'{m}', cal_spectra, chunk_size=_determine_chunk_size(cal_spectra), compression=compression))
546                sync(self._file.create_attr(spectra_dataset, 'Shift', cal_shift))
547                units.add_to_attribute(self._file, spectra_dataset, 'Shift', cal_shift_units)
548                if timestamp is not None:
549                    timestamp_array = np.array(timestamp[m])
550                    if timestamp_array.ndim != 1 or timestamp_array.shape[0] != cal_spectra.shape[0]:
551                        raise ValueError("Each element in 'timestamp' must be a 1D array with the same length as the number of spectra in each calibration data element")
552                    sync(self._file.create_dataset(calibration_group, f'Timestamp_{m}', timestamp_array, compression=compression))
553            # add the index array to the file
554            if index is not None:
555                sync(self._file.create_dataset(calibration_group, 'Index', index, compression=compression))
556        
557        from .calibration import _STANDARD_ATTRIBUTES
558        # add any additional attributes to the calibration group, checking that they do not overwrite the standard
559        if attributes is not None:
560            for key, value in attributes.items():
561                if key not in _STANDARD_ATTRIBUTES:
562                    warnings.warn(f"Attribute '{key}' is not a standard attribute for calibration groups.\
563                                   Standard attributes are: {', '.join(_STANDARD_ATTRIBUTES)}. \
564                                   Make sure this is intentional!")
565                if not isinstance(value, MetadataItem):
566                    value = MetadataItem(value)
567                sync(self._file.create_attr(calibration_group, key, value.value))
568                if value.units is not None:
569                    units.add_to_attribute(self._file, calibration_group, key, value.units)
570
571        return Calibration(self._file, calibration_path, data_group=self)

Create a new calibration group in the current data group. For more details on the expected format of the calibration data, see https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md.

Arguments:
  • index (np.array | None, optional): Index array for the calibration spectra. For sparse data, this must be 1D; for non-sparse data, this must be 3D.
    It can be omitted if each element in calibration_data contains only one spectrum.
  • calibration_data (list[dict[str, Any]] | None, optional): Calibration entries to store. Each dictionary must contain spectra and shift keys, and may provide shift_units.
  • timestamp (list[NDArray[Any]] | None, optional): Timestamp arrays corresponding to each calibration entry. If provided, its length must match calibration_data. Defaults to None.
  • same_as (int | None, optional): If provided, links this calibration group to an existing calibration via the Same_as attribute. When set, the other data arguments are ignored. Defaults to None.
  • attributes (dict[str, MetadataItem], optional): Additional attributes to attach to the calibration group. Can be one of ('Datetime', 'Description', 'Temperature', 'FSR') with the relative units (when relevant).
  • compression (FileAbstraction.Compression, optional): Compression settings used for created datasets. Defaults to FileAbstraction.Compression().
Returns:

Calibration: The newly created calibration group.

Raises:
  • ValueError: If the provided calibration data, index, or timestamp is invalid or inconsistent.
def get_calibration(self) -> brimfile.calibration.Calibration:
573    def get_calibration(self) -> Calibration:
574        """
575        Synchronous wrapper for `get_calibration_async` (see doc for `brimfile.data.Data.get_calibration_async`)
576        """
577        return sync(self.get_calibration_async())
async def get_calibration_async(self) -> brimfile.calibration.Calibration:
579    async def get_calibration_async(self) -> Calibration:
580        """
581        Retrieve the calibration group associated with the current data group.
582
583        Returns:
584            Calibration: The calibration group associated with the current data group.
585
586        Raises:
587            ValueError: If no calibration group is found in the current data group or the referenced calibration group does not exist.
588        """
589        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
590        if not await self._file.object_exists(calibration_path):
591            raise ValueError(f"No calibration group found in {self._path}")
592        same_as = None
593        try:
594            same_as = await self._file.get_attr(calibration_path, 'Same_as')
595        except Exception:
596            pass #  same_as attribute is optional, if it does not exist we just ignore it
597        # if the 'Same_as' attribute exists, find the calibration group with the corresponding index
598        if same_as is not None:
599            try:
600                d_m = await Data.from_existing_async(self._file, same_as)
601                return await d_m.get_calibration_async()
602            except IndexError:
603                raise ValueError(f"Calibration group in {self._path} references non-existing calibration index {same_as} in the file")
604        cal_group = Calibration(self._file, calibration_path, data_group=self, _initialize=False)
605        await cal_group._init_async()
606        return cal_group

Retrieve the calibration group associated with the current data group.

Returns:

Calibration: The calibration group associated with the current data group.

Raises:
  • ValueError: If no calibration group is found in the current data group or the referenced calibration group does not exist.
def create_analysis_results_group( self, data_AntiStokes, data_Stokes=None, *, index: int = None, name: str = None, fit_model: brimfile.fitting_models.FitModel = None) -> brimfile.analysis_results.AnalysisResults:
608    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
609                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
610        """
611        Adds a new AnalysisResults entry to the current data group.
612        Parameters:
613            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
614            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
615            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
616            name (str, optional): The name for the new Analysis group. Defaults to None.
617            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
618        Returns:
619            AnalysisResults: The newly created AnalysisResults object.
620        Raises:
621            IndexError: If the specified index already exists in the dataset.
622            ValueError: If any of the data provided is not valid or consistent
623        """
624        if index is not None:
625            try:
626                self.get_analysis_results(index)
627            except IndexError:
628                pass
629            else:
630                # If the group already exists, raise an error
631                raise IndexError(
632                    f"Analysis {index} already exists in {self._path}")
633        else:
634            ar_groups = self.list_AnalysisResults()
635            indices = [ar['index'] for ar in ar_groups]
636            indices.sort()
637            index = indices[-1] + 1 if indices else 0  # Next available index
638
639        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
640        if name is not None:
641            set_object_name(self._file, ar._path, name)
642        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
643
644        return ar

Adds a new AnalysisResults entry to the current data group.

Arguments:
  • data_AntiStokes (dict or list[dict]): see documentation for brimfile.analysis_results.AnalysisResults.add_data
  • data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
  • index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
  • name (str, optional): The name for the new Analysis group. Defaults to None.
  • fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
Returns:

AnalysisResults: The newly created AnalysisResults object.

Raises:
  • IndexError: If the specified index already exists in the dataset.
  • ValueError: If any of the data provided is not valid or consistent
def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
646    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
647        """
648        List all AnalysisResults groups in the current data group. The list is ordered by index.
649
650        Returns:
651            list: A list of dictionaries, each containing:
652                - 'name' (str): The name of the AnalysisResults group.
653                - 'index' (int): The index extracted from the group name.
654                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
655        """
656
657        analysis_results_groups = []
658
659        matched_objs = sync(list_objects_matching_pattern_async(
660            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$"))
661        async def _make_dict_item(matched_obj, retrieve_custom_name):
662            name = matched_obj[0]
663            index = int(matched_obj[1])
664            curr_obj_dict = {'name': name, 'index': index}
665            if retrieve_custom_name:
666                ar_path = concatenate_paths(self._path, name)
667                custom_name = await get_object_name(self._file, ar_path)
668                curr_obj_dict['custom_name'] = custom_name
669            return curr_obj_dict
670        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
671        dicts = _gather_sync(*coros)
672        for dict_item in dicts:
673            analysis_results_groups.append(dict_item)
674        # Sort the data groups by index
675        analysis_results_groups.sort(key=lambda x: x['index'])
676
677        return analysis_results_groups

List all AnalysisResults groups in the current data group. The list is ordered by index.

Returns:

list: A list of dictionaries, each containing: - 'name' (str): The name of the AnalysisResults group. - 'index' (int): The index extracted from the group name. - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.

def get_analysis_results(self, index: int = 0) -> brimfile.analysis_results.AnalysisResults:
679    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
680        """
681        Returns the AnalysisResults at the specified index
682
683        Args:
684            index (int)                
685
686        Raises:
687            IndexError: If there is no analysis with the corresponding index
688        """
689        name = None
690        ls = self.list_AnalysisResults()
691        for el in ls:
692            if el['index'] == index:
693                name = el['name']
694                break
695        if name is None:
696            raise IndexError(f"Analysis {index} not found")
697        path = concatenate_paths(self._path, name)
698        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
699                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)

Returns the AnalysisResults at the specified index

Arguments:
  • index (int)
Raises:
  • IndexError: If there is no analysis with the corresponding index
@staticmethod
def list_data_groups( file: brimfile.file_abstraction.FileAbstraction, retrieve_custom_name=False) -> list:
837    @staticmethod
838    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
839        """
840        Synchronous wrapper for `list_data_groups_async` (see doc for `brimfile.data.Data.list_data_groups_async`)
841        """
842        return sync(Data.list_data_groups_async(file, retrieve_custom_name))
@staticmethod
async def list_data_groups_async( file: brimfile.file_abstraction.FileAbstraction, retrieve_custom_name=False) -> list:
844    @staticmethod
845    async def list_data_groups_async(file: FileAbstraction, retrieve_custom_name=False) -> list:
846        """
847        List all data groups in the brim file. The list is ordered by index.
848
849        Returns:
850            list: A list of dictionaries, each containing:
851                - 'name' (str): The name of the data group in the file.
852                - 'index' (int): The index extracted from the group name.
853                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
854        """
855
856        data_groups = []
857
858        matched_objs = await list_objects_matching_pattern_async(
859            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
860        
861        async def _make_dict_item(matched_obj, retrieve_custom_name):
862            name = matched_obj[0]
863            index = int(matched_obj[1])
864            curr_obj_dict = {'name': name, 'index': index}
865            if retrieve_custom_name:
866                path = concatenate_paths(
867                    brim_obj_names.Brillouin_base_path, name)
868                custom_name = await get_object_name(file, path)
869                curr_obj_dict['custom_name'] = custom_name
870            return curr_obj_dict
871        
872        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
873        dicts = await asyncio.gather(*coros)
874        for dict_item in dicts:
875            data_groups.append(dict_item)        
876        # Sort the data groups by index
877        data_groups.sort(key=lambda x: x['index'])
878
879        return data_groups

List all data groups in the brim file. The list is ordered by index.

Returns:

list: A list of dictionaries, each containing: - 'name' (str): The name of the data group in the file. - 'index' (int): The index extracted from the group name. - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.

@classmethod
async def from_existing_async( cls, file: brimfile.file_abstraction.FileAbstraction, index: int) -> Data:
901    @classmethod
902    async def from_existing_async(cls, file: FileAbstraction, index: int) -> 'Data':
903        """ 
904        Create a Data object from an existing data group in the file.
905        Args:
906            file (File): The parent File object.
907            index (int): The index of the existing data group.      
908        Returns:
909            Data: A Data object corresponding to the existing data group.   
910        Raises:
911            IndexError: If no data group with the specified index is found in the file.
912        """
913        group_name: str = await cls._get_existing_group_name_async(file, index)
914        if group_name is None:
915            raise IndexError(f"No data group with index {index} found in the file")
916        dg = cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), _initialize=False) 
917        await dg._init_async()
918        return dg

Create a Data object from an existing data group in the file.

Arguments:
  • file (File): The parent File object.
  • index (int): The index of the existing data group.
Returns:

Data: A Data object corresponding to the existing data group.

Raises:
  • IndexError: If no data group with the specified index is found in the file.
class Data.AnalysisResults:
 26class AnalysisResults:
 27    """
 28    Rapresents the analysis results associated with a Data object.
 29    """
 30
 31    class Quantity(Enum):
 32        """
 33        Enum representing the type of analysis results.
 34        """
 35        Shift = "Shift"
 36        # elastic contrast as defined in https://doi.org/10.1007/s12551-020-00701-9
 37        Elastic_contrast = "Elastic_contrast"
 38        # viscous contrast as defined in https://doi.org/10.1007/s12551-020-00701-9
 39        Viscous_contrast = "Viscous_contrast"
 40        Width = "Width"
 41        Amplitude = "Amplitude"
 42        Offset = "Offset"
 43        R2 = "R2"
 44        RMSE = "RMSE"
 45        Cov_matrix = "Cov_matrix"
 46
 47    class PeakType(Enum):
 48        AntiStokes = "AS"
 49        Stokes = "S"
 50        average = "avg"
 51    
 52    FitModel = FitModel
 53
 54    def __init__(self, file: FileAbstraction, full_path: str, *, data_group_path: str,
 55                    spatial_map = None, spatial_map_px_size = None, sparse: bool = False):
 56        """
 57        Initialize the AnalysisResults object.
 58
 59        Args:
 60            file (FileAbstraction): Parent file abstraction object.
 61            full_path (str): Path of the group storing the analysis results.
 62            data_group_path (str): Path of the data group associated with the analysis results.
 63            spatial_map (optional): Spatial map used for sparse analysis results.
 64            spatial_map_px_size (optional): Pixel size associated with ``spatial_map``.
 65            sparse (bool): Whether the analysis results are stored in sparse format.
 66
 67        Raises:
 68            ValueError: If ``sparse`` is ``True`` and either ``spatial_map`` or
 69                ``spatial_map_px_size`` is not provided.
 70        """
 71        self._file = file
 72        self._path = full_path
 73        self._data_group_path = data_group_path
 74        # self._group = file.open_group(full_path)
 75        self._spatial_map = spatial_map
 76        self._spatial_map_px_size = spatial_map_px_size
 77        self._sparse = sparse
 78        if sparse:
 79            if spatial_map is None or spatial_map_px_size is None:
 80                raise ValueError("For sparse analysis results, the spatial map and pixel size must be provided.")
 81    def _get_metadata(self) -> Metadata:
 82        """
 83        Retrieve the Metadata object associated with the current AnalysisResults.
 84
 85        Returns:
 86            Metadata: The Metadata object associated with the current Data group.
 87        """
 88        return Metadata(self._file, self._data_group_path)
 89    def get_name(self):
 90        """
 91        Returns the name of the Analysis group.
 92        """
 93        return sync(get_object_name(self._file, self._path))
 94
 95    @classmethod
 96    def _create_new(cls, data: 'Data', *, index: int, sparse: bool = False) -> 'AnalysisResults':
 97        """
 98        Create a new AnalysisResults group.
 99
100        Args:
101            file (FileAbstraction): The file.
102            index (int): The index for the new AnalysisResults group.
103
104        Returns:
105            AnalysisResults: The newly created AnalysisResults object.
106        """
107        group_name = f"{brim_obj_names.data.analysis_results}_{index}"
108        ar_full_path = concatenate_paths(data._path, group_name)
109        sync(data._file.create_group(ar_full_path))
110        return cls(data._file, ar_full_path, data_group_path=data._path,
111                    spatial_map=data._spatial_map, spatial_map_px_size=data._spatial_map_px_size,
112                    sparse=sparse)
113
114    def add_data(self, data_AntiStokes=None, data_Stokes=None, *,
115                    fit_model: 'AnalysisResults.FitModel' = None):
116        """
117        Adds data for the analysis results for AntiStokes and Stokes peaks to the file.
118        
119        Args:
120            data_AntiStokes (dict or list[dict]): A dictionary containing the analysis results for AntiStokes peaks.
121                In case multiple peaks were fitted, it might be a list of dictionaries with each element corresponding to a single peak.
122            
123                Each dictionary may include the following keys (plus the corresponding units,  e.g. 'shift_units'):
124                    - 'shift': The shift value.
125                    - 'width': The width value.
126                    - 'amplitude': The amplitude value.
127                    - 'offset': The offset value.
128                    - 'R2': The R-squared value.
129                    - 'RMSE': The root mean square error value.
130                    - 'Cov_matrix': The covariance matrix.
131                The above arrays must have one less dimension than the PSD dataset, with the same shape as the first n-1 dimensions of the PSD (i.e. all the dimensions except the last (spectral) one).
132                The 'Cov_matrix' should have 2 additional last dimensions which define the matrix.
133            data_Stokes (dict or list[dict]): same as `data_AntiStokes` for the Stokes peaks.
134            fit_model (AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
135
136            Both `data_AntiStokes` and `data_Stokes` are optional, but at least one of them must be provided.
137        """
138
139        ar_cls = self.__class__
140        ar_group = sync(self._file.open_group(self._path))
141
142        def add_quantity(qt: AnalysisResults.Quantity, pt: AnalysisResults.PeakType, data, index: int = 0):
143            # PSD_nonspectral_shape is an closure variable that is used to check the shape of the data being added, if the PSD dataset is already present in the current data group.
144            if PSD_nonspectral_shape is not None:
145                expected_shape = PSD_nonspectral_shape
146                if qt is AnalysisResults.Quantity.Cov_matrix:
147                    expected_shape += (data.shape[-2], data.shape[-1])
148                if data.shape != expected_shape:
149                    raise ValueError(f"The shape of the '{qt.value}' data is {data.shape}, but it should be {expected_shape} to match the shape of the PSD.")
150            sync(self._file.create_dataset(
151                ar_group, ar_cls._get_quantity_name(qt, pt, index), data))
152
153        def add_data_pt(pt: AnalysisResults.PeakType, data, index: int = 0):
154            if 'shift' in data:
155                add_quantity(ar_cls.Quantity.Shift,
156                                pt, data['shift'], index)
157                if 'shift_units' in data:
158                    self._set_units(data['shift_units'],
159                                    ar_cls.Quantity.Shift, pt, index)
160            if 'width' in data:
161                add_quantity(ar_cls.Quantity.Width,
162                                pt, data['width'], index)
163                if 'width_units' in data:
164                    self._set_units(data['width_units'],
165                                    ar_cls.Quantity.Width, pt, index)
166            if 'amplitude' in data:
167                add_quantity(ar_cls.Quantity.Amplitude,
168                                pt, data['amplitude'], index)
169                if 'amplitude_units' in data:
170                    self._set_units(
171                        data['amplitude_units'], ar_cls.Quantity.Amplitude, pt, index)
172            if 'offset' in data:
173                add_quantity(ar_cls.Quantity.Offset,
174                                pt, data['offset'], index)
175                if 'offset_units' in data:
176                    self._set_units(
177                        data['offset_units'], ar_cls.Quantity.Offset, pt, index)
178            if 'R2' in data:
179                add_quantity(ar_cls.Quantity.R2, pt, data['R2'], index)
180                if 'R2_units' in data:
181                    self._set_units(data['R2_units'],
182                                    ar_cls.Quantity.R2, pt, index)
183            if 'RMSE' in data:
184                add_quantity(ar_cls.Quantity.RMSE, pt, data['RMSE'], index)
185                if 'RMSE_units' in data:
186                    self._set_units(data['RMSE_units'],
187                                    ar_cls.Quantity.RMSE, pt, index)
188            if 'Cov_matrix' in data:
189                add_quantity(ar_cls.Quantity.Cov_matrix,
190                                pt, data['Cov_matrix'], index)
191                if 'Cov_matrix_units' in data:
192                    self._set_units(
193                        data['Cov_matrix_units'], ar_cls.Quantity.Cov_matrix, pt, index)
194
195        PSD_nonspectral_shape = None
196        try:
197            PSD = sync(self._file.open_dataset(concatenate_paths(
198                self._data_group_path, brim_obj_names.data.PSD)))
199            PSD_nonspectral_shape = PSD.shape[:-1]
200        except Exception as e:
201            warnings.warn("It is recommended to add the PSD dataset before adding the analysis results, to ensure the correct shape of the analysis results data.")
202
203        if data_AntiStokes is not None:
204            data_AntiStokes = var_to_singleton(data_AntiStokes)
205            for i, d_as in enumerate(data_AntiStokes):
206                add_data_pt(ar_cls.PeakType.AntiStokes, d_as, i)
207        if data_Stokes is not None:
208            data_Stokes = var_to_singleton(data_Stokes)
209            for i, d_s in enumerate(data_Stokes):
210                add_data_pt(ar_cls.PeakType.Stokes, d_s, i)
211        if fit_model is not None:
212            sync(self._file.create_attr(ar_group, 'Fit_model', fit_model.value))
213
214    def get_units(self, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0) -> str | None:
215        """
216        Retrieve the units of a specified quantity from the data file.
217
218        Args:
219            qt (Quantity): The quantity for which the units are to be retrieved.
220            pt (PeakType, optional): The type of peak (e.g., Stokes or AntiStokes). Defaults to PeakType.AntiStokes.
221            index (int, optional): The index of the quantity in case multiple quantities exist. Defaults to 0.
222
223        Returns:
224            str | None: The units of the specified quantity as a string, or None if no units are defined.
225        """
226        if qt in (AnalysisResults.Quantity.Elastic_contrast, AnalysisResults.Quantity.Viscous_contrast):
227            return None
228        dt_name = AnalysisResults._get_quantity_name(qt, pt, index)
229        full_path = concatenate_paths(self._path, dt_name)
230        return sync(units.of_object(self._file, full_path))
231
232    def _set_units(self, un: str, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0) -> str:
233        """
234        Set the units of a specified quantity.
235
236        Args:
237            un (str): The units to be set.
238            qt (Quantity): The quantity for which the units are to be set.
239            pt (PeakType, optional): The type of peak (e.g., Stokes or AntiStokes). Defaults to PeakType.AntiStokes.
240            index (int, optional): The index of the quantity in case multiple quantities exist. Defaults to 0.
241
242        Returns:
243            str: The units of the specified quantity as a string.
244        """
245        if qt in (AnalysisResults.Quantity.Elastic_contrast, AnalysisResults.Quantity.Viscous_contrast):
246            raise ValueError(f"Units for {qt.name} are not settable because this quantity is computed on-the-fly.")
247        dt_name = AnalysisResults._get_quantity_name(qt, pt, index)
248        full_path = concatenate_paths(self._path, dt_name)
249        return units.add_to_object(self._file, full_path, un)
250
251    async def _compute_elastic_contrast_async(self, shift):
252        shift_arr = np.asarray(shift)
253        try:
254            md = self._get_metadata()
255            coros = [md._get_wavelength_nm_async(), md._get_temperature_c_async(), md._get_scattering_angle_deg_async()]
256            res = await asyncio.gather(*coros, return_exceptions=True)
257            wavelength_nm, temperature_c, scattering_angle_deg = res
258            if isinstance(wavelength_nm, Exception):
259                raise ValueError("Could not retrieve the wavelength for computing Elastic Contrast.")
260            if isinstance(temperature_c, Exception):
261                temperature_c = 22  # default value
262                warnings.warn("Could not retrieve the temperature for computing Elastic Contrast. Using default value of 22 °C.")
263            if isinstance(scattering_angle_deg, Exception):
264                scattering_angle_deg = 180  # default value
265                warnings.warn("Could not retrieve the scattering angle for computing Elastic Contrast. Using default value of 180 deg.")
266            water_shift = Brillouin_shift_water(wavelength_nm, temperature_c, scattering_angle_deg)
267            if np.nanmean(shift_arr) < 0:
268                water_shift = -water_shift
269            return shift_arr / water_shift - 1
270        except Exception as e:
271            raise ValueError(
272                f"Could not compute Elastic_contrast from metadata ({e}).")
273
274    async def _compute_viscous_contrast_async(self, width):
275        width_arr = np.asarray(width)
276        try:
277            md = self._get_metadata()
278            coros = [md._get_wavelength_nm_async(), md._get_temperature_c_async(), md._get_scattering_angle_deg_async()]
279            res = await asyncio.gather(*coros, return_exceptions=True)
280            wavelength_nm, temperature_c, scattering_angle_deg = res
281            if isinstance(wavelength_nm, Exception):
282                raise ValueError("Could not retrieve the wavelength for computing Viscous Contrast.")
283            if isinstance(temperature_c, Exception):
284                temperature_c = 22  # default value
285                warnings.warn("Could not retrieve the temperature for computing Viscous Contrast. Using default value of 22 °C.")
286            if isinstance(scattering_angle_deg, Exception):
287                scattering_angle_deg = 180  # default value
288                warnings.warn("Could not retrieve the scattering angle for computing Viscous Contrast. Using default value of 180 deg.")
289            water_width = Brillouin_width_water(wavelength_nm, temperature_c, scattering_angle_deg)
290            if np.nanmean(width_arr) < 0:
291                water_width = -water_width
292            return width_arr / water_width - 1
293        except Exception as e:
294            raise ValueError(
295                f"Could not compute Viscous_contrast from metadata ({e}).")
296
297    @property
298    def fit_model(self) -> 'AnalysisResults.FitModel':
299        """
300        Retrieve the fit model used for the analysis.
301
302        Returns:
303            AnalysisResults.FitModel: The fit model used for the analysis.
304        """
305        if not hasattr(self, '_fit_model'):
306            try:
307                fit_model_str = sync(self._file.get_attr(self._path, 'Fit_model'))
308                self._fit_model = AnalysisResults.FitModel(fit_model_str)
309            except Exception as e:
310                if isinstance(e, ValueError):
311                    warnings.warn(
312                        f"Unknown fit model '{fit_model_str}' found in the file.")
313                self._fit_model = AnalysisResults.FitModel.Undefined        
314        return self._fit_model
315
316    def save_image_to_OMETiff(self, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0, filename: str = None) -> str:
317        """
318        Saves the image corresponding to the specified quantity and index to an OMETiff file.
319
320        Args:
321            qt (Quantity): The quantity to retrieve the image for (e.g. shift).
322            pt (PeakType, optional): The type of peak to consider (default is PeakType.AntiStokes).
323            index (int, optional): The index of the data to retrieve, if multiple are present (default is 0).
324            filename (str, optional): The name of the file to save the image to. If None, a default name will be used.
325
326        Returns:
327            str: The path to the saved OMETiff file.
328        """
329        try:
330            import tifffile
331        except ImportError:
332            raise ModuleNotFoundError(
333                "The tifffile module is required for saving to OME-Tiff. Please install it using 'pip install tifffile'.")
334        
335        if filename is None:
336            filename = f"{qt.value}_{pt.value}_{index}.ome.tif"
337        if not filename.endswith('.ome.tif'):
338            filename += '.ome.tif'
339        img, px_size = self.get_image(qt, pt, index)
340        if img.ndim > 3:
341            raise NotImplementedError(
342                "Saving images with more than 3 dimensions is not supported yet.")
343        with tifffile.TiffWriter(filename, bigtiff=True) as tif:
344            metadata = {
345                'axes': 'ZYX',
346                'PhysicalSizeX': px_size[2].value,
347                'PhysicalSizeXUnit': px_size[2].units,
348                'PhysicalSizeY': px_size[1].value,
349                'PhysicalSizeYUnit': px_size[1].units,
350                'PhysicalSizeZ': px_size[0].value,
351                'PhysicalSizeZUnit': px_size[0].units,
352            }
353            tif.write(img, metadata=metadata)
354        return filename
355
356    def get_image(self, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0) -> tuple:
357        """
358        Retrieves an image (spatial map) based on the specified quantity, peak type, and index.
359
360        Args:
361            qt (Quantity): The quantity to retrieve the image for (e.g. shift).
362            pt (PeakType, optional): The type of peak to consider (default is PeakType.AntiStokes).
363            index (int, optional): The index of the data to retrieve, if multiple are present (default is 0).
364
365        Returns:
366            A tuple containing the image corresponding to the specified quantity and index and the corresponding pixel size.
367            The image is a 3D dataset where the dimensions are z, y, x.
368            If there are additional parameters, more dimensions are added in the order z, y, x, par1, par2, ...
369            The pixel size is a tuple of 3 Metadata.Item in the order z, y, x.
370        """
371        if qt == AnalysisResults.Quantity.Elastic_contrast:
372            shift_img, px_size = self.get_image(AnalysisResults.Quantity.Shift, pt, index)
373            return sync(self._compute_elastic_contrast_async(shift_img)), px_size
374        if qt == AnalysisResults.Quantity.Viscous_contrast:
375            width_img, px_size = self.get_image(AnalysisResults.Quantity.Width, pt, index)
376            return sync(self._compute_viscous_contrast_async(width_img)), px_size
377
378        pt_type = AnalysisResults.PeakType
379        data = None
380        if pt == pt_type.average:
381            peaks = self.list_existing_peak_types(index)
382            match len(peaks):
383                case 0:
384                    raise ValueError(
385                        "No peaks found for the specified index. Cannot compute average.")
386                case 1:
387                    data = np.array(sync(self._get_quantity(qt, peaks[0], index)))
388                case 2:
389                    data1, data2 = _gather_sync(
390                        self._get_quantity(qt, peaks[0], index),
391                        self._get_quantity(qt, peaks[1], index)
392                        )
393                    data = (np.abs(data1) + np.abs(data2))/2
394        else:
395            data = np.array(sync(self._get_quantity(qt, pt, index)))
396        if self._sparse:
397            sm = np.array(self._spatial_map)
398            img = data[sm, ...]
399            img[sm<0, ...] = np.nan  # set invalid pixels to NaN
400        else:
401            img = data
402        return img, self._spatial_map_px_size
403    def get_quantity_at_pixel(self, coord: tuple, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0):
404        """
405        Synchronous wrapper for `get_quantity_at_pixel_async` (see doc for `brimfile.analysis_results.AnalysisResults.get_quantity_at_pixel_async`)
406        """
407        return sync(self.get_quantity_at_pixel_async(coord, qt, pt, index))
408    async def get_quantity_at_pixel_async(self, coord: tuple, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0):
409        """
410        Retrieves the specified quantity in the image at coord, based on the peak type and index.
411
412        Args:
413            coord (tuple): A tuple of 3 elements corresponding to the z, y, x coordinate in the image
414            qt (Quantity): The quantity to retrieve the image for (e.g. shift).
415            pt (PeakType, optional): The type of peak to consider (default is PeakType.AntiStokes).
416            index (int, optional): The index of the data to retrieve, if multiple peaks are present (default is 0).
417
418        Returns:
419            The requested quantity, which is a scalar or a multidimensional array (depending on whether there are additional parameters in the current Data group)
420        """
421        if len(coord) != 3:
422            raise ValueError(
423                "'coord' must have 3 elements corresponding to z, y, x")
424        if qt == AnalysisResults.Quantity.Elastic_contrast:
425            shift_value = await self.get_quantity_at_pixel_async(coord, AnalysisResults.Quantity.Shift, pt, index)
426            return await self._compute_elastic_contrast_async(shift_value)
427        if qt == AnalysisResults.Quantity.Viscous_contrast:
428            width_value = await self.get_quantity_at_pixel_async(coord, AnalysisResults.Quantity.Width, pt, index)
429            return await self._compute_viscous_contrast_async(width_value)
430        if self._sparse:
431            i = self._spatial_map[*coord]
432            assert i.size == 1
433            if i<0:
434                return np.nan  # invalid pixel
435            i = (int(i), ...)
436        else:
437            i = coord + (...,)
438
439        pt_type = AnalysisResults.PeakType
440        value = None
441        if pt == pt_type.average:
442            value = None
443            peaks = await self.list_existing_peak_types_async(index)
444            match len(peaks):
445                case 0:
446                    raise ValueError(
447                        "No peaks found for the specified index. Cannot compute average.")
448                case 1:
449                    data = await self._get_quantity(qt, peaks[0], index)
450                    value = await _async_getitem(data, i)
451                case 2:
452                    data_p0, data_p1 = await asyncio.gather(
453                        self._get_quantity(qt, peaks[0], index),
454                        self._get_quantity(qt, peaks[1], index)
455                    )
456                    value1, value2 = await asyncio.gather(
457                        _async_getitem(data_p0, i),
458                        _async_getitem(data_p1, i)
459                    )
460                    value = (np.abs(value1) + np.abs(value2))/2
461        else:
462            data = await self._get_quantity(qt, pt, index)
463            value = await _async_getitem(data, i)
464        return value
465    def get_all_quantities_in_image(self, coor: tuple, index_peak: int = 0) -> dict:
466        """
467        Retrieve all available quantities at a specific spatial coordinate.
468
469        Args:
470            coor (tuple): A tuple containing the z, y, x coordinates in the image.
471            index_peak (int, optional): The index of the data to retrieve, if multiple peaks are present (default is 0).
472
473        Returns:
474            dict: A dictionary of Metadata.Item in the form `result[quantity.name][peak.name] = Metadata.Item(value, units)`.
475                The dictionary contains all available quantities (e.g., Shift, Width, etc.) for both Stokes and AntiStokes peaks,
476                as well as their average values.
477        """
478        if len(coor) != 3:
479            raise ValueError("coor must contain 3 values for z, y, x")
480        index = int(self._spatial_map[coor]) if self._sparse else coor
481        return sync(self._get_all_quantities_at_index(index, index_peak))
482    async def _get_all_quantities_at_index(self, index: int | tuple[int, int, int], index_peak: int = 0) -> dict:
483        """
484        Retrieve all available quantities for a specific spatial index.
485        Args:
486            index (int) | tuple[int, int, int]: The spatial index to retrieve quantities for, which can be a tuple for non-sparse data.
487            index_peak (int, optional): The index of the data to retrieve, if multiple peaks are present (default is 0).
488        Returns:
489            dict: A dictionary of Metadata.Item in the form `result[quantity.name][peak.name] = bls.Metadata.Item(value, units)`
490        """
491        async def _get_existing_quantity_at_index_async(self,  index: int | tuple[int, int, int], pt: AnalysisResults.PeakType = AnalysisResults.PeakType.AntiStokes):
492            as_cls = AnalysisResults
493            qts_ls = ()
494            dts_ls = ()
495
496            qts = [qt for qt in as_cls.Quantity if qt not in (as_cls.Quantity.Elastic_contrast, as_cls.Quantity.Viscous_contrast)]
497            coros = [self._file.open_dataset(concatenate_paths(self._path, as_cls._get_quantity_name(qt, pt, index_peak))) for qt in qts]
498            
499            # open the datasets asynchronously, excluding those that do not exist
500            opened_dts = await asyncio.gather(*coros, return_exceptions=True)
501            for i, opened_qt in enumerate(opened_dts):
502                if not isinstance(opened_qt, Exception):
503                    qts_ls += (qts[i],)
504                    dts_ls += (opened_dts[i],)
505            # get the values at the specified index
506            if isinstance(index, tuple):
507                index += (..., )
508            else:
509                index = (index, ...)
510            coros_values = [_async_getitem(dt, index) for dt in dts_ls]
511            coros_units = [units.of_object(self._file, dt) for dt in dts_ls]
512            ret_ls = await asyncio.gather(*coros_values, *coros_units)
513            n = len(coros_values)
514            value_ls = [Metadata.Item(ret_ls[i], ret_ls[n+i]) for i in range(n)]
515            return qts_ls, value_ls
516        antiStokes, stokes = await asyncio.gather(
517            _get_existing_quantity_at_index_async(self, index, AnalysisResults.PeakType.AntiStokes),
518            _get_existing_quantity_at_index_async(self, index, AnalysisResults.PeakType.Stokes)
519        )
520        res = {}
521        # combine the results, including the average
522        for qt in (set(antiStokes[0]) | set(stokes[0])):
523            res[qt.name] = {}
524            pts = ()
525            #Stokes
526            if qt in stokes[0]:
527                res[qt.name][AnalysisResults.PeakType.Stokes.name] = stokes[1][stokes[0].index(qt)]
528                pts += (AnalysisResults.PeakType.Stokes,)
529            #AntiStokes
530            if qt in antiStokes[0]:
531                res[qt.name][AnalysisResults.PeakType.AntiStokes.name] = antiStokes[1][antiStokes[0].index(qt)]
532                pts += (AnalysisResults.PeakType.AntiStokes,)
533            #average getting the units of the first peak
534            res[qt.name][AnalysisResults.PeakType.average.name] = Metadata.Item(
535                np.mean([np.abs(res[qt.name][pt.name].value) for pt in pts]), 
536                res[qt.name][pts[0].name].units
537                )
538            if not all(res[qt.name][pt.name].units == res[qt.name][pts[0].name].units for pt in pts):
539                warnings.warn(f"The units of {pts} are not consistent.")
540
541        if AnalysisResults.Quantity.Shift.name in res:
542            ec_name = AnalysisResults.Quantity.Elastic_contrast.name
543            res[ec_name] = {}
544            for pt_name, item in res[AnalysisResults.Quantity.Shift.name].items():
545                ec = await self._compute_elastic_contrast_async(item.value)
546                res[ec_name][pt_name] = Metadata.Item(ec, None)
547
548        if AnalysisResults.Quantity.Width.name in res:
549            vc_name = AnalysisResults.Quantity.Viscous_contrast.name
550            res[vc_name] = {}
551            for pt_name, item in res[AnalysisResults.Quantity.Width.name].items():
552                vc = await self._compute_viscous_contrast_async(item.value)
553                res[vc_name][pt_name] = Metadata.Item(vc, None)
554        return res
555
556    @classmethod
557    def _get_quantity_name(cls, qt: Quantity, pt: PeakType, index: int) -> str:
558        """
559        Returns the name of the dataset correponding to the specific Quantity, PeakType and index
560
561        Args:
562            qt (Quantity)   
563            pt (PeakType)  
564            intex (int): in case of multiple peaks fitted, the index of the peak to consider       
565        """
566        if not pt in (cls.PeakType.AntiStokes, cls.PeakType.Stokes):
567            raise ValueError("pt has to be either Stokes or AntiStokes")
568        if qt in (cls.Quantity.Elastic_contrast, cls.Quantity.Viscous_contrast):
569            raise ValueError(f"{qt.value} is a computed quantity and is not stored in the file.")
570        if qt == cls.Quantity.R2 or qt == cls.Quantity.RMSE or qt == cls.Quantity.Cov_matrix:
571            name = f"Fit_error_{str(pt.value)}_{index}/{str(qt.value)}"
572        else:
573            name = f"{str(qt.value)}_{str(pt.value)}_{index}"
574        return name
575
576    async def _get_quantity(self, qt: Quantity, pt: PeakType = PeakType.AntiStokes, index: int = 0):
577        """
578        Retrieve a specific quantity dataset from the file.
579
580        Args:
581            qt (Quantity): The type of quantity to retrieve.
582            pt (PeakType, optional): The peak type to consider (default is PeakType.AntiStokes).
583            index (int, optional): The index of the quantity if multiple peaks are available (default is 0).
584
585        Returns:
586            The dataset corresponding to the specified quantity, as stored in the file.
587
588        """
589
590        dt_name = AnalysisResults._get_quantity_name(qt, pt, index)
591        full_path = concatenate_paths(self._path, dt_name)
592        return await self._file.open_dataset(full_path)
593
594    def list_existing_peak_types(self, index: int = 0) -> tuple:
595        """
596        Synchronous wrapper for `list_existing_peak_types_async` (see doc for `brimfile.analysis_results.AnalysisResults.list_existing_peak_types_async`)
597        """
598        return sync(self.list_existing_peak_types_async(index)) 
599    async def list_existing_peak_types_async(self, index: int = 0) -> tuple:
600        """
601        Returns a tuple of existing peak types (Stokes and/or AntiStokes) for the specified index.
602        Args:
603            index (int, optional): The index of the peak to check (in case of multi-peak fit). Defaults to 0.
604        Returns:
605            tuple: A tuple containing `PeakType` members (`Stokes`, `AntiStokes`) that exist for the given index.
606        """
607
608        as_cls = AnalysisResults
609        shift_s_name = as_cls._get_quantity_name(
610            as_cls.Quantity.Shift, as_cls.PeakType.Stokes, index)
611        shift_as_name = as_cls._get_quantity_name(
612            as_cls.Quantity.Shift, as_cls.PeakType.AntiStokes, index)
613        ls = ()
614        coro_as_exists = self._file.object_exists(concatenate_paths(self._path, shift_as_name))
615        coro_s_exists = self._file.object_exists(concatenate_paths(self._path, shift_s_name))
616        as_exists, s_exists = await asyncio.gather(coro_as_exists, coro_s_exists)
617        if as_exists:
618            ls += (as_cls.PeakType.AntiStokes,)
619        if s_exists:
620            ls += (as_cls.PeakType.Stokes,)
621        return ls
622
623    def list_existing_quantities(self,  pt: PeakType = PeakType.AntiStokes, index: int = 0) -> tuple:
624        """
625        Synchronous wrapper for `list_existing_quantities_async` (see doc for `brimfile.analysis_results.AnalysisResults.list_existing_quantities_async`)
626        """
627        return sync(self.list_existing_quantities_async(pt, index))
628    async def list_existing_quantities_async(self,  pt: PeakType = PeakType.AntiStokes, index: int = 0) -> tuple:
629        """
630        Returns a tuple of existing quantities for the specified index.
631        Args:
632            index (int, optional): The index of the peak to check (in case of multi-peak fit). Defaults to 0.
633        Returns:
634            tuple: A tuple containing `Quantity` members that exist for the given index.
635        """
636        as_cls = AnalysisResults
637        ls = ()
638
639        qts = [qt for qt in as_cls.Quantity if qt not in (as_cls.Quantity.Elastic_contrast, as_cls.Quantity.Viscous_contrast)]
640        coros = [self._file.object_exists(concatenate_paths(self._path, as_cls._get_quantity_name(qt, pt, index))) for qt in qts]
641        
642        qt_exists = await asyncio.gather(*coros)
643        for i, exists in enumerate(qt_exists):
644            if exists:
645                ls += (qts[i],)
646        if as_cls.Quantity.Shift in ls:
647            ls += (as_cls.Quantity.Elastic_contrast,)
648        if as_cls.Quantity.Width in ls:
649            ls += (as_cls.Quantity.Viscous_contrast,)
650        return ls

Rapresents the analysis results associated with a Data object.