Source code for lsurf.detectors.base

# The Clear BSD License
#
# Copyright (c) 2026 Tobias Heibges
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted (subject to the limitations in the disclaimer
# below) provided that the following conditions are met:
#
#      * Redistributions of source code must retain the above copyright notice,
#      this list of conditions and the following disclaimer.
#
#      * Redistributions in binary form must reproduce the above copyright
#      notice, this list of conditions and the following disclaimer in the
#      documentation and/or other materials provided with the distribution.
#
#      * Neither the name of the copyright holder nor the names of its
#      contributors may be used to endorse or promote products derived from this
#      software without specific prior written permission.
#
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
Detector Base Class and Types

Defines the abstract base class for all detectors and the DetectionEvent
data class for recording ray detection events.

Design Notes
------------
- Follows Interface Segregation Principle: focused on ray detection
- DetectionEvent is immutable for thread safety
- Base class provides common analysis methods
"""

from abc import ABC, abstractmethod
from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

from ..utilities.ray_data import RayBatch


[docs] @dataclass(frozen=True) class DetectionEvent: """ Represents a single ray detection event. Immutable record of a ray being detected, including its position, direction, timing, and intensity at the moment of detection. Parameters ---------- ray_index : int Index of the ray in the original RayBatch. position : ndarray, shape (3,) 3D position where ray hit detector in meters. direction : ndarray, shape (3,) Ray direction at detection (unit vector). time : float Arrival time at detector in seconds. wavelength : float Ray wavelength in meters. intensity : float Ray intensity at detection. Notes ----- This class is frozen (immutable) for thread safety and to prevent accidental modification of detection records. """ ray_index: int position: NDArray[np.float32] direction: NDArray[np.float32] time: float wavelength: float intensity: float
[docs] class Detector(ABC): """ Abstract base class for all detectors. A detector records rays that pass through its detection volume, capturing their positions, directions, timing, and other properties. Parameters ---------- name : str, optional Detector name for identification. Default is "Detector". Attributes ---------- name : str Detector identifier. events : list of DetectionEvent Recorded detection events. Notes ----- Derived classes must implement the `detect()` method which tests rays against the detector geometry and records detection events. The base class provides common analysis methods for processing the recorded events. Examples -------- Creating a custom detector: >>> class MyDetector(Detector): ... def __init__(self, position, radius, name="MyDetector"): ... super().__init__(name) ... self.position = np.array(position) ... self.radius = radius ... ... def detect(self, rays, current_time=0.0): ... events = [] ... # ... detection logic ... ... return events """
[docs] def __init__(self, name: str = "Detector"): """ Initialize detector. Parameters ---------- name : str, optional Detector name for identification. Default is "Detector". """ self.name = name self.events: list[DetectionEvent] = []
[docs] @abstractmethod def detect(self, rays: RayBatch, current_time: float = 0.0) -> list[DetectionEvent]: """ Check which rays intersect this detector and record events. Parameters ---------- rays : RayBatch Ray batch to test for detection. current_time : float, optional Current simulation time for reference. Default is 0.0. Returns ------- list of DetectionEvent List of newly detected events. Notes ----- Implementations should: - Only test active rays (rays.active == True) - Create DetectionEvent for each detected ray - Append events to self.events - Return the list of newly created events """ pass
[docs] def clear(self) -> None: """ Clear all recorded events. Resets the detector to its initial state with no recorded events. """ self.events = []
[docs] def get_arrival_times(self) -> NDArray[np.float64]: """ Get array of all arrival times. Returns ------- times : ndarray, shape (N,) Arrival times in seconds for all detected rays. Examples -------- >>> times = detector.get_arrival_times() >>> print(f"Mean arrival time: {times.mean():.3e} s") """ return np.array([e.time for e in self.events], dtype=np.float64)
[docs] def get_arrival_angles( self, reference_direction: NDArray[np.float32] ) -> NDArray[np.float64]: """ Get angles between ray directions and reference direction. Parameters ---------- reference_direction : ndarray, shape (3,) Reference vector for angle calculation. Returns ------- angles : ndarray, shape (N,) Angles in radians between each detected ray's direction and the reference direction. Examples -------- >>> angles = detector.get_arrival_angles(np.array([0, 0, 1])) >>> print(f"Mean angle: {np.degrees(angles.mean()):.1f} degrees") """ ref = reference_direction / np.linalg.norm(reference_direction) angles = [] for event in self.events: dir_norm = event.direction / np.linalg.norm(event.direction) cos_angle = np.dot(dir_norm, ref) cos_angle = np.clip(cos_angle, -1.0, 1.0) angles.append(np.arccos(cos_angle)) return np.array(angles, dtype=np.float64)
[docs] def get_intensities(self) -> NDArray[np.float64]: """ Get array of all detected intensities. Returns ------- intensities : ndarray, shape (N,) Intensity values for all detected rays. """ return np.array([e.intensity for e in self.events], dtype=np.float64)
[docs] def get_wavelengths(self) -> NDArray[np.float64]: """ Get array of all detected wavelengths. Returns ------- wavelengths : ndarray, shape (N,) Wavelengths in meters for all detected rays. """ return np.array([e.wavelength for e in self.events], dtype=np.float64)
[docs] def get_positions(self) -> NDArray[np.float32]: """ Get array of all detection positions. Returns ------- positions : ndarray, shape (N, 3) 3D positions where rays were detected. """ if len(self.events) == 0: return np.zeros((0, 3), dtype=np.float32) return np.stack([e.position for e in self.events])
[docs] def get_total_intensity(self) -> float: """ Get sum of all detected intensities. Returns ------- float Total detected intensity (power). """ return float(np.sum(self.get_intensities()))
[docs] def __len__(self) -> int: """Return number of detection events.""" return len(self.events)
[docs] def __repr__(self) -> str: """Return string representation.""" return ( f"{self.__class__.__name__}(name='{self.name}', events={len(self.events)})" )