Source code for thunder4d.design.catalog.mirrors

"""Mirror / coating catalogue for the design helper.

A Coating carries what both the design helper and the solver need:
    reflectivity R(lambda), GDD(lambda) per bounce, usable band, LIDT and its duration scaling.
``coating.reflectivity`` and ``coating.phase`` plug directly into
``MPC(..., mirror_reflectivity=..., mirror_phase=...)``.

Data can be scalars (generic coatings) or tables loaded from the vendor curves (CSV with two
columns: wavelength in nm, value). Tables are interpolated, and set to the edge value outside
the tabulated range unless ``outside="zero"`` (reflectivity) is requested.

LIDT scaling with pulse duration:  LIDT(tau) = lidt_J_cm2 * (tau / lidt_ref_fs) ** lidt_exponent
(exponent ~0.3-0.5 is often quoted; set your own). The design helper evaluates the fluence limit
at the local pulse duration, so the last passes (shortest pulses) are the critical ones.
"""
from __future__ import annotations

import json
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Literal, cast

import numpy as np
from scipy.constants import c as C0
from scipy.integrate import cumulative_trapezoid

Kind = Literal["dielectric", "metallic", "chirped", "other"]
Role = Literal["cavity", "compressor", "both"]


[docs] def load_curve(path, delimiter=None, skip_header=0, wavelength_unit="nm"): """Two-column table (wavelength, value) -> (wavelength_nm, value), sorted.""" data = np.genfromtxt(path, delimiter=delimiter, skip_header=skip_header) lam, val = data[:, 0], data[:, 1] if wavelength_unit == "um": lam = lam * 1e3 elif wavelength_unit == "m": lam = lam * 1e9 i = np.argsort(lam) return lam[i], val[i]
[docs] @dataclass class Coating: """One mirror coating (cavity or compressor). reflectivity : scalar, or (wavelength_nm, R) table, R in [0, 1] (or in % with r_in_percent) gdd_fs2 : scalar, or (wavelength_nm, GDD in fs^2) table, per bounce band_nm : usable band (min, max); the broadened spectrum must fit inside it lidt_J_cm2 : damage threshold (fluence) at lidt_ref_fs """ name: str kind: Kind = "dielectric" role: Role = "cavity" band_nm: tuple[float, float] = (0.0, np.inf) reflectivity: float | tuple[np.ndarray, np.ndarray] = 1.0 gdd_fs2: float | tuple[np.ndarray, np.ndarray] = 0.0 lidt_J_cm2: float | None = None lidt_ref_fs: float = 1000.0 lidt_exponent: float = 0.0 pair: str | None = None # complementary chirped mirror (e.g. L/S pair) diameters_inch: tuple[float, ...] = () # available sizes (empty = any) r_in_percent: bool = False vendor: str = "" source: str = "" # "vendor theoretical", "measured 2026-xx", ... notes: str = "" _phase_cache: tuple | None = field(default=None, repr=False, compare=False) # ------------------------------------------------------------ evaluation def R(self, lam_nm): lam_nm = np.asarray(lam_nm, dtype=float) if np.ndim(self.reflectivity) == 0: r = np.full_like(lam_nm, float(cast(float, self.reflectivity))) else: x, y = cast(tuple, self.reflectivity) r = np.interp(lam_nm, x, y) if self.r_in_percent: r = r / 100.0 return np.clip(r, 0.0, 1.0) def gdd(self, lam_nm): lam_nm = np.asarray(lam_nm, dtype=float) if np.ndim(self.gdd_fs2) == 0: return np.full_like(lam_nm, float(cast(float, self.gdd_fs2))) x, y = cast(tuple, self.gdd_fs2) return np.interp(lam_nm, x, y) def in_band(self, lam_nm): lam_nm = np.asarray(lam_nm, dtype=float) return (lam_nm >= self.band_nm[0]) & (lam_nm <= self.band_nm[1]) def lidt(self, duration_fs): """LIDT [J/cm^2] at the given pulse duration (None if unknown).""" if self.lidt_J_cm2 is None: return None return self.lidt_J_cm2 * (duration_fs / self.lidt_ref_fs) ** self.lidt_exponent # ------------------------------------------------------------ solver hooks (SI, lambda in m) def reflectivity_si(self, lam_m): """Callable for MPC(mirror_reflectivity=...).""" return self.R(np.asarray(lam_m) * 1e9) def phase(self, lam_m, lambda0_m=1030e-9): """Spectral phase per bounce [rad], double integral of GDD(omega); the constant and linear (group delay) parts are removed at lambda0, so only the dispersion is kept. Callable for MPC(mirror_phase=...): use functools.partial to fix lambda0_m. Only meaningful with a tabulated GDD; a scalar GDD is better passed as mirror_gdd_fs2.""" lam_m = np.asarray(lam_m, dtype=float) w = 2 * np.pi * C0 / np.where(lam_m > 0, lam_m, 1.0) * 1e-15 # rad/fs w0 = 2 * np.pi * C0 / lambda0_m * 1e-15 if self._phase_cache is None or self._phase_cache[0] != lambda0_m: if np.ndim(self.gdd_fs2) == 0: wg = np.linspace(w0 * 0.5, w0 * 1.5, 4001) g = np.full_like(wg, float(cast(float, self.gdd_fs2))) else: x, y = cast(tuple, self.gdd_fs2) wg = 2 * np.pi * C0 / (np.asarray(x) * 1e-9) * 1e-15 i = np.argsort(wg) wg, g = wg[i], np.asarray(y, dtype=float)[i] gd = cumulative_trapezoid(g, wg, initial=0.0) gd -= np.interp(w0, wg, gd) ph = cumulative_trapezoid(gd, wg, initial=0.0) ph -= np.interp(w0, wg, ph) self._phase_cache = (lambda0_m, wg, ph) _, wg, ph = self._phase_cache return np.interp(w, wg, ph) def with_(self, **changes): return replace(self, _phase_cache=None, **changes) # ------------------------------------------------------------ I/O
[docs] @classmethod def from_files(cls, name, reflectivity_csv=None, gdd_csv=None, *, delimiter=None, skip_header=0, wavelength_unit="nm", **kw): """Coating from vendor curves (two-column files: wavelength, value).""" if reflectivity_csv is not None: kw["reflectivity"] = load_curve(reflectivity_csv, delimiter, skip_header, wavelength_unit) if gdd_csv is not None: kw["gdd_fs2"] = load_curve(gdd_csv, delimiter, skip_header, wavelength_unit) return cls(name, **kw)
# ============================================================ the catalogue MIRRORS: dict[str, Coating] = { "dielectric_1030nm": Coating( "dielectric_1030nm", kind="dielectric", role="cavity", band_nm=(955.0, 1095.0), reflectivity=0.9995, gdd_fs2=0.0, lidt_J_cm2=0.4, source="Optoman", notes="Custom mirror (ask V. Koltalo for the reference)"), "dielectric_800nm": Coating( "dielectric_800nm", kind="dielectric", role="cavity", band_nm=(720.0, 880.0), reflectivity=0.999, gdd_fs2=0.0, lidt_J_cm2=0.2, source="Eksma", notes="062-8xxxB"), "enhanced_silver": Coating( "enhanced_silver", kind="metallic", role="cavity", band_nm=(600.0, 1300.0), reflectivity=0.985, gdd_fs2=0.0, lidt_J_cm2=0.1, source="Eksma", notes="092-3125FR, LIDT underestimated for safety"), }
[docs] def register_mirror(coating: Coating, overwrite=False): if coating.name in MIRRORS and not overwrite: raise KeyError(f"coating {coating.name!r} already registered (overwrite=True to replace)") MIRRORS[coating.name] = coating
[docs] def get_mirror(name) -> Coating: try: return MIRRORS[name] except KeyError: raise KeyError(f"unknown coating {name!r}; available: {sorted(MIRRORS)}") from None
def cavity_coatings(names=None): names = names if names is not None else MIRRORS.keys() return [get_mirror(n) for n in names if get_mirror(n).role in ("cavity", "both")]
[docs] def load_catalog(path, overwrite=True): """Register coatings from a JSON file. Curve files are resolved relative to the JSON file. [ {"name": "my_hr_1030", "kind": "dielectric", "role": "cavity", "band_nm": [950, 1110], "reflectivity_csv": "hr_R.csv", "gdd_csv": "hr_GDD.csv", "r_in_percent": true, "lidt_J_cm2": 0.4, "vendor": "...", "source": "vendor theoretical"}, ... ] """ path = Path(path) entries = json.loads(path.read_text()) out = [] for e in entries: e = dict(e) name = e.pop("name") r_csv, g_csv = e.pop("reflectivity_csv", None), e.pop("gdd_csv", None) fmt = {k: e.pop(k) for k in ("delimiter", "skip_header", "wavelength_unit") if k in e} for key in ("band_nm", "diameters_inch"): if key in e: e[key] = tuple(e[key]) c = Coating.from_files(name, path.parent / r_csv if r_csv else None, path.parent / g_csv if g_csv else None, **fmt, **e) register_mirror(c, overwrite=overwrite) out.append(c) return out