Source code for exosim.tasks.detector.addCosmicRays

from copy import deepcopy
from typing import List, Tuple

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

from exosim.models.signal import Signal
from exosim.output import Output
from exosim.tasks.task import Task
from exosim.utils import RunConfig
from exosim.utils.checks import check_units
from exosim.utils.iterators import iterate_over_chunks
from exosim.utils.types import ArrayType


[docs]class AddCosmicRays(Task): """ Task to simulate the addition of cosmic rays to sub-exposures in a detector. This task models the impact of cosmic rays on a detector during the exposure time. The model assumes that the cosmic rays can interact with the detector pixels in various predefined shapes like a cross, horizontal rectangle, and vertical rectangle. The number of cosmic ray events and their impact on the detector pixels are calculated based on the given cosmic ray flux, detector characteristics, and integration times for the sub-exposures. Notes ----- - This task assumes that the cosmic ray events saturate the affected pixels, setting their value to the full well depth. - The probabilities for the interaction shapes are configurable. The task issues a warning if the sum of provided probabilities is not 1. - The cosmic ray flux is specified in ct/s/cm^2 and is scaled based on the pixel size and detector dimensions. This is a default class with standardised inputs and outputs. The user can load this class and overwrite the "model" method to implement a custom Task to replace this. """ def __init__(self): """ Parameters ---------- subexposures: :class:`~exosim.models.signal.Counts` sub-exposures cached signal parameters: dict channel parameters dictionary integration_times: :class:`~astropy.units.Quantity` sub-exposures integration times outputs: :class:`~exosim.output.output.Output` (optional) output file """ self.add_task_param("subexposures", "sub-exposures cached signal") self.add_task_param("parameters", "channel parameters dictionary") self.add_task_param( "integration_times", "subexposures integration times", ) self.add_task_param("output", "output file", None)
[docs] def execute(self): self.info("adding cosmic rays") subexposures = self.get_task_param("subexposures") parameters = self.get_task_param("parameters") integration_times = self.get_task_param("integration_times") output = self.get_task_param("output") self.model(subexposures, parameters, integration_times, output)
[docs] def model( self, subexposures: Signal, parameters: dict, integration_times: ArrayType, output=None, ) -> None: """ Default model to simulate the addition of cosmic rays to the sub-exposures. This method saturates the hit pixels in the sub-exposure data based on the cosmic ray rate, detector properties, and the given integration times. The cosmic ray interactions could be in various shapes like single pixel, lines, squares, etc., which are defined in the configuration. Parameters ---------- subexposures : Signal The sub-exposures' cached signal. parameters : dict The channel parameters dictionary containing detector information. integration_times : ArrayType The integration times for the sub-exposures. output : Output, optional The output file where the cosmic ray events will be stored. Notes ----- The method assumes multiple possible shapes for the interaction: - Single pixel - Vertical line: Saturates two pixels vertically aligned. - Horizontal line: Saturates two pixels horizontally aligned. - Square: Saturates four pixels in a square. - Cross: Saturates five pixels in a cross shape. - Horizontal rectangle: Saturates six pixels in a horizontal rectangle shape. - Vertical rectangle: Saturates six pixels in a vertical rectangle shape. """ # Extract relevant parameters from the provided dictionary rate = parameters["detector"]["cosmic_rays_rate"].astype(np.float64) rate = check_units(rate, "ct/s/cm^2") saturation_rate = parameters["detector"]["saturation_rate"] spatial_pix = parameters["detector"]["spatial_pix"] spectral_pix = parameters["detector"]["spectral_pix"] well_depth = parameters["detector"]["well_depth"] well_depth = check_units(well_depth, "ct", force=True).value pixel_size = parameters["detector"]["delta_pix"].astype(np.float64) pixel_size = check_units(pixel_size, "cm", force=False) randomise = parameters["detector"].get("cosmic_rays_randomise", False) # Calculate the number of events to be added to each sub-exposure events_counter = self.count_events( rate, pixel_size, spectral_pix, spatial_pix, integration_times, saturation_rate, randomise, ) self.info(f"cosmic events_counter: {events_counter}") # Get interaction shapes and their probabilities shapes, probs = self.shapes_and_probs(parameters["detector"]) # Initialize a table to store information about the cosmic ray events storing_info = Table( names=( "Sub-exposure", "spectral_pix", "spatial_pix", "saturated_pix_rand", "saturated_pix_seed", "saturated_pix", ), dtype=("i4", "i4", "i4", "f8", "i4", "U16"), ) # Iterate over the sub-exposures and add cosmic ray events for chunk in iterate_over_chunks( subexposures.dataset, desc="adding cosmic rays" ): data = deepcopy(subexposures.dataset[chunk]) events_counter_chunk = events_counter[chunk[0]] for t in range(data.shape[0]): # Randomly select pixels for the cosmic ray events x_pix = RunConfig.random_generator.choice( spectral_pix, events_counter_chunk[t] ) y_pix = RunConfig.random_generator.choice( spatial_pix, events_counter_chunk[t] ) for x, y in zip(x_pix, y_pix): info_row = [chunk[0].start + t, x, y] # Randomly select a shape for the cosmic ray event rand = RunConfig.random_generator.uniform(0, 1) info_row.append(rand) info_row.append(RunConfig.random_seed) r_ = 0 for s, r in zip(shapes, probs): r_ += r if rand <= r_: shape = s break shape_str = "" for i, j in zip(shape[0], shape[1]): shape_str += f"({i + x}, {j + y})" try: data[t, j + y, i + x] = well_depth except IndexError: continue info_row.append(shape_str) storing_info.add_row(info_row) # Update the sub-exposure data subexposures.dataset[chunk] = data subexposures.output.flush() # Write the cosmic ray events information to the output file if provided if output and issubclass(output.__class__, Output): out_grp = output.create_group("cosmic rays") out_grp.write_table("cosmic rays events", storing_info) out_grp.write_array("cosmic events counter", events_counter) if randomise: out_grp.write_scalar("random seed", RunConfig.random_seed)
[docs] def count_events( self, rate: float, pixel_size: u.Quantity, spatial_pix: int, spectral_pix: int, integration_times: ArrayType, saturation_rate: float, randomise: bool, ) -> np.ndarray: """ Calculate the number of cosmic ray events in each sub-exposure, randomly distributing them while keeping the probability proportional to the exposure time. Parameters ---------- rate : float Cosmic rays flux rate in ct/s/cm^2. pixel_size : float Size of a detector pixel in cm^2. spatial_pix : int Number of spatial pixels in the detector. spectral_pix : int Number of spectral pixels in the detector. integration_times : :class:`~astropy.units.Quantity` Sub-exposure integration times. saturation_rate : float Saturation rate for the detector. randomise : bool A flag to randomize the total number of events Returns ------- events_counter_i : np.ndarray An array containing the number of events for each sub-exposure, rounded to the nearest integer. """ # Scale the rate based on detector characteristics scaled_rate = ( rate * pixel_size * pixel_size * spatial_pix * spectral_pix ) scaled_rate = check_units(scaled_rate, "ct/s") # Log the calculated rate self.info(f"cosmic rays rate: {scaled_rate}") # Compute the total expected number of events total_integration_time = np.sum(integration_times) self.debug(f"integration tiems: {integration_times}") total_events = ( scaled_rate * total_integration_time * saturation_rate ).value if randomise: self.debug("Randomizing total number of events.") # Randomize total event total_events = RunConfig.random_generator.poisson(total_events) self.info(f"cosmic total events: {total_events}") # Normalize integration times to create a probability distribution integration_weights = integration_times / total_integration_time probabilities = integration_weights # Convert to simple float array # Generate a random multinomial distribution of events across sub-exposures events_counter = np.random.multinomial( int(total_events), probabilities ) self.debug(f"Probabilities: {probabilities}") self.debug(f"Probabilities sum: {np.sum(probabilities)}") self.debug(f"Events counter before multinomial: {int(total_events)}") return events_counter
[docs] def shapes_and_probs(self, parameters: dict) -> Tuple[List, List]: """ Generate cosmic ray interaction shapes and their corresponding probabilities. Parameters ---------- parameters : dict A dictionary containing optional 'interaction_shapes' which is a sub-dictionary specifying shapes and their probabilities. Returns ------- shapes : list of tuples A list of tuples representing shapes of cosmic ray interactions. probs : list of float A list of probabilities corresponding to the shapes. Raises ------ ValueError If the sum of provided probabilities for all shapes is greater than 1. Examples -------- >>> shapes, probs = AddCosmicRays.shapes_and_probs({"interaction_shapes": {"single": 0.5, "line_h": 0.3, "line_v": 0.2}}) >>> print(shapes) [([0], [0]), ([0, 1], [0, 0]), ([0, 0], [0, 1]), ([0, 0, 1, 1], [0, 1, 0, 1]), ([0, 0, 0, -1, 1], [0, 1, -1, 0, 0])] >>> print(probs) [0.5, 0.3, 0.2, 0.1, 0.1] >>> shapes, probs = AddCosmicRays.shapes_and_probs({"interaction_shapes": {"single": 0.5, "line_h": 0.3, "line_v": 0.2}}) >>> print(shapes) Warnings -------- A warning is issued if the sum of provided probabilities for all shapes is not 1. """ # Predefined cosmic ray interaction shapes predefined_shapes = { "single": ([0], [0]), "line_h": ([0, 1], [0, 0]), "line_v": ([0, 0], [0, 1]), "square": ([0, 0, 1, 1], [0, 1, 0, 1]), "cross": ([0, 0, 0, -1, 1], [0, 1, -1, 0, 0]), "rect_h": ([0, 1, 2, 0, 1, 2], [0, 0, 0, 1, 1, 1]), "rect_v": ([0, 0, 0, 1, 1, 1], [0, 1, 2, 0, 1, 2]), } # Initialize lists to store shapes and probabilities shapes, probs = [], [] # Check if interaction shapes are defined in the parameters if "interaction_shapes" in parameters: for shape_key, prob in parameters["interaction_shapes"].items(): if shape_key in predefined_shapes: shapes.append(predefined_shapes[shape_key]) probs.append(prob) # Check if probabilities sum up to 1 remaining_prob = 1.0 - sum(probs) if remaining_prob < 0: self.error("Total probability greater than 1.") raise ValueError("Total probability greater than 1.") # Adjust the probability for the 'single' shape if needed if remaining_prob != parameters["interaction_shapes"].get( "single", 0 ): self.warning( "Total probability of all interaction shapes should sum to 1. " "Adjusting 'single' interaction shape to make the sum equal to 1." ) shapes.append(predefined_shapes["single"]) probs.append(remaining_prob) return shapes, probs # Default to 'single' shape with probability 1 if no shapes are defined return [predefined_shapes["single"]], [1.0]