Source code for ctapipe.monitoring.aggregator

"""
Algorithms to compute aggregated time-series statistics from columns of an astropy table.

These classes take as input an events table containing any event-wise quantities
(e.g., images, scalars, vectors), divide it into time chunks, which may optionally
overlap, and compute various aggregated statistics for each chunk. The statistics
include the count, mean, median, and standard deviation. The result is a monitoring
table with columns describing the start and stop time of the chunk and the
aggregated statistic values.

The aggregation is always performed along axis=0 (the event dimension), making
these classes suitable for any N-dimensional event-wise data.
"""

__all__ = [
    "BaseChunking",
    "SizeChunking",
    "TimeChunking",
    "BaseAggregator",
    "StatisticsAggregator",
    "PlainAggregator",
    "SigmaClippingAggregator",
]

from abc import ABCMeta, abstractmethod
from collections import defaultdict
from collections.abc import Generator

import astropy.units as u
import numpy as np
from astropy.stats import sigma_clip
from astropy.table import Table

from ..containers import ChunkStatisticsContainer
from ..core import Component
from ..core.traits import AstroQuantity, Bool, ComponentName, Enum, Int


[docs] class BaseChunking(Component, metaclass=ABCMeta): """ Abstract base class for chunking strategies. Chunking components divide tables into overlapping or non-overlapping chunks for processing by aggregators. """ allow_undersized_tables = Bool( default_value=False, help=( "If True, allow processing tables smaller than chunk size/duration by yielding " "the entire table as a single chunk. If False, raise an error." ), ).tag(config=True) last_chunk_policy = Enum( values=["overlap", "truncate", "skip"], default_value="overlap", help=( "Policy for handling the last chunk when data doesn't divide evenly. " "'overlap': Create overlapping chunk with full size (default behavior). " "'truncate': Yield remaining data as smaller chunk. " "'skip': Skip the last partial chunk." ), ).tag(config=True)
[docs] def __call__(self, table) -> Generator[Table, None, None]: """ Generate chunks from the input table. Parameters ---------- table : astropy.table.Table Input table with 'time' column. Yields ------ astropy.table.Table Chunks of the input table. Each chunk is a view/reference to the original table data, meaning modifications to chunk data will affect the original table. """ # Basic validation that all chunking strategies need if "time" not in table.colnames and ( "time_start" not in table.colnames or "time_end" not in table.colnames ): raise ValueError( "Table must have a 'time' column or both 'time_start' and 'time_end' columns for chunking." ) yield from self._generate_chunks(table)
@abstractmethod def _generate_chunks(self, table) -> Generator[Table, None, None]: """Generate chunks from table. Implemented by subclasses.""" pass
[docs] class SizeChunking(BaseChunking): """Divides tables into chunks based on number of rows.""" chunk_size = Int( default_value=None, allow_none=True, help="Number of rows per chunk. If None, use entire table as one chunk.", ).tag(config=True) chunk_shift = Int( default_value=None, allow_none=True, help=( "Number of rows to shift between consecutive chunks. " "If None, chunks do not overlap." ), ).tag(config=True) def _generate_final_chunk(self, table, last_chunk_start): """Generate the final chunk according to last_chunk_policy.""" remaining_rows = len(table) - last_chunk_start # Only add final chunk if there are remaining rows if remaining_rows == 0: return None if self.last_chunk_policy == "overlap": # Ensure last chunk has full size by potentially overlapping return table[-self.chunk_size :] elif self.last_chunk_policy == "truncate": # Yield remaining rows as smaller chunk return table[last_chunk_start:] elif self.last_chunk_policy == "skip": # Skip the last partial chunk return None return None def _generate_chunks(self, table) -> Generator[Table, None, None]: """Generate row-count based chunks.""" # Handle case where chunk_size is None (entire table) if self.chunk_size is None: yield table return # Check table size vs chunk_size if len(table) < self.chunk_size: if self.allow_undersized_tables: yield table # Yield entire table as single chunk return else: raise ValueError( f"Table length ({len(table)}) is less than chunk_size ({self.chunk_size}). " f"Set allow_undersized_tables=True to process as single chunk." ) # Calculate step size step = self.chunk_shift if self.chunk_shift is not None else self.chunk_size # Calculate all main chunk start indices (not extending beyond table) n_chunks = (len(table) - self.chunk_size) // step + 1 main_chunk_indices = np.arange(n_chunks) * step # Filter indices that would create valid chunks main_chunk_indices = main_chunk_indices[ main_chunk_indices + self.chunk_size <= len(table) ] last_chunk_start = main_chunk_indices[-1] + step # Generate chunks for each main chunk start index for start_idx in main_chunk_indices: end_idx = start_idx + self.chunk_size yield table[start_idx:end_idx] # Handle final chunk according to policy final_chunk = self._generate_final_chunk(table, last_chunk_start) if final_chunk is not None: yield final_chunk
[docs] class TimeChunking(BaseChunking): """Divides tables into chunks based on time intervals.""" chunk_duration = AstroQuantity( physical_type=u.s, default_value=0 * u.s, help="Duration of each time chunk.", ).tag(config=True) chunk_shift = AstroQuantity( physical_type=u.s, default_value=0 * u.s, allow_none=True, help=("Time shift between consecutive chunks. If 0, chunks do not overlap."), ).tag(config=True) def _validate_inputs(self, total_duration): """Validate chunk_duration and table duration.""" chunk_duration = self.chunk_duration.to_value(u.s) # Check if chunk_duration is properly set if chunk_duration <= 0: raise ValueError("chunk_duration must be greater than zero.") # Check if total duration is sufficient for chunking if total_duration < chunk_duration: if self.allow_undersized_tables: return True # Signal to yield entire table as single chunk raise ValueError( f"Total duration ({total_duration * u.s}) is less than chunk_duration ({self.chunk_duration}). " f"Set allow_undersized_tables=True to process as single chunk." ) return False # Normal processing def _generate_final_chunk(self, table, last_chunk_idx): """Generate the final chunk according to last_chunk_policy.""" if last_chunk_idx == len(table): return None if self.last_chunk_policy == "overlap": # Ensure last chunk has full duration by potentially overlapping last_chunk_idx = np.searchsorted( table["time"], table["time"][-1] - self.chunk_duration, side="left" ) return table[last_chunk_idx:] elif self.last_chunk_policy == "truncate": # Yield remaining time as smaller chunk return table[last_chunk_idx:] elif self.last_chunk_policy == "skip": # Skip the last partial chunk return None def _generate_chunks(self, table) -> Generator[Table, None, None]: """Generate time-based chunks.""" # Handle case where chunk_duration is None (entire table) if self.chunk_duration is None: yield table return times = table["time"] relative_times = (times - times[0]).to_value(u.s) # Validate inputs and handle undersized tables use_entire_table = self._validate_inputs(relative_times[-1]) if use_entire_table: yield table # Yield entire table as single chunk return # Calculate time step time_step = ( self.chunk_shift if self.chunk_shift > 0 * u.s else self.chunk_duration ).to_value(u.s) chunk_start_time = np.arange( 0, relative_times[-1] - self.chunk_duration.to_value(u.s), time_step, ) chunk_end_time = chunk_start_time + self.chunk_duration.to_value(u.s) chunk_start_idx = np.searchsorted(relative_times, chunk_start_time) chunk_end_idx = np.searchsorted(relative_times, chunk_end_time, side="right") # Generate chunks for each main chunk start time for i, j in zip(chunk_start_idx, chunk_end_idx): chunk = table[i:j] if chunk is not None: yield chunk # Handle final chunk according to policy last_chunk_idx = np.searchsorted( relative_times, chunk_start_time[-1] + time_step ) final_chunk = self._generate_final_chunk(table, last_chunk_idx) if final_chunk is not None: yield final_chunk
[docs] class BaseAggregator(Component, metaclass=ABCMeta): """ Base class for aggregators that compute statistics over chunks of data. Aggregators use a chunking strategy to divide input tables and compute aggregated statistics for each chunk. """ chunking_type = ComponentName( BaseChunking, default_value="SizeChunking", help="The chunking strategy to use for dividing data into chunks.", ).tag(config=True) def __init__(self, config=None, parent=None, **kwargs): """ Parameters ---------- config : traitlets.loader.Config Configuration specified by config file or cmdline arguments parent : ctapipe.core.Component or ctapipe.core.Tool Parent of this component in the configuration hierarchy """ super().__init__(config=config, parent=parent, **kwargs) # Create the chunking component using ComponentName self.chunking = BaseChunking.from_name(self.chunking_type, parent=self)
[docs] def __call__( self, table, masked_elements_of_sample=None, col_name="image", ) -> Table: r""" Divide table into chunks and compute aggregated statistic values. Parameters ---------- table : astropy.table.Table table with event-wise data of shape (n_events, \*data_dimensions), event IDs and timestamps of shape (n_events, ) masked_elements_of_sample : ndarray, optional boolean array of masked elements of shape (\*data_dimensions) that are not available for processing col_name : string column name in the table containing the event-wise data to aggregate Returns ------- astropy.table.Table table containing the start and end values as timestamps and event IDs as well as the aggregated statistic values for each chunk """ # Get chunks using the chunking strategy chunks = self.chunking(table) # Initialize result storage results = defaultdict(list) # Process each chunk for chunk in chunks: # Add time metadata if "time_start" in chunk.colnames and "time_end" in chunk.colnames: results["time_start"].append(chunk["time_start"][0]) results["time_end"].append(chunk["time_end"][-1]) else: results["time_start"].append(chunk["time"][0]) results["time_end"].append(chunk["time"][-1]) # Add event_id metadata if column exists if "event_id" in chunk.colnames: results["event_id_start"].append(chunk["event_id"][0]) results["event_id_end"].append(chunk["event_id"][-1]) # Compute aggregator-specific statistics self._add_result_columns( chunk[col_name].data, masked_elements_of_sample, results ) # Create and return table result_table = Table(results) # Preserve units if present if hasattr(table[col_name], "unit") and table[col_name].unit is not None: self._set_result_units(result_table, table[col_name].unit) return result_table
@abstractmethod def _add_result_columns(self, data, masked_elements_of_sample, results_dict): r""" Compute statistics and add columns to results dictionary. Parameters ---------- data : array-like Data for this chunk (already extracted from table) masked_elements_of_sample : ndarray, optional Boolean mask of shape (\*data_dimensions) for elements to exclude results_dict : dict Dictionary to which statistic columns should be added. """ pass @abstractmethod def _set_result_units(self, table, unit): """Set units for result columns that should inherit from input data.""" pass
[docs] class StatisticsAggregator(BaseAggregator): """ Base component to handle the computation of aggregated statistic values from a table containing any event-wise quantities (e.g., images, scalars, vectors, or other arrays). Aggregation is performed along axis=0 (the event dimension) for any N-dimensional data. """ def _add_result_columns(self, data, masked_elements_of_sample, results_dict): stats = self.compute_stats(data, masked_elements_of_sample) results_dict["n_events"].append(stats.n_events) results_dict["mean"].append(stats.mean) results_dict["median"].append(stats.median) results_dict["std"].append(stats.std) def _set_result_units(self, table, unit): """ Set units for statistics columns that inherit from the input data. For StatisticsAggregator, the mean, median, and std columns should have the same units as the input data. """ for col in ("mean", "median", "std"): table[col].unit = unit
[docs] @abstractmethod def compute_stats( self, data, masked_elements_of_sample ) -> ChunkStatisticsContainer: r""" Compute aggregated statistics for a chunk of data. Parameters ---------- data : ndarray Event-wise data of shape (n_events, \*data_dimensions) masked_elements_of_sample : ndarray, optional Boolean mask of shape (\*data_dimensions) for elements to exclude Returns ------- StatisticsContainer Container with computed statistics """ pass
[docs] class PlainAggregator(StatisticsAggregator): """ Compute aggregated statistic values from a chunk of event-wise data using numpy functions. Works with any N-dimensional event-wise data by aggregating along axis=0 (event dimension). """
[docs] def compute_stats( self, data, masked_elements_of_sample ) -> ChunkStatisticsContainer: # Mask excluded elements and NaN/inf values masked_data = np.ma.array(data, mask=masked_elements_of_sample) masked_data = np.ma.masked_invalid(masked_data) # Compute the mean, median, and std over the event dimension (axis=0) element_mean = np.ma.mean(masked_data, axis=0) element_median = np.ma.median(masked_data, axis=0) element_std = np.ma.std(masked_data, axis=0) # For 1D data, these operations return scalars (not MaskedArrays) # Convert to array and fill masked values with NaN element_mean = np.ma.filled(np.ma.asarray(element_mean), np.nan) element_median = np.ma.filled(np.ma.asarray(element_median), np.nan) element_std = np.ma.filled(np.ma.asarray(element_std), np.nan) # Count non-masked events per element (excludes both masked and NaN/inf values) n_events_per_element = np.count_nonzero(~masked_data.mask, axis=0) return ChunkStatisticsContainer( n_events=n_events_per_element, mean=element_mean, median=element_median, std=element_std, )
[docs] class SigmaClippingAggregator(StatisticsAggregator): """ Compute aggregated statistic values from a chunk of event-wise data using astropy's sigma clipping functions. Works with any N-dimensional event-wise data by aggregating along axis=0 (event dimension) while removing outliers using sigma clipping. """ max_sigma = Int( default_value=4, help="Maximal value for the sigma clipping outlier removal", ).tag(config=True) iterations = Int( default_value=5, help="Number of iterations for the sigma clipping outlier removal", ).tag(config=True)
[docs] def compute_stats( self, data, masked_elements_of_sample ) -> ChunkStatisticsContainer: # Mask excluded elements and NaN/inf values masked_data = np.ma.array(data, mask=masked_elements_of_sample) masked_data = np.ma.masked_invalid(masked_data) # Use sigma_clip to get the clipped data, then compute stats from it # Clipping is performed along axis=0 (event dimension) filtered_data = sigma_clip( masked_data, sigma=self.max_sigma, maxiters=self.iterations, cenfunc="mean", axis=0, ) # Count the number of events remaining after sigma clipping per element # (excludes both masked, NaN/inf, and sigma-clipped values) n_events_after_clipping = np.count_nonzero(~filtered_data.mask, axis=0) # Compute statistics from the filtered data along the event dimension element_mean = np.ma.mean(filtered_data, axis=0) element_median = np.ma.median(filtered_data, axis=0) element_std = np.ma.std(filtered_data, axis=0) # For 1D data, these operations return scalars (not MaskedArrays) # Convert to array and fill masked values with NaN element_mean = np.ma.filled(np.ma.asarray(element_mean), np.nan) element_median = np.ma.filled(np.ma.asarray(element_median), np.nan) element_std = np.ma.filled(np.ma.asarray(element_std), np.nan) return ChunkStatisticsContainer( n_events=n_events_after_clipping, mean=element_mean, median=element_median, std=element_std, )