Source code for solprop.modes.collection

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

r"""Ordered, validated sets of modes, and the overlaps between them.

A multimode simulation propagates a *set* of modes through one transverse quadrature
rule and one pair of transform matrices, which only works if they share a
cross-section. :class:`ModeCollection` is that set: ordered, because the mode axis of
the kernel state is, and validated once at construction rather than at every use.

It also answers the two questions the translation layer asks of a mode set --
whether the azimuthal coordinate has to be resolved, and to what order -- and offers
the overlaps that say whether the set is orthonormal at all.

:func:`overlap` integrates adaptively with :func:`scipy.integrate.dblquad`,
deliberately *not* on the fixed rule. The fixed rule's orthonormality is the thing
being checked in ``tests/modes/test_quadrature.py``; if the check used the same rule it
would be true by construction and would say nothing.

Overlaps use the package normalisation (``docs/dev/conventions.md`` section 4),

.. math:: \langle m_1 | m_2 \rangle
   = \tfrac12 \sqrt{\varepsilon_0/\mu_0} \int e_1^* \cdot e_2 \, \mathrm{d}A
   \Big/ \sqrt{N_1 N_2},

so a mode overlaps itself by one. For a slab set (:mod:`solprop.modes.slab`) the
integral is over x alone, per metre of y, with :func:`scipy.integrate.quad`.
"""

import logging
import math
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass
from typing import cast

import numpy as np
from jaxtyping import Float
from scipy.integrate import dblquad, quad

from solprop._display import Displayed
from solprop.constants import EPS0, ROOM_TEMPERATURE, C
from solprop.exceptions import ModeError
from solprop.modes.antiresonant import TubeLattice, VincettiMode
from solprop.modes.capillary import (
    LossSpec,
    MarcatiliMode,
    MarcatiliModel,
    ModeKind,
)
from solprop.modes.protocol import (
    ChannelLabel,
    CrossSection,
    Mode,
    TransverseMeasure,
)
from solprop.modes.slab import SlabMode, SlabPolarisation
from solprop.modes.stepindex import StepIndexMode
from solprop.physdata.gases import Gas
from solprop.physdata.glasses import GlassIndex
from solprop.physdata.mixtures import Mixture
from solprop.physdata.sellmeier import GasSusceptibility
from solprop.profiles import ZProfile

__all__ = [
    "ModeCollection",
    "orthonormality",
    "overlap",
    "polarisation_pairs",
    "transverse_measure",
]

logger = logging.getLogger(__name__)


def _normalisation(mode: Mode, z: float) -> float:
    """Return a mode's closed-form normalisation.

    Parameters
    ----------
    mode : Mode
        The mode.
    z : float
        Position along the waveguide, m.

    Returns
    -------
    float
        ``N`` in W.

    Raises
    ------
    ModeError
        If the mode has no closed-form normalisation.
    """
    closed_form: Callable[[float], float] | None = getattr(mode, "normalisation", None)
    if not callable(closed_form):
        raise ModeError(
            f"{type(mode).__name__} provides no normalisation(z), so its overlaps "
            "cannot be normalised"
        )
    return float(closed_form(z))


[docs] def overlap(first: Mode, second: Mode, z: float = 0.0) -> float: """Return the normalised overlap integral of two modes. Parameters ---------- first, second : Mode The modes, which must share a cross-section. z : float, optional Position along the waveguide, m. Returns ------- float ``1`` for a mode with itself and ``0`` for an orthogonal pair. Integrated adaptively, not on the transform's fixed rule, so that the rule can be checked against it. Raises ------ ModeError If the modes do not share a cross-section, or either has no closed-form normalisation. Examples -------- >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import overlap >>> mode = MarcatiliMode(125e-6, "He", 1.0) >>> f"{overlap(mode, mode):.9f}" '1.000000000' """ limits = first.dimlimits(z) if second.dimlimits(z) != limits: raise ModeError( f"an overlap needs one cross-section; {limits} and " f"{second.dimlimits(z)} differ" ) kind, lower, upper = limits scale = ( 0.5 * EPS0 * C / math.sqrt(_normalisation(first, z) * _normalisation(second, z)) ) def integrand(inner: float, outer: float) -> float: """Return the product of the two profiles times the area element. Parameters ---------- inner : float The first cross-section coordinate, ``r`` or ``x`` in m. dblquad passes the *inner* variable first, which is why the order looks reversed. outer : float The second coordinate, ``theta`` in rad or ``y`` in m. Returns ------- float The integrand of the overlap. """ point = np.array([[inner, outer]]) product = float(np.sum(first.field(point, z) * second.field(point, z))) return product * inner if kind == "polar" else product if kind == "slab": # Uniform in y: the overlap is per metre of y, over x alone, as N is. return scale * _overlap_along_x(first, second, (lower[0], upper[0]), z) # The outer variable is the second coordinate and the inner the first, with the # polar Jacobian r folded into the integrand above. value, _ = dblquad(integrand, lower[1], upper[1], lower[0], upper[0]) return scale * value
def _overlap_along_x( first: Mode, second: Mode, limits: tuple[float, float], z: float ) -> float: """Return the integral over x of two slab profiles' product, per metre of y. Parameters ---------- first, second : Mode Slab modes sharing one gap. limits : tuple of float The gap's edges in m. z : float Position along the waveguide, m. Returns ------- float ``int e_1 . e_2 dx`` in m, integrated adaptively. """ def along_x(x: float) -> float: """Return the product of the two slab profiles at one x. Parameters ---------- x : float Transverse coordinate in m. Returns ------- float The integrand of the per-unit-width overlap. """ point = np.array([[x, 0.0]]) return float(np.sum(first.field(point, z) * second.field(point, z))) value, _ = quad(along_x, limits[0], limits[1], limit=200) return value
[docs] def orthonormality( modes: Sequence[Mode], z: float = 0.0 ) -> Float[np.ndarray, "n_modes n_modes"]: """Return the matrix of normalised overlaps of a mode set. Parameters ---------- modes : sequence of Mode The modes, non-empty. z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray Shape ``(n_modes, n_modes)``, symmetric, the identity for an orthonormal set. Raises ------ ModeError If `modes` is empty, or the modes do not share a cross-section. Examples -------- >>> import numpy as np >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import orthonormality >>> modes = [MarcatiliMode(125e-6, "He", 1.0, m=m) for m in (1, 2)] >>> bool(np.max(np.abs(orthonormality(modes) - np.eye(2))) < 1e-9) True """ if not modes: raise ModeError("orthonormality needs at least one mode") count = len(modes) matrix = np.eye(count) for row in range(count): matrix[row, row] = overlap(modes[row], modes[row], z) for column in range(row + 1, count): value = overlap(modes[row], modes[column], z) matrix[row, column] = value matrix[column, row] = value return matrix
[docs] def transverse_measure(modes: Sequence[Mode]) -> TransverseMeasure: """Return what a mode set's transverse integrals measure, refusing a mixture. Parameters ---------- modes : sequence of Mode The modes, non-empty. Each reports the optional ``transverse_measure`` trait by name; a mode without it takes the measure of the mode it was built from -- a :class:`solprop.nonlinear.gnlse.SpecifiedMode` made from a slab mode keeps its source's per-unit-width normalisation and area -- and measures an area when there is none. Returns ------- str ``"area"`` or ``"per_unit_width"`` (:data:`solprop.modes.protocol.TransverseMeasure`). Raises ------ ModeError If the set is empty, or mixes a per-unit-width mode with an area mode, whose powers would be in W/m and W in one state. Examples -------- >>> from solprop.modes import MarcatiliMode, SlabMode, transverse_measure >>> transverse_measure([MarcatiliMode(125e-6)]) 'area' >>> transverse_measure([SlabMode(50e-6)]) 'per_unit_width' """ if not modes: raise ModeError("a transverse measure needs at least one mode") measures: list[TransverseMeasure] = [_measure_of(mode) for mode in modes] for index, measure in enumerate(measures[1:], start=1): if measure != measures[0]: raise ModeError( f"mode {index} ({type(modes[index]).__name__}) measures " f"{measure!r} but mode 0 ({type(modes[0]).__name__}) measures " f"{measures[0]!r}: a mode uniform in y carries a power per metre " "of y and an area mode a power, and one state cannot hold both" ) return measures[0]
def _measure_of(mode: Mode) -> TransverseMeasure: """Return one mode's transverse measure, following the modes it was built from. The chain is the one :func:`solprop.translate.modal._radius_kinks` walks: a specified mode keeps its source in its dispersion's ``mode``, and its area -- ``aeff()`` of that source unless one was declared -- is in the source's units. Parameters ---------- mode : Mode The mode. Returns ------- str The first declared ``transverse_measure`` along the chain, else ``"area"``. """ one: object = mode while one is not None: declared = getattr(one, "transverse_measure", None) if declared is not None: return cast("TransverseMeasure", declared) one = getattr(getattr(one, "dispersion", None), "mode", None) return "area"
[docs] def polarisation_pairs(modes: Sequence[Mode]) -> tuple[Mode, ...]: """Expand a mode set so that each degenerate mode carries both polarisations. A vector run propagates the two orthogonal polarisation states of a hybrid mode as two entries on the mode axis. The order is fixed and is the obvious one -- each mode immediately followed by its partner -- because the mode axis of the kernel state is positional and a reordering would be silent. A mode with no degenerate partner, such as ``TE_0m`` or ``TM_0m``, is carried once. Parameters ---------- modes : sequence of Mode The spatial modes. Returns ------- tuple of Mode The expanded set. Raises ------ ModeError If a mode cannot say whether it has a partner, which every ported mode can. Examples -------- >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import polarisation_pairs >>> pairs = polarisation_pairs([MarcatiliMode(125e-6, "He", 1.0)]) >>> len(pairs), f"{pairs[1].phi:.4f}" (2, '1.5708') """ expanded: list[Mode] = [] missing = object() for mode in modes: partner: Mode | None = getattr(mode, "orthogonal_polarisation", missing) # type: ignore[assignment] - checked immediately below if partner is missing: raise ModeError( f"{type(mode).__name__} does not report an orthogonal polarisation, " "so a vector mode set cannot be built from it" ) expanded.append(mode) if partner is not None: expanded.append(partner) return tuple(expanded)
[docs] @dataclass(frozen=True, eq=False) class ModeCollection(Displayed, Sequence[Mode]): """An ordered set of modes sharing one cross-section. Validated once, at construction: the mode axis of the kernel state is positional, and one transform matrix serves the whole set, so a set whose members disagree about the geometry is not a set. Attributes ---------- modes : sequence of Mode The modes, in the order the mode axis will carry them. Any sequence is accepted and is stored as a tuple, so a collection cannot be changed after it has been validated. """ modes: Sequence[Mode] def __post_init__(self) -> None: """Check that the set is non-empty and shares a cross-section. Raises ------ ModeError If the set is empty, mixes a per-unit-width (slab) mode with an area mode, or the cross-sections disagree at ``z = 0``. """ object.__setattr__(self, "modes", tuple(self.modes)) if not self.modes: raise ModeError("a mode collection needs at least one mode") transverse_measure(self.modes) self._validate_channels() limits = self.modes[0].dimlimits(0.0) for index, mode in enumerate(self.modes[1:], start=1): if mode.dimlimits(0.0) != limits and not self._same_stepindex_guide(mode): raise ModeError( f"every mode in a collection must share a cross-section; mode " f"{index} has {mode.dimlimits(0.0)} against {limits}" ) logger.debug( "mode collection of %d modes, %s, azimuthal order %d, full rule %s", len(self.modes), limits[0], self.azimuthal_order, self.needs_full_rule, ) def _same_stepindex_guide(self, mode: Mode) -> bool: """Check whether a channel shares the first physical guide. Parameters ---------- mode : StepIndexMode Existing bound lossless mode. Returns ------- bool Whether radius paths and both material laws agree. """ first = self.modes[0] return ( isinstance(first, StepIndexMode) and isinstance(mode, StepIndexMode) and (first.radius is mode.radius or first.radius == mode.radius) and first._core == mode._core and first._cladding == mode._cladding ) def _validate_channels(self) -> None: """Reject two members that name the same physical channel. Uniqueness is on the members' own :class:`ChannelLabel`, for every family: the label is what the mode axis carries and what output schema 7 persists, so two members sharing one is two rows for one channel. """ seen: set[ChannelLabel] = set() for mode in self.modes: label = mode.channel_label if label in seen: raise ModeError( f"duplicate physical channel {label.display_label}: two members " "name the same channel, and an axially symmetric TE/TM channel " "has no parity partner to tell two of them apart" ) seen.add(label)
[docs] @property def channel_labels(self) -> tuple[ChannelLabel, ...]: """Return each member's physical label, in the order of the mode axis. Returns ------- tuple of ChannelLabel The members' own :attr:`solprop.modes.protocol.Mode.channel_label`, in order. A label a family does not have is ``None``, never a string standing for one: an axially symmetric TE0m or TM0m channel has no parity partner, so its parity is ``None``, exactly as its handedness is. Output schema 7 persists these labels. Examples -------- >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import ModeCollection >>> modes = ModeCollection([MarcatiliMode(125e-6, "He", 1.0)]) >>> [label.display_label for label in modes.channel_labels] ['HE1,1:even'] """ return tuple(mode.channel_label for mode in self.modes)
[docs] @classmethod def with_polarisations(cls, modes: Sequence[Mode]) -> "ModeCollection": # noqa: UP037 - bound after the class body """Build a collection carrying both polarisations of every degenerate mode. Parameters ---------- modes : sequence of Mode The spatial modes. Returns ------- ModeCollection The expanded set, in the fixed order of :func:`polarisation_pairs`. Examples -------- >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import ModeCollection >>> modes = [MarcatiliMode(125e-6, "He", 1.0, m=m) for m in (1, 2)] >>> len(ModeCollection.with_polarisations(modes)) 4 """ return cls(polarisation_pairs(modes))
[docs] @classmethod def he1m( # noqa: PLR0913 - MarcatiliMode's own keywords, carried through cls, radius: float | ZProfile, gas: Gas | str, pressure: float | ZProfile, count: int, *, model: MarcatiliModel = "full", loss: LossSpec = True, cladding: GlassIndex | complex | None = None, temperature: float | ZProfile = ROOM_TEMPERATURE, susceptibility: GasSusceptibility | None = None, ) -> "ModeCollection": # noqa: UP037 - bound after the class body """Build the first `count` HE_1m modes of one gas-filled capillary. The linearly polarised hybrid modes of a hollow capillary, in the order of their radial index. They share a cross-section by construction, their azimuthal order is zero, so no azimuthal quadrature is needed, and they are the set a capillary run uses unless told otherwise. Every keyword after `count` is :class:`~solprop.modes.capillary.MarcatiliMode`'s own and is given to each mode unchanged, so a set can carry a test-only cladding or gas susceptibility the way a single mode can. Parameters ---------- radius : float or ZProfile Core radius, m, constant or along z. gas : Gas or str Filling gas. pressure : float or ZProfile Fill pressure, bar, constant or along z. count : int How many modes, at least one; the modes are HE_11 to HE_1count. model : {'full', 'reduced'}, optional Which Marcatili model the modes use. loss : bool, float or callable, optional Whether to include propagation loss, or the loss to use. cladding : GlassIndex, complex or None, optional The cladding index; ``None`` is the package default silica. temperature : float or ZProfile, optional Gas temperature, K, or a path of it along z. susceptibility : GasSusceptibility or None, optional A susceptibility to use in place of the built-in gas's. Returns ------- ModeCollection The validated set, in radial order. Raises ------ ModeError If `count` is below one. Examples -------- >>> from solprop.modes import ModeCollection >>> modes = ModeCollection.he1m(125e-6, "He", 0.8, 4) >>> len(modes), modes[3].m, modes.azimuthal_order (4, 4, 0) """ if count < 1: raise ModeError( f"a capillary mode set needs at least one mode, got {count}" ) return cls( [ MarcatiliMode( radius, gas, pressure, kind="HE", n=1, m=m, model=model, loss=loss, cladding=cladding, temperature=temperature, susceptibility=susceptibility, ) for m in range(1, count + 1) ] )
[docs] @classmethod def slab( # noqa: PLR0913 - SlabMode's own keywords, carried through cls, half_gap: float | ZProfile, orders: Sequence[int], gas: Gas | str | None = None, pressure: float | ZProfile = 0.0, *, polarisation: SlabPolarisation = "te", loss: LossSpec = True, cladding: GlassIndex | complex | None = None, temperature: float = ROOM_TEMPERATURE, susceptibility: GasSusceptibility | None = None, mixture: Mixture | None = None, ) -> "ModeCollection": # noqa: UP037 - bound after the class body """Build the given orders of one hollow planar guide, uniform in y. A centred, symmetric launch couples only to odd orders, so the set a compressor run wants is usually ``(1, 3, 5)``; an offset or tilted launch needs the even ones too. Every keyword is :class:`~solprop.modes.slab.SlabMode`'s own and is given to each mode unchanged. For TE and TM rows in one set, build the two polarisations and pass them to the constructor, or use :meth:`with_polarisations`: each is a distinct mode with its own row. Parameters ---------- half_gap : float or ZProfile Half the plate separation in m, constant or along z. orders : sequence of int Mode orders ``p >= 1``, non-empty and without repeats, in state order. gas : Gas, str or None, optional Filling gas; None for vacuum or an explicit mixture. pressure : float or ZProfile, optional Gas pressure in bar, constant or along z; default zero. polarisation : {'te', 'tm'}, optional ``"te"`` (E along y, the default) or ``"tm"`` (E along x), for every order. loss : bool, float or callable, optional Inherited mode loss policy. cladding : GlassIndex, complex or None, optional Plate index; None selects the default silica. temperature : float, optional Gas temperature in K. susceptibility : GasSusceptibility or None, optional Explicit gas susceptibility. mixture : Mixture or None, optional Ordered species and density paths; owns the gas and susceptibility. Returns ------- ModeCollection The validated set, in the order given. Raises ------ ModeError If `orders` is empty, repeats an order, or describes an invalid mode. Examples -------- >>> from solprop.modes import ModeCollection >>> modes = ModeCollection.slab(63.5e-6, (1, 3, 5), "Ar", 1.0) >>> [label.display_label for label in modes.channel_labels] ['TE1', 'TE3', 'TE5'] >>> modes.transverse_measure, modes.needs_full_rule ('per_unit_width', False) """ if not orders: raise ModeError("a slab mode set needs at least one order") return cls( tuple( SlabMode( half_gap, gas, pressure, order=order, polarisation=polarisation, loss=loss, cladding=cladding, temperature=temperature, susceptibility=susceptibility, mixture=mixture, ) for order in orders ) )
[docs] @classmethod def vincetti( # noqa: PLR0913 - the mode's material keywords, carried through cls, radius: float | ZProfile, gas: Gas | str | None = None, pressure: float | ZProfile = 0.0, count: int = 1, *, lattice: TubeLattice, loss: LossSpec = True, cladding: GlassIndex | complex | None = None, temperature: float | ZProfile = ROOM_TEMPERATURE, susceptibility: GasSusceptibility | None = None, mixture: Mixture | None = None, ) -> "ModeCollection": # noqa: UP037 - bound after the class body """Build the first `count` HE_1m modes of a tube-lattice fibre. The field and transverse quadrature remain Marcatili's. Corrections and loss are fundamental-mode fits; higher radial orders log that limitation. Parameters ---------- radius : float or ZProfile Positive core radius in m, constant or along z. gas : Gas, str or None, optional Filling gas; None for vacuum or an explicit mixture. pressure : float or ZProfile, optional Gas pressure in bar, constant or along z; default zero. count : int, optional Number of modes, at least one, in radial order. lattice : TubeLattice Tube geometry shared by all modes. loss : bool, float or callable, optional Inherited mode loss policy. cladding : GlassIndex, complex or None, optional Tube index; None selects the default silica. temperature : float or ZProfile, optional Gas temperature in K, or a path of it along z. susceptibility : GasSusceptibility or None, optional Explicit gas susceptibility. mixture : Mixture or None, optional Ordered species and density paths; owns the gas and susceptibility. Returns ------- ModeCollection Validated modes sharing the tube geometry and cross-section. Raises ------ ModeError If the mode count or geometry is invalid. """ if count < 1: raise ModeError(f"a Vincetti mode set needs at least one mode, got {count}") return cls( tuple( VincettiMode( radius, gas, pressure, m=index + 1, lattice=lattice, loss=loss, cladding=cladding, temperature=temperature, susceptibility=susceptibility, mixture=mixture, ) for index in range(count) ) )
[docs] @classmethod def marcatili( # noqa: PLR0913 - mode indices and MarcatiliMode's material keywords cls, radius: float | ZProfile, gas: Gas | str, pressure: float | ZProfile, kinds: Sequence[ModeKind], n: Sequence[int], m: Sequence[int], *, model: MarcatiliModel = "full", loss: LossSpec = True, cladding: GlassIndex | complex | None = None, temperature: float | ZProfile = ROOM_TEMPERATURE, susceptibility: GasSusceptibility | None = None, ) -> "ModeCollection": # noqa: UP037 - bound after the class body """Build an ordered mixture of hybrid and transverse capillary modes. Parameters ---------- radius : float or ZProfile Core radius, m, constant or along z. gas : Gas or str Filling gas. pressure : float or ZProfile Fill pressure, bar, constant or along z. kinds : sequence of {'HE', 'TE', 'TM'} Family of each mode, non-empty. n : sequence of int Azimuthal indices, one per family; nonzero for HE and zero for TE/TM. m : sequence of int Positive radial indices, one per family. model : {'full', 'reduced'}, optional Marcatili approximation used by every mode. loss : bool, float or callable, optional Propagation-loss specification passed to every mode. cladding : GlassIndex, complex or None, optional Cladding index; ``None`` selects the package default silica. temperature : float or ZProfile, optional Gas temperature, K, positive, or a path of it along z. susceptibility : GasSusceptibility or None, optional Explicit susceptibility in place of the selected fit. Returns ------- ModeCollection Modes in the input order, all with ``phi = 0``. Use :meth:`with_polarisations` to add degenerate partners. Raises ------ ModeError If the index sequences are empty, have different lengths, or describe an invalid mode. Examples -------- >>> from solprop.modes import ModeCollection >>> modes = ModeCollection.marcatili( ... 125e-6, "He", 1.0, ["HE", "HE", "TM"], [1, 2, 0], [1, 1, 1] ... ) >>> len(modes), modes.azimuthal_order (3, 1) """ if not kinds or len(kinds) != len(n) or len(kinds) != len(m): raise ModeError( "kinds, n and m must be non-empty sequences of equal length" ) return cls( tuple( MarcatiliMode( radius, gas, pressure, kind=kind, n=azimuthal, m=radial, model=model, loss=loss, cladding=cladding, temperature=temperature, susceptibility=susceptibility, ) for kind, azimuthal, radial in zip(kinds, n, m, strict=True) ) )
def __repr__(self) -> str: """Return a one-line description: how many modes, and the first of them. Returns ------- str For example ``ModeCollection(4 modes: MarcatiliMode(HE11, ...), ...)``. """ count = len(self.modes) rest = ", ..." if count > 1 else "" return ( f"ModeCollection({count} mode{'s' * (count != 1)}: {self.modes[0]!r}{rest})" ) 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. """ count = len(self.modes) return f"ModeCollection: {count} mode{'s' * (count != 1)}", [ (str(index), repr(mode)) for index, mode in enumerate(self.modes) ] def __len__(self) -> int: """Return the number of modes. Returns ------- int The size of the mode axis this set implies. """ return len(self.modes) def __getitem__(self, index: int) -> Mode: # type: ignore[override] - positional only; see below """Return one mode. Narrower than :meth:`collections.abc.Sequence.__getitem__`, which also accepts a slice, because the runtime type checker of the test session enforces the annotation and a slice would then return a sequence where a mode is declared. The mode axis is addressed by position everywhere in the package, so nothing slices a collection. Parameters ---------- index : int Position on the mode axis. Returns ------- Mode The mode. """ return self.modes[index] def __iter__(self) -> Iterator[Mode]: """Iterate over the modes in order. Returns ------- collections.abc.Iterator The modes. """ return iter(self.modes)
[docs] def dimlimits( self, z: float = 0.0 ) -> tuple[CrossSection, tuple[float, ...], tuple[float, ...]]: """Return the cross-section the whole set shares. Parameters ---------- z : float, optional Position along the waveguide, m. Returns ------- tuple As :meth:`solprop.modes.protocol.Mode.dimlimits`. Raises ------ ModeError If the modes disagree at this position, which a taper of one mode but not another would cause. Examples -------- >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import ModeCollection >>> ModeCollection([MarcatiliMode(125e-6, "He", 1.0)]).dimlimits()[0] 'polar' """ limits = self.modes[0].dimlimits(z) for index, mode in enumerate(self.modes[1:], start=1): if mode.dimlimits(z) != limits and self._same_stepindex_guide(mode): other = mode.dimlimits(z) limits = ( limits[0], limits[1], (max(limits[2][0], other[2][0]), limits[2][1]), ) elif mode.dimlimits(z) != limits: raise ModeError( f"the modes disagree about the cross-section at z = {z}: mode " f"{index} has {mode.dimlimits(z)} against {limits}" ) return limits
[docs] @property def azimuthal_order(self) -> int: """Return the highest azimuthal order in the set. Returns ------- int The bound that fixes how many azimuthal nodes a quartic product needs, ``n_theta >= 4 h + 1``. """ return max(mode.azimuthal_order for mode in self.modes)
[docs] @property def needs_full_rule(self) -> bool: """Return whether the azimuthal coordinate has to be resolved. Returns ------- bool ``False`` for a set whose products are azimuthally symmetric -- any set of ``HE_1m`` modes -- which then needs the radial rule alone, and for a slab set, which is uniform in y; ``True`` otherwise. """ kind = self.dimlimits()[0] if kind == "slab": return False return self.azimuthal_order > 0 or kind == "cartesian"
[docs] @property def transverse_measure(self) -> TransverseMeasure: """Return what the set's transverse integrals measure. Returns ------- str ``"per_unit_width"`` for a slab set, whose powers are W/m and energies J/m, and ``"area"`` otherwise (:func:`transverse_measure`). """ return transverse_measure(self.modes)
[docs] @property def z_constant(self) -> bool: """Return whether nothing in the set varies along z. Returns ------- bool ``True`` only when every mode says so. """ return all(mode.z_constant for mode in self.modes)
[docs] @property def scale_invariant(self) -> bool: """Return whether every mode rescales with the core radius. Returns ------- bool ``True`` when the transform may rescale its matrices along a taper rather than rebuilding them. """ return all(mode.scale_invariant for mode in self.modes)
[docs] def orthonormality(self, z: float = 0.0) -> Float[np.ndarray, "n_modes n_modes"]: """Return the matrix of normalised overlaps of the set. Parameters ---------- z : float, optional Position along the waveguide, m. Returns ------- numpy.ndarray Shape ``(n_modes, n_modes)``; the identity for an orthonormal set. Examples -------- >>> import numpy as np >>> from solprop.modes import MarcatiliMode >>> from solprop.modes.collection import ModeCollection >>> modes = ModeCollection( ... [MarcatiliMode(125e-6, "He", 1.0, m=m) for m in (1, 2)] ... ) >>> bool(np.max(np.abs(modes.orthonormality() - np.eye(2))) < 1e-9) True """ return orthonormality(self.modes, z)