Source code for thunder4d.design.specs

"""Design helper, part 1: what the user specifies.

Every knob of the design helper lives here, grouped in dataclasses:

    LaserSpec     the input pulse (front end)
    Target        what "best" means (objective, quality floors)
    Constraints   what is allowed (space, mirrors, Kerr, ionisation, spot pattern, pressure, gas)
    Losses        what is lost on the way in (mode matching)
    SearchSpace   which configurations are tried (gases, RoC, round trips, k, passes, ...)
    Refinement    how the best candidates are fine-tuned with the fast 1+1D code
    Tolerance     alignment tolerances reported for a design (information, not a constraint)

Cell geometry convention
------------------------
A Herriott cell is set by its RoC R, the number of round trips M of one re-entrant cycle and the
index k (coprime with M): d = R (1 + cos(pi k / M)) on the concentric branch (d > R) and
d = R (1 - cos(pi k / M)) on the planar branch (d < R), k < M / 2. One cycle is 2 M passes
(a pass = transit over d + bounce, starting right after a mirror, as in the 4D code) and puts M
spots on each mirror. The number of passes actually used
can be anything from SearchSpace.min_passes to 2 M (odd: the beam leaves on the other mirror).

Conventions
-----------
* SI units unless the field name says otherwise (_fs, _nm, _bar, _mm, _J_cm2, _W_cm2, ...).
* A constraint is a ``Limit``: a value, a bound ("max" | "min"), a mode ("hard" rejects,
  "soft" penalises, "off" ignores), a safety factor and a weight for soft penalties.
  ``value=None`` means "take the value from the catalogue" (e.g. the coating LIDT or the gas
  ionisation threshold), so a knob can stay active without hard-coding a number.
* Defaults are starting points, NOT recommendations: check them against your own setup.
"""
from __future__ import annotations

from collections.abc import Sequence
from dataclasses import asdict, dataclass, field
from typing import Literal

import numpy as np

Mode = Literal["hard", "soft", "off"]

# Peak power of a transform-limited pulse: P = shape_factor * E / FWHM
PEAK_POWER_FACTOR = {"gaussian": 0.9394, "sech2": 0.8814}


# ============================================================ generic constraint
[docs] @dataclass class Limit: """One constraint. value : the limit (None = taken from the catalogue when the constraint is evaluated) bound : "max" (x <= allowed) or "min" (x >= allowed) mode : "hard" -> candidate rejected, "soft" -> penalised in the score, "off" -> ignored safety : allowed = value * safety for "max", value / safety for "min" (safety <= 1 is stricter) weight : weight of the penalty when mode == "soft" """ value: float | None = None bound: Literal["max", "min"] = "max" mode: Mode = "hard" safety: float = 1.0 weight: float = 1.0 def allowed(self, default=None): v = self.value if self.value is not None else default if v is None: return None return v * self.safety if self.bound == "max" else v / self.safety def slack(self, x, default=None): """Normalised margin: >= 0 if satisfied, < 0 if violated, None if inactive.""" a = self.allowed(default) if self.mode == "off" or a is None: return None s = (a - x) if self.bound == "max" else (x - a) return s / abs(a) if a != 0 else s def penalty(self, x, default=None): """0 if satisfied or off, inf if a hard limit is violated, weight * |slack|^2 if soft.""" s = self.slack(x, default) if s is None or s >= 0: return 0.0 return np.inf if self.mode == "hard" else self.weight * s ** 2
[docs] def Max(value=None, mode: Mode = "hard", safety=1.0, weight=1.0): return Limit(value, "max", mode, safety, weight)
[docs] def Min(value=None, mode: Mode = "hard", safety=1.0, weight=1.0): return Limit(value, "min", mode, safety, weight)
[docs] def Off(): return Limit(None, "max", "off")
# ============================================================ input laser
[docs] @dataclass class LaserSpec: """Front-end pulse injected in the cell. duration_fs is the FWHM intensity duration of the injected pulse. If it is chirped, give its transform limit as tl_duration_fs: the chirp is then added as a positive GDD. """ energy_J: float = 1e-3 duration_fs: float = 300.0 wavelength_nm: float = 1030.0 tl_duration_fs: float | None = None # None -> duration_fs (transform limited) shape: Literal["gaussian", "sech2"] = "gaussian" # spectral phase added before injection (e.g. to explore chirped injection) prechirp_gdd_fs2: float = 0.0 prechirp_tod_fs3: float = 0.0 rep_rate_Hz: float = 1e3 polarization: Literal["linear", "circular"] = "linear" name: str = "laser" @property def lambda0(self): return self.wavelength_nm * 1e-9 @property def peak_power_W(self): """Peak power at the given (possibly chirped) duration.""" return PEAK_POWER_FACTOR[self.shape] * self.energy_J / (self.duration_fs * 1e-15) @property def average_power_W(self): return self.energy_J * self.rep_rate_Hz @property def n2_polarization_factor(self): """Effective n2 scaling: 1 for linear, 2/3 for circular polarisation (isotropic medium).""" return 1.0 if self.polarization == "linear" else 2.0 / 3.0
[docs] @classmethod def preset(cls, name, **overrides): """LaserSpec from the preset registry, any field overridable.""" if name not in LASER_PRESETS: raise KeyError(f"unknown preset {name!r}; available: {sorted(LASER_PRESETS)}") kw = dict(LASER_PRESETS[name]) kw.update(overrides) kw.setdefault("name", name) return cls(**kw)
# Manufacturer datasheet values (checked 2026-11): durations are the specified upper bounds # ("< x fs", gaussian assumed), i.e. worst case. rep_rate_Hz = max power / max energy (the rate # at which the max energy is specified), not a datasheet line. Override any field at call time: # LaserSpec.preset("pharos_ph2_sp_2mJ", duration_fs=170.0, rep_rate_Hz=1e3) LASER_PRESETS: dict[str, dict] = { "mpc1_18mJ_350fs": {"energy_J": 18e-3, "duration_fs": 350.0, "wavelength_nm": 1030.0}, # Light Conversion PHAROS-PH2 (datasheet rev. 260601) "pharos_ph2_10w": {"energy_J": 0.2e-3, "duration_fs": 290.0, "rep_rate_Hz": 50e3}, "pharos_ph2_sp_0.4mJ": {"energy_J": 0.4e-3, "duration_fs": 190.0, "rep_rate_Hz": 50e3}, "pharos_ph2_sp_1mJ": {"energy_J": 1e-3, "duration_fs": 190.0, "rep_rate_Hz": 20e3}, "pharos_ph2_sp_2mJ": {"energy_J": 2e-3, "duration_fs": 190.0, "rep_rate_Hz": 10e3}, "pharos_ph2_5mJ": {"energy_J": 5e-3, "duration_fs": 250.0, "rep_rate_Hz": 4e3}, "pharos_ph2_up_0.4mJ": {"energy_J": 0.4e-3, "duration_fs": 100.0, "rep_rate_Hz": 50e3}, "pharos_ph2_up_1mJ": {"energy_J": 1e-3, "duration_fs": 100.0, "rep_rate_Hz": 20e3}, # Light Conversion CARBIDE (product page 2026-09) "carbide_cb3_20w": {"energy_J": 0.4e-3, "duration_fs": 250.0, "rep_rate_Hz": 50e3}, "carbide_cb3_40w": {"energy_J": 0.4e-3, "duration_fs": 250.0, "rep_rate_Hz": 100e3}, "carbide_cb3_80w_0.8mJ": {"energy_J": 0.8e-3, "duration_fs": 250.0, "rep_rate_Hz": 100e3}, "carbide_cb3_80w_2mJ": {"energy_J": 2e-3, "duration_fs": 350.0, "rep_rate_Hz": 40e3}, "carbide_cb3_120w": {"energy_J": 1e-3, "duration_fs": 250.0, "rep_rate_Hz": 120e3}, "carbide_cb5_6w": {"energy_J": 100e-6, "duration_fs": 290.0, "rep_rate_Hz": 60e3}, "carbide_cb5_5w": {"energy_J": 83e-6, "duration_fs": 290.0, "rep_rate_Hz": 60e3}, "carbide_cb5_sp": {"energy_J": 100e-6, "duration_fs": 190.0, "rep_rate_Hz": 50e3}, # TRUMPF Scientific Lasers Dira (trumpf-scientific-lasers.com, technical data) "dira_200_100": {"energy_J": 2e-3, "duration_fs": 1000.0, "rep_rate_Hz": 100e3}, "dira_200_1": {"energy_J": 200e-3, "duration_fs": 600.0, "rep_rate_Hz": 1e3}, "dira_500_10": {"energy_J": 50e-3, "duration_fs": 600.0, "rep_rate_Hz": 10e3}, "dira_1000_5": {"energy_J": 200e-3, "duration_fs": 600.0, "rep_rate_Hz": 5e3}, "dira_1000_1": {"energy_J": 500e-3, "duration_fs": 600.0, "rep_rate_Hz": 1e3}, }
[docs] def register_laser_preset(name, **fields): LASER_PRESETS[name] = fields
# ============================================================ objective
[docs] @dataclass class Target: """What the design should achieve. objective: "shortest" minimise the compressed duration "max_peak_power" maximise the compressed peak power (duration AND throughput) "target_duration" reach target_duration_fs, not shorter "target_compression" reach target_compression (input duration / output duration) "weighted" minimise sum of weights[key] * metric (normalised), keys below """ objective: Literal["shortest", "max_peak_power", "target_duration", "target_compression", "weighted"] = "max_peak_power" target_duration_fs: float | None = None target_compression: float | None = None # "weighted" objective: duration / input duration, -throughput, separation / max separation, # pressure / max pressure weights: dict[str, float] = field(default_factory=lambda: { "duration": 1.0, "throughput": 1.0, "length": 0.0, "pressure": 0.0}) # quality floors (constraints on the result, not on the hardware) min_throughput: Limit = field(default_factory=lambda: Min(0.80, mode="soft")) min_main_peak_fraction: Limit = field(default_factory=lambda: Min(0.70, mode="soft"))
# ============================================================ constraints
[docs] @dataclass class SpaceConstraints: max_separation_m: Limit = field(default_factory=lambda: Max(4.0)) chamber_margin_m: float = 0.2 # chamber length = d + mirror mounts margin max_chamber_length_m: Limit = field(default_factory=Off) fixed_chamber_length_m: float | None = None # existing chamber: d + margin must fit inside it
[docs] @dataclass class MirrorConstraints: allowed_roc_m: tuple[float, ...] = (0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0) allowed_diameters_inch: tuple[float, ...] = (3.0,) # cavity coatings allowed (names from the mirror catalogue); None = every cavity coating allowed_coatings: Sequence[str] | None = None # peak fluence on the mirrors; value None -> LIDT of the coating at the local pulse duration fluence: Limit = field(default_factory=lambda: Max(None)) # fraction of the output energy outside the coating band (spectral wings are never exactly 0) bandwidth: Limit = field(default_factory=lambda: Max(0.01)) # thermal: average power on one mirror (high repetition rate) max_average_power_W: Limit = field(default_factory=Off)
[docs] @dataclass class KerrConstraints: p_over_pcr: Limit = field(default_factory=lambda: Max(0.5)) b_per_pass_rad: Limit = field(default_factory=lambda: Max(1.0)) # on-axis, per pass b_total_rad: Limit = field(default_factory=Off)
[docs] @dataclass class IonizationConstraints: # peak intensity at the waist; value None -> ionisation threshold of the gas (catalogue) peak_intensity_W_cm2: Limit = field(default_factory=lambda: Max(None, safety=0.5))
[docs] @dataclass class SpotConstraints: """Circular spot pattern on each mirror: M spots on a circle of radius r. r = pattern_radius_m, or by default the largest radius that keeps edge_clearance_w between the spot centres and the mirror edge (mirror radius - edge_clearance_w * w_mirror). Adjacent spots are 2 r sin(pi / M) apart.""" min_spot_separation_w: Limit = field(default_factory=lambda: Min(3.0)) # in units of w_mirror edge_clearance_w: Limit = field(default_factory=lambda: Min(2.5)) # spot centre to edge pattern_radius_m: float | None = None
[docs] @dataclass class PressureConstraints: pressure_bar: tuple[float, float] = (0.0, 3.0) # chamber / window rating resolution_bar: float = 0.01 # your regulator's resolution
[docs] @dataclass class GasConstraints: allowed: tuple[str, ...] = ("He", "Ne", "Ar", "Kr", "Xe", "N2")
[docs] @dataclass class Constraints: space: SpaceConstraints = field(default_factory=SpaceConstraints) mirrors: MirrorConstraints = field(default_factory=MirrorConstraints) kerr: KerrConstraints = field(default_factory=KerrConstraints) ionization: IonizationConstraints = field(default_factory=IonizationConstraints) spots: SpotConstraints = field(default_factory=SpotConstraints) pressure: PressureConstraints = field(default_factory=PressureConstraints) gas: GasConstraints = field(default_factory=GasConstraints)
# ============================================================ losses
[docs] @dataclass class Losses: """Losses not contained in the catalogues (the coating R(lambda) is).""" mode_matching_efficiency: float = 1.0 # fraction of the energy injected in the cell mode
# ============================================================ tolerance (information only)
[docs] @dataclass class Tolerance: """Alignment tolerances reported for a design: how much the waist and the mirror spot change for these errors. Information for the user, not a constraint.""" separation_error_mm: float = 1.0 roc_tolerance_rel: float = 0.005
# ============================================================ search
[docs] @dataclass class SearchSpace: """What starting_points() enumerates. Restrict a field to a single value to lock it ("fix what I already own": one RoC, one coating, ...).""" gases: tuple[str, ...] | None = None # None -> Constraints.gas.allowed roc_m: tuple[float, ...] | None = None # None -> Constraints.mirrors.allowed_roc_m diameters_inch: tuple[float, ...] | None = None # None -> Constraints.mirrors.allowed_diameters_inch coatings: Sequence[str] | None = None # None -> Constraints.mirrors.allowed_coatings branches: tuple[Literal["concentric", "planar"], ...] = ("concentric", "planar") round_trips: tuple[int, int] = (3, 40) # inclusive range of M (one re-entrant cycle) k_range: tuple[int, int] | None = None # None -> every k < M/2 coprime with M # passes tried per geometry, relative to a full cycle: 2 M + offset (0: full cycle, back on # the input mirror; -1: one pass less, out on the other mirror) pass_offsets: tuple[int, ...] = (0, -1) min_passes: int = 5
# ============================================================ refinement with the 1+1D code
[docs] @dataclass class Refinement: """The best candidates are re-run with the fast 1+1D code: their continuous parameters are tuned, and the number of passes (same geometry) and the RoC are scanned around them.""" n_candidates: int = 5 diversity: bool = True # spread the candidates over gases, then RoC / branch # continuous parameters tuned by the optimiser: "pressure_bar", "prechirp_gdd_fs2" tune: tuple[str, ...] = ("pressure_bar",) neighbour_n_passes: int = 2 # passes +- this, same geometry, within [min_passes, 2 M] neighbour_roc_steps: int = 0 # RoC +- this many steps of the allowed list (same M, k) method: Literal["nelder-mead", "powell"] = "nelder-mead" max_evaluations: int = 60 xtol_rel: float = 1e-3 keep_traces: bool = True # keep every evaluation for plots
[docs] @dataclass class DesignProblem: """Everything in one object (what MPCDesigner takes, and what gets saved with the results).""" laser: LaserSpec = field(default_factory=LaserSpec) target: Target = field(default_factory=Target) constraints: Constraints = field(default_factory=Constraints) losses: Losses = field(default_factory=Losses) search: SearchSpace = field(default_factory=SearchSpace) refinement: Refinement = field(default_factory=Refinement) tolerance: Tolerance = field(default_factory=Tolerance) def to_dict(self): def clean(v): if isinstance(v, np.ndarray): return v.tolist() if isinstance(v, dict): return {k: clean(x) for k, x in v.items()} if isinstance(v, (list, tuple)): return [clean(x) for x in v] return v return clean(asdict(self))