thunder4d 0.9.0__py3-none-any.whl
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.
- src/__init__.py +16 -0
- src/backend.py +64 -0
- src/cell.py +142 -0
- src/diagnostics.py +362 -0
- src/grid.py +199 -0
- src/materials.py +74 -0
- src/pulse.py +470 -0
- src/simulation.py +218 -0
- src/solver.py +152 -0
- thunder4d-0.9.0.dist-info/METADATA +126 -0
- thunder4d-0.9.0.dist-info/RECORD +14 -0
- thunder4d-0.9.0.dist-info/WHEEL +5 -0
- thunder4d-0.9.0.dist-info/licenses/LICENSE +21 -0
- thunder4d-0.9.0.dist-info/top_level.txt +1 -0
src/__init__.py
ADDED
|
@@ -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
|
src/backend.py
ADDED
|
@@ -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)
|
src/cell.py
ADDED
|
@@ -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
|
src/diagnostics.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Diagnostics on a field A(x, y, Om) (FFT order). Outputs are returned SORTED (ascending axes).
|
|
2
|
+
|
|
3
|
+
far field = the plane where the field is (the cell centre = focus / waist plane when recorded
|
|
4
|
+
by MPCSimulation)
|
|
5
|
+
near field = the collimated beam: angular spectrum on a common angle grid theta (what you see after
|
|
6
|
+
collimating the output with a lens of focal f: x_out = f * theta). Computed with an
|
|
7
|
+
exact per-colour matrix Fourier transform, so a given theta means the same angle for
|
|
8
|
+
all colours.
|
|
9
|
+
"""
|
|
10
|
+
import numpy as np
|
|
11
|
+
from .grid import C0
|
|
12
|
+
from .backend import to_host
|
|
13
|
+
|
|
14
|
+
FS = 1e-15
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ------------------------------------------------------------------ basic quantities
|
|
18
|
+
def spectral_support(grid, Aw, rel=1e-4):
|
|
19
|
+
Aw = to_host(Aw)
|
|
20
|
+
S = np.sum(np.abs(Aw) ** 2, axis=(0, 1))
|
|
21
|
+
return np.nonzero((S > rel * S.max()) & grid.valid)[0]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def total_spectrum(grid, Aw, per_lambda=True):
|
|
25
|
+
"""Spatially integrated spectrum, sorted by wavelength. Returns (lam [m], S)."""
|
|
26
|
+
Aw = to_host(Aw)
|
|
27
|
+
S = np.sum(np.abs(Aw) ** 2, axis=(0, 1)) * grid.dA * grid.dt / grid.Nt
|
|
28
|
+
return _to_lambda(grid, S, per_lambda)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _to_lambda(grid, S_w, per_lambda=True, axis=-1):
|
|
32
|
+
"""Om-ordered array -> ascending-wavelength array (Jacobian 2 pi c / lam^2 if per_lambda)."""
|
|
33
|
+
idx = np.nonzero(grid.valid)[0]
|
|
34
|
+
idx = idx[np.argsort(grid.lam[idx])]
|
|
35
|
+
lam = grid.lam[idx]
|
|
36
|
+
S = np.take(S_w, idx, axis=axis)
|
|
37
|
+
if per_lambda:
|
|
38
|
+
shape = [1] * S.ndim
|
|
39
|
+
shape[axis] = -1
|
|
40
|
+
S = S * (2 * np.pi * C0 / lam ** 2).reshape(shape)
|
|
41
|
+
return lam, S
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fluence(grid, Aw):
|
|
45
|
+
"""F(x, y) [J/m^2], sorted."""
|
|
46
|
+
Aw = to_host(Aw)
|
|
47
|
+
F = np.sum(np.abs(Aw) ** 2, axis=2) * grid.dt / grid.Nt
|
|
48
|
+
return grid.shift_xy(F)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def temporal_power(grid, Aw):
|
|
52
|
+
"""P(t) [W], sorted time axis."""
|
|
53
|
+
A = grid.hfft.ift_t(to_host(Aw))
|
|
54
|
+
return grid.ts, grid.shift_w(np.sum(np.abs(A) ** 2, axis=(0, 1)) * grid.dA)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------ near field (collimated beam)
|
|
58
|
+
def near_field(grid, Aw, theta_max=None, n_theta=None, idx=None, theta_y=None):
|
|
59
|
+
"""Near field (collimated beam): angular spectrum E(theta_x, theta_y, Om) on a common angle grid (sorted, odd size so that
|
|
60
|
+
theta = 0 is sampled). theta_max default: half the largest kx at lambda0.
|
|
61
|
+
theta_y: optional explicit theta_y values (e.g. [0.] for a single line).
|
|
62
|
+
Returns (theta_x, Ef) with Ef of shape (n_theta, len(theta_y), Nt), FFT order along Om."""
|
|
63
|
+
Aw = to_host(Aw)
|
|
64
|
+
n_theta = n_theta or (grid.Nx // 2) * 2 + 1
|
|
65
|
+
if theta_max is None:
|
|
66
|
+
theta_max = 0.5 * np.abs(grid.kx).max() / (grid.omega0 / C0)
|
|
67
|
+
th = np.linspace(-theta_max, theta_max, n_theta)
|
|
68
|
+
thy = th if theta_y is None else np.atleast_1d(np.asarray(theta_y, float))
|
|
69
|
+
idx = spectral_support(grid, Aw) if idx is None else idx
|
|
70
|
+
Ef = np.zeros((len(th), len(thy), grid.Nt), complex)
|
|
71
|
+
for j in idx:
|
|
72
|
+
k = grid.omega[j] / C0
|
|
73
|
+
Fx = np.exp(-1j * k * np.outer(th, grid.x)) * grid.dx * k / (2 * np.pi)
|
|
74
|
+
Fy = np.exp(-1j * k * np.outer(thy, grid.y)) * grid.dy * k / (2 * np.pi)
|
|
75
|
+
Ef[:, :, j] = Fx @ Aw[:, :, j] @ Fy.T
|
|
76
|
+
return th, Ef
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ spectrograms
|
|
80
|
+
def spectrogram_x_lambda(grid, Aw, y0=0.0, slit=None, axis="x", per_lambda=True):
|
|
81
|
+
"""Spatio-spectral trace S(x, lambda) along a line (imaging spectrometer).
|
|
82
|
+
slit : width [m] integrated around y0 (None = single row). axis 'x' or 'y'.
|
|
83
|
+
Returns (x_sorted [m], lam [m], S[x, lam])."""
|
|
84
|
+
Aw = to_host(Aw)
|
|
85
|
+
if axis == "y":
|
|
86
|
+
Aw = np.swapaxes(Aw, 0, 1)
|
|
87
|
+
coord, other, d = grid.y, grid.x, grid.dx
|
|
88
|
+
else:
|
|
89
|
+
coord, other, d = grid.x, grid.y, grid.dy
|
|
90
|
+
if slit is None:
|
|
91
|
+
sel = [int(np.argmin(np.abs(other - y0)))]
|
|
92
|
+
else:
|
|
93
|
+
sel = np.nonzero(np.abs(other - y0) <= slit / 2)[0]
|
|
94
|
+
S = np.sum(np.abs(Aw[:, sel, :]).astype(np.float64) ** 2, axis=1)
|
|
95
|
+
lam, S = _to_lambda(grid, S, per_lambda, axis=1)
|
|
96
|
+
return np.fft.fftshift(coord), lam, np.fft.fftshift(S, axes=0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def spectrogram_theta_lambda(grid, Aw, theta_max=None, n_theta=None, per_lambda=True):
|
|
100
|
+
"""Angle-resolved spectrum S(theta_x, lambda) at theta_y = 0 (output after collimation:
|
|
101
|
+
x_out = f * theta). Returns (theta [rad], lam [m], S[theta, lam])."""
|
|
102
|
+
th, Ef = near_field(grid, Aw, theta_max, n_theta, theta_y=[0.0])
|
|
103
|
+
S = np.abs(Ef[:, 0, :]).astype(np.float64) ** 2
|
|
104
|
+
lam, S = _to_lambda(grid, S, per_lambda, axis=1)
|
|
105
|
+
return th, lam, S
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ homogeneity
|
|
109
|
+
def homogeneity(grid, Aw=None, S_xyw=None, rel=1e-3):
|
|
110
|
+
"""Spectral homogeneity V(x, y) = (sum sqrt(S S0))^2 / (sum S sum S0), S0 = total spectrum,
|
|
111
|
+
and its fluence-weighted mean. Pass either a field or an intensity cube S_xyw."""
|
|
112
|
+
S = (np.abs(to_host(Aw)) ** 2 if S_xyw is None else S_xyw).astype(np.float64)
|
|
113
|
+
S = S / S.max()
|
|
114
|
+
S0 = S.sum(axis=(0, 1))
|
|
115
|
+
num = np.sum(np.sqrt(S * S0[None, None, :]), axis=2) ** 2
|
|
116
|
+
den = S.sum(axis=2) * S0.sum()
|
|
117
|
+
F = S.sum(axis=2)
|
|
118
|
+
V = np.where(F > rel * F.max(), num / np.maximum(den, 1e-300), np.nan)
|
|
119
|
+
Vm = np.nansum(V * F) / np.sum(F[~np.isnan(V)])
|
|
120
|
+
return np.fft.fftshift(V, axes=(0, 1)) if S_xyw is None else V, float(Vm)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------ modal content
|
|
124
|
+
def _hermite_functions(x, w, M):
|
|
125
|
+
"""Normalised 1D HG functions u_m(x) for m = 0..M, shape (M+1, len(x))."""
|
|
126
|
+
xi = np.sqrt(2) * x / w
|
|
127
|
+
psi = np.zeros((M + 1,) + xi.shape)
|
|
128
|
+
psi[0] = np.pi ** -0.25 * np.exp(-xi ** 2 / 2)
|
|
129
|
+
if M >= 1:
|
|
130
|
+
psi[1] = np.sqrt(2) * xi * psi[0]
|
|
131
|
+
for m in range(1, M):
|
|
132
|
+
psi[m + 1] = np.sqrt(2 / (m + 1)) * xi * psi[m] - np.sqrt(m / (m + 1)) * psi[m - 1]
|
|
133
|
+
return psi * np.sqrt(np.sqrt(2) / w)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def mode_content(grid, Aw, w0_of_lambda, max_order=6, idx=None):
|
|
137
|
+
"""Decomposition on Hermite-Gauss modes of waist w0(lambda) with flat phase (cell centre).
|
|
138
|
+
Returns dict: lam, eta00(lam) (fundamental fraction per colour), order_frac[N] (energy fraction
|
|
139
|
+
in modes of order m+n = N, spectrally integrated, last bin = everything above max_order)."""
|
|
140
|
+
Aw = to_host(Aw)
|
|
141
|
+
idx = spectral_support(grid, Aw, 1e-3) if idx is None else idx
|
|
142
|
+
lam = grid.lam[idx]
|
|
143
|
+
M = max_order
|
|
144
|
+
P = np.sum(np.abs(Aw[:, :, idx]) ** 2, axis=(0, 1)) * grid.dA
|
|
145
|
+
order_E = np.zeros(M + 2)
|
|
146
|
+
eta = np.zeros(len(idx))
|
|
147
|
+
for n, j in enumerate(idx):
|
|
148
|
+
w = w0_of_lambda(lam[n])
|
|
149
|
+
ux = _hermite_functions(grid.x, w, M)
|
|
150
|
+
uy = _hermite_functions(grid.y, w, M)
|
|
151
|
+
c = ux @ Aw[:, :, j] @ uy.T * grid.dA # (M+1, M+1)
|
|
152
|
+
p = np.abs(c) ** 2
|
|
153
|
+
eta[n] = p[0, 0] / P[n]
|
|
154
|
+
mm, nn = np.meshgrid(np.arange(M + 1), np.arange(M + 1), indexing="ij")
|
|
155
|
+
for N in range(M + 1):
|
|
156
|
+
order_E[N] += p[mm + nn == N].sum()
|
|
157
|
+
order_E[M + 1] += max(P[n] - p[mm + nn <= M].sum(), 0.0)
|
|
158
|
+
o = np.argsort(lam)
|
|
159
|
+
return {"lam": lam[o], "eta00": eta[o], "order_frac": order_E / P.sum(),
|
|
160
|
+
"eta00_total": float(np.sum(eta * P) / P.sum())}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lg_mode_content(grid, Aw, w0_of_lambda, p_max=2, l_max=2, idx=None):
|
|
164
|
+
"""Decomposition on Laguerre-Gauss modes LG_pl (exp(i l phi) convention, waist w0(lambda), flat
|
|
165
|
+
phase, cell centre), spectrally integrated.
|
|
166
|
+
Returns dict: p = 0..p_max, l = -l_max..l_max, frac[p, l] (fraction of the total energy in each
|
|
167
|
+
mode, shape (p_max+1, 2*l_max+1)) and residual (energy outside the listed modes)."""
|
|
168
|
+
from .pulse import laguerre_gauss
|
|
169
|
+
Aw = to_host(Aw)
|
|
170
|
+
idx = spectral_support(grid, Aw, 1e-3) if idx is None else idx
|
|
171
|
+
lam = grid.lam[idx]
|
|
172
|
+
ps, ls = np.arange(p_max + 1), np.arange(-l_max, l_max + 1)
|
|
173
|
+
P = np.sum(np.abs(Aw[:, :, idx]) ** 2, axis=(0, 1)) * grid.dA
|
|
174
|
+
E = np.zeros((len(ps), len(ls)))
|
|
175
|
+
for n, j in enumerate(idx):
|
|
176
|
+
w = w0_of_lambda(lam[n])
|
|
177
|
+
for a, p in enumerate(ps):
|
|
178
|
+
for b, l in enumerate(ls):
|
|
179
|
+
u = laguerre_gauss(w, int(p), int(l))(grid.X, grid.Y)
|
|
180
|
+
E[a, b] += np.abs(np.sum(np.conj(u) * Aw[:, :, j]) * grid.dA) ** 2
|
|
181
|
+
frac = E / P.sum()
|
|
182
|
+
return {"p": ps, "l": ls, "frac": frac, "residual": max(1.0 - float(frac.sum()), 0.0)}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ beam size / M2
|
|
186
|
+
def beam_moments(grid, Aw, idx=None):
|
|
187
|
+
"""Per colour: second-moment diameters/2 (wx, wy) [m] and M2x, M2y. Sorted by wavelength."""
|
|
188
|
+
Aw = to_host(Aw)
|
|
189
|
+
idx = spectral_support(grid, Aw, 1e-3) if idx is None else idx
|
|
190
|
+
E = Aw[:, :, idx].astype(np.complex128)
|
|
191
|
+
E /= np.abs(E).max()
|
|
192
|
+
I = np.abs(E) ** 2
|
|
193
|
+
P = I.sum(axis=(0, 1))
|
|
194
|
+
out = {}
|
|
195
|
+
for ax, coord, kc in ((0, grid.x, grid.kx), (1, grid.y, grid.ky)):
|
|
196
|
+
sh = [1, 1, 1]
|
|
197
|
+
sh[ax] = -1
|
|
198
|
+
c = coord.reshape(sh)
|
|
199
|
+
kk = kc.reshape(sh)
|
|
200
|
+
xm = (I * c).sum(axis=(0, 1)) / P
|
|
201
|
+
x2 = (I * c ** 2).sum(axis=(0, 1)) / P - xm ** 2
|
|
202
|
+
Ek = np.fft.fft(E, axis=ax)
|
|
203
|
+
Ik = np.abs(Ek) ** 2
|
|
204
|
+
Pk = Ik.sum(axis=(0, 1))
|
|
205
|
+
km = (Ik * kk).sum(axis=(0, 1)) / Pk
|
|
206
|
+
k2 = (Ik * kk ** 2).sum(axis=(0, 1)) / Pk - km ** 2
|
|
207
|
+
dE = np.fft.ifft(1j * kk * Ek, axis=ax)
|
|
208
|
+
xk = (c * np.imag(np.conj(E) * dE)).sum(axis=(0, 1)) / P - xm * km
|
|
209
|
+
name = "x" if ax == 0 else "y"
|
|
210
|
+
out["w" + name] = 2 * np.sqrt(np.maximum(x2, 0))
|
|
211
|
+
out["M2" + name] = 2 * np.sqrt(np.maximum(x2 * k2 - xk ** 2, 0))
|
|
212
|
+
lam = grid.lam[idx]
|
|
213
|
+
o = np.argsort(lam)
|
|
214
|
+
out = {k: v[o] for k, v in out.items()}
|
|
215
|
+
out["lam"] = lam[o]
|
|
216
|
+
wts = P[o] / P.sum()
|
|
217
|
+
out["M2x_mean"] = float(np.sum(out["M2x"] * wts))
|
|
218
|
+
out["M2y_mean"] = float(np.sum(out["M2y"] * wts))
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------ compression
|
|
223
|
+
def _fwhm(t, y):
|
|
224
|
+
y = y / y.max()
|
|
225
|
+
above = np.nonzero(y >= 0.5)[0]
|
|
226
|
+
if len(above) < 2:
|
|
227
|
+
return np.nan
|
|
228
|
+
i0, i1 = above[0], above[-1]
|
|
229
|
+
tl = np.interp(0.5, [y[i0 - 1], y[i0]], [t[i0 - 1], t[i0]]) if i0 > 0 else t[i0]
|
|
230
|
+
tr = np.interp(0.5, [y[i1 + 1], y[i1]], [t[i1 + 1], t[i1]]) if i1 < len(t) - 1 else t[i1]
|
|
231
|
+
return tr - tl
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def compress(grid, Aw, gdd_bounds_fs2=(-30000, 30000), tod_fs3=0.0, n_scan=61, rel=1e-4):
|
|
235
|
+
"""Best uniform GDD (e.g. chirped mirrors) maximising the peak of the spatially integrated
|
|
236
|
+
power: coarse scan over gdd_bounds_fs2 then golden-section refinement.
|
|
237
|
+
Returns dict with gdd_fs2, t_fs, P(t) [W] (compressed), FWHM, transform-limited FWHM,
|
|
238
|
+
peak power and peak power of the (spatially resolved) transform limit."""
|
|
239
|
+
Aw = to_host(Aw)
|
|
240
|
+
F = np.sum(np.abs(Aw) ** 2, axis=2)
|
|
241
|
+
mask = F > rel * F.max()
|
|
242
|
+
E = Aw[mask] # (npix, Nt)
|
|
243
|
+
W = grid.Omega * FS
|
|
244
|
+
|
|
245
|
+
def P_of(gdd):
|
|
246
|
+
ph = np.exp(1j * (gdd / 2 * W ** 2 + tod_fs3 / 6 * W ** 3)).astype(E.dtype)
|
|
247
|
+
A = grid.hfft.ift_t(E * ph[None, :])
|
|
248
|
+
return np.sum(np.abs(A).astype(np.float64) ** 2, axis=0) * grid.dA
|
|
249
|
+
|
|
250
|
+
scan = np.linspace(*gdd_bounds_fs2, n_scan)
|
|
251
|
+
pk = [P_of(g).max() for g in scan]
|
|
252
|
+
i = int(np.argmax(pk))
|
|
253
|
+
a, b = scan[max(i - 1, 0)], scan[min(i + 1, n_scan - 1)]
|
|
254
|
+
gr = (np.sqrt(5) - 1) / 2
|
|
255
|
+
c, d = b - gr * (b - a), a + gr * (b - a)
|
|
256
|
+
fc, fd = P_of(c).max(), P_of(d).max()
|
|
257
|
+
for _ in range(30):
|
|
258
|
+
if fc > fd:
|
|
259
|
+
b, d, fd = d, c, fc
|
|
260
|
+
c = b - gr * (b - a)
|
|
261
|
+
fc = P_of(c).max()
|
|
262
|
+
else:
|
|
263
|
+
a, c, fc = c, d, fd
|
|
264
|
+
d = a + gr * (b - a)
|
|
265
|
+
fd = P_of(d).max()
|
|
266
|
+
g = (a + b) / 2
|
|
267
|
+
P = np.fft.fftshift(P_of(g))
|
|
268
|
+
t = grid.ts / FS
|
|
269
|
+
Etl = np.abs(E).astype(np.float64) # flat phase in every pixel
|
|
270
|
+
Ptl = np.fft.fftshift(np.sum(np.abs(grid.hfft.ift_t(Etl)) ** 2, axis=0) * grid.dA)
|
|
271
|
+
return {"gdd_fs2": g, "t_fs": t, "P": P, "fwhm_fs": _fwhm(t, P), "tl_fwhm_fs": _fwhm(t, Ptl),
|
|
272
|
+
"peak_power": float(P.max()), "peak_power_TL": float(Ptl.max()),
|
|
273
|
+
"gdd_scan_fs2": scan, "peak_scan": np.array(pk)}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def local_gdd_map(grid, Aw, rel_fluence=1e-2, rel_spectrum=1e-2):
|
|
277
|
+
"""Pixel-wise GDD [fs^2] from a weighted quadratic fit of the spectral phase
|
|
278
|
+
(radial chirp map). Returns sorted GDD(x, y) with NaN outside the beam."""
|
|
279
|
+
Aw = to_host(Aw)
|
|
280
|
+
F = np.sum(np.abs(Aw) ** 2, axis=2)
|
|
281
|
+
S0 = np.sum(np.abs(Aw) ** 2, axis=(0, 1))
|
|
282
|
+
idx = np.nonzero((S0 > rel_spectrum * S0.max()) & grid.valid)[0]
|
|
283
|
+
idx = idx[np.argsort(grid.Omega[idx])]
|
|
284
|
+
W = grid.Omega[idx] * FS
|
|
285
|
+
mask = F > rel_fluence * F.max()
|
|
286
|
+
E = Aw[mask][:, idx].astype(np.complex128)
|
|
287
|
+
E /= np.abs(E).max()
|
|
288
|
+
ph = np.unwrap(np.angle(E), axis=1)
|
|
289
|
+
wt = np.abs(E) ** 2
|
|
290
|
+
V = np.stack([np.ones_like(W), W, W ** 2], axis=1) # (n, 3)
|
|
291
|
+
G = np.einsum("pn,ni,nj->pij", wt, V, V)
|
|
292
|
+
r = np.einsum("pn,ni,pn->pi", wt, V, ph)
|
|
293
|
+
coef = np.linalg.solve(G, r[..., None])[..., 0]
|
|
294
|
+
out = np.full(F.shape, np.nan)
|
|
295
|
+
out[mask] = 2 * coef[:, 2]
|
|
296
|
+
return grid.shift_xy(out)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def radial_profile(grid, M2d, nbins=None):
|
|
300
|
+
"""Azimuthal average of a sorted 2D map, returns (r, value)."""
|
|
301
|
+
X, Y = np.meshgrid(grid.xs, grid.ys, indexing="ij")
|
|
302
|
+
r = np.sqrt(X ** 2 + Y ** 2)
|
|
303
|
+
nb = nbins or grid.Nx // 2
|
|
304
|
+
edges = np.linspace(0, r.max() / np.sqrt(2), nb + 1)
|
|
305
|
+
ok = ~np.isnan(M2d)
|
|
306
|
+
s, _ = np.histogram(r[ok], edges, weights=M2d[ok])
|
|
307
|
+
n, _ = np.histogram(r[ok], edges)
|
|
308
|
+
with np.errstate(invalid="ignore"):
|
|
309
|
+
return 0.5 * (edges[1:] + edges[:-1]), s / n
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------ wavelength-resolved profiles
|
|
313
|
+
def band_indices(grid, lam_c, bandwidth=0.0):
|
|
314
|
+
"""Om indices inside [lam_c - bw/2, lam_c + bw/2]; bandwidth 0 -> nearest bin only
|
|
315
|
+
(the grid spectral resolution is lambda^2 / (c T))."""
|
|
316
|
+
if bandwidth > 0:
|
|
317
|
+
idx = np.nonzero(grid.valid & (np.abs(grid.lam - lam_c) <= bandwidth / 2))[0]
|
|
318
|
+
if len(idx):
|
|
319
|
+
return idx
|
|
320
|
+
return np.array([int(np.argmin(np.where(grid.valid, np.abs(grid.lam - lam_c), np.inf)))])
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def profiles_1d(grid, Aw, wavelengths, bandwidth=0.0, axis="x", mode="cut"):
|
|
324
|
+
"""1D beam profiles for selected wavelengths (bandpass of width `bandwidth`) + the
|
|
325
|
+
spectrally integrated ("global") profile, in the plane where the field is (the far field
|
|
326
|
+
when it is recorded at the cell centre).
|
|
327
|
+
|
|
328
|
+
mode 'cut' : line through y = 0 (x = 0 if axis='y')
|
|
329
|
+
mode 'integrated' : projection on the axis (like a 1D camera / slit-less lineout)
|
|
330
|
+
Returns (coord_sorted [m], profiles[n_lambda + 1, N] (last row = global, not normalised),
|
|
331
|
+
band_energy[n_lambda + 1] (J, fraction of the pulse in each band; last = total)).
|
|
332
|
+
"""
|
|
333
|
+
Aw = to_host(Aw)
|
|
334
|
+
A = Aw if axis == "x" else np.swapaxes(Aw, 0, 1)
|
|
335
|
+
coord = grid.x if axis == "x" else grid.y
|
|
336
|
+
d_other = grid.dy if axis == "x" else grid.dx
|
|
337
|
+
|
|
338
|
+
def lineout(idx):
|
|
339
|
+
sub = np.abs(A[:, :, idx]).astype(np.float64) ** 2
|
|
340
|
+
s = sub.sum(axis=2)
|
|
341
|
+
return s[:, 0] if mode == "cut" else s.sum(axis=1) * d_other
|
|
342
|
+
|
|
343
|
+
rows, en = [], []
|
|
344
|
+
norm = grid.dA * grid.dt / grid.Nt
|
|
345
|
+
for lc in wavelengths:
|
|
346
|
+
idx = band_indices(grid, lc, bandwidth)
|
|
347
|
+
rows.append(lineout(idx))
|
|
348
|
+
en.append(float(np.sum(np.abs(A[:, :, idx]).astype(np.float64) ** 2)) * norm)
|
|
349
|
+
rows.append(lineout(np.nonzero(grid.valid)[0]))
|
|
350
|
+
en.append(grid.energy(Aw))
|
|
351
|
+
return np.fft.fftshift(coord), np.fft.fftshift(np.array(rows), axes=1), np.array(en)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def near_profiles_1d(grid, Aw, wavelengths, bandwidth=0.0, theta_max=None, n_theta=None):
|
|
355
|
+
"""Same as profiles_1d (cut through theta_y = 0) in the near field (collimated beam: angle,
|
|
356
|
+
common for all colours). Returns (theta [rad], profiles[n_lambda + 1, n_theta])."""
|
|
357
|
+
idx_all = spectral_support(grid, Aw)
|
|
358
|
+
th, Ef = near_field(grid, Aw, theta_max, n_theta, idx=idx_all, theta_y=[0.0])
|
|
359
|
+
S = np.abs(Ef[:, 0, :]) ** 2
|
|
360
|
+
rows = [S[:, band_indices(grid, lc, bandwidth)].sum(axis=1) for lc in wavelengths]
|
|
361
|
+
rows.append(S.sum(axis=1))
|
|
362
|
+
return th, np.array(rows)
|