Source code for exosim.utils.output_cleaners

import os
import shutil

import h5py

from exosim.log import with_logger


def _compact_hdf5_file(filename, logger=None):
    """
    Compact an HDF5 file by creating a new file with only the existing data.
    This physically removes deleted data and reduces file size.

    Parameters
    ----------
    filename : str
        Path to the HDF5 file to compact
    logger : logger object, optional
        Logger instance for logging messages
    """
    if not os.path.exists(filename):
        if logger:
            logger.error(f"File {filename} does not exist")
        return

    original_size = os.path.getsize(filename) / (1024**2)
    if logger:
        logger.info(
            f"Compacting HDF5 file: {filename} (original size: {original_size:.1f} MB)"
        )

    # Create temporary file for compacted version
    temp_file = filename + ".tmp"

    try:
        with h5py.File(filename, "r") as source, h5py.File(temp_file, "w") as dest:

            def copy_item(name, obj):
                if isinstance(obj, h5py.Group):
                    dest.create_group(name)
                    # Copy group attributes
                    for attr_name, attr_value in obj.attrs.items():
                        dest[name].attrs[attr_name] = attr_value
                elif isinstance(obj, h5py.Dataset):
                    # Copy dataset with same properties
                    dest.create_dataset_like(name, obj)
                    dest[name][...] = obj[...]
                    # Copy dataset attributes
                    for attr_name, attr_value in obj.attrs.items():
                        dest[name].attrs[attr_name] = attr_value

            # Copy all items
            source.visititems(copy_item)

            # Copy root attributes
            for attr_name, attr_value in source.attrs.items():
                dest.attrs[attr_name] = attr_value

        # Replace original with compacted version
        shutil.move(temp_file, filename)

        compacted_size = os.path.getsize(filename) / (1024**2)
        reduction = original_size - compacted_size
        reduction_pct = (reduction / original_size * 100) if original_size > 0 else 0

        if logger:
            logger.info(
                f"File compacted: {compacted_size:.1f} MB (reduced by {reduction:.1f} MB, {reduction_pct:.1f}%)"
            )

    except Exception as e:
        # Clean up temp file if something went wrong
        if os.path.exists(temp_file):
            os.remove(temp_file)
        if logger:
            logger.error(f"Error compacting file {filename}: {e}")
        raise


@with_logger
[docs] def prune_output( out, logger=None, folders_to_remove=None, folders_to_keep=None, root_groups_to_keep=None, compact_file=True, ) -> None: """ It prunes the output file, removing unnecessary data to reduce its size. After pruning, the file is automatically compacted to physically remove deleted data. Parameters ---------- out: :class:`~exosim.output.output.OutputGroup` output group logger: logger object (optional) logger instance for logging messages folders_to_remove: list of str (optional) list of folder names to remove entirely from the output (searched recursively) folders_to_keep: list of str (optional) list of folder names to keep entirely in the output root_groups_to_keep: list of str (optional) list of root-level groups to keep (defaults to standard groups) compact_file: bool (optional) if True, recompacts the file after pruning to physically reduce file size (default: True) """ logger.info("pruning output to reduce size") # Get the target group to work on if hasattr(out, "_group") and out._group is not None: target_group = out._group group_path = ( target_group.name if hasattr(target_group, "name") else "target_group" ) else: target_group = out.fd if hasattr(out, "fd") and out.fd is not None else None group_path = "root" if target_group is None or not target_group.id.valid: logger.error("No valid HDF5 group or file found") return logger.debug(f"Working on group: {group_path}") # STEP 1: Remove folders_to_remove recursively throughout the entire structure def remove_folders_recursively(group, folders_to_delete, path=""): """Recursively remove specified folders from entire HDF5 structure""" deleted_count = 0 # Initialize the counter for deleted folders if not isinstance(group, h5py.Group) or not folders_to_delete: return deleted_count # Validate group before accessing keys if not group.id.valid: logger.error(f"Invalid group ID for path: {path}") return deleted_count keys_to_check = list(group.keys()) for key in keys_to_check: current_path = f"{path}/{key}" if path else key # Delete if key matches any folder to remove if key in folders_to_delete: try: logger.debug(f"Deleting folder: {current_path}") del group[key] deleted_count += 1 continue # Skip recursion since we deleted it except KeyError: logger.warning(f"Folder '{current_path}' does not exist. Skipping.") continue except Exception as e: logger.error(f"Error deleting folder '{current_path}': {e}") # Recurse into subgroups try: item = group[key] if isinstance(item, h5py.Group): deleted_count += remove_folders_recursively( item, folders_to_delete, current_path ) except KeyError: logger.warning(f"Subgroup '{current_path}' does not exist. Skipping.") except Exception as e: logger.debug(f"Could not process subgroup {current_path}: {e}") return deleted_count # Remove unwanted folders first total_deleted = 0 if folders_to_remove: folders_to_delete = set(folders_to_remove) total_deleted = remove_folders_recursively( target_group, folders_to_delete, group_path ) if total_deleted > 0: logger.info( f"Deleted {total_deleted} folders matching: {folders_to_remove}" ) # STEP 2: Apply standard pruning rules (optional - can be simplified or removed) # Define what to keep at root level if root_groups_to_keep is None: root_groups_to_keep = {"info", "configuration", "radiometric", "channels"} else: root_groups_to_keep = set(root_groups_to_keep) if folders_to_keep: root_groups_to_keep.update(folders_to_keep) # Helper function to check if a group contains channels (directly or nested) def contains_channels(group): """Recursively check if a group contains a 'channels' subgroup""" if not isinstance(group, h5py.Group): return False # Check direct children if "channels" in group: return True # Check nested groups for key in group: item = group.get(key) if isinstance(item, h5py.Group) and contains_channels(item): return True return False # Remove any remaining unwanted root-level groups # But keep groups that contain channels subgroups current_keys = list(target_group.keys()) deleted_groups = [] for key in current_keys: if key not in root_groups_to_keep: # Check if this group contains channels before deleting item = target_group.get(key) if isinstance(item, h5py.Group) and contains_channels(item): logger.debug(f"Keeping root group '{key}' because it contains channels") continue try: logger.debug(f"Deleting root group: {key}") del target_group[key] deleted_groups.append(key) except Exception as e: logger.error(f"Error deleting root group '{key}': {e}") if deleted_groups: logger.debug(f"Deleted {len(deleted_groups)} root groups: {deleted_groups}") # STEP 3: Clean up channel datasets - keep only essential data def clean_channel_datasets(group, path=""): """Remove non-essential datasets from channel groups""" essential_datasets = { "focal_plane", "efficiency", "frg_focal_plane", "bkg_focal_plane", "responsivity", } if not isinstance(group, h5py.Group): return 0 deleted_count = 0 # Check if this is a channel group (has datasets that match essential ones) keys = list(group.keys()) datasets = [k for k in keys if isinstance(group.get(k), h5py.Dataset)] # If we have some essential datasets, assume this is a channel and clean it has_essential = any(d in essential_datasets for d in datasets) if has_essential and datasets: for key in keys: item = group.get(key) if isinstance(item, h5py.Dataset) and key not in essential_datasets: try: logger.debug(f"Deleting channel dataset: {path}/{key}") del group[key] deleted_count += 1 except Exception as e: logger.error(f"Error deleting dataset '{path}/{key}': {e}") # Recurse into subgroups for key in list(group.keys()): item = group.get(key) if isinstance(item, h5py.Group): current_path = f"{path}/{key}" if path else key deleted_count += clean_channel_datasets(item, current_path) return deleted_count # Clean channel datasets deleted_datasets = clean_channel_datasets(target_group, group_path) if deleted_datasets > 0: logger.debug(f"Deleted {deleted_datasets} non-essential channel datasets") # Flush changes if hasattr(out, "fd") and out.fd is not None: out.fd.flush() # STEP 4: Compact the file if requested if compact_file: filename = None if hasattr(out, "fname"): filename = out.fname elif hasattr(out, "filename"): filename = out.filename if filename: logger.info(f"Starting file compaction for: {filename}") # Close the file first if it's open if hasattr(out, "fd") and out.fd is not None: out.fd.close() _compact_hdf5_file(filename, logger) else: logger.warning("Cannot compact file: could not determine filename") else: logger.debug("File compaction skipped (compact_file=False)") logger.info(f"Output pruning completed for {group_path}")