# 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.
"""
Collimated Beam Source Implementation
Provides a collimated (parallel) beam source with optional spatial intensity
profiles. Useful for modeling laser beams and plane wave illumination.
Examples
--------
>>> from surface_roughness.sources import CollimatedBeam
>>>
>>> source = CollimatedBeam(
... center=(0, 0, -10),
... direction=(0, 0, 1),
... radius=0.001, # 1 mm
... num_rays=5000,
... wavelength=633e-9, # HeNe laser
... power=5e-3
... )
>>> rays = source.generate()
"""
from typing import Literal
import numpy as np
from ..utilities.ray_data import RayBatch
from .base import RaySource
[docs]
class CollimatedBeam(RaySource):
"""
Collimated beam with parallel rays.
Generates rays with identical directions and positions distributed
in a circular cross-section. Supports uniform and Gaussian intensity
profiles.
Parameters
----------
center : tuple of float
Beam center position (x, y, z) in meters.
direction : tuple of float
Beam propagation direction (dx, dy, dz), will be normalized.
radius : float
Beam radius in meters.
num_rays : int
Number of rays to generate.
wavelength : float or tuple of float
Single wavelength (m) or (min, max) range.
power : float, optional
Total beam power in watts. Default is 1.0.
profile : {'uniform', 'gaussian'}, optional
Spatial intensity profile. Default is 'uniform'.
Attributes
----------
center : ndarray, shape (3,)
Beam center position.
direction : ndarray, shape (3,)
Normalized beam direction.
radius : float
Beam radius.
profile : str
Intensity profile type.
Notes
-----
For Gaussian profile, the radius corresponds to 2σ (where σ is the
standard deviation of the Gaussian). Ray intensities are weighted
according to the Gaussian distribution.
Ray timing is initialized so that all rays cross the reference plane
(at center) at time=0. This ensures coherent phase fronts.
Examples
--------
>>> # Uniform circular beam
>>> source = CollimatedBeam(
... center=(0, 0, 0),
... direction=(0, 0, 1),
... radius=1e-3,
... num_rays=5000,
... wavelength=633e-9,
... power=5e-3
... )
>>> # Gaussian beam profile
>>> source = CollimatedBeam(
... center=(0, 0, 0),
... direction=(0, 0, 1),
... radius=2e-3,
... num_rays=10000,
... wavelength=1064e-9,
... power=1.0,
... profile='gaussian'
... )
"""
[docs]
def __init__(
self,
center: tuple[float, float, float],
direction: tuple[float, float, float],
radius: float,
num_rays: int,
wavelength: float | tuple[float, float],
power: float = 1.0,
profile: Literal["uniform", "gaussian"] = "uniform",
):
"""
Initialize collimated beam.
Parameters
----------
center : tuple of float
Beam center position (x, y, z) in meters.
direction : tuple of float
Beam propagation direction, will be normalized.
radius : float
Beam radius in meters.
num_rays : int
Number of rays to generate.
wavelength : float or tuple of float
Wavelength in meters or (min, max) range.
power : float, optional
Total beam power in watts. Default is 1.0.
profile : {'uniform', 'gaussian'}, optional
Spatial intensity profile. Default is 'uniform'.
Raises
------
ValueError
If radius <= 0 or profile not in {'uniform', 'gaussian'}.
"""
super().__init__(num_rays, wavelength, power)
self.center = np.array(center, dtype=np.float32)
self.radius = radius
self.profile = profile
# Normalize direction
direction_arr = np.array(direction, dtype=np.float32)
self.direction = direction_arr / np.linalg.norm(direction_arr)
if radius <= 0:
raise ValueError("radius must be positive")
if profile not in ("uniform", "gaussian"):
raise ValueError("profile must be 'uniform' or 'gaussian'")
[docs]
def generate(self) -> RayBatch:
"""
Generate collimated beam.
Creates rays with parallel directions and positions sampled
in a disk perpendicular to the beam direction.
Returns
-------
RayBatch
Ray batch with collimated ray directions.
Notes
-----
For uniform profile, positions are uniformly distributed in a disk.
For Gaussian profile, positions follow a 2D Gaussian distribution
and intensities are weighted accordingly.
"""
rays = self._allocate_rays()
# All rays have same direction
rays.directions[:] = self.direction
# Create perpendicular basis
v1, v2 = self._create_perpendicular_basis(self.direction)
if self.profile == "uniform":
# Uniform disk sampling
r = self.radius * np.sqrt(np.random.uniform(0, 1, self.num_rays))
theta = np.random.uniform(0, 2 * np.pi, self.num_rays)
x_local = r * np.cos(theta)
y_local = r * np.sin(theta)
else: # gaussian
# Gaussian profile (radius = 2*sigma)
sigma = self.radius / 2
x_local = np.random.normal(0, sigma, self.num_rays)
y_local = np.random.normal(0, sigma, self.num_rays)
# Adjust intensities for Gaussian profile
r_squared = x_local**2 + y_local**2
rays.intensities[:] *= np.exp(-r_squared / (2 * sigma**2))
# Renormalize to conserve power
rays.intensities[:] *= self.power / np.sum(rays.intensities)
# Convert to 3D positions
rays.positions[:] = (
self.center + x_local[:, np.newaxis] * v1 + y_local[:, np.newaxis] * v2
)
# Initialize accumulated_time for coherent phase front
# All rays should cross the reference plane (at center) at t=0
c = 299792458.0 # Speed of light in vacuum
n = 1.0 # Assume initial medium is air/vacuum
# Distance along beam direction from center to each ray position
offset_along_beam = np.sum(
(rays.positions - self.center) * self.direction, axis=1
)
# Time offset: negative for rays ahead, positive for rays behind
rays.accumulated_time[:] = -offset_along_beam * n / c
self._assign_wavelengths(rays)
return rays
[docs]
def __repr__(self) -> str:
"""Return string representation."""
return (
f"CollimatedBeam(center={self.center.tolist()}, "
f"radius={self.radius}, profile='{self.profile}')"
)