Source code for solprop.recipe.pins

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

"""Pins: a recipe value set from a design target instead of a literal (SEP 41, S1).

A recipe's ``[pins]`` table sets a whole value from a target of
:func:`solprop.tools.solve_parameter`::

    [pins]
    pressure = {target = "zdw", wavelength = "800 nm", search = ["0.1 bar", "10 bar"]}
    energy = {target = "soliton_order", order = 4}

Pins resolve in two fixed stages after the literals: at most one pin on a parameter
of the guide or fill (``zdw`` or ``rdw``), solved on the launch mode at the launch
position; then the pins on pulses (``soliton_order``), which read the mode the first
stage settled. So no order has to be declared and no cycle can be written.
:func:`solve_pin` is the solver call on a route's own keywords, which the Python
export writes before the route's call.
"""

import math
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final, Literal, cast

import numpy as np

from solprop.exceptions import SolpropError
from solprop.recipe.errors import RecipeError
from solprop.recipe.metadata import OBJECT_ROUTES
from solprop.recipe.paths import Attribute, Index, format_path, parse_path
from solprop.recipe.quantity import Unit, parse_quantity

if TYPE_CHECKING:
    from solprop.driver.simulation import Simulation
    from solprop.modes.base import Mode
    from solprop.physdata.mixtures import Mixture
    from solprop.recipe.core import Recipe
    from solprop.tools import DesignRoots, DesignTarget, RootChoice

__all__ = [
    "GUIDE_PINS",
    "PULSE_PINS",
    "TARGETS",
    "Pin",
    "PinStage",
    "design_target",
    "placeholder",
    "read_pins",
    "solve_pin",
    "solve_pulse",
]

type PinStage = Literal["guide", "pulse"]
"""The stage a pin resolves in: a parameter of the guide or fill, or a pulse's."""

GUIDE_PINS: Final[Mapping[str, Mapping[str, str]]] = {
    "capillary": {"radius": "core_radius", "pressure": "pressure"},
}
"""The guide and fill keywords each route can pin, and the mode parameter each sets."""

PULSE_PINS: Final = ("energy", "peak_power")
"""The pulse keywords a pin can set, on the route or on one of its ``pulses``."""

TARGETS: Final[Mapping[str, tuple[PinStage, tuple[str, ...], tuple[str, ...]]]] = {
    "zdw": ("guide", ("wavelength",), ()),
    "rdw": (
        "guide",
        ("rdw",),
        ("peak_power", "fwhm", "shape", "nonlinear_phase"),
    ),
    "soliton_order": ("pulse", ("order",), ()),
}
"""Each target: its stage, its required keys and its optional ones, beside
``target``, ``search`` and ``root``."""

_UNITS: Final[Mapping[str, str | None]] = {
    "wavelength": "m",
    "rdw": "m",
    "peak_power": "W",
    "fwhm": "s",
    "shape": None,
    "nonlinear_phase": None,
    "order": None,
}
"""The unit each target key is read in."""

# The placeholder a pulse pin stands at while the guide is solved: the mode does not
# depend on it, and the route needs some energy or peak power to build.
_PLACEHOLDER: Final[Mapping[str, float]] = {"energy": 1e-6, "peak_power": 1e6}


[docs] @dataclass(frozen=True) class Pin: """One entry of a recipe's ``[pins]`` table, checked. Attributes ---------- path : str The canonical parameter path the pin sets. stage : {'guide', 'pulse'} Whether it sets a parameter of the guide or fill, or a pulse's energy or peak power. target : {'zdw', 'rdw', 'soliton_order'} The design target. keys : collections.abc.Mapping The target's keys, numbers in SI. search : tuple of float or None The search range in the pinned keyword's unit; ``None`` for a pulse pin without one. root : {'lowest', 'highest'} or int Which root, where there are several. """ path: str stage: PinStage target: str keys: Mapping[str, object] search: tuple[float, float] | None root: "RootChoice" # noqa: UP037 - TYPE_CHECKING only
[docs] def read_pins(recipe: "Recipe") -> tuple[Pin, ...]: # noqa: UP037 - run time """Return a recipe's pins, checked, guide pin first. Parameters ---------- recipe : Recipe The recipe. Returns ------- tuple of Pin The pins, the one on the guide or fill (if any) first. Raises ------ RecipeError On the pinned path, if a pin sets a value the route cannot pin, an element of an array, a path also set literally, or a second guide or fill parameter; or if its target, keys, search range or root do not read. On the object routes, which have no mode to solve a target on, any pin. """ table = recipe.to_dict().get("pins", {}) if table and recipe.route in OBJECT_ROUTES: msg = ( f"the {recipe.route} route has no guide or launch mode for a design " "target: write the value in [build]" ) raise RecipeError(msg, path=next(iter(table))) pins = [_read_pin(recipe, path, entry) for path, entry in table.items()] guide = [pin for pin in pins if pin.stage == "guide"] if len(guide) > 1: names = ", ".join(pin.path for pin in guide) msg = ( f"at most one parameter of the guide or fill can be pinned, and {names} " "are: solve one and write the other as a literal" ) raise RecipeError(msg, path=guide[1].path) return (*guide, *(pin for pin in pins if pin.stage == "pulse"))
def _read_pin(recipe: "Recipe", path: str, entry: object) -> Pin: # noqa: UP037 - one check per rule """Return one ``[pins]`` entry, checked. Parameters ---------- recipe : Recipe The recipe. path : str The entry's key, a parameter path. entry : object Its value, a table. Returns ------- Pin The pin. Raises ------ RecipeError On `path`, naming the rule broken. """ steps = parse_path(path) path = format_path(steps) if isinstance(steps[-1], Index): msg = ( "an element of an array cannot be pinned: pin the whole value (a pressure " "gradient cannot be pinned at all, because a target needs one mode)" ) raise RecipeError(msg, path=path) if _is_set(recipe, path): msg = "a pinned value cannot also be set in [build]: remove one of the two" raise RecipeError(msg, path=path) if not isinstance(entry, Mapping): msg = "a pin is a table: {target = ..., search = [low, high], ...}" raise RecipeError(msg, path=path) entry = cast("Mapping[str, object]", entry) target = entry.get("target") if target not in TARGETS: msg = f"a pin's target is one of {', '.join(TARGETS)}, not {target!r}" raise RecipeError(msg, path=path) target = cast("str", target) stage, required, optional = TARGETS[target] keyword = ( cast("Attribute", steps[0]).name if isinstance(steps[0], Attribute) else "" ) guide = GUIDE_PINS.get(recipe.route, {}) if stage == "guide" and (len(steps) != 1 or keyword not in guide): pinnable = ", ".join(guide) or "nothing" msg = ( f"a {target} pin sets a parameter of the guide or fill, which on the " f"{recipe.route} route is one of: {pinnable}" ) raise RecipeError(msg, path=path) if stage == "pulse" and not _is_pulse_path(steps): msg = ( f"a {target} pin sets a pulse's energy or peak_power (as energy or " "pulses[i].energy)" ) raise RecipeError(msg, path=path) allowed = {"target", "search", "root", *required, *optional} for key in entry: if key not in allowed: takes = ", ".join(sorted(allowed)) msg = f"a {target} pin has no key {key!r}; it takes {takes}" raise RecipeError(msg, path=path) for key in required: if key not in entry: msg = f"a {target} pin needs {key!r}" raise RecipeError(msg, path=path) if target == "rdw" and "peak_power" not in entry and "fwhm" in entry: msg = "an rdw pin's fwhm goes with its peak_power" raise RecipeError(msg, path=path) keys = { key: _read_key(entry[key], key, path) for key in (*required, *optional) if key in entry } unit = recipe._unit_at(path) search = entry.get("search") if search is None and stage == "guide": msg = "a pin on the guide or fill needs search = [low, high]" raise RecipeError(msg, path=path) return Pin( path=path, stage=stage, target=target, keys=keys, search=None if search is None else _read_search(search, unit, path), root=_read_root(entry.get("root", "lowest"), path), ) def _is_set(recipe: "Recipe", path: str) -> bool: # noqa: UP037 - run time """Return whether a path is also set in ``[build]``. Parameters ---------- recipe : Recipe The recipe. path : str The path. Returns ------- bool Whether ``recipe.get(path)`` finds a value. """ try: recipe.get(path) except RecipeError, KeyError, IndexError, TypeError: return False return True def _is_pulse_path(steps: tuple[object, ...]) -> bool: """Return whether a path names a pulse's energy or peak power. Parameters ---------- steps : tuple The parsed path. Returns ------- bool For ``energy``, ``peak_power`` or ``pulses[i].energy`` and the like. """ # A path starts with a keyword (parse_path), so names[0] is that keyword. names = [step.name for step in steps if isinstance(step, Attribute)] if len(steps) == 1: return names[0] in PULSE_PINS return ( len(steps) == 3 and names[0] == "pulses" and isinstance(steps[1], Index) and names[-1] in PULSE_PINS ) def _read_key(value: object, key: str, path: str) -> object: """Return a target key's value, a quantity in SI. Parameters ---------- value : object The value as written. key : str The key. path : str The pin's path, for errors. Returns ------- object A float in SI, or a string for a choice. Raises ------ RecipeError If the value does not read in the key's unit. """ unit = _UNITS[key] if unit is None: if key == "order": return _number(value, None, path) if not isinstance(value, str): msg = f"the pin's {key} is a string, not {value!r}" raise RecipeError(msg, path=path) return value return _number(value, unit, path) def _number(value: object, unit: str | None, path: str) -> float: """Return a number written plain or as a quantity. Parameters ---------- value : object A number, or a quantity string such as ``"800 nm"``. unit : str or None The unit it is read in; ``None`` for a plain number. path : str The pin's path, for errors. Returns ------- float The number in `unit`. Raises ------ RecipeError If it does not read, or is not finite. """ if isinstance(value, bool): msg = f"expected a number, got {value!r}" raise RecipeError(msg, path=path) if isinstance(value, int | float): number = float(value) elif isinstance(value, str) and unit is not None: number = float(parse_quantity(value, Unit(unit), path=path)) else: kind = "a number" if unit is None else "a number or a quantity" msg = f"expected {kind}, got {value!r}" raise RecipeError(msg, path=path) if not math.isfinite(number): msg = f"expected a finite number, got {value!r}" raise RecipeError(msg, path=path) return number def _read_search(value: object, unit: str | None, path: str) -> tuple[float, float]: """Return a search range, read in the pinned keyword's unit. Parameters ---------- value : object ``[low, high]``. unit : str or None The pinned keyword's unit. path : str The pin's path. Returns ------- tuple of float ``(low, high)``, ordered. Raises ------ RecipeError If it is not two ordered numbers. """ if not isinstance(value, list) or len(value) != 2: msg = "a pin's search is [low, high]" raise RecipeError(msg, path=path) low, high = (_number(item, unit, path) for item in value) if not low < high: msg = f"a pin's search is [low, high] with low < high, got {value!r}" raise RecipeError(msg, path=path) return low, high def _read_root(value: object, path: str) -> "RootChoice": # noqa: UP037 - run time """Return a root choice. Parameters ---------- value : object ``"lowest"``, ``"highest"`` or an integer index. path : str The pin's path. Returns ------- {'lowest', 'highest'} or int The choice. Raises ------ RecipeError If it is none of these. """ if value in ("lowest", "highest"): return cast("Literal['lowest', 'highest']", value) if isinstance(value, int) and not isinstance(value, bool): return value msg = f"a pin's root is 'lowest', 'highest' or an index, not {value!r}" raise RecipeError(msg, path=path) # ---------------------------------------------------------------------- solving
[docs] def design_target(pin: Pin, keywords: Mapping[str, object]) -> "DesignTarget": # noqa: UP037 - run time """Return the solver's target for a guide pin. Parameters ---------- pin : Pin A ``zdw`` or ``rdw`` pin. keywords : collections.abc.Mapping The route's keywords, for the pump ``wavelength``. Returns ------- ZDW or RDW The target. """ from solprop.tools import RDW, ZDW # noqa: PLC0415 - layer above recipes' import keys = pin.keys if pin.target == "zdw": return ZDW(cast("float", keys["wavelength"])) optional: dict[str, object] = { key: keys[key] for key in ("peak_power", "fwhm", "shape", "nonlinear_phase") if key in keys } return RDW( cast("float", keywords["wavelength"]), cast("float", keys["rdw"]), **optional, # type: ignore[arg-type] )
[docs] def solve_pin( route: str, keywords: Mapping[str, object], path: str, target: "DesignTarget", # noqa: UP037 - run time search: tuple[float, float], ) -> "DesignRoots": # noqa: UP037 - run time """Solve a parameter of a route's guide or fill for a design target. The route is built once from `keywords`, with the parameter at the middle of `search`, and its launch mode is re-evaluated at each trial value (no further builds), as :func:`solprop.tools.pressure_zdw` does; a target that needs the fill's Kerr coefficient is given it at each trial value. This is the call the Python export of a pinned recipe writes before the route's call. Parameters ---------- route : str The route, such as ``"capillary"``. keywords : collections.abc.Mapping The route's keywords, without the one solved for. path : str The keyword solved for, one of :data:`GUIDE_PINS` for the route. target : ZDW or RDW The target. search : tuple of float The search range, in the keyword's unit (bar for a pressure, m for a radius). Returns ------- solprop.tools.DesignRoots Every root in the range, or none with the attainable range. Raises ------ RecipeError If the keyword cannot be pinned on the route, or the route does not build with it at the middle of the range. Examples -------- >>> from solprop.recipe.pins import solve_pin >>> from solprop.tools import ZDW >>> keywords = dict(radius=125e-6, gas="He", length=1.0, wavelength=800e-9, ... fwhm=10e-15, trange=400e-15, wavelength_limits=(130e-9, 4e-6)) >>> roots = solve_pin("capillary", keywords, "pressure", ZDW(500e-9), (0.01, 10.0)) >>> f"{roots.pick('lowest'):.3f} bar" '1.278 bar' """ from solprop.tools import solve_parameter # noqa: PLC0415 - layer above parameter = GUIDE_PINS.get(route, {}).get(path) if parameter is None: msg = f"the {route} route cannot pin {path}" raise RecipeError(msg, path=path) low, high = search middle = math.sqrt(low * high) if low > 0.0 else 0.5 * (low + high) simulation = _build(route, {**keywords, path: middle}, path) mode = _launch_mode(simulation) wavelength = cast("float", keywords["wavelength"]) nonlinear = _needs_gamma(target) def build(value: float) -> "Mode | tuple[Mode, float]": # noqa: UP037 """Return the launch mode at a trial value, with its Kerr coefficient. Parameters ---------- value : float The trial value of the pinned parameter. Returns ------- Mode or tuple The mode, or the mode and ``gamma`` where the target needs it. """ trial = _at(mode, parameter, value) return (trial, _gamma(trial, wavelength)) if nonlinear else trial return solve_parameter(build, search, target)
[docs] def solve_pulse( route: str, keywords: Mapping[str, object], path: str, order: float, search: tuple[float, float] | None = None, ) -> "DesignRoots": # noqa: UP037 - run time """Return the energy or peak power that gives a pulse a soliton order. Closed form on the launch mode at the launch position, with the fill's Kerr coefficient (:func:`solprop.tools.soliton_peak_power`). The route is built once, the pulse at a placeholder, for its mode. This is the call the Python export of a recipe with a ``soliton_order`` pin writes before the route's call. Parameters ---------- route : str The route, such as ``"capillary"``. keywords : collections.abc.Mapping The route's keywords. For a route-level `path` (``"energy"`` or ``"peak_power"``) without it; for ``"pulses[i].energy"`` with the pulse built at any energy, which is not read. path : str The pulse value solved for. order : float The conventional soliton order, > 0. search : tuple of float or None, optional A range the value must lie in, in its unit (J or W); ``None`` for any. Returns ------- solprop.tools.DesignRoots The one value, or none with it as the attainable range when it lies outside `search`. Raises ------ RecipeError If the pulse is not a sech or ordinary Gaussian pulse, the dispersion at its wavelength is not anomalous, or the route does not build. Examples -------- >>> from solprop.recipe.pins import solve_pulse >>> keywords = dict(radius=125e-6, gas="He", pressure=0.5, length=1.0, ... wavelength=800e-9, fwhm=10e-15, trange=400e-15, ... wavelength_limits=(130e-9, 4e-6)) >>> roots = solve_pulse("capillary", keywords, "energy", 3.0) >>> f"{roots.pick() * 1e6:.1f} uJ" '348.3 uJ' """ from solprop.modes.base import beta2 # noqa: PLC0415 - layer above from solprop.pulses import GaussianPulse, SechPulse # noqa: PLC0415 from solprop.tools import ( # noqa: PLC0415 DesignRoots, SolitonOrder, pulse_energy, soliton_peak_power, ) steps = parse_path(path) name = cast("Attribute", steps[-1]).name built = dict(keywords) if len(steps) == 1: built[name] = _PLACEHOLDER[name] try: if len(steps) == 1: pulse = _route_pulse(built) else: pulses = cast("list[object]", built["pulses"]) pulse = pulses[cast("Index", steps[1]).position] if not isinstance(pulse, GaussianPulse | SechPulse): msg = "a soliton_order pin needs a sech or Gaussian pulse" raise RecipeError(msg, path=path) target = SolitonOrder(order, pulse) mode = _launch_mode(_build(route, built, path)) wavelength = pulse.wavelength omega = np.array([2 * math.pi * _C / wavelength]) dispersion = float(beta2(mode, omega)[0]) if not dispersion < 0.0: msg = ( f"the dispersion at {wavelength * 1e9:.4g} nm is normal " f"(beta2 = {dispersion:.3e} s^2/m): no soliton order to pin" ) raise RecipeError(msg, path=path) power = soliton_peak_power( target.order, target.fwhm, _gamma(mode, wavelength), dispersion, target.shape, ) except SolpropError as error: if isinstance(error, RecipeError): raise raise RecipeError(str(error), path=path) from error value = ( pulse_energy(power, target.fwhm, target.shape) if name == "energy" else power ) inside = search is None or search[0] <= value <= search[1] return DesignRoots( roots=(value,) if inside else (), search=(value, value) if search is None else search, target=target, builds=1, quantity=name, attainable=None if inside else (value, value), )
_C: Final = 299_792_458.0 def _route_pulse(keywords: Mapping[str, object]) -> object: """Return the pulse a route's own pulse keywords describe. Parameters ---------- keywords : collections.abc.Mapping The route's keywords: ``wavelength``, ``fwhm`` or ``width``, ``shape`` and ``energy`` or ``peak_power``. Returns ------- GaussianPulse or SechPulse The pulse. """ from solprop.pulses import GaussianPulse, SechPulse # noqa: PLC0415 shape = keywords.get("shape", "gauss") power = { name: keywords[name] for name in ("energy", "peak_power") if keywords.get(name) is not None } wavelength = cast("float", keywords["wavelength"]) if shape == "sech": widths = { name: keywords[name] for name in ("fwhm", "width") if keywords.get(name) is not None } return SechPulse(wavelength=wavelength, **widths, **power) # type: ignore[arg-type] return GaussianPulse( wavelength=wavelength, fwhm=cast("float", keywords["fwhm"]), **power, # type: ignore[arg-type] ) def _build(route: str, keywords: Mapping[str, object], path: str) -> "Simulation": # noqa: UP037 - run time """Build a route for its mode, a refusal named on the pinned path. Parameters ---------- route : str The route. keywords : collections.abc.Mapping Its keywords; a pulse without an energy or peak power (one a later pin solves) is built at a placeholder. path : str The pinned path. Returns ------- solprop.driver.simulation.Simulation The simulation, not translated. Raises ------ RecipeError If the route refuses the keywords. """ from solprop.recipe.metadata import surface # noqa: PLC0415 - cycle if keywords.get("pulses") is None and all( keywords.get(name) is None for name in PULSE_PINS ): # A pulse pin not yet solved: the mode does not depend on the pulse. keywords = {**keywords, "energy": _PLACEHOLDER["energy"]} try: return surface(route)(**keywords) except SolpropError as error: msg = f"the route does not build while the pin is solved: {error}" raise RecipeError(msg, path=path) from error def _launch_mode(simulation: "Simulation") -> "Mode": # noqa: UP037 - run time """Return the mode a pin solves on: the launch mode, the first of the set. Parameters ---------- simulation : solprop.driver.simulation.Simulation The built route. Returns ------- solprop.modes.base.Mode Its first mode. """ return simulation.modes[0] def _at(mode: "Mode", parameter: str, value: float) -> "Mode": # noqa: UP037 """Return the launch mode at a trial value of the pinned parameter. Parameters ---------- mode : solprop.modes.base.Mode The mode as built. parameter : str ``"pressure"`` (bar) or a mode parameter such as ``"core_radius"`` (m). value : float The trial value. Returns ------- solprop.modes.base.Mode The mode there. """ from solprop.tools import _capillary_at # noqa: PLC0415 - the tools' own re-fill if parameter == "pressure": return _capillary_at(cast("object", mode), value) # type: ignore[arg-type] return mode.at_parameters({parameter: value}) # type: ignore[attr-defined] def _gamma(mode: "Mode", wavelength: float) -> float: # noqa: UP037 - run time """Return the fill's nonlinear coefficient on a mode at the launch position. Parameters ---------- mode : solprop.modes.base.Mode The mode, filled. wavelength : float The pump wavelength, m. Returns ------- float ``gamma`` in rad/(W m). """ from solprop.tools import _fill_n2, nonlinear_coefficient # noqa: PLC0415 # The capillary and waveguide routes bind every mode to their fill. mixture = cast("Mixture", getattr(mode, "mixture", None)) return nonlinear_coefficient(mode, _fill_n2(mixture, wavelength)[0], wavelength) def _needs_gamma(target: "DesignTarget") -> bool: # noqa: UP037 - run time """Return whether a target needs the fill's Kerr coefficient. Parameters ---------- target : ZDW, RDW or SolitonOrder The target. Returns ------- bool For an RDW with a peak power, and a soliton order. """ from solprop.tools import RDW, SolitonOrder # noqa: PLC0415 return isinstance(target, SolitonOrder) or ( isinstance(target, RDW) and target.peak_power is not None )
[docs] def placeholder(path: str) -> float: """Return the value a pulse pin stands at while the guide is solved. Parameters ---------- path : str The pulse pin's path. Returns ------- float An energy of 1 uJ or a peak power of 1 MW, in SI. """ name = cast("Attribute", parse_path(path)[-1]).name return _PLACEHOLDER[name]