thunder4d 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Koltalo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: thunder4d
3
+ Version: 0.9.0
4
+ Summary: thunder4d: Transverse-resolved Herriott-cell UPPE solver for Nonlinear Dynamics close to Experimental Reality, 4D
5
+ Author-email: Victor Koltalo <victor.koltalo@ensta.fr>
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://victorkolt.github.io/thunder4D/
8
+ Project-URL: Source, https://github.com/victorkolt/thunder4D
9
+ Keywords: UPPE,multipass cell,MPC,nonlinear optics,ultrafast,post-compression,Herriott cell,spatio-temporal,pulse propagation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: Physics
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=2.0
19
+ Requires-Dist: scipy>=1.10
20
+ Provides-Extra: examples
21
+ Requires-Dist: matplotlib; extra == "examples"
22
+ Provides-Extra: gpu
23
+ Requires-Dist: cupy-cuda12x; extra == "gpu"
24
+ Provides-Extra: docs
25
+ Requires-Dist: sphinx>=7; extra == "docs"
26
+ Requires-Dist: sphinx_rtd_theme>=2; extra == "docs"
27
+ Dynamic: license-file
28
+
29
+ # thunder4D
30
+
31
+ **T**ransverse-resolved **H**erriott-cell **U**PPE solver for **N**onlinear **D**ynamics close to **E**xperimental **R**eality, **4D**: a 4D (x, y, z, t) UPPE solver for multipass cells. Python package: `thunder4d`.
32
+
33
+ Full documentation: `https://victorkolt.github.io/thunder4d/`.
34
+
35
+ `thunder4D` simulates nonlinear pulse propagation in a gas-filled Herriott-type multipass cell (MPC) with full spatial and temporal resolution: the field is A(x, y, t) and it is propagated along z. It is built to study spatial and spatio-spectral effects that 1D or radially symmetric codes cannot capture:
36
+
37
+ - spatial effects in non-Gaussian inputs (clipped, aberrated, high-order beams),
38
+ - spatio-spectral homogeneity, radial chirp,
39
+ - time-dependent nonlinear mode mismatch,
40
+ - influence of space-time couplings (spatial chirp, pulse-front tilt, …) on the output.
41
+
42
+ The code is pure Python. NumPy (≥ 2.0) is required. SciPy is optional but strongly recommended, because `scipy.fft` is about 8× faster than `numpy.fft` for the 2D transforms. Matplotlib is only needed for the examples. An NVIDIA GPU is optional: with CuPy installed (`pip install cupy-cuda12x`), `suggest_grid(..., device="auto")` (the default) runs the propagation on the GPU, and `device="cpu"` / `"gpu"` force one or the other. `thunder4d.gpu_available()` tells you whether CuPy works; `thunder4d.to_host(array)` converts a device array to NumPy. Everything returned by `thunder4d.diagnostics` is a NumPy array on either device.
43
+
44
+ ## Quick start
45
+
46
+ ```python
47
+ import thunder4d as m
48
+ from thunder4d import diagnostics as dg
49
+
50
+ # medium and cell
51
+ gas = m.Material("Ar", pressure_bar=1.0)
52
+ cell = m.MPC.herriott(R=0.5, N=20, k=3, branch="planar", medium=gas) # 20 passes, 54 deg Gouy/pass, re-entrant
53
+
54
+ # grid sized from the cell
55
+ grid = m.suggest_grid(cell, lambda0=1030e-9, lambda_min=900e-9, lambda_max=1200e-9, pulse_fwhm=200e-15, box_factor=12)
56
+ print(grid.describe())
57
+
58
+ # 1) spectrum (+ phase)
59
+ E = m.gaussian_spectrum(grid, fwhm_fs=200)
60
+ E = m.add_spectral_phase(grid, E, gdd_fs2=0)
61
+
62
+ # 2) nonlinear mode matching, computed from a probe pulse of the right energy
63
+ probe = m.Pulse.at_focus(grid, E, m.Beam(m.gaussian(1e-3)), 1.2e-3)
64
+ mm = cell.match(probe)
65
+
66
+ # 3) the actual input beam
67
+ pulse = m.Pulse.at_focus(grid, E, m.Beam(m.gaussian(mm["w0"])), 1.2e-3)
68
+
69
+ # 4) run
70
+ sim = m.MPCSimulation(pulse, cell, phi_max=0.02, mode_reference=mm["w0"], profile_wavelengths=[1010e-9, 1030e-9, 1050e-9], profile_bandwidth=2e-9)
71
+ sim.run()
72
+ sim.save("run.npz")
73
+
74
+ # 5) analyse the output
75
+ x, lam, S = dg.spectrogram_x_lambda(grid, sim.Aw)
76
+ c = dg.compress(grid, sim.Aw)
77
+ print(c["fwhm_fs"], c["gdd_fs2"])
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Physical model
83
+
84
+ The field is a complex envelope A(x, y, t) with carrier exp[i(k₀z − ω₀t)], normalised so that |A|² is the intensity in W/m². It is propagated with the unidirectional pulse propagation equation (UPPE), written in the frame moving at the group velocity at λ₀:
85
+
86
+ ```
87
+ ∂A(kx, ky, Ω)/∂z = i [ kz(kx, ky, ω) − k₀ − k₁Ω ] A + i γ(Ω) FT_t[ |A|² A ]
88
+
89
+ kz = sqrt( k(ω)² − kx² − ky² ) (non-paraxial, full gas dispersion)
90
+ k(ω) = n(ω) ω / c, ω = ω₀ + Ω, k₁ = dk/dω at ω₀
91
+ γ(Ω) = n₂ ω/c · n(ω₀)/n(ω) (self-steepening included)
92
+ ```
93
+
94
+ - **Linear operator:** it is exact. It includes diffraction without the paraxial approximation, gas dispersion to all orders, and evanescent components, which decay.
95
+ - **Kerr term:** it is the instantaneous electronic response. Its frequency dependence gives self-steepening. With `self_steepening=False` it becomes γ = n₂ω₀/c.
96
+ - **Edge taper:** the nonlinear source is tapered to zero at the edges of the spectral window to avoid aliasing.
97
+
98
+ ## Numerical scheme
99
+
100
+ - **Home domain:** the solver keeps the field in the mixed domain (x, y, Ω).
101
+ - The linear step needs only 2D spatial FFTs, because Ω is already a coordinate.
102
+ - The Kerr term does not depend on (kx, ky), so it needs only 1D temporal FFTs.
103
+ - **Splitting:** symmetric Strang splitting (linear half-step, nonlinear step, linear half-step). Consecutive linear half-steps are merged.
104
+ - **Nonlinear step:** fourth-order Runge–Kutta.
105
+ - **Adaptive step size:** h = φ_max / (k₀ n₂ I_peak), where φ_max is the peak nonlinear phase allowed per step. It is limited to `dz_max` (default d/8, a quarter of a half-pass) and quantised to `dz_max · 2^(−n/4)`. The quantisation lets the linear propagators exp(iDh) be reused from a small cache.
106
+
107
+ ## Performance and memory
108
+
109
+ - **Cost per step:** 2 spatial 2D FFTs, 8 temporal 1D FFTs (RK4) and a few array operations. With SciPy on one core, a 150 × 150 × 135 grid takes about 0.1–0.4 s per step. A 20-pass run at B ≈ 16 rad needs about 900 steps.
110
+ - **Steps per pass:** they scale with the B-integral per pass divided by `phi_max`.
111
+ - **Memory:** about 8–10 field arrays: the field, RK4 buffers, the linear operator `D`, up to `cache_size` cached propagators and the cached mirror transfer. One complex64 array is Nx·Ny·Nt·8 bytes, so 256 × 256 × 256 is 134 MB.
112
+ - **Speed-ups:**
113
+ - SciPy backend with all cores (`fft_backend="scipy"`, `workers=-1`, the default with `"auto"`).
114
+ - `record_every > 1`, or `near_field=False` (near-field diagnostics, i.e. of the collimated beam, use per-colour matrix transforms).
115
+ - Fewer `profile_wavelengths`.
116
+ - A smaller `cache_size` if memory is tight.
117
+ - **Long runs:** use `run(max_seconds=…)` with `checkpoint` / `resume`.
118
+
119
+ ## Limitations
120
+
121
+ **Not included**
122
+ - **Ionisation and plasma:** fine for MPCs operated well below ionisation thresholds.
123
+ - **Raman response:** relevant for N₂, air and other molecular gases.
124
+ - **Off-axis geometry:** the real cell is folded, so the beam hits the mirrors at small angles. The resulting astigmatism and the actual spot pattern are not modelled; the unfolded on-axis picture is used.
125
+ - **Non-uniform media:** gas plates, windows or pressure gradients are not built in. The nonlinear mode matching formula also assumes a uniformly filled cell with constant power during a pass.
126
+ - **Vectorial effects:** the field is scalar, with a single polarisation.
@@ -0,0 +1,98 @@
1
+ # thunder4D
2
+
3
+ **T**ransverse-resolved **H**erriott-cell **U**PPE solver for **N**onlinear **D**ynamics close to **E**xperimental **R**eality, **4D**: a 4D (x, y, z, t) UPPE solver for multipass cells. Python package: `thunder4d`.
4
+
5
+ Full documentation: `https://victorkolt.github.io/thunder4d/`.
6
+
7
+ `thunder4D` simulates nonlinear pulse propagation in a gas-filled Herriott-type multipass cell (MPC) with full spatial and temporal resolution: the field is A(x, y, t) and it is propagated along z. It is built to study spatial and spatio-spectral effects that 1D or radially symmetric codes cannot capture:
8
+
9
+ - spatial effects in non-Gaussian inputs (clipped, aberrated, high-order beams),
10
+ - spatio-spectral homogeneity, radial chirp,
11
+ - time-dependent nonlinear mode mismatch,
12
+ - influence of space-time couplings (spatial chirp, pulse-front tilt, …) on the output.
13
+
14
+ The code is pure Python. NumPy (≥ 2.0) is required. SciPy is optional but strongly recommended, because `scipy.fft` is about 8× faster than `numpy.fft` for the 2D transforms. Matplotlib is only needed for the examples. An NVIDIA GPU is optional: with CuPy installed (`pip install cupy-cuda12x`), `suggest_grid(..., device="auto")` (the default) runs the propagation on the GPU, and `device="cpu"` / `"gpu"` force one or the other. `thunder4d.gpu_available()` tells you whether CuPy works; `thunder4d.to_host(array)` converts a device array to NumPy. Everything returned by `thunder4d.diagnostics` is a NumPy array on either device.
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ import thunder4d as m
20
+ from thunder4d import diagnostics as dg
21
+
22
+ # medium and cell
23
+ gas = m.Material("Ar", pressure_bar=1.0)
24
+ cell = m.MPC.herriott(R=0.5, N=20, k=3, branch="planar", medium=gas) # 20 passes, 54 deg Gouy/pass, re-entrant
25
+
26
+ # grid sized from the cell
27
+ grid = m.suggest_grid(cell, lambda0=1030e-9, lambda_min=900e-9, lambda_max=1200e-9, pulse_fwhm=200e-15, box_factor=12)
28
+ print(grid.describe())
29
+
30
+ # 1) spectrum (+ phase)
31
+ E = m.gaussian_spectrum(grid, fwhm_fs=200)
32
+ E = m.add_spectral_phase(grid, E, gdd_fs2=0)
33
+
34
+ # 2) nonlinear mode matching, computed from a probe pulse of the right energy
35
+ probe = m.Pulse.at_focus(grid, E, m.Beam(m.gaussian(1e-3)), 1.2e-3)
36
+ mm = cell.match(probe)
37
+
38
+ # 3) the actual input beam
39
+ pulse = m.Pulse.at_focus(grid, E, m.Beam(m.gaussian(mm["w0"])), 1.2e-3)
40
+
41
+ # 4) run
42
+ sim = m.MPCSimulation(pulse, cell, phi_max=0.02, mode_reference=mm["w0"], profile_wavelengths=[1010e-9, 1030e-9, 1050e-9], profile_bandwidth=2e-9)
43
+ sim.run()
44
+ sim.save("run.npz")
45
+
46
+ # 5) analyse the output
47
+ x, lam, S = dg.spectrogram_x_lambda(grid, sim.Aw)
48
+ c = dg.compress(grid, sim.Aw)
49
+ print(c["fwhm_fs"], c["gdd_fs2"])
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Physical model
55
+
56
+ The field is a complex envelope A(x, y, t) with carrier exp[i(k₀z − ω₀t)], normalised so that |A|² is the intensity in W/m². It is propagated with the unidirectional pulse propagation equation (UPPE), written in the frame moving at the group velocity at λ₀:
57
+
58
+ ```
59
+ ∂A(kx, ky, Ω)/∂z = i [ kz(kx, ky, ω) − k₀ − k₁Ω ] A + i γ(Ω) FT_t[ |A|² A ]
60
+
61
+ kz = sqrt( k(ω)² − kx² − ky² ) (non-paraxial, full gas dispersion)
62
+ k(ω) = n(ω) ω / c, ω = ω₀ + Ω, k₁ = dk/dω at ω₀
63
+ γ(Ω) = n₂ ω/c · n(ω₀)/n(ω) (self-steepening included)
64
+ ```
65
+
66
+ - **Linear operator:** it is exact. It includes diffraction without the paraxial approximation, gas dispersion to all orders, and evanescent components, which decay.
67
+ - **Kerr term:** it is the instantaneous electronic response. Its frequency dependence gives self-steepening. With `self_steepening=False` it becomes γ = n₂ω₀/c.
68
+ - **Edge taper:** the nonlinear source is tapered to zero at the edges of the spectral window to avoid aliasing.
69
+
70
+ ## Numerical scheme
71
+
72
+ - **Home domain:** the solver keeps the field in the mixed domain (x, y, Ω).
73
+ - The linear step needs only 2D spatial FFTs, because Ω is already a coordinate.
74
+ - The Kerr term does not depend on (kx, ky), so it needs only 1D temporal FFTs.
75
+ - **Splitting:** symmetric Strang splitting (linear half-step, nonlinear step, linear half-step). Consecutive linear half-steps are merged.
76
+ - **Nonlinear step:** fourth-order Runge–Kutta.
77
+ - **Adaptive step size:** h = φ_max / (k₀ n₂ I_peak), where φ_max is the peak nonlinear phase allowed per step. It is limited to `dz_max` (default d/8, a quarter of a half-pass) and quantised to `dz_max · 2^(−n/4)`. The quantisation lets the linear propagators exp(iDh) be reused from a small cache.
78
+
79
+ ## Performance and memory
80
+
81
+ - **Cost per step:** 2 spatial 2D FFTs, 8 temporal 1D FFTs (RK4) and a few array operations. With SciPy on one core, a 150 × 150 × 135 grid takes about 0.1–0.4 s per step. A 20-pass run at B ≈ 16 rad needs about 900 steps.
82
+ - **Steps per pass:** they scale with the B-integral per pass divided by `phi_max`.
83
+ - **Memory:** about 8–10 field arrays: the field, RK4 buffers, the linear operator `D`, up to `cache_size` cached propagators and the cached mirror transfer. One complex64 array is Nx·Ny·Nt·8 bytes, so 256 × 256 × 256 is 134 MB.
84
+ - **Speed-ups:**
85
+ - SciPy backend with all cores (`fft_backend="scipy"`, `workers=-1`, the default with `"auto"`).
86
+ - `record_every > 1`, or `near_field=False` (near-field diagnostics, i.e. of the collimated beam, use per-colour matrix transforms).
87
+ - Fewer `profile_wavelengths`.
88
+ - A smaller `cache_size` if memory is tight.
89
+ - **Long runs:** use `run(max_seconds=…)` with `checkpoint` / `resume`.
90
+
91
+ ## Limitations
92
+
93
+ **Not included**
94
+ - **Ionisation and plasma:** fine for MPCs operated well below ionisation thresholds.
95
+ - **Raman response:** relevant for N₂, air and other molecular gases.
96
+ - **Off-axis geometry:** the real cell is folded, so the beam hits the mirrors at small angles. The resulting astigmatism and the actual spot pattern are not modelled; the unfolded on-axis picture is used.
97
+ - **Non-uniform media:** gas plates, windows or pressure gradients are not built in. The nonlinear mode matching formula also assumes a uniformly filled cell with constant power during a pass.
98
+ - **Vectorial effects:** the field is scalar, with a single polarisation.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "thunder4d"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dynamic = ["version"]
10
+ description = "thunder4d: Transverse-resolved Herriott-cell UPPE solver for Nonlinear Dynamics close to Experimental Reality, 4D"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Victor Koltalo", email = "victor.koltalo@ensta.fr" }]
14
+ keywords = ["UPPE", "multipass cell", "MPC", "nonlinear optics", "ultrafast", "post-compression",
15
+ "Herriott cell", "spatio-temporal", "pulse propagation"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Topic :: Scientific/Engineering :: Physics",
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+ dependencies = [
24
+ "numpy>=2.0",
25
+ "scipy>=1.10",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ examples = ["matplotlib"]
30
+ gpu = ["cupy-cuda12x"]
31
+ docs = ["sphinx>=7", "sphinx_rtd_theme>=2"]
32
+
33
+ [project.urls]
34
+ Documentation = "https://victorkolt.github.io/thunder4D/"
35
+ Source = "https://github.com/victorkolt/thunder4D"
36
+
37
+ [tool.setuptools]
38
+ packages = ["src"]
39
+
40
+ [tool.setuptools.dynamic]
41
+ version = { attr = "src.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """THUNDER4D - Transverse-resolved Herriott-cell UPPE solver for Nonlinear Dynamics close to Experimental Reality, 4D.
2
+
3
+ 4D (x, y, z, t) UPPE simulation of multipass cells.
4
+ """
5
+ __version__ = "0.9.0"
6
+ from .backend import gpu_available, to_host
7
+ from .grid import Grid, suggest_grid, C0
8
+ from .materials import Material
9
+ from .pulse import (Pulse, Beam, gaussian, super_gaussian, top_hat, knife_edge, measured_profile,
10
+ hermite_gauss, laguerre_gauss, mode_superposition,
11
+ gaussian_spectrum, measured_spectrum, spectrum_from_temporal_field,
12
+ add_spectral_phase, zernike_wavefront)
13
+ from .cell import MPC
14
+ from .solver import UPPESolver
15
+ from .simulation import MPCSimulation
16
+ from . import diagnostics
@@ -0,0 +1,64 @@
1
+ """Array backend: NumPy on the CPU, CuPy on an NVIDIA GPU.
2
+
3
+ Only the heavy arrays live on the device: the field A(x, y, Om), the propagators, the absorbers and
4
+ the mirror transfer function. Axes (grid.x, grid.lam, ...) and everything returned by
5
+ `thunder4d.diagnostics` are always NumPy arrays; `to_host` converts a device array.
6
+ """
7
+ import numpy as np
8
+
9
+ _GPU_OK = None
10
+
11
+
12
+ def gpu_available():
13
+ """True if CuPy is installed and a working CUDA device can run a kernel and an FFT."""
14
+ global _GPU_OK
15
+ if _GPU_OK is None:
16
+ try:
17
+ import cupy as cp
18
+ import cupyx.scipy.fft as cfft
19
+ if cp.cuda.runtime.getDeviceCount() < 1:
20
+ raise RuntimeError("no CUDA device")
21
+ a = cp.ones((8, 8), cp.complex64)
22
+ cfft.fft2(a, axes=(0, 1))
23
+ float(cp.sum(cp.abs(a) ** 2)) # compiles a kernel: fails on unsupported GPUs
24
+ _GPU_OK = True
25
+ except Exception:
26
+ _GPU_OK = False
27
+ return _GPU_OK
28
+
29
+
30
+ def resolve_device(device="auto"):
31
+ """'auto' -> 'gpu' if CuPy works else 'cpu'; 'cpu' / 'gpu' are forced."""
32
+ device = "cpu" if device is None else str(device).lower()
33
+ if device == "auto":
34
+ return "gpu" if gpu_available() else "cpu"
35
+ if device == "cpu":
36
+ return "cpu"
37
+ if device in ("gpu", "cuda"):
38
+ if not gpu_available():
39
+ raise RuntimeError("device='gpu' requested but CuPy with a working CUDA device was not "
40
+ "found (pip install cupy-cuda12x); use device='auto' or 'cpu'")
41
+ return "gpu"
42
+ raise ValueError("device must be 'auto', 'cpu' or 'gpu'")
43
+
44
+
45
+ def get_xp(device):
46
+ """Array module for a resolved device name."""
47
+ if device == "gpu":
48
+ import cupy
49
+ return cupy
50
+ return np
51
+
52
+
53
+ def is_device_array(a):
54
+ return type(a).__module__.split(".")[0] == "cupy"
55
+
56
+
57
+ def xp_of(a):
58
+ """Array module that owns `a` (numpy for anything that is not a CuPy array)."""
59
+ return get_xp("gpu") if is_device_array(a) else np
60
+
61
+
62
+ def to_host(a):
63
+ """NumPy view/copy of an array that may live on the GPU (no copy for NumPy input)."""
64
+ return a.get() if is_device_array(a) else np.asarray(a)
@@ -0,0 +1,142 @@
1
+ """Module 2: the multipass cell.
2
+
3
+ Unfolded picture used by the solver: one pass = cell centre -> d/2 -> curved mirror -> d/2 -> cell
4
+ centre. The field is recorded at the cell centre (waist plane of the eigenmode, flat phase) after
5
+ every pass, which is where the modal decomposition on the cell eigenmodes is exact.
6
+
7
+ Nonlinear mode matching (aberration-free Kerr lens, uniform medium, power P constant along a pass):
8
+ the envelope equation of a gaussian becomes the linear one with lambda -> lambda sqrt(1 - P/Pc),
9
+ Pc = lambda^2/(2 pi n0 n2). Hence the matched waist and mirror spot are the linear ones scaled by
10
+ (1 - P/Pc)^(1/4), and the B-integral per pass (on axis, peak) is
11
+ B = 8 pi n2 P / (lambda lambda_eff) * atan(d / 2 zR') (-> 2 theta P/Pc for P << Pc).
12
+ A pulse can only be matched at one power: the wings of the pulse see the linear mode. This
13
+ time-dependent mismatch is precisely one of the couplings the 4D code resolves.
14
+ """
15
+ import numpy as np
16
+ from .grid import C0
17
+
18
+ FS = 1e-15
19
+
20
+
21
+ class MPC:
22
+ """Symmetric two-mirror cell.
23
+
24
+ R : mirror radius of curvature [m]
25
+ d : mirror separation [m]
26
+ medium : Material
27
+ n_passes : number of passes (centre -> mirror -> centre = 1 pass = 1 bounce)
28
+ mirror_gdd_fs2, mirror_tod_fs3 : dispersion per bounce
29
+ mirror_reflectivity : scalar, or callable R(lambda[m]) (intensity)
30
+ mirror_phase : optional callable phi(lambda[m]) [rad] per bounce (measured GDD curves, ...)
31
+ mirror_diameter : clear aperture [m] (soft super-gaussian edge), None = infinite
32
+ """
33
+
34
+ def __init__(self, R, d, medium, n_passes, *, mirror_gdd_fs2=0.0, mirror_tod_fs3=0.0,
35
+ mirror_reflectivity=1.0, mirror_phase=None, mirror_diameter=None):
36
+ if not 0 < d < 2 * R:
37
+ raise ValueError("unstable cell: need 0 < d < 2R")
38
+ self.R, self.d, self.medium, self.n_passes = float(R), float(d), medium, int(n_passes)
39
+ self.gdd, self.tod = mirror_gdd_fs2, mirror_tod_fs3
40
+ self.refl, self.mphase = mirror_reflectivity, mirror_phase
41
+ self.diameter = mirror_diameter
42
+ self.g = 1 - d / R
43
+ self.theta = np.arccos(self.g) # Gouy phase per pass
44
+ self._bounce_cache = None
45
+
46
+ @classmethod
47
+ def herriott(cls, R, N, k, medium, branch="concentric", n_passes=None, **kw):
48
+ """Re-entrant Herriott cell: N passes (twice the spots per mirror), d = R (1 +- cos(2*pi k/N)).
49
+ N must be even for the concentric branch. Default n_passes = N (one full re-entrant cycle);
50
+ pass n_passes to simulate any other number of passes in the same cell."""
51
+ value = np.cos(2 * np.pi * k / N)
52
+ if branch == "concentric":
53
+ if N % 2:
54
+ raise ValueError(
55
+ f"N = {N} is odd: the concentric branch is only re-entrant for even N. N is the "
56
+ f"number of passes of one re-entrant cycle (e.g. N = {2 * N}, k = {2 * k}); "
57
+ f"to simulate a different number of passes, set n_passes separately.")
58
+ d = R * (1+ value)
59
+ elif branch == "planar":
60
+ d = R * (1 - value)
61
+ else:
62
+ raise ValueError(f"unknown branch {branch!r}: use 'concentric' or 'planar'")
63
+ return cls(R, d, medium, n_passes or N, **kw)
64
+
65
+ # ------------------------------------------------------------ modes
66
+ def linear_mode(self, lam):
67
+ n0 = self.medium.n(2 * np.pi * C0 / lam)
68
+ lm = lam / n0
69
+ w0 = np.sqrt(lm / (2 * np.pi) * np.sqrt(self.d * (2 * self.R - self.d)))
70
+ wm = np.sqrt(lm * self.R / np.pi * np.sqrt(self.d / (2 * self.R - self.d)))
71
+ return {"w0": w0, "w_mirror": wm, "zR": np.pi * w0 ** 2 / lm, "gouy_per_pass": self.theta}
72
+
73
+ def nonlinear_mode(self, P, lam=None, verbose=False):
74
+ """Kerr-lens matched eigenmode for power P [W]."""
75
+ lam = lam or self._lam0
76
+ Pc = self.medium.critical_power(lam)
77
+ r = P / Pc
78
+ if r >= 1:
79
+ raise ValueError(f"P/Pc = {r:.2f} >= 1: no matched mode (self-focusing)")
80
+ lin = self.linear_mode(lam)
81
+ s = (1 - r) ** 0.25
82
+ w0, wm = lin["w0"] * s, lin["w_mirror"] * s
83
+ lam_eff = lam * np.sqrt(1 - r)
84
+ zRp = np.pi * w0 ** 2 / lam_eff
85
+ B = 8 * np.pi * self.medium.n2 * P / (lam * lam_eff) * np.arctan(self.d / (2 * zRp))
86
+ out = {"P": P, "Pc": Pc, "P_over_Pc": r, "w0": w0, "w_mirror": wm,
87
+ "w0_linear": lin["w0"], "w_mirror_linear": lin["w_mirror"], "B_per_pass": B,
88
+ "B_total": B * self.n_passes,
89
+ "I_peak_center_W_cm2": 2 * P / (np.pi * w0 ** 2) * 1e-4}
90
+ if verbose:
91
+ print(self.report(out))
92
+ return out
93
+
94
+ def match(self, pulse, power="peak", verbose=True):
95
+ """Nonlinear mode matching from a Pulse (or a power in W). power: 'peak' | 'weighted'."""
96
+ self._lam0 = pulse.grid.lambda0 if hasattr(pulse, "grid") else self._lam0
97
+ if hasattr(pulse, "grid"):
98
+ P = pulse.peak_power() if power == "peak" else pulse.weighted_power()
99
+ else:
100
+ P = float(pulse)
101
+ return self.nonlinear_mode(P, self._lam0, verbose=verbose)
102
+
103
+ _lam0 = 1030e-9
104
+
105
+ def report(self, m=None):
106
+ s = (f"MPC: R = {self.R:.3f} m, d = {self.d:.4f} m, {self.n_passes} passes, "
107
+ f"Gouy/pass = {np.degrees(self.theta):.2f} deg, {self.medium}")
108
+ if m:
109
+ s += (f"\n P = {m['P']/1e9:.3g} GW, Pc = {m['Pc']/1e9:.3g} GW, P/Pc = {m['P_over_Pc']:.3f}"
110
+ f"\n waist {m['w0_linear']*1e6:.0f} um (linear) -> {m['w0']*1e6:.0f} um (matched)"
111
+ f"\n mirror {m['w_mirror_linear']*1e6:.0f} um (linear) -> {m['w_mirror']*1e6:.0f} um (matched)"
112
+ f"\n B/pass ~ {m['B_per_pass']:.3f} rad, total ~ {m['B_total']:.2f} rad, "
113
+ f"I_peak(centre) ~ {m['I_peak_center_W_cm2']:.3g} W/cm^2")
114
+ return s
115
+
116
+ # ------------------------------------------------------------ mirror
117
+ def bounce_factor(self, grid):
118
+ """Complex transfer of one mirror in (x, y, Om) (cached)."""
119
+ if self._bounce_cache is not None and self._bounce_cache[0] is grid:
120
+ return self._bounce_cache[1]
121
+ W = grid.Omega * FS
122
+ spec = np.exp(1j * (self.gdd / 2 * W ** 2 + self.tod / 6 * W ** 3)).astype(complex)
123
+ lam = grid.lam
124
+ if callable(self.refl):
125
+ spec *= np.sqrt(np.clip(self.refl(np.where(grid.valid, lam, 1.0)), 0, 1))
126
+ else:
127
+ spec *= np.sqrt(self.refl)
128
+ if self.mphase is not None:
129
+ spec *= np.exp(1j * self.mphase(np.where(grid.valid, lam, 1.0)))
130
+ spec *= grid.valid
131
+ F = np.exp(-1j * (grid.r2[:, :, None] * (grid.omega / C0 / self.R)[None, None, :]))
132
+ F *= spec[None, None, :]
133
+ if self.diameter is not None:
134
+ ap = np.exp(-(np.sqrt(grid.r2) / (self.diameter / 2)) ** 30)
135
+ F *= ap[:, :, None]
136
+ F = grid.asarray(F.astype(grid.dtype)) # built on the host, stored on the grid's device
137
+ self._bounce_cache = (grid, F)
138
+ return F
139
+
140
+ def bounce(self, Aw, grid):
141
+ Aw *= self.bounce_factor(grid)
142
+ return Aw