Sources [Subpackage]
Checklist
- [x] I have searched the existing issues and discussions for a similar question or feature request.
- [x] I have read the documentation and tried to find an answer there.
- [x] I am using the latest version of Optiland.
- [x] I have included all necessary context.
Feature Request
Feature Description As of now, Optiland does not have any source modelling subpackage. It would be of interest to a lot of users I believe to build up this subpackage that allows users to specify which rays to use and propagate through the system (i.e. via a certain mathematical distribution of the input rays). As an example, this is useful for instance in the field of illumination, where the optical designer is in charge of designing a beam shaper for achieving a certain prescribed irradiance distribution at the image plane. Although this is possible with Optiland, given that the user correctly models the sources, it would be good to already have these sources coded up and ready to be used in the design pipeline. As Optiland is torch-compatible, and so far from the tests we have made, it is capable of automatic differentiation from input ray to output analysis, this would open up an easy integration of extended source modelling via differentiable ray tracing.
Description of the current approach I have Below you will find the code I have experimented with in order to define a source type, specifically the light coming out of a single mode fiber, modelled through the equivalent rays.
class ExtendedSource():
"""
ExtendedSource class to handle source parameters and ray generation.
This class is designed to be backend-agnostic, allowing for flexible ray generation
using either NumPy or PyTorch as the backend.
It generates rays based on a Gaussian distribution defined by the source parameters.
Manuel Fragata Mendes, 2025
"""
def __repr__(self):
return f"ExtendedSource(mfd={self.mfd}, wavelength={self.wavelength}, total_power={self.total_power})"
def __init__(self, mfd=10.4, wavelength=1.55, total_power=1.0):
"""
Initializes the ExtendedSource with source-specific parameters.
Args:
mfd (float): Mode Field Diameter in micrometers (µm).
wavelength (float): Wavelength of the source in micrometers (µm).
total_power (float): Total optical power of the source in Watts (W).
"""
self.mfd = mfd
self.wavelength = wavelength
self.total_power = total_power
# --- Derived parameters for Gaussian distribution ---
w0_um = self.mfd / 2.0
s_x_um = w0_um # 1/e^2 spatial radius for luminance in µm
s_L_rad = self.wavelength / (be.pi * w0_um) # 1/e^2 angular radius in L-space (radians)
# units conversion
s_x_mm = s_x_um * 1e-3
# Sampling parameters for the Gaussian distribution
self.sigma_spatial_mm = s_x_mm / 2.0
self.sigma_angular_rad = s_L_rad / 2.0
def generate_rays(self, num_rays):
"""
Generates a RealRays object using backend-agnostic functions based on the source parameters.
Args:
num_rays (int): The number of rays to attempt to generate.
Returns:
RealRays: An object containing the generated rays.
"""
x_start = be.random_normal(loc=0.0, scale=self.sigma_spatial_mm, size=num_rays)
y_start = be.random_normal(loc=0.0, scale=self.sigma_spatial_mm, size=num_rays)
z_start = be.zeros(num_rays)
L_initial = be.random_normal(loc=0.0, scale=self.sigma_angular_rad, size=num_rays)
M_initial = be.random_normal(loc=0.0, scale=self.sigma_angular_rad, size=num_rays)
# filter out rays that do not satisfy the condition L^2 + M^2 < 1
valid_mask = L_initial**2 + M_initial**2 < 1.0
x_start = x_start[valid_mask]
y_start = y_start[valid_mask]
z_start = z_start[valid_mask]
L_initial = L_initial[valid_mask]
M_initial = M_initial[valid_mask]
num_valid_rays = be.size(L_initial)
if num_valid_rays == 0:
raise ValueError("No valid rays generated after filtering. Check parameters.")
print(f"Generated {num_valid_rays} valid rays for simulation using backend: '{be.get_backend()}'")
# --- Calculate Power per Ray ---
power_per_ray = self.total_power / num_valid_rays
intensity_power_array = be.full((num_valid_rays,), power_per_ray)
# --- Create the final RealRays object ---
N_initial = be.sqrt(be.maximum(be.array(0.0), 1.0 - L_initial**2 - M_initial**2))
wavelength_array = be.full((num_valid_rays,), self.wavelength)
rays = RealRays(
x=x_start, y=y_start, z=z_start,
L=L_initial, M=M_initial, N=N_initial,
intensity=intensity_power_array,
wavelength=wavelength_array
)
return rays
# Example of how to use the class:
# source = ExtendedSource(mfd=10.4, wavelength=1.55, total_power=1.0)
# initial_smf_rays = source.generate_rays(num_rays=4_000_000)
Describe alternatives you've considered N/A
Additional context As an additional feature, I expect that several types of visualization of the ray distributions might be nice to have, such as the following:
Hi there!
As we approach the culmination of probably one of the biggest releases for Optiland, I wanted to get started working on this issue in time for the big release. After thinking about it for a while and playing around with different implementations, I made the following implementation plan, that I would like you all feedback if possible :)
Extended Source Modeling Subpackage
Implement a new subpackage optiland.sources to enable the definition and use of extended light sources for physical simulation, with a focus on supporting differentiable ray tracing.
1. Create New sources Subpackage
This subpackage will contain all source modeling logic, keeping it modular and separate from the existing tools.
- [x] Create new directory
optiland/sources/. - [x] Create
optiland/sources/base.pyto define an abstractBaseSourceclass with an abstractgenerate_rays(num_rays)method. - [x] Create
optiland/sources/gaussian.pyto implement aGaussianSourceclass that inherits fromBaseSource. - [x] Implement the
GaussianSource.generate_raysmethod using Sobol* sequences and the inverse error function (erfinv) to create a low-discrepancy, differentiable ray set based on physical parameters (e.g., MFD, wavelength, power).
'* for those more curious about the theory: https://optics.ansys.com/hc/en-us/articles/43071055058323-Understanding-Sobol-sampling
2. Enhance Backend Abstraction Layer
To support differentiable sources, the backend needs to provide the necessary quasi-random samplers and mathematical functions.
- [x] Add a backend-agnostic
erfinvfunction tooptiland/backend/.- Map to
scipy.special.erfinvinnumpy_backend.py. - Map to
torch.erfinvintorch_backend.py.
- Map to
- [x] Add a backend-agnostic Sobol sampler interface to
optiland/backend/.- Implement using
scipy.stats.qmc.Sobolfor the NumPy backend. - Implement using
torch.quasirandom.SobolEnginefor the PyTorch backend.
- Implement using
3. Integrate Sources with the Core Optic Class
The Optic class will be updated to manage and use the new source objects, providing a clean user-facing API.
- [x] In
optiland/optic/optic.py, add asourceattribute to theOpticclass, initialized toNone. - [x] Add a new method
Optic.set_source(self, source: BaseSource)to attach a source object. - [x] Add a new method
Optic.trace_source(self, num_rays: int)that uses the attached source to generate rays and then traces them through thesurface_group. - [x] Update the existing
Optic.trace()method to intelligently use the attached source if one is present, allowing users to seamlessly switch from field/pupil sampling to physical source simulation.
4. Update Visualization Tools
The existing 2D and 3D visualization tools will be adapted to plot rays generated from the new extended sources.
- [x] In
optiland/optic/optic.py, update thedraw()anddraw3D()methods to use thetrace_source()method when a source is attached to theOpticinstance. - [ ] In
optiland/visualization/system/rays.py, adapt theRays2DandRays3Dclasses to handle plotting aRealRaysobject that originates from an extended source, rather than being tied to a specific field/wavelength combination.
We can attach and detach a certain source from the optical system object and the draw() method will follow the correct logic - plots the source rays if the source is attached, plots the usual ray distributions if the source is not attached.
and we can also plot the source individually, to understand the spatial and angular distributions. For example for a mfd=10.4µm fiber, diffraction limited:
What do you think @HarrisonKramer ?
Hi @manuelFragata,
Thanks for sharing the update! Looks great so far!
For the most part, I agree with the approach, but I have some suggestions to better align with Optiland conventions, or to simplify the architecture.
First, I strongly prefer that we do not modify Optic in any way. A few reasons:
- Adding
set_sourceandtrace_sourcechanges the API in a way that makes Optic behave differently than expected. Functions that expect a plainOpticcould then behave in unexpected ways. For example, the PSF calculation would still work (in theory), but the results would not be meaningful and the user would never know. Opticcould start to do "too much", i.e., orchestrating the optical system, and now it also manages sources. It could creep towards becoming a "god class".- The new functionality is fundamentally different from what is currently done with an
Opticinstance. Combining them is not needed, because users generally won't need to use "both types" ofOptic(custom source & field/pupil-based source) at the same time.
From a user perspective, Optic.trace() has a well-defined meaning today (field/pupil-based). Changing it to conditionally use an attached source introduces hidden behavior that could be confusing or cause unexpected results.
Instead, I’d propose we define a new type of Optic, e.g., ExtendedSourceOptic (open to better names too). This could expose only the methods that are relevant for the source-based optical system. For example, you could build a new class like
class ExtendedSourceOptic:
def __init__(self, optic: Optic):
self.optic = optic
def __getattr__(self, name):
"""Delegate attribute access to the underlying optic."""
return getattr(self.optic, name)
def optic_function_you_dont_need(self):
raise NotImplementedError("This function is not implemented in the wrapper.")
def draw(self):
# your new draw implementation
pass
Use of __getattr__ might not be needed if you only need a few of Optic's methods. In that case, you'd manually define each and just pass the arguments directly to the optic instance via self.optic.some_function(argument1, argument2).
I really like your proposed optiland.sources subpackage and backend abstractions for erfinv and Sobol sampling - those make perfect sense and fit nicely into the package. The main change I’d recommend is simply not tying sources into the core Optic class directly.
Happy to discuss further here. Perhaps there is a cleaner option I have not considered yet.
What do you think?
Kramer
Hi @HarrisonKramer ,
Thanks a lot for the feedback! This is one of the many reasons I like to work on Optiland - the feedback is always constructive and insightful, truly appreciate that :)
- regarding the
Opticclass, true - I see the increased complexity of the class. - the
tracemethod, like you say would also become more tricky for the users to understand, and attaching and detaching a source while I do like the idea I agree that it can be confusing for users and can induced some undesired behaviour. - I like the
ExtendedSourceOpticclass idea, thank you for suggesting that.
I will make some changes, iterate a bit, and update you again once I've implemented them.
Once again, thanks for the feedback!
Any updates or plans when this will be released?
Hi @bodokaiser,
The work here is on hold while @manuelFragata needs to focus on other things. He can provide an update if he has one.
@manuelFragata - I do not know how far you got here or if the status above is up-to-date, but I can also support and/or take over some aspects if needed. Just let me know.
@bodokaiser - if you have any other thoughts or suggestions for this subpackage, let us know. We have not yet landed on every detail of the implementation.
Regards, Kramer
Hi there @bodokaiser !
As @HarrisonKramer already mentioned, this work is currently on hold while I focus on other things.
@HarrisonKramer , regarding the implementation, I have not yet implemented that approach with the class ExtendedSourceOptic. We can re-think the approach if you have some new suggestions!
Best, Manuel
Hi @manuelFragata,
If you would offer some occasional help, I would give it a try to complete your work.
@HarrisonKramer Any reason to "wrap" the Optic class instead of doing inheritance, e.g., ExtendedSourceOptic(Optic)?
@bodokaiser
Good question. In this case, subclassing Optic isn’t really appropriate from a design standpoint IMO. The Optic class has a clearly defined structure and behavior, while ExtendedSourceOptic is doing something fundamentally different. Inheriting from Optic would create illogical outcomes, e.g., most analysis functions would no longer make sense, since ray generation is overridden, leading to nonsensical results that users likely would not catch.
Wrapping keeps things explicit and avoids that ambiguity. It’s a cleaner and safer approach, though I’m open to discussing alternatives if there’s a compelling reason.
Kramer