Source code for optiland.nonsequential.units

"""Photometric conversion layer.

The NSQ radiometric core is unchanged: everything inside the trace stays in
watts, W/mm^2, and micrometres. This module is a *read-only* conversion
layer on top of finished results (and, for sources, an input-side
lumens-to-watts helper) -- it never touches the trace loop.

Guardrail : converting a monochromatic result outside the visible
band, or a spectrum with negligible V(lambda) overlap, raises rather than
returning a near-zero photometric value that looks like a valid (if dim)
answer. Silently returning ~0 is the same defect class as D-2 (an accepted
configuration that quietly does nothing).

Kramer Harrison, 2026
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

import numpy as np

if TYPE_CHECKING:
    from optiland.nonsequential.results.far_field_pattern import FarFieldPattern
    from optiland.nonsequential.results.irradiance_map import IrradianceMap
    from optiland.nonsequential.results.spectral_result import SpectralResult
    from optiland.nonsequential.sources.base import Spectrum

Weighting = Literal["photopic", "scotopic"]

# CIE 1931 2-degree photopic luminous efficiency function V(lambda), and
# CIE 1951 scotopic V'(lambda), tabulated at 10 nm from 380-780 nm (standard
# reference values). optiland.nonsequential uses micrometres everywhere
# else, so wavelengths are stored in um to match.
_PHOTOPIC_WAVELENGTHS_UM = np.arange(0.380, 0.781, 0.010)
_PHOTOPIC_V = np.array(
    [
        0.0000,
        0.0001,
        0.0004,
        0.0012,
        0.0040,
        0.0116,
        0.0230,
        0.0380,
        0.0600,
        0.0910,
        0.1390,
        0.2080,
        0.3230,
        0.5030,
        0.7100,
        0.8620,
        0.9540,
        0.9950,
        0.9950,
        0.9520,
        0.8700,
        0.7570,
        0.6310,
        0.5030,
        0.3810,
        0.2650,
        0.1750,
        0.1070,
        0.0610,
        0.0320,
        0.0170,
        0.0082,
        0.0041,
        0.0021,
        0.0010,
        0.00052,
        0.00025,
        0.00012,
        0.00006,
        0.00003,
        0.000015,
    ]
)

_SCOTOPIC_WAVELENGTHS_UM = _PHOTOPIC_WAVELENGTHS_UM
_SCOTOPIC_V = np.array(
    [
        0.000589,
        0.002209,
        0.00929,
        0.03484,
        0.0966,
        0.1998,
        0.3281,
        0.4550,
        0.5670,
        0.6760,
        0.7930,
        0.9040,
        0.9820,
        0.9970,
        0.9350,
        0.8110,
        0.6500,
        0.4810,
        0.3288,
        0.2076,
        0.1212,
        0.0655,
        0.03315,
        0.01593,
        0.00737,
        0.003335,
        0.001497,
        0.000677,
        0.000313,
        0.000148,
        0.0000715,
        0.0000353,
        0.0000178,
        0.00000914,
        0.00000478,
        0.00000255,
        0.00000139,
        0.000000760,
        0.000000425,
        0.000000241,
        0.000000139,
    ]
)

# Luminous efficacy at the peak of each weighting function (CIE-defined for
# photopic; the commonly-cited value for scotopic).
KM_PHOTOPIC = 683.002  # lm/W at 555 nm
KM_SCOTOPIC = 1700.0  # lm/W at 507 nm

# Visible band the tables above cover. A wavelength (or an entire spectrum)
# outside this range has V(lambda) == 0 everywhere it's defined and 0
# (by extension, not tabulated) everywhere else -- the guardrail treats
# both the same way.
VISIBLE_BAND_UM = (0.380, 0.780)

# Below this fraction of the peak luminous efficacy, a spectrum's overlap
# with V(lambda) is treated as negligible (guardrail) rather than as
# a very dim but valid photometric answer.
_NEGLIGIBLE_EFFICACY_FRACTION = 1e-6


def _table(weighting: Weighting) -> tuple[np.ndarray, np.ndarray, float]:
    """Return ``(wavelengths_um, V, Km)`` for a weighting function.

    Args:
        weighting: ``"photopic"`` or ``"scotopic"``.

    Returns:
        The wavelength grid, the V(lambda)/V'(lambda) table, and the peak
        luminous efficacy Km/K'm [lm/W].

    Raises:
        ValueError: If ``weighting`` is not recognised.
    """
    if weighting == "photopic":
        return _PHOTOPIC_WAVELENGTHS_UM, _PHOTOPIC_V, KM_PHOTOPIC
    if weighting == "scotopic":
        return _SCOTOPIC_WAVELENGTHS_UM, _SCOTOPIC_V, KM_SCOTOPIC
    raise ValueError(
        f"Unknown weighting {weighting!r}; expected 'photopic' or 'scotopic'."
    )


[docs] def v_lambda(wavelength_um: float | np.ndarray, weighting: Weighting = "photopic"): """Evaluate V(lambda) (or V'(lambda)) at one or more wavelengths. Args: wavelength_um: Wavelength(s) [um]. weighting: ``"photopic"`` (default) or ``"scotopic"``. Returns: The luminous efficiency function value(s), 0 outside the tabulated visible band -- correct physics (the eye has no response there), not a missing-data placeholder. Callers that must distinguish "genuinely zero" from "out of band" should check :data:`VISIBLE_BAND_UM` themselves; :func:`to_photometric` and :func:`lumens_to_watts` do this via the negligible-overlap guardrail. """ wl_grid, v_grid, _ = _table(weighting) return np.interp(wavelength_um, wl_grid, v_grid, left=0.0, right=0.0)
[docs] def luminous_efficacy_of_spectrum( wavelengths_um: np.ndarray, weights: np.ndarray, weighting: Weighting = "photopic", ) -> float: """Luminous efficacy [lm/W] of a normalised spectral power distribution. Args: wavelengths_um: Wavelength samples [um]. weights: Relative spectral power at each wavelength (need not be normalised; only the shape matters). weighting: ``"photopic"`` or ``"scotopic"``. Returns: ``Km * sum(weights * V(wavelengths)) / sum(weights)`` -- the spectrum-averaged luminous efficacy, in [0, Km]. """ wl = np.asarray(wavelengths_um, dtype=np.float64) w = np.asarray(weights, dtype=np.float64) _, _, km = _table(weighting) total_weight = w.sum() if total_weight <= 0: return 0.0 return float(km * np.sum(w * v_lambda(wl, weighting)) / total_weight)
def _require_in_band( efficacy_or_v: float, km: float, weighting: Weighting, context: str ) -> None: """Raise if a photometric quantity's spectral overlap is negligible. Args: efficacy_or_v: Either a luminous efficacy [lm/W] or a bare V(lambda) value -- both are compared against ``km`` on the same fractional scale. km: The weighting's peak luminous efficacy [lm/W]. weighting: ``"photopic"`` or ``"scotopic"``, for the error message. context: What was being converted, for the error message. Raises: ValueError: If ``efficacy_or_v`` is a negligible fraction of ``km``. """ if efficacy_or_v < km * _NEGLIGIBLE_EFFICACY_FRACTION: lo, hi = VISIBLE_BAND_UM raise ValueError( f"Cannot convert {context} to a {weighting} photometric quantity: " f"its spectral content has negligible overlap with the " f"{weighting} V(lambda) curve (nonzero only within " f"{lo:g}-{hi:g} um). This would silently report ~0 rather than " "a meaningful value, so it raises instead (guardrail). " "Pass an explicit in-band wavelength_um, or verify the trace's " "source spectrum actually falls in the visible band." )
[docs] def lumens_to_watts( total_flux_lm: float, spectrum: Spectrum, weighting: Weighting = "photopic" ) -> float: """Convert a source's lumens to the radiometric watts NSQ traces in. Args: total_flux_lm: Source output in lumens. spectrum: The source's :class:`~optiland.nonsequential.sources.base.Spectrum` -- its shape (not absolute scale) determines the conversion. weighting: ``"photopic"`` (default) or ``"scotopic"``. Returns: Radiant flux [W] such that a source emitting this many watts, with this spectral shape, emits ``total_flux_lm`` lumens. Raises: ValueError: If the spectrum has negligible overlap with the chosen V(lambda) curve (guardrail) -- e.g. a monochromatic 1.5 um source has no photopic lumen equivalent worth reporting. """ _, _, km = _table(weighting) efficacy = luminous_efficacy_of_spectrum( spectrum.wavelengths, spectrum.weights, weighting ) _require_in_band(efficacy, km, weighting, "this source's spectrum") return total_flux_lm / efficacy
def _resolve_wavelength_weighting( result: object, weighting: Weighting, wavelength_um: float | None, ) -> tuple[np.ndarray, np.ndarray]: """Resolve the (wavelengths, V) pair a result should be weighted by. Args: result: A detector result object. weighting: ``"photopic"`` or ``"scotopic"``. wavelength_um: Explicit monochromatic wavelength, required for a result type that carries no spectral breakdown of its own (``IrradianceMap``, ``FarFieldPattern``). Returns: ``(wavelengths_um, V_values)``, both length >= 1, describing the weighting to apply. For a ``SpectralResult`` this is its own wavelength bins; otherwise the single ``wavelength_um``. Raises: ValueError: If a spectral breakdown is required but neither the result nor ``wavelength_um`` provides one. """ result_wavelengths = getattr(result, "wavelengths", None) if result_wavelengths is not None: wl = np.asarray(result_wavelengths, dtype=np.float64) return wl, v_lambda(wl, weighting) if wavelength_um is None: raise ValueError( f"{type(result).__name__} carries no per-wavelength breakdown " "(only SpectralResult does); pass wavelength_um= explicitly -- " "e.g. the monochromatic wavelength the source scene used." ) wl = np.asarray([wavelength_um], dtype=np.float64) return wl, v_lambda(wl, weighting)
[docs] class PhotometricMap: """A 2D photometric map -- illuminance [lux] or luminance-equivalent. Mirrors :class:`~optiland.nonsequential.results.irradiance_map.IrradianceMap`'s shape so it can be plotted the same way, but the values and units are photometric, not radiometric. Attributes: data: Photometric map, shape (ny, nx). Lux for ``quantity= "illuminance"``. x_coords: Bin centre x-coordinates [mm], shape (nx,). y_coords: Bin centre y-coordinates [mm], shape (ny,). total: Total luminous flux [lm] over the whole map. quantity: The photometric quantity computed (``"illuminance"``). weighting: ``"photopic"`` or ``"scotopic"``. """ def __init__( self, data: np.ndarray, x_coords: np.ndarray, y_coords: np.ndarray, total: float, quantity: str, weighting: Weighting, ) -> None: """Initialize PhotometricMap.""" self.data = data self.x_coords = x_coords self.y_coords = y_coords self.total = float(total) self.quantity = quantity self.weighting = weighting
[docs] def plot(self, ax=None, **kwargs): """Plot the photometric map. Args: ax: Optional Matplotlib Axes. If None, a new figure is created. **kwargs: Additional arguments passed to imshow. Returns: The Matplotlib Figure object. """ import matplotlib.pyplot as plt # noqa: PLC0415 if ax is None: fig, ax = plt.subplots() else: fig = ax.get_figure() unit = "lux" if self.quantity == "illuminance" else self.quantity extent = [ self.x_coords[0], self.x_coords[-1], self.y_coords[0], self.y_coords[-1], ] im = ax.imshow( self.data, origin="lower", extent=extent, aspect="equal", **kwargs ) plt.colorbar(im, ax=ax, label=f"{self.quantity.capitalize()} [{unit}]") ax.set_xlabel("x [mm]") ax.set_ylabel("y [mm]") ax.set_title(f"{self.quantity.capitalize()} ({self.weighting}) map") return fig
[docs] class PhotometricScalar: """A single photometric quantity (e.g. total luminous flux). Attributes: value: The photometric value. quantity: ``"luminous_flux"`` (lumens). weighting: ``"photopic"`` or ``"scotopic"``. """ def __init__(self, value: float, quantity: str, weighting: Weighting) -> None: """Initialize PhotometricScalar.""" self.value = float(value) self.quantity = quantity self.weighting = weighting def __float__(self) -> float: return self.value def __repr__(self) -> str: unit = "lm" if self.quantity == "luminous_flux" else self.quantity return f"PhotometricScalar({self.value:.6g} {unit}, {self.weighting})"
[docs] def to_photometric( result: IrradianceMap | SpectralResult | FarFieldPattern, quantity: Literal["illuminance", "luminous_flux"] = "illuminance", weighting: Weighting = "photopic", wavelength_um: float | None = None, ) -> PhotometricMap | PhotometricScalar: """Convert a detector result to a photometric quantity. The radiometric result is unaffected; this returns a new object. Args: result: An ``IrradianceMap``, ``SpectralResult``, or ``FarFieldPattern`` (from ``SimulationResult.detectors[name]``). quantity: ``"illuminance"`` (lux, per-pixel map) or ``"luminous_flux"`` (total lumens, scalar). weighting: ``"photopic"`` (default, CIE 1931) or ``"scotopic"`` (CIE 1951). wavelength_um: Required when ``result`` carries no per-wavelength breakdown of its own (i.e. anything but ``SpectralResult``) -- the monochromatic wavelength to weight by. Returns: A :class:`PhotometricMap` for ``quantity="illuminance"``, or a :class:`PhotometricScalar` for ``quantity="luminous_flux"``. Raises: ValueError: If the result's spectral content -- explicit ``wavelength_um`` or the result's own wavelength bins -- has negligible overlap with the chosen V(lambda) curve (guardrail: this would otherwise silently return ~0). TypeError: If ``result`` has neither a pixel grid nor per -wavelength data (e.g. a ``RayDatabase``), or ``quantity`` is not recognised. """ _, _, km = _table(weighting) wl, v_vals = _resolve_wavelength_weighting(result, weighting, wavelength_um) if v_vals.sum() <= 0.0: lo, hi = VISIBLE_BAND_UM wl_desc = f"{wl[0]:g}" if wl.size == 1 else f"{wl.min():g}-{wl.max():g}" raise ValueError( f"Cannot convert to a {weighting} photometric quantity: " f"wavelength(s) {wl_desc} um have negligible overlap with the " f"{weighting} V(lambda) curve (nonzero only within " f"{lo:g}-{hi:g} um). This would silently report ~0 rather than " "a meaningful value, so it raises instead (guardrail)." ) if quantity == "illuminance": irradiance = getattr(result, "irradiance", None) if irradiance is None: raise TypeError( f"{type(result).__name__} has no 'irradiance' map; " "illuminance requires IrradianceMap or SpectralResult." ) irradiance = np.asarray(irradiance, dtype=np.float64) irradiance_w_per_m2 = irradiance * 1.0e6 # W/mm^2 -> W/m^2 if irradiance.ndim == 3: # SpectralResult: (ny, nx, n_lambda). Each bin's irradiance is # already the flux collected *within* that wavelength bin (not # a spectral density), so summing V(lambda_bin) * irradiance_bin # over bins is the correct discrete form of the lux integral -- # no additional bin-width factor. weight = km * v_vals illuminance = np.tensordot(irradiance_w_per_m2, weight, axes=([2], [0])) else: illuminance = irradiance_w_per_m2 * (km * v_vals[0]) pixel_area_mm2 = ( (result.x_coords[1] - result.x_coords[0]) * (result.y_coords[1] - result.y_coords[0]) if len(result.x_coords) > 1 and len(result.y_coords) > 1 else 1.0 ) total_lumens = float(illuminance.sum() * pixel_area_mm2 * 1e-6) return PhotometricMap( data=illuminance, x_coords=result.x_coords, y_coords=result.y_coords, total=total_lumens, quantity="illuminance", weighting=weighting, ) if quantity == "luminous_flux": total_flux_w = getattr(result, "total_flux", None) if total_flux_w is None: raise TypeError( f"{type(result).__name__} has no 'total_flux'; cannot " "compute luminous_flux." ) from optiland.backend.utils import to_numpy # noqa: PLC0415 total_flux_w = float(to_numpy(total_flux_w)) irradiance = getattr(result, "irradiance", None) if wl.size > 1 and irradiance is not None: # SpectralResult: weight each bin by its actual share of the # collected flux (summed over pixels), not a flat average -- # a spectrum concentrated near the V(lambda) peak should get # a higher effective efficacy than one spread evenly across # the same bins. per_bin_flux = np.asarray(irradiance, dtype=np.float64).sum(axis=(0, 1)) total = per_bin_flux.sum() weight = ( km * float(np.sum(per_bin_flux * v_vals) / total) if total > 0 else km * v_vals.mean() ) elif wl.size > 1: weight = km * v_vals.mean() else: weight = km * v_vals[0] return PhotometricScalar( value=total_flux_w * weight, quantity="luminous_flux", weighting=weighting ) raise TypeError( f"Unknown quantity {quantity!r}; expected 'illuminance' or 'luminous_flux'." )