# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
r"""Synthesise a guide's exit field on a spatial grid (SEP 31, sections 4.1 and 5.2).
:class:`Outcouple` turns an ordinary modal plane -- true-power coefficients with
their scalar profiles (:class:`~solprop.coupling.models.ScalarModeBasis`) -- into a
radial or Cartesian :class:`~solprop.coupling.plane.FieldPlane` of the physical
field, under an **explicitly declared** forward interface model. No Fresnel factor
is implied: the one model offered, ``"matched"``, is the guide's exit into a medium
of its own core index, where the transverse field is continuous; a change of index is
a separate ``PlanarInterface`` element in the transport sequence that follows,
applied once.
The guide's field per true-power coefficient is
:math:`\sqrt{2/(\varepsilon_0c\operatorname{Re}n_{\rm eff})}\,v_m` (``docs/dev/
conventions.md`` section 4). It is written into the grid's columns through each
profile's closed-form transform (:func:`~solprop.coupling.coupler.target_columns`),
so a profile's kink at the core radius is not sampled; what the grid's spectral
window cannot hold is reported, not renormalised.
"""
from dataclasses import dataclass
from typing import Literal, cast
import numpy as np
from jaxtyping import Float
from solprop import fourier
from solprop.constants import EPS0, C
from solprop.coupling.coupler import target_columns
from solprop.coupling.plane import FieldPlane, PlaneBasis, PlaneMetric
from solprop.coupling.port import METRIC_TOLERANCE
from solprop.exceptions import CouplingError
from solprop.grid import FreeGrid, RadialGrid
from solprop.spatial import SpatialFlux, SpatialMedium
__all__ = ["Outcouple", "OutcoupleResult", "OutcouplingModel"]
OutcouplingModel = Literal["matched"]
"""A declared forward interface model of :class:`Outcouple`."""
[docs]
@dataclass(frozen=True, eq=False)
class OutcoupleResult:
"""The synthesised exit plane and its energy account.
Attributes
----------
plane : solprop.coupling.plane.FieldPlane
The radial or Cartesian plane of the exit field.
modal : numpy.ndarray
The modal plane's energy per bin in its native metric, J, shape
``(n_omega,)``.
synthesised : numpy.ndarray
The synthesised plane's energy per bin in its axial-admittance metric, J,
shape ``(n_omega,)``. It differs from `modal` by the scalar model's metric
defect (core index against effective index), the profiles' angular content
and what the grid's window misses; nothing is renormalised.
window : float
The largest departure of the profiles' Gram in the grid's spectral window
from the identity: the share of a profile the grid cannot represent.
"""
plane: FieldPlane
modal: Float[np.ndarray, " n_omega"]
synthesised: Float[np.ndarray, " n_omega"]
window: float
[docs]
@dataclass(frozen=True, eq=False)
class Outcouple:
"""Synthesise an ordinary guide's exit field onto a spatial grid.
Attributes
----------
space : solprop.grid.RadialGrid or solprop.grid.FreeGrid
The grid of the synthesised plane, centred on the guide's axis.
interface : {'matched'}
The declared forward interface model. ``'matched'``: the exit medium has
the guide's core index, so the transverse field is continuous and there is
no reflection; any other exit medium is refused, and its index change is a
separate planar interface.
medium : solprop.spatial.SpatialMedium or None, optional
The exit medium, whose index must match the core's.
index : numpy.ndarray or None, optional
Its real phase index at each bin instead, shape ``(n_omega,)``; by default
the guide's own core index.
Raises
------
CouplingError
For both a medium and an index.
Examples
--------
>>> from solprop.grid import RadialGrid
>>> Outcouple(RadialGrid(300e-6, 128))
Outcouple(matched, RadialGrid)
"""
space: RadialGrid | FreeGrid
interface: OutcouplingModel = "matched"
medium: SpatialMedium | None = None
index: Float[np.ndarray, " n_omega"] | None = None
def __post_init__(self) -> None:
"""Check that the exit medium is given once."""
if self.medium is not None and self.index is not None:
msg = "give the exit medium or its index, not both"
raise CouplingError(msg)
def __repr__(self) -> str:
"""Return a one-line description without arrays.
Returns
-------
str
The interface model and the grid's class.
"""
return f"Outcouple({self.interface}, {type(self.space).__name__})"
[docs]
def apply(self, plane: FieldPlane) -> OutcoupleResult:
"""Synthesise a modal plane's exit field on the grid.
Parameters
----------
plane : solprop.coupling.plane.FieldPlane
An ordinary modal plane carrying its scalar profiles (read with
``field_plane(run, source=simulation)``).
Returns
-------
OutcoupleResult
The spatial plane and its energy account.
Raises
------
CouplingError
If the plane is not modal or has no profiles, or the exit medium's index
is not the core's.
"""
profiles = plane.profiles
if plane.kind != "modal" or profiles is None:
msg = (
"Outcouple synthesises an ordinary modal plane with its profiles: read "
"it with field_plane(run, source=simulation)"
)
raise CouplingError(msg)
grid = plane.grid
core = cast("np.ndarray", profiles.core_index)
if self.medium is not None:
flux = SpatialFlux.resolve(grid, self.space, self.medium, z=0.0)
metric = PlaneMetric.from_flux(flux)
live = (grid.omega > 0) & grid.active_mask
outside = np.zeros(grid.n_omega)
outside[live] = np.real(
np.asarray(self.medium.index(grid.omega[live], 0.0))
)
else:
outside = (
core if self.index is None else np.asarray(self.index, dtype=np.float64)
)
metric = PlaneMetric.paraxial(self.space, outside, grid)
occupied = np.sum(np.abs(plane.samples) ** 2, axis=(0, 1)) > 0.0
scale = np.maximum(np.abs(core[occupied]), 1e-300)
if np.any(
np.abs(outside[occupied] - core[occupied]) > METRIC_TOLERANCE * scale
):
msg = (
"the exit medium's index is not the guide's core index: the 'matched' "
"model has no index change; put a PlanarInterface after it"
)
raise CouplingError(msg)
columns = target_columns(profiles, self.space)
jones = np.asarray(profiles.jones)
n_pol_in = plane.samples.shape[1]
if n_pol_in == 2:
jones_c = np.tile(np.eye(2), (profiles.n_modes, 1))
mode_of = np.repeat(np.arange(profiles.n_modes), 2)
else:
jones_c, mode_of = jones, np.arange(profiles.n_modes)
n_pol = 2 if np.any(jones_c[:, 1] != 0.0) else 1
effective = profiles.effective_index[mode_of]
factor = np.where(
effective > 0,
np.sqrt(2.0 / (EPS0 * C * np.where(effective > 0, effective, 1.0))),
0.0,
)
coefficients = fourier.spectrum_to_internal(plane.samples, kind=grid.kind)
amplitudes = coefficients.reshape(-1, grid.n_omega) * factor
internal = np.einsum(
"cj,cp,cw->jpw",
columns[mode_of],
jones_c[:, :n_pol].astype(complex),
amplitudes,
)
samples = fourier.spectrum_to_optics(internal, kind=grid.kind)
weights = np.asarray(self.space.spectral_weights)
gram = np.einsum("j,mj,nj->mn", weights, columns.conj(), columns)
values = np.linalg.eigvalsh(0.5 * (gram + gram.conj().T))
window = float(max(abs(values[0] - 1.0), abs(values[-1] - 1.0)))
basis = PlaneBasis.of_space(self.space)
synthesised_plane = FieldPlane(
basis.kind,
samples,
grid,
"V m" if basis.kind == "radial" else "V/m",
basis,
plane.reference,
plane.geometry,
metric,
plane.exterior,
self.medium,
plane.certificate,
provenance=plane.provenance.then(
f"Outcouple({self.interface}) onto {type(self.space).__name__}"
),
)
return OutcoupleResult(
synthesised_plane,
plane.spectral_energy(),
synthesised_plane.spectral_energy(),
window,
)