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 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)