Source code for pyfli.io.data_operations

"""
Load raw decay, IRF, background, mask, and hot-pixel data from common FLI sources.

This module belongs to :mod:`pyfli.io` and is part of PyFLI detector importers, file
readers, saving helpers, and processed-data loaders. Public API includes classes
:class:`DataOperations`.
"""

import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any

import numpy as np
from tqdm import tqdm

from pyfli import logging

# Import the static logic from your utility file
from .data_ops_static import StaticDataOps as ds


[docs] class DataOperations: """ Load primary data, IRF, background, masks, and hot-pixel maps from common FLI file formats. It provides a path-centric interface for raw loading, correction, and packaging into PyFLI-ready structures. Parameters ---------- data_path : str | None Path to the primary decay data source. irf_path : str | None Path to the instrument response data source. bg_path : str | None Path to the background measurement used for subtraction or correction. mask_path : str | None Path to a binary or labeled mask used to select valid pixels. hp_path : str | None Path to a hot-pixel mask or image used for interpolation. """ def __init__( self, data_path: str | None = None, irf_path: str | None = None, bg_path: str | None = None, mask_path: str | None = None, hp_path: str | None = None, ) -> None: self.data_path = data_path self.irf_path = irf_path self.bg_path = bg_path self.mask_path = mask_path self.hp_path = hp_path # --- MODULAR REGISTRY --- self.loader_registry = { ".mat": ds.load_mat_file, ".sdt": ds.load_sdt_file, ".tif": ds.load_tiff_file, ".tiff": ds.load_tiff_file, ".npy": ds.load_npy_file, ".txt": ds.load_txt_file, ".asc": ds.load_asc_file, }
[docs] def load_data( self, sub_bg: bool = True, pile_up: bool = False, hot_pixel: bool = False ) -> Any: """ Load data. Parameters ---------- sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. Returns ------- Any Object produced by load data. """ logging.info(f"Initiating DATA load from: {self.data_path}") return self._general_loader( self.data_path, sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel, label="DATA", )
[docs] def load_background(self, pile_up: bool = False, hot_pixel: bool = False) -> Any: """Loads background. If folder, returns the mean average of all files.""" if self.bg_path and os.path.isdir(self.bg_path): logging.info(f"Background FOLDER detected: {self.bg_path}") return self._load_from_folder( self.bg_path, sub_bg=False, pile_up=pile_up, hot_pixel=True, mode="mean", is_background=True, label="BG", ) if self.bg_path: logging.info(f"Background FILE detected: {self.bg_path}") return self._general_loader( self.bg_path, sub_bg=False, pile_up=pile_up, hot_pixel=hot_pixel, label="BG", ) logging.info("No background path provided.") return None
[docs] def load_irf( self, sub_bg: bool = False, pile_up: bool = False, hot_pixel: bool = False ) -> Any: """ Load irf. Parameters ---------- sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. Returns ------- Any Object produced by load IRF. """ logging.info(f"Initiating IRF load from: {self.irf_path}") return self._general_loader( self.irf_path, sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel, label="IRF", )
[docs] def load_all_parallel( self, sub_bg: bool = True, pile_up: bool = False, hot_pixel: bool = False ) -> tuple[Any, ...]: """ Load all parallel. Parameters ---------- sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. Returns ------- tuple[Any, ...] Tuple containing loaded data arrays, labels, and file metadata. """ logging.info("Starting synchronized parallel loading for DATA, IRF, and BG...") with ThreadPoolExecutor(max_workers=3) as executor: data_future = executor.submit( self.load_data, sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel ) irf_future = executor.submit( self.load_irf, sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel ) bg_future = executor.submit( self.load_background, pile_up=pile_up, hot_pixel=hot_pixel ) return data_future.result(), irf_future.result(), bg_future.result()
[docs] def make_dataset( self, name: str = "Experiment_1", source: str = "ICCD", sub_bg: bool = True, pile_up: bool = False, hot_pixel: bool = False, ) -> dict[Any, Any]: # Fix 2: Check for dimension consistency """ Create dataset. Parameters ---------- name : str Dataset, experiment, figure, or output name. source : str Source label recorded with the loaded dataset. sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. Returns ------- dict[Any, Any] Dictionary containing the data produced by make dataset. """ if all([self.data_path, self.irf_path, self.bg_path]): data, irf, background = self.load_all_parallel( sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel ) else: background = ( self.load_background(pile_up=pile_up, hot_pixel=hot_pixel) if self.bg_path else None ) data = ( self.load_data(sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel) if self.data_path else None ) irf = ( self.load_irf(sub_bg=sub_bg, pile_up=pile_up, hot_pixel=hot_pixel) if self.irf_path else None ) mask = self.load_mask() if data is not None and irf is not None: if data.shape[-1] != irf.shape[-1]: logging.warning( f"[WARN] Temporal dimension mismatch! DATA: {data.shape[-1]}, IRF: {irf.shape[-1]}" ) return { "name": name, "source": source, "raw_data": { "decay": data, "irf": irf, "background": background, "mask": mask, }, "metadata": { "shape": data.shape if data is not None else None, "processing": { "bg_sub": sub_bg, "pile_up": pile_up, "hot_pixel": hot_pixel, }, }, "result": { "maps": {"tau1_map": None, "tau2_map": None}, "TR_maps": {"fit_map": None, "residuals_maps": None}, }, }
[docs] def load_mask(self) -> Any: """ Load mask. Returns ------- Any Object produced by load mask. """ if not self.mask_path: return None logging.info(f"Loading mask from: {self.mask_path}") mask = self._load_single_file(self.mask_path) if mask is None: return None if mask.ndim == 3: mask = np.mean(mask, axis=-1) return (mask > np.min(mask)).astype(bool)
def _general_loader( self, path: str, sub_bg: bool = True, pile_up: bool = False, hot_pixel: bool = False, label: str = "Data", ) -> Any: """ Run the general loader routine. Parameters ---------- path : str Filesystem path loaded or saved by the routine. sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. label : str Display label assigned to the data or plot element. Returns ------- Any Object produced by general loader. """ if not path or not os.path.exists(path): abs_path = os.path.abspath(path) if path else "(None)" logging.error(f"[ERROR] {label} path not found: {abs_path}") return None if os.path.isfile(path): return self._load_single_file(path, pile_up, hot_pixel) else: return self._load_from_folder(path, sub_bg, pile_up, hot_pixel, label=label) def _load_single_file( self, file_path: str, pile_up: bool = False, hot_pixel: bool = False, active_hp: np.ndarray | None = None, ) -> Any: """ Load single file. Parameters ---------- file_path : str Path to the file being loaded. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. active_hp : np.ndarray | None Hot-pixel mask currently applied to the loaded data. Returns ------- Any Object produced by load single file. """ ext = os.path.splitext(file_path)[-1].lower() active_hp = active_hp or self.hp_path # Fix 3: Path validation for hot pixel mask if hot_pixel and (active_hp is None or not os.path.exists(active_hp)): logging.warning( f"[WARN] Hot-pixel correction skipped for {os.path.basename(file_path)}: hp_path invalid." ) hot_pixel = False if ext in (".hdf5", ".h5"): return ds.SS3HDF5read( file_path, pileCorr=pile_up, hot_pixels=hot_pixel, hp_path=active_hp ) loader_func = self.loader_registry.get(ext) if not loader_func: return None try: data_content = loader_func(file_path) if data_content is not None: # Fix 4: Pre-cast to float32 for processing safety data_content = data_content.astype(np.float32) if pile_up: data_content = ds.pileup_correction(data_content) if hot_pixel: data_content = ds.apply_interpolation_mask( data_content, hp_path=active_hp ) return data_content except Exception as e: logging.error(f"[ERROR] Failed to load {file_path}: {e}") return None def _load_from_folder( self, folder_path: str, sub_bg: bool = True, pile_up: bool = False, hot_pixel: bool = False, active_hp: np.ndarray | None = None, mode: str = "sum", is_background: bool = False, label: str = "Data", ) -> Any: """ Load from folder. Parameters ---------- folder_path : str Directory containing detector files to load. sub_bg : bool Whether background subtraction is applied. pile_up : bool Whether pile-up correction should be applied. hot_pixel : bool Whether hot-pixel correction should be applied. active_hp : np.ndarray | None Hot-pixel mask currently applied to the loaded data. mode : str Mode selector used by the fitting, loading, or plotting routine. is_background : bool Whether the file should be loaded as a background measurement. label : str Display label assigned to the data or plot element. Returns ------- Any Object produced by load from folder. """ valid_exts = (".tif", ".tiff", ".hdf5", ".h5") files = sorted( [f for f in os.listdir(folder_path) if f.lower().endswith(valid_exts)] ) if not files: raise FileNotFoundError(f"No valid files found in {folder_path}") # Strict Requirement: HDF5 folders force correction True if any(f.lower().endswith((".hdf5", ".h5")) for f in files): pile_up, hot_pixel = True, True logging.info("[INFO] HDF5 folder detected. Corrections forced to True.") full_paths = [os.path.join(folder_path, f) for f in files] bg_avg = ( self.load_background(pile_up=pile_up, hot_pixel=hot_pixel) if (sub_bg and not is_background) else None ) first = self._load_single_file(full_paths[0], pile_up, hot_pixel, active_hp) if first is None: return None # Pre-allocate as float32 stack = np.zeros((*first.shape, len(files)), dtype=np.float32) stack[..., 0] = first if len(files) > 1: task_args = [ (i, p, pile_up, hot_pixel, active_hp or self.hp_path) for i, p in enumerate(full_paths[1:], start=1) ] with ThreadPoolExecutor(max_workers=os.cpu_count()) as executor: results = list( tqdm( executor.map(self._load_single_file_parallel, task_args), total=len(task_args), desc=f"Loading {label}", leave=False, ) ) for idx, res_data in results: if res_data is not None and res_data.shape == first.shape: # FIXED: Use ellipsis to target the last axis for 'idx' stack[..., idx] = res_data # Fix 1: Subtraction with zero-floor if bg_avg is not None: for i in range(stack.shape[-1]): if bg_avg.shape == stack[..., i].shape: stack[..., i] -= bg_avg stack = np.maximum(stack, 0) if is_background: return np.mean(stack, axis=-1) if stack.ndim == 4: return np.sum(stack, axis=-1) if mode == "sum" else np.mean(stack, axis=-1) return stack def _load_single_file_parallel(self, args: Any) -> tuple[Any, ...]: """ Load single file parallel. Parameters ---------- args : Any Worker argument tuple passed to the parallel file-processing helper. Returns ------- tuple[Any, ...] Tuple containing loaded file data and file metadata. """ idx, path, pile_up, hot_pixel, active_hp = args res_data = self._load_single_file(path, pile_up, hot_pixel, active_hp) return idx, res_data