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/brillouin-imaging/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/brillouin-imaging/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, *, broadcast_frequency: bool = True) -> tuple:
245        """
246        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
247        Arguments:
248            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
249                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
250                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
251        Returns:
252            tuple: (PSD, frequency, PSD_units, frequency_units)
253                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
254                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
255                - PSD_units: The units of the PSD.
256                - frequency_units: The units of the frequency.
257        """
258        PSD, frequency = _gather_sync(
259            self._file.open_dataset(concatenate_paths(
260                self._path, brim_obj_names.data.PSD)),        
261            self._file.open_dataset(concatenate_paths(
262                self._path, brim_obj_names.data.frequency))
263            )        
264        # retrieve the units of the PSD and frequency
265        PSD_units, frequency_units = _gather_sync(
266            units.of_object(self._file, PSD),
267            units.of_object(self._file, frequency)
268        )
269
270        # ensure PSD and frequency are numpy arrays
271        PSD = np.array(PSD)  
272        frequency = np.array(frequency)  # ensure it's a numpy array
273        
274        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
275        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
276        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
277            frequency = np.broadcast_to(frequency, PSD.shape)
278        
279        if self._sparse:
280            if self._spatial_map is None:
281                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
282            sm = np.array(self._spatial_map)
283            # reshape the PSD and frequency to have the spatial dimensions first      
284            PSD = PSD[sm, ...]
285            # reshape the frequency only if it is not the same for all spectra
286            if frequency.ndim > 1:
287                frequency = frequency[sm, ...]
288
289        return PSD, frequency, PSD_units, frequency_units
290
291    def _get_spectrum(self, index: int | tuple[int, int, int]) -> tuple:
292        """
293        Synchronous wrapper for `_get_spectrum_async` (see doc for `brimfile.data.Data._get_spectrum_async`)
294        """
295        return sync(self._get_spectrum_async(index))
296    async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
297        """
298        Retrieve a spectrum from the data group by its index or coordinates.
299
300        Args:
301            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.
302
303        Returns:
304            tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. 
305                    PSD can be 1D or more (if there are additional parameters);
306                    frequency has the same size as PSD
307        Raises:
308            IndexError: If the index is out of range for the PSD dataset.
309        """
310        if self._sparse and not isinstance(index, int):
311            raise ValueError("For sparse data, index must be an integer.")
312        elif not self._sparse and not (isinstance(index, tuple) and len(index) == 3):
313            raise ValueError("For non-sparse data, index must be a tuple of (z, y, x) coordinates.")
314            
315        # index = -1 corresponds to no spectrum
316        if self._sparse and index < 0:
317            return None, None, None, None
318        elif not self._sparse and any(i < 0 for i in index):
319            return None, None, None, None
320        PSD, frequency = await asyncio.gather(
321            self._file.open_dataset(concatenate_paths(
322                self._path, brim_obj_names.data.PSD)),                       
323            self._file.open_dataset(concatenate_paths(
324                self._path, brim_obj_names.data.frequency))
325            )
326        if self._sparse and index >= PSD.shape[0]:
327            raise IndexError(
328                f"index {index} out of range for PSD with shape {PSD.shape}")
329        elif not self._sparse and any(i >= PSD.shape[j] for j, i in enumerate(index)):
330            raise IndexError(
331                f"index {index} out of range for PSD with shape {PSD.shape}")
332        # retrieve the units of the PSD and frequency
333        PSD_units, frequency_units = await asyncio.gather(
334            units.of_object(self._file, PSD),
335            units.of_object(self._file, frequency)
336        )
337        # add ellipsis to the index to select the spectrum and the corresponding frequency
338        if self._sparse:
339            index = (index, ...)
340        else:
341            index = index + (..., )
342        # map index to the frequency array, considering the broadcasting rules
343        index_frequency = index
344        if frequency.ndim < PSD.ndim:
345            if self._sparse:
346                # given the definition of the brim file format,
347                # if the frequency has less dimensions that PSD,
348                # it can only be because it is the same for all the spatial position (first dimension)
349                index_frequency = (..., )
350            else:
351                unassigned_indices = PSD.ndim - frequency.ndim
352                if unassigned_indices == 3:
353                    # if the frequency has no spatial dimension, it is the same for all the spatial positions
354                    index_frequency = (..., )
355                else:
356                    # if the frequency has some spatial dimensions but not all, we need to add the corresponding indices to the index of the frequency
357                    index_frequency = index[-unassigned_indices:] + (..., )
358        #get the spectrum and the corresponding frequency at the specified index
359        PSD, frequency = await asyncio.gather(
360            _async_getitem(PSD, index),
361            _async_getitem(frequency, index_frequency)
362        )
363        #broadcast the frequency to match the shape of PSD if needed
364        if frequency.ndim < PSD.ndim:
365            frequency = np.broadcast_to(frequency, PSD.shape)
366        return PSD, frequency, PSD_units, frequency_units
367
368    def get_spectrum_in_image(self, coor: tuple) -> tuple:
369        """
370        Retrieve a spectrum from the data group using spatial coordinates.
371
372        Args:
373            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
374
375        Returns:
376            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
377        """
378        if len(coor) != 3:
379            raise ValueError("coor must contain 3 values for z, y, x")
380
381        if self._sparse:
382            index = int(self._spatial_map[coor])
383            return self._get_spectrum(index)
384        else:
385            return self._get_spectrum(coor)
386          
387    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
388        """
389        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
390
391        Args:
392            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
393            coor (tuple): A tuple containing the z, y, x coordinates in the image.
394            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
395
396        Returns:
397            tuple: A tuple containing:
398                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
399                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
400        """
401        if len(coor) != 3:
402            raise ValueError("coor must contain 3 values for z, y, x")
403        index = coor
404        if self._sparse:
405            index = int(self._spatial_map[coor])
406        spectrum, quantities = await asyncio.gather(
407            self._get_spectrum_async(index),
408            ar._get_all_quantities_at_index(index, index_peak)
409        )
410        return spectrum, quantities
411    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
412        """
413        Synchronous wrapper for `get_spectrum_and_all_quantities_in_image_async` (see doc for `brimfile.data.Data.get_spectrum_and_all_quantities_in_image_async`)
414        """
415        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
416
417    def get_metadata(self):
418        """
419        Returns the metadata associated with the current Data group
420        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
421        and the ones specific for this data group
422        """
423        return Metadata(self._file, self._path)
424
425    def get_num_parameters(self) -> tuple:
426        """
427        Retrieves the number of parameters
428
429        Returns:
430            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
431        """
432        pars, _ = self.get_parameters()
433        return pars.shape if pars is not None else ()
434
435    def get_parameters(self) -> list:
436        """
437        Retrieves the parameters  and their associated names.
438
439        If PSD.ndims > 2, the parameters are stored in a separate dataset.
440
441        Returns:
442            list: A tuple containing the parameters and their names if there are any, otherwise None.
443        """
444        pars_full_path = concatenate_paths(
445            self._path, brim_obj_names.data.parameters)
446        if sync(self._file.object_exists(pars_full_path)):
447            pars = sync(self._file.open_dataset(pars_full_path))
448            pars_names = sync(self._file.get_attr(pars, 'Name'))
449            return (pars, pars_names)
450        return (None, None)
451
452    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
453                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
454        """
455        Adds a new AnalysisResults entry to the current data group.
456        Parameters:
457            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
458            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
459            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
460            name (str, optional): The name for the new Analysis group. Defaults to None.
461            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
462        Returns:
463            AnalysisResults: The newly created AnalysisResults object.
464        Raises:
465            IndexError: If the specified index already exists in the dataset.
466            ValueError: If any of the data provided is not valid or consistent
467        """
468        if index is not None:
469            try:
470                self.get_analysis_results(index)
471            except IndexError:
472                pass
473            else:
474                # If the group already exists, raise an error
475                raise IndexError(
476                    f"Analysis {index} already exists in {self._path}")
477        else:
478            ar_groups = self.list_AnalysisResults()
479            indices = [ar['index'] for ar in ar_groups]
480            indices.sort()
481            index = indices[-1] + 1 if indices else 0  # Next available index
482
483        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
484        if name is not None:
485            set_object_name(self._file, ar._path, name)
486        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
487
488        return ar
489
490    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
491        """
492        List all AnalysisResults groups in the current data group. The list is ordered by index.
493
494        Returns:
495            list: A list of dictionaries, each containing:
496                - 'name' (str): The name of the AnalysisResults group.
497                - 'index' (int): The index extracted from the group name.
498                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
499        """
500
501        analysis_results_groups = []
502
503        matched_objs = list_objects_matching_pattern(
504            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$")
505        async def _make_dict_item(matched_obj, retrieve_custom_name):
506            name = matched_obj[0]
507            index = int(matched_obj[1])
508            curr_obj_dict = {'name': name, 'index': index}
509            if retrieve_custom_name:
510                ar_path = concatenate_paths(self._path, name)
511                custom_name = await get_object_name(self._file, ar_path)
512                curr_obj_dict['custom_name'] = custom_name
513            return curr_obj_dict
514        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
515        dicts = _gather_sync(*coros)
516        for dict_item in dicts:
517            analysis_results_groups.append(dict_item)
518        # Sort the data groups by index
519        analysis_results_groups.sort(key=lambda x: x['index'])
520
521        return analysis_results_groups
522
523    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
524        """
525        Returns the AnalysisResults at the specified index
526
527        Args:
528            index (int)                
529
530        Raises:
531            IndexError: If there is no analysis with the corresponding index
532        """
533        name = None
534        ls = self.list_AnalysisResults()
535        for el in ls:
536            if el['index'] == index:
537                name = el['name']
538                break
539        if name is None:
540            raise IndexError(f"Analysis {index} not found")
541        path = concatenate_paths(self._path, name)
542        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
543                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)
544
545    def _add_data(self, PSD: np.ndarray, frequency: np.ndarray, *, scanning: dict = None, freq_units='GHz',
546                  timestamp: np.ndarray = None, compression: FileAbstraction.Compression = FileAbstraction.Compression()):
547        """
548        Add data to the current data group.
549
550        This method adds the provided PSD, frequency, and scanning data to the HDF5 group 
551        associated with this `Data` object. It validates the inputs to ensure they meet 
552        the required specifications before adding them.
553
554        Args:
555            PSD (np.ndarray): A 2D numpy array representing the Power Spectral Density (PSD) data. The last dimension contains the spectra.
556            frequency (np.ndarray): A 1D or 2D numpy array representing the frequency data. 
557                It must be broadcastable to the shape of the PSD array.
558            scanning (dict, optional): A dictionary containing scanning-related data. 
559                Required for sparse data (sparse=True), optional for non-sparse data.
560                For sparse data, must include at least one of 'Spatial_map' or 'Cartesian_visualisation'.
561                It may include the following keys:
562                - 'Spatial_map' (optional): A dictionary containing coordinate arrays:
563                    - 'x', 'y', 'z' (optional): 1D numpy arrays of same length with coordinate values
564                    - 'units' (optional): string with the unit (e.g., 'um')
565                - 'Cartesian_visualisation' (optional): A 3D numpy array (z, y, x) with integer values 
566                   mapping spatial positions to spectra indices. Values must be -1 (invalid/empty pixel) 
567                   or between 0 and PSD.shape[0]-1.
568                - 'Cartesian_visualisation_pixel' (recommended with Cartesian_visualisation): 
569                   Tuple/list of 3 float values (z, y, x) representing pixel size. Unused dimensions can be None.
570                - 'Cartesian_visualisation_pixel_unit' (optional): String for pixel size unit (default: 'um').
571            timestamp (np.ndarray, optional): Timestamps in milliseconds for each spectrum.
572                Must be a 1D array with length equal to PSD.shape[0].
573
574
575        Raises:
576            ValueError: If any of the data provided is not valid or consistent
577        """
578
579        # Check if frequency is broadcastable to PSD
580        try:
581            np.broadcast_shapes(tuple(frequency.shape), tuple(PSD.shape))
582        except ValueError as e:
583            raise ValueError(f"frequency (shape: {frequency.shape}) is not broadcastable to PSD (shape: {PSD.shape}): {e}")
584
585        # Check if at least one of 'Spatial_map' or 'Cartesian_visualisation' is present in the scanning dictionary
586        # This is required for sparse data to establish the spatial mapping
587        has_spatial_mapping = False
588        if scanning is not None:
589            if 'Spatial_map' in scanning:
590                sm = scanning['Spatial_map']
591                size = 0
592
593                def check_coor(coor: str):
594                    if coor in sm:
595                        sm[coor] = np.array(sm[coor])
596                        size1 = sm[coor].size
597                        if size1 != size and size != 0:
598                            raise ValueError(
599                                f"'{coor}' in 'Spatial_map' is invalid!")
600                        return size1
601                    return size
602                size = check_coor('x')
603                size = check_coor('y')
604                size = check_coor('z')
605                if size == 0:
606                    raise ValueError(
607                        "'Spatial_map' should contain at least one x, y or z")
608                has_spatial_mapping = True
609            if 'Cartesian_visualisation' in scanning:
610                cv = scanning['Cartesian_visualisation']
611                if not isinstance(cv, np.ndarray) or cv.ndim != 3:
612                    raise ValueError(
613                        "Cartesian_visualisation must be a 3D numpy array")
614                if not np.issubdtype(cv.dtype, np.integer) or np.min(cv) < -1 or np.max(cv) >= PSD.shape[0]:
615                    raise ValueError(
616                        "Cartesian_visualisation values must be integers between -1 and PSD.shape[0]-1")
617                if 'Cartesian_visualisation_pixel' in scanning:
618                    if len(scanning['Cartesian_visualisation_pixel']) != 3:
619                        raise ValueError(
620                            "Cartesian_visualisation_pixel must always contain 3 values for z, y, x (set to None if not used)")
621                else:
622                    warnings.warn(
623                        "It is recommended to include 'Cartesian_visualisation_pixel' in the scanning dictionary to define pixel size for proper spatial calibration")
624                has_spatial_mapping = True
625        if not has_spatial_mapping and self._sparse:
626            raise ValueError("For sparse data, 'scanning' must be provided and must contain at least one of 'Spatial_map' or 'Cartesian_visualisation'")
627
628        if timestamp is not None:
629            if not isinstance(timestamp, np.ndarray) or timestamp.ndim != 1 or len(timestamp) != PSD.shape[0]:
630                raise ValueError("timestamp is not compatible with PSD")
631
632        # TODO: add and validate additional datasets (i.e. 'Parameters', 'Calibration_index', etc.)
633
634        # Add datasets to the group
635        def determine_chunk_size(arr: np.array) -> tuple:
636            """"
637            Use the same heuristic as the zarr library to determine the chunk size, but without splitting the last dimension
638            """
639            shape = arr.shape
640            typesize = arr.itemsize
641            #if the array is 1D, do not chunk it
642            if len(shape) <= 1:
643                return (shape[0],)
644            target_sizes = _guess_chunks.__kwdefaults__
645            # divide the target size by the last dimension size to get the chunk size for the other dimensions
646            target_sizes = {k: target_sizes[k] // shape[-1] 
647                            for k in target_sizes.keys()}
648            chunks = _guess_chunks(shape[0:-1], typesize, arr.nbytes, **target_sizes)
649            return chunks + (shape[-1],)  # keep the last dimension size unchanged
650        sync(self._file.create_dataset(
651            self._group, brim_obj_names.data.PSD, data=PSD,
652            chunk_size=determine_chunk_size(PSD), compression=compression))
653        freq_ds = sync(self._file.create_dataset(
654            self._group,  brim_obj_names.data.frequency, data=frequency,
655            chunk_size=determine_chunk_size(frequency), compression=compression))
656        units.add_to_object(self._file, freq_ds, freq_units)
657
658        if scanning is not None:
659            if 'Spatial_map' in scanning:
660                sm = scanning['Spatial_map']
661                sm_group = sync(self._file.create_group(concatenate_paths(
662                    self._path, brim_obj_names.data.spatial_map)))
663                if 'units' in sm:
664                    units.add_to_object(self._file, sm_group, sm['units'])
665
666                def add_sm_dataset(coord: str):
667                    if coord in sm:
668                        sync(self._file.create_dataset(
669                            sm_group, coord, data=sm[coord], compression=compression))
670
671                add_sm_dataset('x')
672                add_sm_dataset('y')
673                add_sm_dataset('z')
674            if 'Cartesian_visualisation' in scanning:
675                # convert the Cartesian_visualisation to the smallest integer type
676                cv_arr = np_array_to_smallest_int_type(scanning['Cartesian_visualisation'])
677                cv = sync(self._file.create_dataset(self._group, brim_obj_names.data.cartesian_visualisation,
678                                            data=cv_arr, compression=compression))
679                if 'Cartesian_visualisation_pixel' in scanning:
680                    sync(self._file.create_attr(
681                        cv, 'element_size', scanning['Cartesian_visualisation_pixel']))
682                    if 'Cartesian_visualisation_pixel_unit' in scanning:
683                        px_unit = scanning['Cartesian_visualisation_pixel_unit']
684                    else:
685                        warnings.warn(
686                            "No unit provided for Cartesian_visualisation_pixel, defaulting to 'um'")
687                        px_unit = 'um'
688                    units.add_to_attribute(self._file, cv, 'element_size', px_unit)
689
690        self._spatial_map, self._spatial_map_px_size = self._load_spatial_mapping()
691
692        if timestamp is not None:
693            sync(self._file.create_dataset(
694                self._group, 'Timestamp', data=timestamp, compression=compression))
695
696    @staticmethod
697    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
698        """
699        List all data groups in the brim file. The list is ordered by index.
700
701        Returns:
702            list: A list of dictionaries, each containing:
703                - 'name' (str): The name of the data group in the file.
704                - 'index' (int): The index extracted from the group name.
705                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
706        """
707
708        data_groups = []
709
710        matched_objs = list_objects_matching_pattern(
711            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
712        
713        async def _make_dict_item(matched_obj, retrieve_custom_name):
714            name = matched_obj[0]
715            index = int(matched_obj[1])
716            curr_obj_dict = {'name': name, 'index': index}
717            if retrieve_custom_name:
718                path = concatenate_paths(
719                    brim_obj_names.Brillouin_base_path, name)
720                custom_name = await get_object_name(file, path)
721                curr_obj_dict['custom_name'] = custom_name
722            return curr_obj_dict
723        
724        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
725        dicts = _gather_sync(*coros)
726        for dict_item in dicts:
727            data_groups.append(dict_item)        
728        # Sort the data groups by index
729        data_groups.sort(key=lambda x: x['index'])
730
731        return data_groups
732
733    @staticmethod
734    def _get_existing_group_name(file: FileAbstraction, index: int) -> str:
735        """
736        Get the name of an existing data group by index.
737
738        Args:
739            file (File): The parent File object.
740            index (int): The index of the data group.
741
742        Returns:
743            str: The name of the data group, or None if not found.
744        """
745        group_name: str = None
746        data_groups = Data.list_data_groups(file)
747        for dg in data_groups:
748            if dg['index'] == index:
749                group_name = dg['name']
750                break
751        return group_name
752
753    @classmethod
754    def _create_new(cls, file: FileAbstraction, index: int, sparse: bool = False, name: str = None) -> 'Data':
755        """
756        Create a new data group with the specified index.
757
758        Args:
759            file (File): The parent File object.
760            index (int): The index for the new data group.
761            sparse (bool): Whether the data is sparse. See https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md for details. Defaults to False.
762            name (str, optional): The name for the new data group. Defaults to None.
763
764        Returns:
765            Data: The newly created Data object.
766        """
767        group_name = Data._generate_group_name(index)
768        group = sync(file.create_group(concatenate_paths(
769            brim_obj_names.Brillouin_base_path, group_name)))
770        sync(file.create_attr(group, 'Sparse', sparse))
771        if name is not None:
772            set_object_name(file, group, name)
773        return cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), newly_created=True)
774
775    @staticmethod
776    def _generate_group_name(index: int, n_digits: int = None) -> str:
777        """
778        Generate a name for a data group based on the index.
779
780        Args:
781            index (int): The index for the data group.
782            n_digits (int, optional): The number of digits to pad the index with. If None no padding is applied. Defaults to None.
783
784        Returns:
785            str: The generated group name.
786
787        Raises:
788            ValueError: If the index is negative.
789        """
790        if index < 0:
791            raise ValueError("index must be positive")
792        num = str(index)
793        if n_digits is not None:
794            num = num.zfill(n_digits)
795        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/brillouin-imaging/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/brillouin-imaging/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, *, broadcast_frequency: bool = True) -> tuple:
246        """
247        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
248        Arguments:
249            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
250                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
251                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
252        Returns:
253            tuple: (PSD, frequency, PSD_units, frequency_units)
254                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
255                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
256                - PSD_units: The units of the PSD.
257                - frequency_units: The units of the frequency.
258        """
259        PSD, frequency = _gather_sync(
260            self._file.open_dataset(concatenate_paths(
261                self._path, brim_obj_names.data.PSD)),        
262            self._file.open_dataset(concatenate_paths(
263                self._path, brim_obj_names.data.frequency))
264            )        
265        # retrieve the units of the PSD and frequency
266        PSD_units, frequency_units = _gather_sync(
267            units.of_object(self._file, PSD),
268            units.of_object(self._file, frequency)
269        )
270
271        # ensure PSD and frequency are numpy arrays
272        PSD = np.array(PSD)  
273        frequency = np.array(frequency)  # ensure it's a numpy array
274        
275        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
276        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
277        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
278            frequency = np.broadcast_to(frequency, PSD.shape)
279        
280        if self._sparse:
281            if self._spatial_map is None:
282                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
283            sm = np.array(self._spatial_map)
284            # reshape the PSD and frequency to have the spatial dimensions first      
285            PSD = PSD[sm, ...]
286            # reshape the frequency only if it is not the same for all spectra
287            if frequency.ndim > 1:
288                frequency = frequency[sm, ...]
289
290        return PSD, frequency, PSD_units, frequency_units
291
292    def _get_spectrum(self, index: int | tuple[int, int, int]) -> tuple:
293        """
294        Synchronous wrapper for `_get_spectrum_async` (see doc for `brimfile.data.Data._get_spectrum_async`)
295        """
296        return sync(self._get_spectrum_async(index))
297    async def _get_spectrum_async(self, index: int | tuple[int, int, int]) -> tuple:
298        """
299        Retrieve a spectrum from the data group by its index or coordinates.
300
301        Args:
302            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.
303
304        Returns:
305            tuple: (PSD, frequency, PSD_units, frequency_units) for the specified index. 
306                    PSD can be 1D or more (if there are additional parameters);
307                    frequency has the same size as PSD
308        Raises:
309            IndexError: If the index is out of range for the PSD dataset.
310        """
311        if self._sparse and not isinstance(index, int):
312            raise ValueError("For sparse data, index must be an integer.")
313        elif not self._sparse and not (isinstance(index, tuple) and len(index) == 3):
314            raise ValueError("For non-sparse data, index must be a tuple of (z, y, x) coordinates.")
315            
316        # index = -1 corresponds to no spectrum
317        if self._sparse and index < 0:
318            return None, None, None, None
319        elif not self._sparse and any(i < 0 for i in index):
320            return None, None, None, None
321        PSD, frequency = await asyncio.gather(
322            self._file.open_dataset(concatenate_paths(
323                self._path, brim_obj_names.data.PSD)),                       
324            self._file.open_dataset(concatenate_paths(
325                self._path, brim_obj_names.data.frequency))
326            )
327        if self._sparse and index >= PSD.shape[0]:
328            raise IndexError(
329                f"index {index} out of range for PSD with shape {PSD.shape}")
330        elif not self._sparse and any(i >= PSD.shape[j] for j, i in enumerate(index)):
331            raise IndexError(
332                f"index {index} out of range for PSD with shape {PSD.shape}")
333        # retrieve the units of the PSD and frequency
334        PSD_units, frequency_units = await asyncio.gather(
335            units.of_object(self._file, PSD),
336            units.of_object(self._file, frequency)
337        )
338        # add ellipsis to the index to select the spectrum and the corresponding frequency
339        if self._sparse:
340            index = (index, ...)
341        else:
342            index = index + (..., )
343        # map index to the frequency array, considering the broadcasting rules
344        index_frequency = index
345        if frequency.ndim < PSD.ndim:
346            if self._sparse:
347                # given the definition of the brim file format,
348                # if the frequency has less dimensions that PSD,
349                # it can only be because it is the same for all the spatial position (first dimension)
350                index_frequency = (..., )
351            else:
352                unassigned_indices = PSD.ndim - frequency.ndim
353                if unassigned_indices == 3:
354                    # if the frequency has no spatial dimension, it is the same for all the spatial positions
355                    index_frequency = (..., )
356                else:
357                    # if the frequency has some spatial dimensions but not all, we need to add the corresponding indices to the index of the frequency
358                    index_frequency = index[-unassigned_indices:] + (..., )
359        #get the spectrum and the corresponding frequency at the specified index
360        PSD, frequency = await asyncio.gather(
361            _async_getitem(PSD, index),
362            _async_getitem(frequency, index_frequency)
363        )
364        #broadcast the frequency to match the shape of PSD if needed
365        if frequency.ndim < PSD.ndim:
366            frequency = np.broadcast_to(frequency, PSD.shape)
367        return PSD, frequency, PSD_units, frequency_units
368
369    def get_spectrum_in_image(self, coor: tuple) -> tuple:
370        """
371        Retrieve a spectrum from the data group using spatial coordinates.
372
373        Args:
374            coor (tuple): A tuple containing the z, y, x coordinates of the spectrum to retrieve.
375
376        Returns:
377            tuple: A tuple containing the PSD, frequency, PSD_units, frequency_units for the specified coordinates. See `Data._get_spectrum_async` for details.
378        """
379        if len(coor) != 3:
380            raise ValueError("coor must contain 3 values for z, y, x")
381
382        if self._sparse:
383            index = int(self._spatial_map[coor])
384            return self._get_spectrum(index)
385        else:
386            return self._get_spectrum(coor)
387          
388    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
389        """
390        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
391
392        Args:
393            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
394            coor (tuple): A tuple containing the z, y, x coordinates in the image.
395            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
396
397        Returns:
398            tuple: A tuple containing:
399                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
400                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
401        """
402        if len(coor) != 3:
403            raise ValueError("coor must contain 3 values for z, y, x")
404        index = coor
405        if self._sparse:
406            index = int(self._spatial_map[coor])
407        spectrum, quantities = await asyncio.gather(
408            self._get_spectrum_async(index),
409            ar._get_all_quantities_at_index(index, index_peak)
410        )
411        return spectrum, quantities
412    def get_spectrum_and_all_quantities_in_image(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
413        """
414        Synchronous wrapper for `get_spectrum_and_all_quantities_in_image_async` (see doc for `brimfile.data.Data.get_spectrum_and_all_quantities_in_image_async`)
415        """
416        return sync(self.get_spectrum_and_all_quantities_in_image_async(ar, coor, index_peak))
417
418    def get_metadata(self):
419        """
420        Returns the metadata associated with the current Data group
421        Note that this contains both the general metadata stored in the file (which might be redifined by the specific data group)
422        and the ones specific for this data group
423        """
424        return Metadata(self._file, self._path)
425
426    def get_num_parameters(self) -> tuple:
427        """
428        Retrieves the number of parameters
429
430        Returns:
431            tuple: The shape of the parameters if they exist, otherwise an empty tuple.
432        """
433        pars, _ = self.get_parameters()
434        return pars.shape if pars is not None else ()
435
436    def get_parameters(self) -> list:
437        """
438        Retrieves the parameters  and their associated names.
439
440        If PSD.ndims > 2, the parameters are stored in a separate dataset.
441
442        Returns:
443            list: A tuple containing the parameters and their names if there are any, otherwise None.
444        """
445        pars_full_path = concatenate_paths(
446            self._path, brim_obj_names.data.parameters)
447        if sync(self._file.object_exists(pars_full_path)):
448            pars = sync(self._file.open_dataset(pars_full_path))
449            pars_names = sync(self._file.get_attr(pars, 'Name'))
450            return (pars, pars_names)
451        return (None, None)
452
453    def create_analysis_results_group(self, data_AntiStokes, data_Stokes=None, *,
454                                          index: int = None, name: str = None, fit_model: 'Data.AnalysisResults.FitModel' = None) -> AnalysisResults:
455        """
456        Adds a new AnalysisResults entry to the current data group.
457        Parameters:
458            data_AntiStokes (dict or list[dict]): see documentation for `brimfile.analysis_results.AnalysisResults.add_data`
459            data_Stokes (dict or list[dict]): same as data_AntiStokes for the Stokes peaks.
460            index (int, optional): The index for the new data entry. If None, the next available index is used. Defaults to None.
461            name (str, optional): The name for the new Analysis group. Defaults to None.
462            fit_model (Data.AnalysisResults.FitModel, optional): The fit model used for the analysis. Defaults to None (no attribute is set).
463        Returns:
464            AnalysisResults: The newly created AnalysisResults object.
465        Raises:
466            IndexError: If the specified index already exists in the dataset.
467            ValueError: If any of the data provided is not valid or consistent
468        """
469        if index is not None:
470            try:
471                self.get_analysis_results(index)
472            except IndexError:
473                pass
474            else:
475                # If the group already exists, raise an error
476                raise IndexError(
477                    f"Analysis {index} already exists in {self._path}")
478        else:
479            ar_groups = self.list_AnalysisResults()
480            indices = [ar['index'] for ar in ar_groups]
481            indices.sort()
482            index = indices[-1] + 1 if indices else 0  # Next available index
483
484        ar = Data.AnalysisResults._create_new(self, index=index, sparse=self._sparse)
485        if name is not None:
486            set_object_name(self._file, ar._path, name)
487        ar.add_data(data_AntiStokes, data_Stokes, fit_model=fit_model)
488
489        return ar
490
491    def list_AnalysisResults(self, retrieve_custom_name=False) -> list:
492        """
493        List all AnalysisResults groups in the current data group. The list is ordered by index.
494
495        Returns:
496            list: A list of dictionaries, each containing:
497                - 'name' (str): The name of the AnalysisResults group.
498                - 'index' (int): The index extracted from the group name.
499                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the AnalysisResults group as returned from utils.get_object_name.
500        """
501
502        analysis_results_groups = []
503
504        matched_objs = list_objects_matching_pattern(
505            self._file, self._group, brim_obj_names.data.analysis_results + r"_(\d+)$")
506        async def _make_dict_item(matched_obj, retrieve_custom_name):
507            name = matched_obj[0]
508            index = int(matched_obj[1])
509            curr_obj_dict = {'name': name, 'index': index}
510            if retrieve_custom_name:
511                ar_path = concatenate_paths(self._path, name)
512                custom_name = await get_object_name(self._file, ar_path)
513                curr_obj_dict['custom_name'] = custom_name
514            return curr_obj_dict
515        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
516        dicts = _gather_sync(*coros)
517        for dict_item in dicts:
518            analysis_results_groups.append(dict_item)
519        # Sort the data groups by index
520        analysis_results_groups.sort(key=lambda x: x['index'])
521
522        return analysis_results_groups
523
524    def get_analysis_results(self, index: int = 0) -> AnalysisResults:
525        """
526        Returns the AnalysisResults at the specified index
527
528        Args:
529            index (int)                
530
531        Raises:
532            IndexError: If there is no analysis with the corresponding index
533        """
534        name = None
535        ls = self.list_AnalysisResults()
536        for el in ls:
537            if el['index'] == index:
538                name = el['name']
539                break
540        if name is None:
541            raise IndexError(f"Analysis {index} not found")
542        path = concatenate_paths(self._path, name)
543        return Data.AnalysisResults(self._file, path, data_group_path=self._path,
544                                    spatial_map=self._spatial_map, spatial_map_px_size=self._spatial_map_px_size, sparse=self._sparse)
545
546    def _add_data(self, PSD: np.ndarray, frequency: np.ndarray, *, scanning: dict = None, freq_units='GHz',
547                  timestamp: np.ndarray = None, compression: FileAbstraction.Compression = FileAbstraction.Compression()):
548        """
549        Add data to the current data group.
550
551        This method adds the provided PSD, frequency, and scanning data to the HDF5 group 
552        associated with this `Data` object. It validates the inputs to ensure they meet 
553        the required specifications before adding them.
554
555        Args:
556            PSD (np.ndarray): A 2D numpy array representing the Power Spectral Density (PSD) data. The last dimension contains the spectra.
557            frequency (np.ndarray): A 1D or 2D numpy array representing the frequency data. 
558                It must be broadcastable to the shape of the PSD array.
559            scanning (dict, optional): A dictionary containing scanning-related data. 
560                Required for sparse data (sparse=True), optional for non-sparse data.
561                For sparse data, must include at least one of 'Spatial_map' or 'Cartesian_visualisation'.
562                It may include the following keys:
563                - 'Spatial_map' (optional): A dictionary containing coordinate arrays:
564                    - 'x', 'y', 'z' (optional): 1D numpy arrays of same length with coordinate values
565                    - 'units' (optional): string with the unit (e.g., 'um')
566                - 'Cartesian_visualisation' (optional): A 3D numpy array (z, y, x) with integer values 
567                   mapping spatial positions to spectra indices. Values must be -1 (invalid/empty pixel) 
568                   or between 0 and PSD.shape[0]-1.
569                - 'Cartesian_visualisation_pixel' (recommended with Cartesian_visualisation): 
570                   Tuple/list of 3 float values (z, y, x) representing pixel size. Unused dimensions can be None.
571                - 'Cartesian_visualisation_pixel_unit' (optional): String for pixel size unit (default: 'um').
572            timestamp (np.ndarray, optional): Timestamps in milliseconds for each spectrum.
573                Must be a 1D array with length equal to PSD.shape[0].
574
575
576        Raises:
577            ValueError: If any of the data provided is not valid or consistent
578        """
579
580        # Check if frequency is broadcastable to PSD
581        try:
582            np.broadcast_shapes(tuple(frequency.shape), tuple(PSD.shape))
583        except ValueError as e:
584            raise ValueError(f"frequency (shape: {frequency.shape}) is not broadcastable to PSD (shape: {PSD.shape}): {e}")
585
586        # Check if at least one of 'Spatial_map' or 'Cartesian_visualisation' is present in the scanning dictionary
587        # This is required for sparse data to establish the spatial mapping
588        has_spatial_mapping = False
589        if scanning is not None:
590            if 'Spatial_map' in scanning:
591                sm = scanning['Spatial_map']
592                size = 0
593
594                def check_coor(coor: str):
595                    if coor in sm:
596                        sm[coor] = np.array(sm[coor])
597                        size1 = sm[coor].size
598                        if size1 != size and size != 0:
599                            raise ValueError(
600                                f"'{coor}' in 'Spatial_map' is invalid!")
601                        return size1
602                    return size
603                size = check_coor('x')
604                size = check_coor('y')
605                size = check_coor('z')
606                if size == 0:
607                    raise ValueError(
608                        "'Spatial_map' should contain at least one x, y or z")
609                has_spatial_mapping = True
610            if 'Cartesian_visualisation' in scanning:
611                cv = scanning['Cartesian_visualisation']
612                if not isinstance(cv, np.ndarray) or cv.ndim != 3:
613                    raise ValueError(
614                        "Cartesian_visualisation must be a 3D numpy array")
615                if not np.issubdtype(cv.dtype, np.integer) or np.min(cv) < -1 or np.max(cv) >= PSD.shape[0]:
616                    raise ValueError(
617                        "Cartesian_visualisation values must be integers between -1 and PSD.shape[0]-1")
618                if 'Cartesian_visualisation_pixel' in scanning:
619                    if len(scanning['Cartesian_visualisation_pixel']) != 3:
620                        raise ValueError(
621                            "Cartesian_visualisation_pixel must always contain 3 values for z, y, x (set to None if not used)")
622                else:
623                    warnings.warn(
624                        "It is recommended to include 'Cartesian_visualisation_pixel' in the scanning dictionary to define pixel size for proper spatial calibration")
625                has_spatial_mapping = True
626        if not has_spatial_mapping and self._sparse:
627            raise ValueError("For sparse data, 'scanning' must be provided and must contain at least one of 'Spatial_map' or 'Cartesian_visualisation'")
628
629        if timestamp is not None:
630            if not isinstance(timestamp, np.ndarray) or timestamp.ndim != 1 or len(timestamp) != PSD.shape[0]:
631                raise ValueError("timestamp is not compatible with PSD")
632
633        # TODO: add and validate additional datasets (i.e. 'Parameters', 'Calibration_index', etc.)
634
635        # Add datasets to the group
636        def determine_chunk_size(arr: np.array) -> tuple:
637            """"
638            Use the same heuristic as the zarr library to determine the chunk size, but without splitting the last dimension
639            """
640            shape = arr.shape
641            typesize = arr.itemsize
642            #if the array is 1D, do not chunk it
643            if len(shape) <= 1:
644                return (shape[0],)
645            target_sizes = _guess_chunks.__kwdefaults__
646            # divide the target size by the last dimension size to get the chunk size for the other dimensions
647            target_sizes = {k: target_sizes[k] // shape[-1] 
648                            for k in target_sizes.keys()}
649            chunks = _guess_chunks(shape[0:-1], typesize, arr.nbytes, **target_sizes)
650            return chunks + (shape[-1],)  # keep the last dimension size unchanged
651        sync(self._file.create_dataset(
652            self._group, brim_obj_names.data.PSD, data=PSD,
653            chunk_size=determine_chunk_size(PSD), compression=compression))
654        freq_ds = sync(self._file.create_dataset(
655            self._group,  brim_obj_names.data.frequency, data=frequency,
656            chunk_size=determine_chunk_size(frequency), compression=compression))
657        units.add_to_object(self._file, freq_ds, freq_units)
658
659        if scanning is not None:
660            if 'Spatial_map' in scanning:
661                sm = scanning['Spatial_map']
662                sm_group = sync(self._file.create_group(concatenate_paths(
663                    self._path, brim_obj_names.data.spatial_map)))
664                if 'units' in sm:
665                    units.add_to_object(self._file, sm_group, sm['units'])
666
667                def add_sm_dataset(coord: str):
668                    if coord in sm:
669                        sync(self._file.create_dataset(
670                            sm_group, coord, data=sm[coord], compression=compression))
671
672                add_sm_dataset('x')
673                add_sm_dataset('y')
674                add_sm_dataset('z')
675            if 'Cartesian_visualisation' in scanning:
676                # convert the Cartesian_visualisation to the smallest integer type
677                cv_arr = np_array_to_smallest_int_type(scanning['Cartesian_visualisation'])
678                cv = sync(self._file.create_dataset(self._group, brim_obj_names.data.cartesian_visualisation,
679                                            data=cv_arr, compression=compression))
680                if 'Cartesian_visualisation_pixel' in scanning:
681                    sync(self._file.create_attr(
682                        cv, 'element_size', scanning['Cartesian_visualisation_pixel']))
683                    if 'Cartesian_visualisation_pixel_unit' in scanning:
684                        px_unit = scanning['Cartesian_visualisation_pixel_unit']
685                    else:
686                        warnings.warn(
687                            "No unit provided for Cartesian_visualisation_pixel, defaulting to 'um'")
688                        px_unit = 'um'
689                    units.add_to_attribute(self._file, cv, 'element_size', px_unit)
690
691        self._spatial_map, self._spatial_map_px_size = self._load_spatial_mapping()
692
693        if timestamp is not None:
694            sync(self._file.create_dataset(
695                self._group, 'Timestamp', data=timestamp, compression=compression))
696
697    @staticmethod
698    def list_data_groups(file: FileAbstraction, retrieve_custom_name=False) -> list:
699        """
700        List all data groups in the brim file. The list is ordered by index.
701
702        Returns:
703            list: A list of dictionaries, each containing:
704                - 'name' (str): The name of the data group in the file.
705                - 'index' (int): The index extracted from the group name.
706                - 'custom_name' (str, optional): if retrieve_custom_name==True, it contains the name of the data group as returned from utils.get_object_name.
707        """
708
709        data_groups = []
710
711        matched_objs = list_objects_matching_pattern(
712            file, brim_obj_names.Brillouin_base_path, brim_obj_names.data.base_group + r"_(\d+)$")
713        
714        async def _make_dict_item(matched_obj, retrieve_custom_name):
715            name = matched_obj[0]
716            index = int(matched_obj[1])
717            curr_obj_dict = {'name': name, 'index': index}
718            if retrieve_custom_name:
719                path = concatenate_paths(
720                    brim_obj_names.Brillouin_base_path, name)
721                custom_name = await get_object_name(file, path)
722                curr_obj_dict['custom_name'] = custom_name
723            return curr_obj_dict
724        
725        coros = [_make_dict_item(matched_obj, retrieve_custom_name) for matched_obj in matched_objs]
726        dicts = _gather_sync(*coros)
727        for dict_item in dicts:
728            data_groups.append(dict_item)        
729        # Sort the data groups by index
730        data_groups.sort(key=lambda x: x['index'])
731
732        return data_groups
733
734    @staticmethod
735    def _get_existing_group_name(file: FileAbstraction, index: int) -> str:
736        """
737        Get the name of an existing data group by index.
738
739        Args:
740            file (File): The parent File object.
741            index (int): The index of the data group.
742
743        Returns:
744            str: The name of the data group, or None if not found.
745        """
746        group_name: str = None
747        data_groups = Data.list_data_groups(file)
748        for dg in data_groups:
749            if dg['index'] == index:
750                group_name = dg['name']
751                break
752        return group_name
753
754    @classmethod
755    def _create_new(cls, file: FileAbstraction, index: int, sparse: bool = False, name: str = None) -> 'Data':
756        """
757        Create a new data group with the specified index.
758
759        Args:
760            file (File): The parent File object.
761            index (int): The index for the new data group.
762            sparse (bool): Whether the data is sparse. See https://github.com/brillouin-imaging/Brillouin-standard-file/blob/main/docs/brim_file_specs.md for details. Defaults to False.
763            name (str, optional): The name for the new data group. Defaults to None.
764
765        Returns:
766            Data: The newly created Data object.
767        """
768        group_name = Data._generate_group_name(index)
769        group = sync(file.create_group(concatenate_paths(
770            brim_obj_names.Brillouin_base_path, group_name)))
771        sync(file.create_attr(group, 'Sparse', sparse))
772        if name is not None:
773            set_object_name(file, group, name)
774        return cls(file, concatenate_paths(brim_obj_names.Brillouin_base_path, group_name), newly_created=True)
775
776    @staticmethod
777    def _generate_group_name(index: int, n_digits: int = None) -> str:
778        """
779        Generate a name for a data group based on the index.
780
781        Args:
782            index (int): The index for the data group.
783            n_digits (int, optional): The number of digits to pad the index with. If None no padding is applied. Defaults to None.
784
785        Returns:
786            str: The generated group name.
787
788        Raises:
789            ValueError: If the index is negative.
790        """
791        if index < 0:
792            raise ValueError("index must be positive")
793        num = str(index)
794        if n_digits is not None:
795            num = num.zfill(n_digits)
796        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/brillouin-imaging/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/brillouin-imaging/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, *, broadcast_frequency: bool = True) -> tuple:
245    def get_PSD_as_spatial_map(self, *, broadcast_frequency: bool = True) -> tuple:
246        """
247        Retrieve the Power Spectral Density (PSD) as a spatial map and the frequency from the current data group.
248        Arguments:
249            broadcast_frequency (bool): Whether to broadcast the frequency array to match the shape of the PSD if they have different shapes. 
250                This is useful when the frequency is the same for all spectra and thus stored as a 1D array, while the PSD has a spatial dimension. 
251                If False, the function will return a 1D array for the frequency, if the frequency is the same for all spectra.
252        Returns:
253            tuple: (PSD, frequency, PSD_units, frequency_units)
254                - PSD: A 4D (or more) numpy array containing all the spectra. Dimensions are z, y, x, [parameters], spectrum.
255                - frequency: A numpy array representing the frequency data, which has the same shape as PSD or a 1D array (see `broadcast_frequency`).
256                - PSD_units: The units of the PSD.
257                - frequency_units: The units of the frequency.
258        """
259        PSD, frequency = _gather_sync(
260            self._file.open_dataset(concatenate_paths(
261                self._path, brim_obj_names.data.PSD)),        
262            self._file.open_dataset(concatenate_paths(
263                self._path, brim_obj_names.data.frequency))
264            )        
265        # retrieve the units of the PSD and frequency
266        PSD_units, frequency_units = _gather_sync(
267            units.of_object(self._file, PSD),
268            units.of_object(self._file, frequency)
269        )
270
271        # ensure PSD and frequency are numpy arrays
272        PSD = np.array(PSD)  
273        frequency = np.array(frequency)  # ensure it's a numpy array
274        
275        # if the frequency is not the same for all spectra, broadcast it to match the shape of PSD
276        # if it is the same for all spectra, broadcast_frequency determines whether to return it as a 1D array or broadcast it to match the shape of PSD
277        if frequency.ndim > 1 or (broadcast_frequency and frequency.shape != PSD.shape):
278            frequency = np.broadcast_to(frequency, PSD.shape)
279        
280        if self._sparse:
281            if self._spatial_map is None:
282                raise ValueError("The data is defined as sparse, but no spatial mapping is provided.")
283            sm = np.array(self._spatial_map)
284            # reshape the PSD and frequency to have the spatial dimensions first      
285            PSD = PSD[sm, ...]
286            # reshape the frequency only if it is not the same for all spectra
287            if frequency.ndim > 1:
288                frequency = frequency[sm, ...]
289
290        return PSD, frequency, PSD_units, frequency_units

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

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

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

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

Retrieve a spectrum from the data group using spatial coordinates.

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

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

async def get_spectrum_and_all_quantities_in_image_async( self, ar: brimfile.analysis_results.AnalysisResults, coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
388    async def get_spectrum_and_all_quantities_in_image_async(self, ar: 'Data.AnalysisResults', coor: tuple, index_peak: int = 0) -> tuple[tuple, dict]:
389        """
390        Retrieve the spectrum and all available quantities from the analysis results at a specific spatial coordinate.
391
392        Args:
393            ar (Data.AnalysisResults): The analysis results object to retrieve quantities from.
394            coor (tuple): A tuple containing the z, y, x coordinates in the image.
395            index_peak (int, optional): The index of the peak to retrieve (for multi-peak fits). Defaults to 0.
396
397        Returns:
398            tuple: A tuple containing:
399                - spectrum (tuple): (PSD, frequency, PSD_units, frequency_units) at the specified coordinate
400                - quantities (dict): Dictionary of Metadata.Item in the form result[quantity.name][peak.name]
401        """
402        if len(coor) != 3:
403            raise ValueError("coor must contain 3 values for z, y, x")
404        index = coor
405        if self._sparse:
406            index = int(self._spatial_map[coor])
407        spectrum, quantities = await asyncio.gather(
408            self._get_spectrum_async(index),
409            ar._get_all_quantities_at_index(index, index_peak)
410        )
411        return spectrum, quantities

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

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

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

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

Rapresents the analysis results associated with a Data object.