Source code for lsurf.utilities.time_spread

# 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.

"""
Time Spread Estimation Utilities

Provides geometric estimation of time spread for diverging beams reflecting
off surfaces and reaching a detector. Useful for computing upper/lower bounds
on arrival time distributions without full ray tracing.

Examples
--------
>>> from lsurf.utilities.time_spread import estimate_time_spread
>>> from lsurf.surfaces import GerstnerWaveSurface, GerstnerWaveParams
>>>
>>> # Create a wave surface
>>> wave = GerstnerWaveParams(amplitude=1.0, wavelength=50.0)
>>> surface = GerstnerWaveSurface(wave_params=[wave])
>>>
>>> result = estimate_time_spread(
...     source_position=(0, 0, 500),
...     beam_direction=(0.98, 0, -0.17),
...     divergence_angle=np.radians(1.0),
...     detector_position=(32000, 0, 5700),
...     surface=surface,
... )
>>> print(f"Time spread: {result.time_spread_ns:.2f} ns")
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import numpy as np
from numpy.typing import NDArray

if TYPE_CHECKING:
    from ..surfaces import Surface

# Speed of light in m/s
SPEED_OF_LIGHT = 299792458.0


[docs] @dataclass class TimeSpreadResult: """ Results from time spread estimation. Attributes ---------- min_path : float Shortest path length in meters max_path : float Longest path length in meters path_spread : float Difference between max and min path in meters time_spread_s : float Time spread in seconds time_spread_ns : float Time spread in nanoseconds min_path_point : ndarray Surface point with shortest path max_path_point : ndarray Surface point with longest path edge_points : ndarray All beam edge intersection points on surface path_lengths : ndarray Path lengths for all edge points center_point : ndarray Center ray intersection point """ min_path: float max_path: float path_spread: float time_spread_s: float time_spread_ns: float min_path_point: NDArray[np.float64] max_path_point: NDArray[np.float64] edge_points: NDArray[np.float64] path_lengths: NDArray[np.float64] center_point: NDArray[np.float64]
[docs] def compute_beam_footprint( source_position: tuple[float, float, float], beam_direction: tuple[float, float, float], divergence_angle: float, n_edge_points: int = 100, surface: Surface | None = None, ) -> dict[str, float | NDArray[np.float64]]: """ Compute where the edges of a diverging beam hit a surface. Parameters ---------- source_position : tuple (x, y, z) position of the source in meters beam_direction : tuple Unit vector of beam center direction divergence_angle : float Half-angle divergence of the beam in radians n_edge_points : int Number of points around the beam edge cone surface : Surface, optional Surface object to intersect with (e.g., GerstnerWaveSurface, CurvedWaveSurface, PlanarSurface). If None, uses flat surface at z=0. Returns ------- dict Dictionary containing: - edge_points : ndarray, shape (N, 3) - Surface intersection points - center_point : ndarray, shape (3,) - Center ray intersection - edge_distances : ndarray - Distances from source to each edge point - center_distance : float - Distance from source to center point """ src = np.array(source_position, dtype=np.float64) beam_dir = np.array(beam_direction, dtype=np.float64) beam_dir = beam_dir / np.linalg.norm(beam_dir) # Create orthonormal basis around beam direction if abs(beam_dir[2]) < 0.9: up = np.array([0.0, 0.0, 1.0]) else: up = np.array([1.0, 0.0, 0.0]) v1 = np.cross(beam_dir, up) v1 = v1 / np.linalg.norm(v1) v2 = np.cross(beam_dir, v1) v2 = v2 / np.linalg.norm(v2) # Generate all edge ray directions phi_angles = np.linspace(0, 2 * np.pi, n_edge_points, endpoint=False) edge_directions = [] for phi in phi_angles: edge_dir = np.cos(divergence_angle) * beam_dir + np.sin(divergence_angle) * ( np.cos(phi) * v1 + np.sin(phi) * v2 ) edge_dir = edge_dir / np.linalg.norm(edge_dir) edge_directions.append(edge_dir) edge_directions = np.array(edge_directions, dtype=np.float32) origins = np.tile(src.astype(np.float32), (n_edge_points, 1)) # Find intersections edge_points = [] edge_distances = [] if surface is not None: # Use surface.intersect() for batch intersection distances, hit_mask = surface.intersect(origins, edge_directions) for i in range(n_edge_points): if hit_mask[i] and distances[i] > 0: t = float(distances[i]) hit_point = src + t * edge_directions[i].astype(np.float64) edge_points.append(hit_point) edge_distances.append(t) else: # Flat surface at z=0 for i in range(n_edge_points): edge_dir = edge_directions[i].astype(np.float64) if abs(edge_dir[2]) > 1e-10: t = -src[2] / edge_dir[2] if t > 0: hit_point = src + t * edge_dir edge_points.append(hit_point) edge_distances.append(t) edge_points = np.array(edge_points) if edge_points else np.empty((0, 3)) edge_distances = np.array(edge_distances) # Center ray intersection center_origins = src.astype(np.float32).reshape(1, 3) center_directions = beam_dir.astype(np.float32).reshape(1, 3) if surface is not None: distances, hit_mask = surface.intersect(center_origins, center_directions) if hit_mask[0] and distances[0] > 0: t_center = float(distances[0]) center_point = src + t_center * beam_dir else: # Fallback to flat surface t_center = -src[2] / beam_dir[2] center_point = src + t_center * beam_dir else: t_center = -src[2] / beam_dir[2] center_point = src + t_center * beam_dir return { "edge_points": edge_points, "center_point": center_point, "edge_distances": edge_distances, "center_distance": t_center, }
[docs] def estimate_time_spread( source_position: tuple[float, float, float], beam_direction: tuple[float, float, float], divergence_angle: float, detector_position: tuple[float, float, float], surface: Surface | None = None, n_edge_points: int = 100, speed_of_light: float = SPEED_OF_LIGHT, ) -> TimeSpreadResult: """ Estimate time spread for a diverging beam reflecting to a detector. Computes geometric path lengths from source through surface edge points to detector, giving an upper bound estimate on arrival time spread. Parameters ---------- source_position : tuple (x, y, z) source position in meters beam_direction : tuple Beam center direction (will be normalized) divergence_angle : float Beam half-angle divergence in radians detector_position : tuple (x, y, z) detector position in meters surface : Surface, optional Surface object to intersect with (e.g., GerstnerWaveSurface, CurvedWaveSurface, PlanarSurface). If None, uses flat surface at z=0. n_edge_points : int Number of points around beam edge for sampling speed_of_light : float Speed of light in m/s Returns ------- TimeSpreadResult Dataclass containing path lengths, time spread, and geometry info Examples -------- >>> # Flat surface estimate >>> result = estimate_time_spread( ... source_position=(-2800, 0, 500), ... beam_direction=(0.98, 0, -0.17), ... divergence_angle=np.radians(1.0), ... detector_position=(32000, 0, 5700), ... ) >>> print(f"Flat surface: {result.time_spread_ns:.2f} ns") >>> # Wavy surface estimate with GerstnerWaveSurface >>> from lsurf.surfaces import GerstnerWaveSurface, GerstnerWaveParams >>> wave = GerstnerWaveParams(amplitude=1.0, wavelength=50.0, direction=(0, 1)) >>> surface = GerstnerWaveSurface(wave_params=[wave]) >>> result = estimate_time_spread( ... source_position=(-2800, 0, 500), ... beam_direction=(0.98, 0, -0.17), ... divergence_angle=np.radians(1.0), ... detector_position=(32000, 0, 5700), ... surface=surface, ... ) >>> print(f"Wavy surface: {result.time_spread_ns:.2f} ns") """ src = np.array(source_position, dtype=np.float64) det = np.array(detector_position, dtype=np.float64) # Get beam footprint on surface footprint = compute_beam_footprint( source_position, beam_direction, divergence_angle, n_edge_points=n_edge_points, surface=surface, ) edge_points = footprint["edge_points"] center_point = footprint["center_point"] if len(edge_points) == 0: # No intersections found return TimeSpreadResult( min_path=0.0, max_path=0.0, path_spread=0.0, time_spread_s=0.0, time_spread_ns=0.0, min_path_point=center_point, max_path_point=center_point, edge_points=edge_points, path_lengths=np.array([]), center_point=center_point, ) # Compute total path lengths: source -> surface -> detector path_lengths = [] for pt in edge_points: d_src_to_surface = np.linalg.norm(pt - src) d_surface_to_det = np.linalg.norm(det - pt) total_path = d_src_to_surface + d_surface_to_det path_lengths.append(total_path) path_lengths = np.array(path_lengths) min_path = np.min(path_lengths) max_path = np.max(path_lengths) path_spread = max_path - min_path time_spread_s = path_spread / speed_of_light time_spread_ns = time_spread_s * 1e9 min_idx = np.argmin(path_lengths) max_idx = np.argmax(path_lengths) return TimeSpreadResult( min_path=min_path, max_path=max_path, path_spread=path_spread, time_spread_s=time_spread_s, time_spread_ns=time_spread_ns, min_path_point=edge_points[min_idx], max_path_point=edge_points[max_idx], edge_points=edge_points, path_lengths=path_lengths, center_point=center_point, )
[docs] def estimate_time_spread_bounds( source_position: tuple[float, float, float], beam_direction: tuple[float, float, float], divergence_angle: float, detector_positions: NDArray[np.float64], surface: Surface | None = None, n_edge_points: int = 100, ) -> dict[str, NDArray[np.float64]]: """ Estimate time spread bounds for multiple detector positions. Useful for computing time spread maps across a detector surface. Parameters ---------- source_position : tuple (x, y, z) source position in meters beam_direction : tuple Beam center direction divergence_angle : float Beam half-angle divergence in radians detector_positions : ndarray, shape (N, 3) Array of detector positions to evaluate surface : Surface, optional Surface object to intersect with. If None, uses flat surface at z=0. n_edge_points : int Number of edge points for beam footprint Returns ------- dict Dictionary containing: - time_spread_ns : ndarray, shape (N,) - Time spread at each position - path_spread : ndarray, shape (N,) - Path spread at each position - min_path : ndarray, shape (N,) - Minimum path at each position - max_path : ndarray, shape (N,) - Maximum path at each position Examples -------- >>> # Compute time spread for grid of detector positions >>> lon = np.linspace(-5, 5, 20) >>> lat = np.linspace(5, 15, 20) >>> lon_grid, lat_grid = np.meshgrid(lon, lat) >>> r = 33000 # 33 km >>> x = r * np.cos(np.radians(lat_grid)) * np.cos(np.radians(lon_grid)) >>> y = r * np.cos(np.radians(lat_grid)) * np.sin(np.radians(lon_grid)) >>> z = r * np.sin(np.radians(lat_grid)) >>> positions = np.stack([x.ravel(), y.ravel(), z.ravel()], axis=1) >>> >>> bounds = estimate_time_spread_bounds( ... source_position=(-2800, 0, 500), ... beam_direction=(0.98, 0, -0.17), ... divergence_angle=np.radians(1.0), ... detector_positions=positions, ... ) >>> time_spread_map = bounds['time_spread_ns'].reshape(lat_grid.shape) """ n_detectors = len(detector_positions) time_spreads = np.zeros(n_detectors) path_spreads = np.zeros(n_detectors) min_paths = np.zeros(n_detectors) max_paths = np.zeros(n_detectors) # Compute footprint once (same for all detectors) footprint = compute_beam_footprint( source_position, beam_direction, divergence_angle, n_edge_points=n_edge_points, surface=surface, ) src = np.array(source_position, dtype=np.float64) edge_points = footprint["edge_points"] if len(edge_points) == 0: return { "time_spread_ns": time_spreads, "path_spread": path_spreads, "min_path": min_paths, "max_path": max_paths, } # Pre-compute source-to-surface distances (same for all detectors) src_to_surface = np.linalg.norm(edge_points - src, axis=1) for i, det_pos in enumerate(detector_positions): det = np.array(det_pos, dtype=np.float64) # Surface-to-detector distances surface_to_det = np.linalg.norm(edge_points - det, axis=1) # Total paths total_paths = src_to_surface + surface_to_det min_paths[i] = np.min(total_paths) max_paths[i] = np.max(total_paths) path_spreads[i] = max_paths[i] - min_paths[i] time_spreads[i] = path_spreads[i] / SPEED_OF_LIGHT * 1e9 return { "time_spread_ns": time_spreads, "path_spread": path_spreads, "min_path": min_paths, "max_path": max_paths, }