optiland icon indicating copy to clipboard operation
optiland copied to clipboard

Sources [Subpackage]

Open manuelFragata opened this issue 5 months ago • 9 comments

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:

Image

manuelFragata avatar Jul 22 '25 15:07 manuelFragata

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.py to define an abstract BaseSource class with an abstract generate_rays(num_rays) method.
  • [x] Create optiland/sources/gaussian.py to implement a GaussianSource class that inherits from BaseSource.
  • [x] Implement the GaussianSource.generate_rays method 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 erfinv function to optiland/backend/.
    • Map to scipy.special.erfinv in numpy_backend.py.
    • Map to torch.erfinv in torch_backend.py.
  • [x] Add a backend-agnostic Sobol sampler interface to optiland/backend/.
    • Implement using scipy.stats.qmc.Sobol for the NumPy backend.
    • Implement using torch.quasirandom.SobolEngine for the PyTorch backend.

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 a source attribute to the Optic class, initialized to None.
  • [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 the surface_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 the draw() and draw3D() methods to use the trace_source() method when a source is attached to the Optic instance.
  • [ ] In optiland/visualization/system/rays.py, adapt the Rays2D and Rays3D classes to handle plotting a RealRays object that originates from an extended source, rather than being tied to a specific field/wavelength combination.

manuelFragata avatar Sep 09 '25 08:09 manuelFragata

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.

Image

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:

Image

What do you think @HarrisonKramer ?

manuelFragata avatar Sep 10 '25 08:09 manuelFragata

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_source and trace_source changes the API in a way that makes Optic behave differently than expected. Functions that expect a plain Optic could 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.
  • Optic could 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 Optic instance. Combining them is not needed, because users generally won't need to use "both types" of Optic (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

HarrisonKramer avatar Sep 10 '25 17:09 HarrisonKramer

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 Optic class, true - I see the increased complexity of the class.
  • the trace method, 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 ExtendedSourceOptic class 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!

manuelFragata avatar Sep 11 '25 07:09 manuelFragata

Any updates or plans when this will be released?

bodokaiser avatar Oct 21 '25 19:10 bodokaiser

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

HarrisonKramer avatar Oct 22 '25 10:10 HarrisonKramer

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

manuelFragata avatar Nov 04 '25 10:11 manuelFragata

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 avatar Nov 04 '25 10:11 bodokaiser

@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

HarrisonKramer avatar Nov 05 '25 15:11 HarrisonKramer