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