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.
- thunder4d-0.9.0/LICENSE +21 -0
- thunder4d-0.9.0/PKG-INFO +126 -0
- thunder4d-0.9.0/README.md +98 -0
- thunder4d-0.9.0/pyproject.toml +41 -0
- thunder4d-0.9.0/setup.cfg +4 -0
- thunder4d-0.9.0/src/__init__.py +16 -0
- thunder4d-0.9.0/src/backend.py +64 -0
- thunder4d-0.9.0/src/cell.py +142 -0
- thunder4d-0.9.0/src/diagnostics.py +362 -0
- thunder4d-0.9.0/src/grid.py +199 -0
- thunder4d-0.9.0/src/materials.py +74 -0
- thunder4d-0.9.0/src/pulse.py +470 -0
- thunder4d-0.9.0/src/simulation.py +218 -0
- thunder4d-0.9.0/src/solver.py +152 -0
- thunder4d-0.9.0/thunder4d.egg-info/PKG-INFO +126 -0
- thunder4d-0.9.0/thunder4d.egg-info/SOURCES.txt +17 -0
- thunder4d-0.9.0/thunder4d.egg-info/dependency_links.txt +1 -0
- thunder4d-0.9.0/thunder4d.egg-info/requires.txt +12 -0
- thunder4d-0.9.0/thunder4d.egg-info/top_level.txt +1 -0
thunder4d-0.9.0/LICENSE
ADDED
|
@@ -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.
|
thunder4d-0.9.0/PKG-INFO
ADDED
|
@@ -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,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
|