Source code for solprop.modes.protocol

# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""The mode protocol: what the rest of solprop needs from a waveguide mode.

A mode is anything with an effective index and a transverse field profile. The linear
operator needs ``neff`` and its analytic frequency derivative (the co-moving frame,
ADR-006); the multimode transform needs the field at quadrature nodes and the geometry
of the cross-section; the mode-averaged normalisation needs the effective area; the
translation layer needs the three traits that say how the mode changes along z, and, for
a mode whose whole z dependence is a handful of scalar paths, the two members of
:class:`ParameterisedMode` that let the operator be evaluated at parameter *values*
rather than at positions. Nothing here computes anything: implementations live in
:mod:`solprop.modes` and, for a dispersion-specified GNLSE mode, in
``solprop.nonlinear.gnlse``.

Conventions (``docs/dev/conventions.md`` section 5): ``Im n_eff > 0`` is loss;
``n_eff - 1`` is available without cancellation for gases through
:meth:`Mode.neff_minus_one`; points are ``(r, theta)`` for a polar cross-section and
``(x, y)`` for a Cartesian or slab one, in metres and radians; fields are the transverse
Cartesian components ``(E_x, E_y)`` of the *unnormalised* profile, whose normalisation
``N`` and effective area follow from them.

A mode **uniform in y** -- the hollow planar guide of :mod:`solprop.modes.slab` --
declares the ``"slab"`` cross-section and the optional ``transverse_measure`` trait
``"per_unit_width"``: its normalisation is a power per metre of y, its effective
"area" is an effective width in metres, and the powers and energies of a run on it are
per unit width (:data:`TransverseMeasure`). A mode without the trait is ``"area"``.
"""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal, Protocol, cast, runtime_checkable

import numpy as np
from jaxtyping import Complex, Float

from solprop._display import Displayed, line
from solprop.exceptions import ModeError
from solprop.profiles import ZProfile

__all__ = [
    "GEOMETRY_PATH_NAMES",
    "ChannelLabel",
    "CrossSection",
    "Handedness",
    "Mode",
    "ParameterisedMode",
    "Parity",
    "PolarisationComponent",
    "TransverseMeasure",
]

GEOMETRY_PATH_NAMES = ("core_radius", "width_x", "width_y", "area_path")
"""The attribute names under which a mode declares its cross-section along z.

A hollow capillary declares ``core_radius``, a rectangular guide its two widths, and
a :class:`solprop.nonlinear.gnlse.SpecifiedMode` its ``area_path``: the
effective area is the whole of its cross-section, so its breakpoints are
cross-section kinks exactly as a bore corner is. Each is a
:class:`solprop.profiles.ZProfile`.

The names live here because two modules must agree on them and neither may import
the other: :mod:`solprop.translate.modal` walks them for the kinks the tables and the
schedule land on, and :mod:`solprop.nonlinear.gnlse` walks them for the z ownership of
a mode a specified fibre is built from. The translation layer's tuple is these names
plus ``dispersion_path``.
"""

CrossSection = Literal["polar", "cartesian", "slab"]
"""How a mode parametrises its cross-section: ``(r, theta)``, ``(x, y)`` or ``x`` alone.

A ``"slab"`` cross-section is uniform in y: its limits are one-tuples ``(-a,)`` and
``(a,)`` in x, and its points are still ``(x, y)`` pairs, with ``y = 0`` on the rule's
nodes and the profile independent of it.
"""

TransverseMeasure = Literal["area", "per_unit_width"]
"""What the transverse integral of a mode or a grid measures.

``"area"`` is an integral over the whole cross-section: powers in W, energies in J,
effective areas in m². ``"per_unit_width"`` is an integral over x alone for a geometry
uniform in y: powers in W/m, energies in J/m, the effective "area" an effective width
in m. Intensities are W/m² either way. A mode declares it with an optional
``transverse_measure`` property, read by name, whose absence means ``"area"``.
"""

PolarisationComponent = Literal["x", "y"]
"""Name one transverse Cartesian field component."""

Parity = Literal["even", "odd"]
"""Which of a degenerate azimuthal pair a channel is; :class:`ChannelLabel` defines it.

Not restated here: one definition, in one place.
"""

Handedness = Literal["left", "right"]
"""Which circular partner a complex degenerate channel is."""


[docs] @dataclass(frozen=True) class ChannelLabel(Displayed): """Name one physical channel of a waveguide, independently of its position. The identity the mode axis carries: the family and the quantum numbers a person quotes, with ``None`` wherever a family has no such number -- an axially symmetric ``TE_0m`` has no parity partner, so its parity is ``None``, and a real basis has no handedness. The label belongs to the mode, not to its index in a collection: a set's order is the order of its tuple, and two members may carry the same label only where they are genuinely the same physical channel in different gauges. **Parity is defined once, here, for every family.** ``"even"`` is the member whose transverse pattern is the ``cos((n - 1) theta)`` one about the **x** axis -- for ``n = 1`` that is the x-polarised member -- and ``"odd"`` is its quarter-turn partner, ``sin((n - 1) theta)`` about x. A family reports the parity of the physical pattern, whatever its own construction is written in: an exact step-index ``HE_nm`` builds the pair from ``cos(n theta)`` and ``sin(n theta)`` on the *radial* component and a Marcatili ``HE_nm`` from a rotation angle, and both land on this one definition. Note that :attr:`azimuthal_order` is the family index ``n`` while the parity names the ``(n - 1)``-fold pattern the transverse vector field actually has; the two differ by one, and that is the physics of a hybrid mode rather than a slip. Attributes ---------- family : str Non-empty family name: ``HE``, ``EH``, ``TE``, ``TM``, ``LP``, ``rect`` or a provider's own declared family. azimuthal_order : int or None Non-negative azimuthal index ``n``, or ``None`` where the family has none. radial_order : int or None Positive radial index ``m``, or ``None`` where the family has none. parity : {'even', 'odd'} or None Which of a degenerate azimuthal pair, by the definition above, or ``None`` for a channel with no parity partner or in an unlabelled gauge. handedness : {'left', 'right'} or None Which circular partner of a complex degenerate pair, or ``None`` for a real basis. The convention is defined once, by :func:`solprop.modes.guide.circular_pair`: ``left`` is ``(e_even - i e_odd)/sqrt(2)``, positive helicity in the internal convention and positive Stokes ``V`` in the optics one (``docs/guide/setup/polarisation.md``). display_label : str Non-empty stable text for a person, a legend or a file: ``HE1,1:even``. Examples -------- >>> from solprop.modes.protocol import ChannelLabel >>> label = ChannelLabel("TE", 0, 1, None, None, "TE0,1") >>> label.display_label, label.parity ('TE0,1', None) """ family: str azimuthal_order: int | None radial_order: int | None parity: Parity | None handedness: Handedness | None display_label: str def __repr__(self) -> str: """Return a one-line description: the display label and the family. Returns ------- str For example ``ChannelLabel(HE1,1:even, family=HE)``. """ return line("ChannelLabel", [("", self.display_label), ("family", self.family)]) def _describe(self) -> tuple[str, list[tuple[str, object]]]: """Return the title and rows of the printed block. Returns ------- tuple The title and ``(key, value)`` rows. """ return f"ChannelLabel: {self.display_label}", [ ("family", self.family), ("azimuthal order", self.azimuthal_order), ("radial order", self.radial_order), ("parity", self.parity or "none"), ("handedness", self.handedness or "none"), ] def __post_init__(self) -> None: """Validate the family, the orders and the two partner labels. Raises ------ solprop.exceptions.ModeError If the family or the display label is empty, an order is negative, or a parity or handedness is not one of its two names. """ if not self.family or not self.display_label: raise ModeError("a channel label needs a family and a display label") for name, lowest in (("azimuthal_order", 0), ("radial_order", 1)): value = getattr(self, name) # bool is an int in Python, so True would otherwise become an order # here while :meth:`from_mapping` refuses it: one rule, both doors. if value is not None and ( not isinstance(value, int) or isinstance(value, bool) or value < lowest ): raise ModeError( f"{name} must be None or an integer of at least {lowest}, " f"got {value!r}" ) if self.parity not in (None, "even", "odd"): raise ModeError( f"parity must be 'even', 'odd' or None, got {self.parity!r}" ) if self.handedness not in (None, "left", "right"): raise ModeError( f"handedness must be 'left', 'right' or None, got {self.handedness!r}" )
[docs] @classmethod def from_mapping(cls, mapping: Mapping[str, object]) -> "ChannelLabel": # noqa: UP037 - the runtime hook resolves this while the class body runs """Rebuild a label from the mapping :meth:`as_mapping` produced. The round trip a provider's ``identity["channels"]`` and output schema 7 need: the mapping has been through JSON, so every value arrives as an ordinary Python object and is checked here before it becomes a label. Parameters ---------- mapping : collections.abc.Mapping Exactly the six keys of :meth:`as_mapping`, with a string family and display label, integer or ``None`` orders, and string or ``None`` parity and handedness. Returns ------- ChannelLabel The label. Raises ------ solprop.exceptions.ModeError If a key is missing or extra, or a value has the wrong type. Examples -------- >>> from solprop.modes.protocol import ChannelLabel >>> label = ChannelLabel("TE", 0, 1, None, None, "TE0,1") >>> ChannelLabel.from_mapping(label.as_mapping()) == label True """ allowed: Mapping[str, tuple[type, ...]] = { "family": (str,), "azimuthal_order": (int, type(None)), "radial_order": (int, type(None)), "parity": (str, type(None)), "handedness": (str, type(None)), "display_label": (str,), } if set(mapping) != set(allowed): raise ModeError( f"a channel label mapping needs exactly {sorted(allowed)}, got " f"{sorted(mapping)}" ) for name, kinds in allowed.items(): # bool is an int in Python and would silently become an order. if not isinstance(mapping[name], kinds) or isinstance(mapping[name], bool): names = " or ".join(kind.__name__ for kind in kinds) raise ModeError( f"a channel label's {name} must be {names}, got {mapping[name]!r}" ) return cls( cast("str", mapping["family"]), cast("int | None", mapping["azimuthal_order"]), cast("int | None", mapping["radial_order"]), cast("Parity | None", mapping["parity"]), cast("Handedness | None", mapping["handedness"]), cast("str", mapping["display_label"]), )
[docs] def as_mapping(self) -> Mapping[str, str | int | None]: """Return the label as the serialisable mapping an identity carries. Returns ------- collections.abc.Mapping The six fields by name, in the order they are declared, with ``None`` where a family has no such quantum number. This is what a provider's ``identity`` publishes and what output schema 7 will persist. Examples -------- >>> from solprop.modes.protocol import ChannelLabel >>> mapping = ChannelLabel("TE", 0, 1, None, None, "TE0,1").as_mapping() >>> mapping["display_label"], mapping["parity"] ('TE0,1', None) """ return { "family": self.family, "azimuthal_order": self.azimuthal_order, "radial_order": self.radial_order, "parity": self.parity, "handedness": self.handedness, "display_label": self.display_label, }
[docs] @runtime_checkable class Mode(Protocol): """A guided mode as consumed by the linear operator, the transforms and the stats. Every method takes the position ``z`` in metres, so that tapers and pressure gradients are ordinary cases; a z-independent mode ignores it and reports ``z_constant``. """
[docs] @property def z_constant(self) -> bool: """Whether the mode is independent of ``z`` (no taper, no gradient).""" ...
[docs] @property def scale_invariant(self) -> bool: """Whether the profile scales exactly with the core radius along ``z``. A Marcatili mode has ``e(r; a) = f(r / a)`` and ``N ∝ a²``, so the synthesis and projection matrices of the multimode transform rescale by two scalars per ``z`` instead of being re-evaluated. """ ...
[docs] @property def channel_label(self) -> ChannelLabel: """Name the physical channel this mode is, with its quantum numbers. The label is a property of the mode itself, not of its place in a set: :attr:`solprop.modes.collection.ModeCollection.channel_labels` is the ordered tuple of its members' labels, and output schema 7 persists them beside the mode axis. """ ...
[docs] @property def azimuthal_order(self) -> int: """Highest azimuthal order ``h`` of the profile, which sets the theta rule size. The periodic trapezoid rule is exact for trigonometric polynomials of degree at most ``n_theta - 1`` and cubic products of order-``h`` modes reach degree ``4 h``, so the transform needs ``n_theta > 4 h``. """ ...
[docs] def principal_component(self, z: float = 0.0) -> PolarisationComponent | None: """Return the sole nonzero Cartesian component, or both components as None. This is a property of the continuous profile, not a test at quadrature nodes: an aliased rule must not hide a component that the mode actually carries. Parameters ---------- z : float, optional Position along the waveguide, m. Returns ------- {'x', 'y'} or None The sole nonzero component; ``None`` means both components are needed. """ ...
[docs] def neff( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: """Return the complex effective index. Parameters ---------- omega : numpy.ndarray Angular frequencies, rad/s, shape ``(n_omega,)``, strictly positive: no waveguide has a mode at zero frequency, and the linear operator evaluates on the active band, which excludes it. z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray ``n_eff(omega, z)``, complex, shape ``(n_omega,)``; ``Im n_eff > 0`` is loss. """ ...
[docs] def neff_minus_one( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: """Return ``n_eff - 1`` formed without cancellation. Parameters ---------- omega : numpy.ndarray Angular frequencies, rad/s, shape ``(n_omega,)``, strictly positive: no waveguide has a mode at zero frequency, and the linear operator evaluates on the active band, which excludes it. z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray ``n_eff - 1``, complex, shape ``(n_omega,)``, exact to rounding for a gas-filled waveguide (``docs/dev/conventions.md`` section 5). """ ...
[docs] def dneff_domega( self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 ) -> Complex[np.ndarray, " n_omega"]: """Return the analytic frequency derivative of the effective index. Parameters ---------- omega : numpy.ndarray Angular frequencies, rad/s, shape ``(n_omega,)``, strictly positive: no waveguide has a mode at zero frequency, and the linear operator evaluates on the active band, which excludes it. z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray ``d n_eff / d omega`` in s/rad, complex, shape ``(n_omega,)``. Analytic where the model allows (ADR-006); an implementation that differences must say so in its docstring and report the accuracy it reaches. """ ...
[docs] def aeff(self, z: float = 0.0) -> float: """Return the effective area. Parameters ---------- z : float, optional Position along the waveguide, m. Returns ------- float ``A_eff = (∫|e|² dA)² / ∫|e|⁴ dA`` in m²; for a mode uniform in y (``transverse_measure = "per_unit_width"``) the same ratio over x alone, an effective width in m. """ ...
[docs] def dimlimits( self, z: float = 0.0 ) -> tuple[CrossSection, tuple[float, ...], tuple[float, ...]]: """Return the cross-section parametrisation and the integration limits. Parameters ---------- z : float, optional Position along the waveguide, m. Returns ------- tuple ``(kind, lower, upper)``: ``"polar"`` with ``(r, theta)`` limits, ``"cartesian"`` with ``(x, y)`` limits, or ``"slab"`` with the one-tuple ``x`` limits of a guide uniform in y, in m and rad. """ ...
[docs] def field( self, points: Float[np.ndarray, "n_points 2"], z: float = 0.0 ) -> Float[np.ndarray, "n_points 2"]: """Return the transverse field components at points of the cross-section. Parameters ---------- points : numpy.ndarray Shape ``(n_points, 2)``: ``(r, theta)`` or ``(x, y)`` per :meth:`dimlimits`; a slab mode reads ``x`` and ignores ``y``. z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray ``(E_x, E_y)`` of the unnormalised profile at each point, shape ``(n_points, 2)``, real. """ ...
[docs] @runtime_checkable class ParameterisedMode(Mode, Protocol): """A mode whose whole z dependence runs through named scalar paths. A Marcatili mode reaches ``z`` only through its core radius ``a(z)`` and its number density ``rho(z)``, so its effective index is a function of those two scalars and the frequency: ``n_eff(omega, z) = n_eff(omega; a(z), rho(z))``. Stating that here is what lets :func:`solprop.linear.dispersion_operator` be called on parameter values rather than on positions, and so lets the translation layer expand the cumulative operator in the parameters instead of tabulating it along z (``docs/dev/kernel-contract.md`` section 3). A declared path is a statement of **z ownership** -- this is where the mode's z dependence lives, so these are its kinks and this is what an expansion may be taken in -- and not a statement that the linear operator depends on it: a specified mode declares its effective area, whose whole effect is on the nonlinear coefficient, and the operator is then expanded in a parameter it does not depend on, at degree zero. A mode whose declared paths are **not** the whole of its z dependence says so with the optional ``parameters_own_z`` trait, which :func:`solprop.translate.linop.parameters_own_z` reads by name. It defaults to ``True`` -- what every mode written before the trait existed implied -- and a mode that reports ``False`` keeps the translation layer on the z table instead of an expansion its parameters cannot reproduce. The case is a :class:`solprop.nonlinear.gnlse.SpecifiedMode` whose dispersion follows a path beside its declared area: the area is still its kinks, but freezing the area would leave the dispersion moving. A mode that does not implement this is not less capable, only less exploitable: the translation layer falls back to the z table for it, which is the general representation. A z-constant mode need not implement it either, having a cheaper path still. """
[docs] @property def parameter_paths(self) -> Mapping[str, ZProfile]: """Return the named scalar paths the effective index depends on. Returns ------- collections.abc.Mapping Path name to :class:`~solprop.profiles.ZProfile`, for example ``{"core_radius": ..., "density": ...}``. Every key is accepted by :meth:`at_parameters`; constant paths are included, so that a caller sees the whole dependence and decides for itself which of them vary. """ ...
[docs] def at_parameters(self, values: Mapping[str, float]) -> "Mode": # noqa: UP037 - beartype evaluates the annotation eagerly """Return the same mode with the named paths frozen at the given values. Parameters ---------- values : collections.abc.Mapping Path name to value, in the units of :attr:`parameter_paths`. Every name must be one of that mapping's keys; a name it does not know is an error rather than a silent no-op. Names left out keep their path. Returns ------- Mode A mode that reports ``z_constant`` and reproduces this one at every position where its paths take the given values. Raises ------ solprop.exceptions.ModeError If a name is not a declared path, or a value is outside the path's range of validity. """ ...