# 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)})"
)