brimfile.data v1.7.0rc1

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

Returns the name of the data group.

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

Returns the index of the data group.

def get_PSD(self) -> tuple:
230    def get_PSD(self) -> tuple:
231        """
232        LOW LEVEL FUNCTION
233
234        Retrieve the Power Spectral Density (PSD) and frequency from the current data group.
235        Note: this function exposes the internals of the brim file and thus the interface might change in future versions.
236        Use only if more specialized functions are not working for your application!
237        Returns:
238            tuple: (PSD, frequency, PSD_units, frequency_units)
239                - 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).
240                - 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).
241                - PSD_units: The units of the PSD.
242                - frequency_units: The units of the frequency.
243        """
244        warnings.warn(
245            "Data.get_PSD is deprecated and will be removed in a future release. "
246            "Use Data.get_PSD_as_spatial_map instead.",
247            DeprecationWarning,
248            stacklevel=2,
249        )
250        PSD, frequency = _gather_sync(
251            self._file.open_dataset(concatenate_paths(
252                self._path, brim_obj_names.data.PSD)),
253            self._file.open_dataset(concatenate_paths(
254                self._path, brim_obj_names.data.frequency))
255        )
256        # retrieve the units of the PSD and frequency
257        PSD_units, frequency_units = _gather_sync(
258            units.of_object(self._file, PSD),
259            units.of_object(self._file, frequency)
260        )
261
262        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:
264    def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
265        """
266        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
267        Arguments:
268            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
269                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. 
270                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
271        Returns:
272            tuple: (PSD, frequency, PSD_units, frequency_units)
273                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
274                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
275                - PSD_units: The units of the PSD.
276                - frequency_units: The units of the frequency.
277        """
278        PSD, frequency = _gather_sync(
279            self._file.open_dataset(concatenate_paths(
280                self._path, brim_obj_names.data.PSD)),        
281            self._file.open_dataset(concatenate_paths(
282                self._path, brim_obj_names.data.frequency))
283            )        
284        # retrieve the units of the PSD and frequency
285        PSD_units, frequency_units = _gather_sync(
286            units.of_object(self._file, PSD),
287            units.of_object(self._file, frequency)
288        )
289
290        # ensure PSD and frequency are numpy arrays
291        PSD = np.array(PSD)  
292        frequency = np.array(frequency)  # ensure it's a numpy array
293        
294        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
295        # 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
296        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
297            frequency = np.broadcast_to(frequency, PSD.shape)
298        
299        if self._sparse:
300            if self._spatial_map is None:
301                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
302            sm = np.array(self._spatial_map)
303            # reshape the PSD and frequency to have the spatial dimensions first      
304            PSD = PSD[sm, ...]
305            # reshape the frequency only if it is not the same for all spectra
306            if frequency.ndim > 1:
307                frequency = frequency[sm, ...]
308
309        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.

async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
316    async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
317        """
318        @public
319
320        Retrieve a spectrum from the data group by its index or coordinates.
321
322        Args:
323            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.
324
325        Returns:
326            tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. 
327                    PSD can be 1D or more (if there are additional parameters);
328                    frequency has the same size as PSD
329        Raises:
330            IndexError: If the index is out of range for the PSD dataset.
331        """
332        if self._sparse and not isinstance(index, int):
333            raise ValueError("For sparse data, index must be an integer.")
334        elif not self._sparse and not (isinstance(index, tuple) and len(index) == 3):
335            raise ValueError("For non-sparse data, index must be a tuple of (z, y, x) coordinates.")
336            
337        # index = -1 corresponds to no spectrum
338        if self._sparse and index < 0:
339            return None, None, None, None
340        elif not self._sparse and any(i < 0 for i in index):
341            return None, None, None, None
342        PSD, frequency = await asyncio.gather(
343            self._file.open_dataset(concatenate_paths(
344                self._path, brim_obj_names.data.PSD)),                       
345            self._file.open_dataset(concatenate_paths(
346                self._path, brim_obj_names.data.frequency))
347            )
348        if self._sparse and index >= PSD.shape[0]:
349            raise IndexError(
350                f"index {index} out of range for PSD with shape {PSD.shape}")
351        elif not self._sparse and any(i >= PSD.shape[j] for j, i in enumerate(index)):
352            raise IndexError(
353                f"index {index} out of range for PSD with shape {PSD.shape}")
354        # retrieve the units of the PSD and frequency
355        PSD_units, frequency_units = await asyncio.gather(
356            units.of_object(self._file, PSD),
357            units.of_object(self._file, frequency)
358        )
359        # add ellipsis to the index to select the spectrum and the corresponding frequency
360        if self._sparse:
361            index = (index, ...)
362        else:
363            index = index + (..., )
364        # map index to the frequency array, considering the broadcasting rules
365        index_frequency = index
366        if frequency.ndim < PSD.ndim:
367            if self._sparse:
368                # given the definition of the brim file format,
369                # if the frequency has less dimensions that PSD,
370                # it can only be because it is the same for all the spatial position (first dimension)
371                index_frequency = (..., )
372            else:
373                unassigned_indices = PSD.ndim - frequency.ndim
374                if unassigned_indices == 3:
375                    # if the frequency has no spatial dimension, it is the same for all the spatial positions
376                    index_frequency = (..., )
377                else:
378                    # if the frequency has some spatial dimensions but not all, we need to add the corresponding indices to the index of the frequency
379                    index_frequency = index[-unassigned_indices:] + (..., )
380        #get the spectrum and the corresponding frequency at the specified index
381        PSD, frequency = await asyncio.gather(
382            _async_getitem(PSD, index),
383            _async_getitem(frequency, index_frequency)
384        )
385        #broadcast the frequency to match the shape of PSD if needed
386        if frequency.ndim < PSD.ndim:
387            frequency = np.broadcast_to(frequency, PSD.shape)
388        return PSD, frequency, PSD_units, frequency_units

Retrieve a spectrum from the data group by its index or coordinates.

Arguments:
  • 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.
Returns:

tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. PSD can be 1D or more (if there are additional parameters); frequency has the same size as PSD

Raises:
  • IndexError: If the index is out of range for the PSD dataset.
def get_spectrum_in_image(self, coor: tuple) -> tuple:
390    def get_spectrum_in_image(self, coor: tuple) -> tuple:
391        """
392        Retrieve a spectrum from the data group using spatial coordinates.
393
394        Args:
395            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
396
397        Returns:
398            tuple: A tuple containing the PSD, frequency, PSD_units, and
399            frequency_units for the specified coordinates. See
400            `brimfile.data.Data._get_spectrum_async` for details.
401
402        Raises:
403            ValueError: If `coor` does not contain three coordinates `(z, y, x)`.
404            IndexError: If coordinates map outside the available data.
405        """
406        if len(coor) != 3:
407            raise ValueError("coor must contain 3 values for z, y, x")
408
409        if self._sparse:
410            index = int(self._spatial_map[coor])
411            return self._get_spectrum(index)
412        else:
413            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, and frequency_units for the specified coordinates. See brimfile.data.Data._get_spectrum_async for details.

Raises:
  • ValueError: If coor does not contain three coordinates (z, y, x).
  • IndexError: If coordinates map outside the available data.
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]:
415    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
416        """
417        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
418
419        Args:
420            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
421            coor (tuple): A tuple containing the z, y, x coordinates in the image.
422            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
423
424        Returns:
425            tuple: A tuple containing:
426                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
427                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
428        """
429        if len(coor) != 3:
430            raise ValueError("coor must contain 3 values for z, y, x")
431        index = coor
432        if self._sparse:
433            index = int(self._spatial_map[coor])
434        spectrum, quantities = await asyncio.gather(
435            self._get_spectrum_async(index),
436            ar._get_all_quantities_at_index(index, index_peak)
437        )
438        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]:
439    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
440        """
441        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`)
442        """
443        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
def get_metadata(self):
445    def get_metadata(self):
446        """
447        Returns the metadata associated with the current Data group
448        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
449        and the ones specific for this data group
450        """
451        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:
453    def get_num_parameters(self) -> tuple:
454        """
455        Retrieves the number of parameters
456
457        Returns:
458            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
459        """
460        pars, _ = self.get_parameters()
461        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:
463    def get_parameters(self) -> list:
464        """
465        Retrieves the parameters  and their associated names.
466
467        If PSD.ndims > 2, the parameters are stored in a separate dataset.
468
469        Returns:
470            list: A tuple containing the parameters and their names if there are any, otherwise None.
471        """
472        pars_full_path = concatenate_paths(
473            self._path, brim_obj_names.data.parameters)
474        if sync(self._file.object_exists(pars_full_path)):
475            pars = sync(self._file.open_dataset(pars_full_path))
476            pars_names = sync(self._file.get_attr(pars, 'Name'))
477            return (pars, pars_names)
478        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: NDArray[numpy.integer] | None = None, calibration_data: list[dict[str, 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:
480    def create_calibration_group(self, *, index: NDArray[np.integer] | None = None, calibration_data: list[dict[str, Any]] | None = None,
481                                 same_as: int | None = None, attributes: dict[str, MetadataItem] = None,
482                                 compression: FileAbstraction.Compression = FileAbstraction.Compression()) -> Calibration:
483        """
484        Create a new calibration group in the current data group.
485        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.
486
487        Parameters:
488            index (np.array | None, optional): Index array for the calibration spectra. For sparse data,
489                this must be 1D; for non-sparse data, this must be 3D.  
490                It can be omitted if each element in `calibration_data` contains only one spectrum.
491            calibration_data (list[dict[str, Any]] | None, optional): Calibration entries to store.
492                Each dictionary must contain `spectra` and `shift` keys, and may provide `shift_units`.   
493            same_as (int | None, optional): If provided, links this calibration group to an existing
494                calibration via the `Same_as` attribute. When set, the other data arguments are ignored.
495                Defaults to None.
496            attributes (dict[str, MetadataItem], optional): Additional attributes to attach to the calibration group.
497                Can be one of ('Datetime', 'Description', 'Temperature', 'FSR') with the relative units (when relevant).
498            compression (FileAbstraction.Compression, optional): Compression settings used for created
499                datasets. Defaults to FileAbstraction.Compression().
500
501        Returns:
502            Calibration: The newly created calibration group.
503
504        Raises:
505            ValueError: If the provided calibration data or index is invalid or inconsistent.
506        """
507        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
508        calibration_group = sync(self._file.create_group(calibration_path))
509
510        # if same_as is provided, create the 'Same_as' attribute to link the calibration group to an existing one
511        if same_as is not None:
512            sync(self._file.create_attr(calibration_group, 'Same_as', same_as))
513        else: # if same_as is provided, the other parameters are ignored
514            # check that calibration_data is provided and valid
515            if calibration_data is None:
516                raise ValueError("'calibration_data' is required when 'same_as' is not provided")
517            if not isinstance(calibration_data, (list, tuple)):
518                calibration_data = [calibration_data,]
519            # check that index is valid if provided
520            if index is not None:
521                # TODO: check of the shape of 'index' is compatible with PSD
522                if self._sparse and index.ndim != 1:
523                    raise ValueError("'index' must be a 1D array for sparse data")
524                if not self._sparse and index.ndim != 3:
525                    raise ValueError("'index' must be a 3D array for non-sparse data")           
526
527            for m, calib in enumerate(calibration_data):
528                # check that each element in calibration_data is a dictionary containing 'spectra' and 'shift' keys
529                if not isinstance(calib, dict):
530                    raise ValueError("Each element in 'calibration_data' must be a dictionary")
531                if 'spectra' not in calib.keys() or 'shift' not in calib.keys():
532                    raise ValueError("Each calibration data dictionary must contain 'spectra' and 'shift' keys")
533                # retrieve the spectra, shift and shift_units from the calibration data and check that they are valid
534                cal_spectra = np.array(calib['spectra'])
535                if cal_spectra.ndim != 2:
536                    raise ValueError("'spectra' in calibration data must be a 2D array. If only one spectrum is provided, set the first dimension to 1.")
537                cal_shift = calib['shift']
538                cal_shift_units = calib.get('shift_units', None)
539                if cal_shift_units is None:
540                    cal_shift_units = 'GHz'
541                    warnings.warn("No units provided for 'shift' in calibration data, defaulting to GHz")
542                # check that index is compatible with the shape of the spectra
543                if index is None and cal_spectra.shape[0] != 1:
544                    raise ValueError("If 'index' is not provided, each element in 'calibration_data' must contain only one spectrum (i.e. have shape (1, n))")
545                if index is not None and np.max(index) >= cal_spectra.shape[0]:
546                    raise ValueError("If 'index' is provided, its maximum value must be less than the number of spectra in each calibration data element")
547                # add the m arrays together with their attributes to the file
548                spectra_dataset = sync(self._file.create_dataset(calibration_group, f'{m}', cal_spectra, chunk_size=_determine_chunk_size(cal_spectra), compression=compression))
549                sync(self._file.create_attr(spectra_dataset, 'Shift', cal_shift))
550                units.add_to_attribute(self._file, spectra_dataset, 'Shift', cal_shift_units)
551            # add the index array to the file
552            if index is not None:
553                sync(self._file.create_dataset(calibration_group, 'Index', index, compression=compression))
554        
555        from .calibration import _STANDARD_ATTRIBUTES
556        # add any additional attributes to the calibration group, checking that they do not overwrite the standard
557        if attributes is not None:
558            for key, value in attributes.items():
559                if key not in _STANDARD_ATTRIBUTES:
560                    warnings.warn(f"Attribute '{key}' is not a standard attribute for calibration groups.\
561                                   Standard attributes are: {', '.join(_STANDARD_ATTRIBUTES)}. \
562                                   Make sure this is intentional!")
563                if not isinstance(value, MetadataItem):
564                    value = MetadataItem(value)
565                sync(self._file.create_attr(calibration_group, key, value.value))
566                if value.units is not None:
567                    units.add_to_attribute(self._file, calibration_group, key, value.units)
568
569        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.
  • 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 or index is invalid or inconsistent.
def get_calibration(self) -> brimfile.calibration.Calibration:
571    def get_calibration(self) -> Calibration:
572        """
573        Synchronous wrapper for `get_calibration_async` (see doc for `brimfile.data.Data.get_calibration_async`)
574        """
575        return sync(self.get_calibration_async())
async def get_calibration_async(self) -> brimfile.calibration.Calibration:
577    async def get_calibration_async(self) -> Calibration:
578        """
579        Retrieve the calibration group associated with the current data group.
580
581        Returns:
582            Calibration: The calibration group associated with the current data group.
583
584        Raises:
585            ValueError: If no calibration group is found in the current data group or the referenced calibration group does not exist.
586        """
587        calibration_path = concatenate_paths(self._path, brim_obj_names.data.calibration)
588        if not await self._file.object_exists(calibration_path):
589            raise ValueError(f"No calibration group found in {self._path}")
590        same_as = None
591        try:
592            same_as = await self._file.get_attr(calibration_path, 'Same_as')
593        except Exception:
594            pass #  same_as attribute is optional, if it does not exist we just ignore it
595        # if the 'Same_as' attribute exists, find the calibration group with the corresponding index
596        if same_as is not None:
597            try:
598                d_m = await Data.from_existing_async(self._file, same_as)
599                return await d_m.get_calibration_async()
600            except IndexError:
601                raise ValueError(f"Calibration group in {self._path} references non-existing calibration index {same_as} in the file")
602        cal_group = Calibration(self._file, calibration_path, data_group=self, _initialize=False)
603        await cal_group._init_async()
604        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:
606    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
607                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
608        """
609        Adds a new AnalysisResults entry to the current data group.
610        Parameters:
611            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
612            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
613            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
614            name (str, optional): The name for the new Analysis group. Defaults to None.
615            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
616        Returns:
617            AnalysisResults: The newly created AnalysisResults object.
618        Raises:
619            IndexError: If the specified index already exists in the dataset.
620            ValueError: If any of the data provided is not valid or consistent
621        """
622        if index is not None:
623            try:
624                self.get_analysis_results(index)
625            except IndexError:
626                pass
627            else:
628                # If the group already exists, raise an error
629                raise IndexError(
630                    f"Analysis {index} already exists in {self._path}")
631        else:
632            ar_groups = self.list_AnalysisResults()
633            indices = [ar['index'] for ar in ar_groups]
634            indices.sort()
635            index = indices[-1] + 1 if indices else 0  # Next available index
636
637        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
638        if name is not None:
639            set_object_name(self._file, ar._path, name)
640        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
641
642        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:
644    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
645        """
646        List all AnalysisResults groups in the current data group. The list is ordered by index.
647
648        Returns:
649            list: A list of dictionaries, each containing:
650                - 'name' (str): The name of the AnalysisResults group.
651                - 'index' (int): The index extracted from the group name.
652                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
653        """
654
655        analysis_results_groups = []
656
657        matched_objs = sync(list_objects_matching_pattern_async(
658            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$"))
659        async def _make_dict_item(matched_obj, retrieve_custom_name):
660            name = matched_obj[0]
661            index = int(matched_obj[1])
662            curr_obj_dict = {'name': name, 'index': index}
663            if retrieve_custom_name:
664                ar_path = concatenate_paths(self._path, name)
665                custom_name = await get_object_name(self._file, ar_path)
666                curr_obj_dict['custom_name'] = custom_name
667            return curr_obj_dict
668        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
669        dicts = _gather_sync(*coros)
670        for dict_item in dicts:
671            analysis_results_groups.append(dict_item)
672        # Sort the data groups by index
673        analysis_results_groups.sort(key=lambda x: x['index'])
674
675        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:
677    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
678        """
679        Returns the AnalysisResults at the specified index
680
681        Args:
682            index (int)                
683
684        Raises:
685            IndexError: If there is no analysis with the corresponding index
686        """
687        name = None
688        ls = self.list_AnalysisResults()
689        for el in ls:
690            if el['index'] == index:
691                name = el['name']
692                break
693        if name is None:
694            raise IndexError(f"Analysis {index} not found")
695        path = concatenate_paths(self._path, name)
696        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
697                                    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
def _add_data( self, PSD: numpy.ndarray, frequency: numpy.ndarray, *, scanning: dict = None, freq_units='GHz', compression: brimfile.file_abstraction.FileAbstraction.Compression = <brimfile.file_abstraction.FileAbstraction.Compression object>):
699    def _add_data(self, PSD: np.ndarray, frequency: np.ndarray, *, scanning: dict = None, freq_units='GHz',
700                compression: FileAbstraction.Compression = FileAbstraction.Compression()):
701        """
702        @public
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
728        Raises:
729            ValueError: If any of the data provided is not valid or consistent
730        """
731
732        # Check if frequency is broadcastable to PSD
733        try:
734            np.broadcast_shapes(tuple(frequency.shape), tuple(PSD.shape))
735        except ValueError as e:
736            raise ValueError(f"frequency (shape: {frequency.shape}) is not broadcastable to PSD (shape: {PSD.shape}): {e}")
737
738        # Check if at least one of 'Spatial_map' or 'Cartesian_visualisation' is present in the scanning dictionary
739        # This is required for sparse data to establish the spatial mapping
740        has_spatial_mapping = False
741        if scanning is not None:
742            if 'Spatial_map' in scanning:
743                sm = scanning['Spatial_map']
744                size = 0
745
746                def check_coor(coor: str):
747                    if coor in sm:
748                        sm[coor] = np.array(sm[coor])
749                        size1 = sm[coor].size
750                        if size1 != size and size != 0:
751                            raise ValueError(
752                                f"'{coor}' in 'Spatial_map' is invalid!")
753                        return size1
754                    return size
755                size = check_coor('x')
756                size = check_coor('y')
757                size = check_coor('z')
758                if size == 0:
759                    raise ValueError(
760                        "'Spatial_map' should contain at least one x, y or z")
761                has_spatial_mapping = True
762            if 'Cartesian_visualisation' in scanning:
763                cv = scanning['Cartesian_visualisation']
764                if not isinstance(cv, np.ndarray) or cv.ndim != 3:
765                    raise ValueError(
766                        "Cartesian_visualisation must be a 3D numpy array")
767                if not np.issubdtype(cv.dtype, np.integer) or np.min(cv) < -1 or np.max(cv) >= PSD.shape[0]:
768                    raise ValueError(
769                        "Cartesian_visualisation values must be integers between -1 and PSD.shape[0]-1")
770                if 'Cartesian_visualisation_pixel' in scanning:
771                    if len(scanning['Cartesian_visualisation_pixel']) != 3:
772                        raise ValueError(
773                            "Cartesian_visualisation_pixel must always contain 3 values for z, y, x (set to None if not used)")
774                else:
775                    warnings.warn(
776                        "It is recommended to include 'Cartesian_visualisation_pixel' in the scanning dictionary to define pixel size for proper spatial calibration")
777                has_spatial_mapping = True
778        if not has_spatial_mapping and self._sparse:
779            raise ValueError("For sparse data, 'scanning' must be provided and must contain at least one of 'Spatial_map' or 'Cartesian_visualisation'")
780
781        # TODO: add and validate additional datasets (i.e. 'Parameters', 'Calibration_index', etc.)
782
783        # Add datasets to the group
784        sync(self._file.create_dataset(
785            self._group, brim_obj_names.data.PSD, data=PSD,
786            chunk_size=_determine_chunk_size(PSD), compression=compression))
787        freq_ds = sync(self._file.create_dataset(
788            self._group,  brim_obj_names.data.frequency, data=frequency,
789            chunk_size=_determine_chunk_size(frequency), compression=compression))
790        units.add_to_object(self._file, freq_ds, freq_units)
791
792        if scanning is not None:
793            if 'Spatial_map' in scanning:
794                sm = scanning['Spatial_map']
795                sm_group = sync(self._file.create_group(concatenate_paths(
796                    self._path, brim_obj_names.data.spatial_map)))
797                if 'units' in sm:
798                    units.add_to_object(self._file, sm_group, sm['units'])
799
800                def add_sm_dataset(coord: str):
801                    if coord in sm:
802                        sync(self._file.create_dataset(
803                            sm_group, coord, data=sm[coord], compression=compression))
804
805                add_sm_dataset('x')
806                add_sm_dataset('y')
807                add_sm_dataset('z')
808            if 'Cartesian_visualisation' in scanning:
809                # convert the Cartesian_visualisation to the smallest integer type
810                cv_arr = np_array_to_smallest_int_type(scanning['Cartesian_visualisation'])
811                cv = sync(self._file.create_dataset(self._group, brim_obj_names.data.cartesian_visualisation,
812                                            data=cv_arr, compression=compression))
813                if 'Cartesian_visualisation_pixel' in scanning:
814                    sync(self._file.create_attr(
815                        cv, 'element_size', scanning['Cartesian_visualisation_pixel']))
816                    if 'Cartesian_visualisation_pixel_unit' in scanning:
817                        px_unit = scanning['Cartesian_visualisation_pixel_unit']
818                    else:
819                        warnings.warn(
820                            "No unit provided for Cartesian_visualisation_pixel, defaulting to 'um'")
821                        px_unit = 'um'
822                    units.add_to_attribute(self._file, cv, 'element_size', px_unit)
823
824        self._spatial_map, self._spatial_map_px_size = sync(self._load_spatial_mapping_async())

Add data to the current data group.

This method adds the provided PSD, frequency, and scanning data to the HDF5 group associated with this Data object. It validates the inputs to ensure they meet the required specifications before adding them.

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

Rapresents the analysis results associated with a Data object.