brimfile.data

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

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.
def get_name(self):
47    def get_name(self):
48        """
49        Returns the name of the data group.
50        """
51        return sync(get_object_name(self._file, self._path))

Returns the name of the data group.

def get_index(self):
53    def get_index(self):
54        """
55        Returns the index of the data group.
56        """
57        return int(self._path.split('/')[-1].split('_')[-1])

Returns the index of the data group.

def get_PSD(self) -> tuple:
211    def get_PSD(self) -> tuple:
212        """
213        LOW LEVEL FUNCTION
214
215        Retrieve the Power Spectral Density (PSD) and frequency from the current data group.
216        Note: this function exposes the internals of the brim file and thus the interface might change in future versions.
217        Use only if more specialized functions are not working for your application!
218        Returns:
219            tuple: (PSD, frequency, PSD_units, frequency_units)
220                - PSD: A 2D (or more) numpy array containing all the spectra (see [specs](https://github.com/prevedel-lab/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
221                - frequency: A numpy array representing the frequency data (see [specs](https://github.com/prevedel-lab/Brillouin-standard-file/blob/main/docs/brim_file_specs.md) for more details).
222                - PSD_units: The units of the PSD.
223                - frequency_units: The units of the frequency.
224        """
225        warnings.warn(
226            "Data.get_PSD is deprecated and will be removed in a future release. "
227            "Use Data.get_PSD_as_spatial_map instead.",
228            DeprecationWarning,
229            stacklevel=2,
230        )
231        PSD, frequency = _gather_sync(
232            self._file.open_dataset(concatenate_paths(
233                self._path, brim_obj_names.data.PSD)),
234            self._file.open_dataset(concatenate_paths(
235                self._path, brim_obj_names.data.frequency))
236        )
237        # retrieve the units of the PSD and frequency
238        PSD_units, frequency_units = _gather_sync(
239            units.of_object(self._file, PSD),
240            units.of_object(self._file, frequency)
241        )
242
243        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) -> tuple:
245    def get_PSD_as_spatial_map(self) -> tuple:
246        """
247        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
248        Returns:
249            tuple: (PSD, frequency, PSD_units, frequency_units)
250                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
251                - frequency: A numpy array representing the frequency data, which can be broadcastable to PSD.
252                - PSD_units: The units of the PSD.
253                - frequency_units: The units of the frequency.
254        """
255        PSD, frequency = _gather_sync(
256            self._file.open_dataset(concatenate_paths(
257                self._path, brim_obj_names.data.PSD)),        
258            self._file.open_dataset(concatenate_paths(
259                self._path, brim_obj_names.data.frequency))
260            )        
261        # retrieve the units of the PSD and frequency
262        PSD_units, frequency_units = _gather_sync(
263            units.of_object(self._file, PSD),
264            units.of_object(self._file, frequency)
265        )
266
267        # ensure PSD and frequency are numpy arrays
268        PSD = np.array(PSD)  
269        frequency = np.array(frequency)  # ensure it's a numpy array
270        
271        #if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
272        if frequency.ndim > 1:
273            frequency = np.broadcast_to(frequency, PSD.shape)
274        
275        if self._sparse:
276            if self._spatial_map is None:
277                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
278            sm = np.array(self._spatial_map)
279            # reshape the PSD to have the spatial dimensions first      
280            PSD = PSD[sm, ...]
281            # reshape the frequency only if it is not the same for all spectra
282            if frequency.ndim > 1:
283                frequency = frequency[sm, ...]
284
285        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.

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 can be broadcastable to PSD. - PSD_units: The units of the PSD. - frequency_units: The units of the frequency.

def get_spectrum_in_image(self, coor: tuple) -> tuple:
364    def get_spectrum_in_image(self, coor: tuple) -> tuple:
365        """
366        Retrieve a spectrum from the data group using spatial coordinates.
367
368        Args:
369            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
370
371        Returns:
372            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
373        """
374        if len(coor) != 3:
375            raise ValueError("coor must contain 3 values for z, y, x")
376
377        if self._sparse:
378            index = int(self._spatial_map[coor])
379            return self._get_spectrum(index)
380        else:
381            return self._get_spectrum(coor)

Retrieve a spectrum from the data group using spatial coordinates.

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

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

def get_spectrum_and_all_quantities_in_image( self, ar: brimfile.analysis_results.AnalysisResults, coor: tuple, index_peak: int = 0):
383    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0):
384        """
385        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
386
387        Args:
388            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
389            coor (tuple): A tuple containing the z, y, x coordinates in the image.
390            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
391
392        Returns:
393            tuple: A tuple containing:
394                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
395                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
396        """
397        if len(coor) != 3:
398            raise ValueError("coor must contain 3 values for z, y, x")
399        index = coor
400        if self._sparse:
401            index = int(self._spatial_map[coor])
402        spectrum, quantities = _gather_sync(
403            self._get_spectrum_async(index),
404            ar._get_all_quantities_at_index(index, index_peak)
405        )
406        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_metadata(self):
408    def get_metadata(self):
409        """
410        Returns the metadata associated with the current Data group
411        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
412        and the ones specific for this data group
413        """
414        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:
416    def get_num_parameters(self) -> tuple:
417        """
418        Retrieves the number of parameters
419
420        Returns:
421            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
422        """
423        pars, _ = self.get_parameters()
424        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:
426    def get_parameters(self) -> list:
427        """
428        Retrieves the parameters  and their associated names.
429
430        If PSD.ndims > 2, the parameters are stored in a separate dataset.
431
432        Returns:
433            list: A tuple containing the parameters and their names if there are any, otherwise None.
434        """
435        pars_full_path = concatenate_paths(
436            self._path, brim_obj_names.data.parameters)
437        if sync(self._file.object_exists(pars_full_path)):
438            pars = sync(self._file.open_dataset(pars_full_path))
439            pars_names = sync(self._file.get_attr(pars, 'Name'))
440            return (pars, pars_names)
441        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_analysis_results_group( self, data_AntiStokes, data_Stokes=None, *, index: int = None, name: str = None, fit_model: brimfile.analysis_results.AnalysisResults.FitModel = None) -> brimfile.analysis_results.AnalysisResults:
443    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
444                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
445        """
446        Adds a new AnalysisResults entry to the current data group.
447        Parameters:
448            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
449            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
450            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
451            name (str, optional): The name for the new Analysis group. Defaults to None.
452            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
453        Returns:
454            AnalysisResults: The newly created AnalysisResults object.
455        Raises:
456            IndexError: If the specified index already exists in the dataset.
457            ValueError: If any of the data provided is not valid or consistent
458        """
459        if index is not None:
460            try:
461                self.get_analysis_results(index)
462            except IndexError:
463                pass
464            else:
465                # If the group already exists, raise an error
466                raise IndexError(
467                    f"Analysis {index} already exists in {self._path}")
468        else:
469            ar_groups = self.list_AnalysisResults()
470            indices = [ar['index'] for ar in ar_groups]
471            indices.sort()
472            index = indices[-1] + 1 if indices else 0  # Next available index
473
474        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
475        if name is not None:
476            set_object_name(self._file, ar._path, name)
477        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
478
479        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:
481    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
482        """
483        List all AnalysisResults groups in the current data group. The list is ordered by index.
484
485        Returns:
486            list: A list of dictionaries, each containing:
487                - 'name' (str): The name of the AnalysisResults group.
488                - 'index' (int): The index extracted from the group name.
489                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
490        """
491
492        analysis_results_groups = []
493
494        matched_objs = list_objects_matching_pattern(
495            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$")
496        async def _make_dict_item(matched_obj, retrieve_custom_name):
497            name = matched_obj[0]
498            index = int(matched_obj[1])
499            curr_obj_dict = {'name': name, 'index': index}
500            if retrieve_custom_name:
501                ar_path = concatenate_paths(self._path, name)
502                custom_name = await get_object_name(self._file, ar_path)
503                curr_obj_dict['custom_name'] = custom_name
504            return curr_obj_dict
505        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
506        dicts = _gather_sync(*coros)
507        for dict_item in dicts:
508            analysis_results_groups.append(dict_item)
509        # Sort the data groups by index
510        analysis_results_groups.sort(key=lambda x: x['index'])
511
512        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:
514    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
515        """
516        Returns the AnalysisResults at the specified index
517
518        Args:
519            index (int)                
520
521        Raises:
522            IndexError: If there is no analysis with the corresponding index
523        """
524        name = None
525        ls = self.list_AnalysisResults()
526        for el in ls:
527            if el['index'] == index:
528                name = el['name']
529                break
530        if name is None:
531            raise IndexError(f"Analysis {index} not found")
532        path = concatenate_paths(self._path, name)
533        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
534                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)

Returns the AnalysisResults at the specified index

Arguments:
  • index (int)
Raises:
  • IndexError: If there is no analysis with the corresponding index
@staticmethod
def list_data_groups( file: brimfile.file_abstraction.FileAbstraction, retrieve_custom_name=False) -> list:
686    @staticmethod
687    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
688        """
689        List all data groups in the brim file. The list is ordered by index.
690
691        Returns:
692            list: A list of dictionaries, each containing:
693                - 'name' (str): The name of the data group in the file.
694                - 'index' (int): The index extracted from the group name.
695                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
696        """
697
698        data_groups = []
699
700        matched_objs = list_objects_matching_pattern(
701            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
702        
703        async def _make_dict_item(matched_obj, retrieve_custom_name):
704            name = matched_obj[0]
705            index = int(matched_obj[1])
706            curr_obj_dict = {'name': name, 'index': index}
707            if retrieve_custom_name:
708                path = concatenate_paths(
709                    brim_obj_names.Brillouin_base_path, name)
710                custom_name = await get_object_name(file, path)
711                curr_obj_dict['custom_name'] = custom_name
712            return curr_obj_dict
713        
714        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
715        dicts = _gather_sync(*coros)
716        for dict_item in dicts:
717            data_groups.append(dict_item)        
718        # Sort the data groups by index
719        data_groups.sort(key=lambda x: x['index'])
720
721        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.

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

Rapresents the analysis results associated with a Data object.