Astimator 0.0.1__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,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: Astimator
3
+ Version: 0.0.1
4
+ Summary: Estimate Hermite-Gauss mode coefficients from intensity images taken through a (tilted) lens.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: numpy
8
+ Requires-Dist: scipy
9
+ Requires-Dist: torch
10
+
11
+ # Astimator
12
+
13
+ The ASTIgmatic estiMATOR. Estimate complex mode coefficients from several intensity pictures of the same
14
+ beam, each taken after a different transformation. PyTorch based.
15
+
16
+ ```
17
+ images (K*H, W) ─┐
18
+ ├─> Estimator ──> coefficients
19
+ basis (K, P, M) ─┘ (L-BFGS-B, torch autodiff, random restarts)
20
+ ```
21
+
22
+ ## Inputs
23
+
24
+ - **images**: the K pictures of one sample v-stacked, shape `(K*H, W)` (array or
25
+ tensor). A 3D input `(n_samples, K*H, W)` fits every sample. Each picture is
26
+ normalised to max 1 internally; background subtraction / flips / cropping are
27
+ up to you, beforehand.
28
+ - **basis**: tensor `(K, n_pix, n_modes)` or a list of K tensors `(n_pix, n_modes)`.
29
+ `basis[k]` is the basis already transformed for picture k, `n_pix = H*W`
30
+ (C-order ravel of an `(H, W)` picture).
31
+
32
+ ```python
33
+ est = Estimator(basis, EstimationConfig(device="auto", n_restarts=10))
34
+ result = est.estimate(images) # dict: results, coeffs (n_restarts, M), best (M,)
35
+ ```
36
+
37
+ ## Helpers
38
+
39
+ - `make_hg_basis(N, (H, W), pixel_size, waist)` — fast HG basis. Separable 1D
40
+ recurrence + one batched product on the device; only modes with n+m<=N are built.
41
+ Returns `HGBasis(basis, ni, mi, N, shape)`.
42
+ - `transform_hg_basis(hg, phis)` — applies `exp(i(phi_x n + phi_y m))` per picture
43
+ → `(K, n_pix, n_modes)`. `phis` is K pairs `(phi_x, phi_y)`.
44
+ - `transform_basis(my_basis, hg, phis)` — same for a non-HG basis: project onto
45
+ `hg`, apply the phases, recombine. `hg`'s order must be high enough to
46
+ represent `my_basis`.
47
+ - `coeffs_to_matrix(coef, ni, mi, N)` — flat coefficients → `[n, m]` matrix.
48
+
49
+
50
+ ## Files
51
+
52
+ `pipeline.py` Estimator · `torch_engine.py` device + loss/grad · `hg_basis.py` ·
53
+ `lens_transform.py` · `config.py` · `examples/run_estimation.py`
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ Astimator/Astimator.egg-info/PKG-INFO
4
+ Astimator/Astimator.egg-info/SOURCES.txt
5
+ Astimator/Astimator.egg-info/dependency_links.txt
6
+ Astimator/Astimator.egg-info/requires.txt
7
+ Astimator/Astimator.egg-info/top_level.txt
8
+ tests/test_synthetic.py
9
+ tests/test_vs_reference.py
@@ -0,0 +1,3 @@
1
+ numpy
2
+ scipy
3
+ torch
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: Astimator
3
+ Version: 0.0.1
4
+ Summary: Estimate Hermite-Gauss mode coefficients from intensity images taken through a (tilted) lens.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: numpy
8
+ Requires-Dist: scipy
9
+ Requires-Dist: torch
10
+
11
+ # Astimator
12
+
13
+ The ASTIgmatic estiMATOR. Estimate complex mode coefficients from several intensity pictures of the same
14
+ beam, each taken after a different transformation. PyTorch based.
15
+
16
+ ```
17
+ images (K*H, W) ─┐
18
+ ├─> Estimator ──> coefficients
19
+ basis (K, P, M) ─┘ (L-BFGS-B, torch autodiff, random restarts)
20
+ ```
21
+
22
+ ## Inputs
23
+
24
+ - **images**: the K pictures of one sample v-stacked, shape `(K*H, W)` (array or
25
+ tensor). A 3D input `(n_samples, K*H, W)` fits every sample. Each picture is
26
+ normalised to max 1 internally; background subtraction / flips / cropping are
27
+ up to you, beforehand.
28
+ - **basis**: tensor `(K, n_pix, n_modes)` or a list of K tensors `(n_pix, n_modes)`.
29
+ `basis[k]` is the basis already transformed for picture k, `n_pix = H*W`
30
+ (C-order ravel of an `(H, W)` picture).
31
+
32
+ ```python
33
+ est = Estimator(basis, EstimationConfig(device="auto", n_restarts=10))
34
+ result = est.estimate(images) # dict: results, coeffs (n_restarts, M), best (M,)
35
+ ```
36
+
37
+ ## Helpers
38
+
39
+ - `make_hg_basis(N, (H, W), pixel_size, waist)` — fast HG basis. Separable 1D
40
+ recurrence + one batched product on the device; only modes with n+m<=N are built.
41
+ Returns `HGBasis(basis, ni, mi, N, shape)`.
42
+ - `transform_hg_basis(hg, phis)` — applies `exp(i(phi_x n + phi_y m))` per picture
43
+ → `(K, n_pix, n_modes)`. `phis` is K pairs `(phi_x, phi_y)`.
44
+ - `transform_basis(my_basis, hg, phis)` — same for a non-HG basis: project onto
45
+ `hg`, apply the phases, recombine. `hg`'s order must be high enough to
46
+ represent `my_basis`.
47
+ - `coeffs_to_matrix(coef, ni, mi, N)` — flat coefficients → `[n, m]` matrix.
48
+
49
+
50
+ ## Files
51
+
52
+ `pipeline.py` Estimator · `torch_engine.py` device + loss/grad · `hg_basis.py` ·
53
+ `lens_transform.py` · `config.py` · `examples/run_estimation.py`
@@ -0,0 +1,43 @@
1
+ # Astimator
2
+
3
+ The ASTIgmatic estiMATOR. Estimate complex mode coefficients from several intensity pictures of the same
4
+ beam, each taken after a different transformation. PyTorch based.
5
+
6
+ ```
7
+ images (K*H, W) ─┐
8
+ ├─> Estimator ──> coefficients
9
+ basis (K, P, M) ─┘ (L-BFGS-B, torch autodiff, random restarts)
10
+ ```
11
+
12
+ ## Inputs
13
+
14
+ - **images**: the K pictures of one sample v-stacked, shape `(K*H, W)` (array or
15
+ tensor). A 3D input `(n_samples, K*H, W)` fits every sample. Each picture is
16
+ normalised to max 1 internally; background subtraction / flips / cropping are
17
+ up to you, beforehand.
18
+ - **basis**: tensor `(K, n_pix, n_modes)` or a list of K tensors `(n_pix, n_modes)`.
19
+ `basis[k]` is the basis already transformed for picture k, `n_pix = H*W`
20
+ (C-order ravel of an `(H, W)` picture).
21
+
22
+ ```python
23
+ est = Estimator(basis, EstimationConfig(device="auto", n_restarts=10))
24
+ result = est.estimate(images) # dict: results, coeffs (n_restarts, M), best (M,)
25
+ ```
26
+
27
+ ## Helpers
28
+
29
+ - `make_hg_basis(N, (H, W), pixel_size, waist)` — fast HG basis. Separable 1D
30
+ recurrence + one batched product on the device; only modes with n+m<=N are built.
31
+ Returns `HGBasis(basis, ni, mi, N, shape)`.
32
+ - `transform_hg_basis(hg, phis)` — applies `exp(i(phi_x n + phi_y m))` per picture
33
+ → `(K, n_pix, n_modes)`. `phis` is K pairs `(phi_x, phi_y)`.
34
+ - `transform_basis(my_basis, hg, phis)` — same for a non-HG basis: project onto
35
+ `hg`, apply the phases, recombine. `hg`'s order must be high enough to
36
+ represent `my_basis`.
37
+ - `coeffs_to_matrix(coef, ni, mi, N)` — flat coefficients → `[n, m]` matrix.
38
+
39
+
40
+ ## Files
41
+
42
+ `pipeline.py` Estimator · `torch_engine.py` device + loss/grad · `hg_basis.py` ·
43
+ `lens_transform.py` · `config.py` · `examples/run_estimation.py`
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "Astimator"
7
+ version = "0.0.1"
8
+ description = "Estimate Hermite-Gauss mode coefficients from intensity images taken through a (tilted) lens."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["numpy", "scipy", "torch"]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["Astimator"]
15
+
16
+ [tool.pytest.ini_options]
17
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,102 @@
1
+ """Synthetic tests: generate noiseless pictures from known coefficients, then
2
+ check that Estimator recovers them. Run with: pytest tests/ -s"""
3
+ import numpy as np
4
+ from numpy.polynomial.hermite import hermval
5
+ from math import factorial, sqrt
6
+
7
+ from Astimator import (
8
+ EstimationConfig, Estimator,
9
+ make_hg_basis, transform_hg_basis, transform_basis, coeffs_to_matrix,
10
+ )
11
+
12
+
13
+ N, RES, W_BEAM = 3, 64, 1.0
14
+ PIXEL = 6.0 / (RES - 1) # np.linspace(-3, 3, RES) pitch
15
+ PHIX = [0.0, 0.9, 2.1, 3.0]
16
+ PHIY = [0.0, 1.3, 0.4, 2.2]
17
+ PHIS = list(zip(PHIX, PHIY))
18
+
19
+
20
+ def hg1d(n, x, w):
21
+ c = np.zeros(n + 1); c[n] = 1
22
+ return hermval(sqrt(2) * x / w, c) * np.exp(-x**2 / w**2) / sqrt(2**n * factorial(n))
23
+
24
+
25
+ def reference_basis(N, res=RES, w=W_BEAM):
26
+ """Independent basis with modes n+m<=N as columns (res*res, n_modes); returns also ns, ms.
27
+ Same column order as make_hg_basis: n outer loop, m inner loop; n along x, m along y."""
28
+ x = np.linspace(-3, 3, res)
29
+ X, Y = np.meshgrid(x, x)
30
+ cols, ns, ms = [], [], []
31
+ for n in range(N + 1):
32
+ for m in range(N + 1 - n):
33
+ b = hg1d(n, X, w) * hg1d(m, Y, w)
34
+ cols.append((b / np.sqrt((np.abs(b) ** 2).sum())).ravel())
35
+ ns.append(n); ms.append(m)
36
+ return np.array(cols).T.astype(complex), np.array(ns), np.array(ms)
37
+
38
+
39
+ def make_truth(ns, ms, seed=0):
40
+ rng = np.random.default_rng(seed)
41
+ c = rng.standard_normal(len(ns)) + 1j * rng.standard_normal(len(ns))
42
+ return c / np.linalg.norm(c)
43
+
44
+
45
+ def make_images(transformed, c):
46
+ """transformed: (K, n_pix, n_modes). Returns the K pictures v-stacked -> (K*RES, RES)."""
47
+ imgs = []
48
+ for b in transformed:
49
+ I = np.abs(b @ c) ** 2
50
+ imgs.append((I / I.max()).reshape(RES, RES))
51
+ return np.vstack(imgs)
52
+
53
+
54
+ def align(est, truth):
55
+ """Remove the unobservable global phase of the estimate.
56
+ vdot(est, truth) = sum(conj(est) * truth) has phase -delta when est = truth * e^{i delta}."""
57
+ return est * np.exp(1j * np.angle(np.vdot(est, truth)))
58
+
59
+
60
+ def check_recovery(basis, images, truth):
61
+ np.random.seed(0)
62
+ est = Estimator(basis, EstimationConfig(device="auto", n_restarts=5, maxiter=1000))
63
+ res = est.estimate(images)
64
+ err = np.linalg.norm(align(res["best"], truth) - truth)
65
+ print("loss", res["results"].min(), "err", err)
66
+ assert err < 1e-3
67
+ return res
68
+
69
+
70
+ def test_make_hg_basis_matches_reference():
71
+ hg = make_hg_basis(N, (RES, RES), PIXEL, W_BEAM, norm="l2", device="cpu")
72
+ B, ns, ms = reference_basis(N)
73
+ assert np.array_equal(hg.ns.numpy(), ns) and np.array_equal(hg.ms.numpy(), ms)
74
+ assert np.abs(hg.basis.numpy() - B).max() < 1e-10
75
+
76
+
77
+ def test_recovers_coefs_with_helpers():
78
+ hg = make_hg_basis(N, (RES, RES), PIXEL, W_BEAM, norm="l2", device="cpu")
79
+ ns, ms = hg.ns.numpy(), hg.ms.numpy()
80
+ C = make_truth(ns, ms)
81
+ basis = transform_hg_basis(hg, PHIS) # (4, n_pix, n_modes)
82
+ res = check_recovery(basis, make_images(basis.numpy(), C), C)
83
+ # matrix view [n, m], like the old (N+1, N+1) layout
84
+ Cm = coeffs_to_matrix(align(res["best"], C), ns, ms, N)
85
+ assert Cm.shape == (N + 1, N + 1)
86
+
87
+
88
+ def test_recovers_coefs_with_custom_basis_list():
89
+ """Your own basis, with the per-picture transformed bases built by hand."""
90
+ B, ns, ms = reference_basis(N)
91
+ C = make_truth(ns, ms, seed=1)
92
+ basis_list = [B * np.exp(1j * (px * ns + py * ms))[None, :] for px, py in PHIS]
93
+ check_recovery(basis_list, make_images(np.array(basis_list), C), C)
94
+
95
+
96
+ def test_transform_basis_generic_matches_manual():
97
+ """Generic path (project onto HG, phase, recombine) == manual phases for an HG-spanned basis."""
98
+ hg = make_hg_basis(N, (RES, RES), PIXEL, W_BEAM, norm="l2", device="cpu")
99
+ B, ns, ms = reference_basis(N)
100
+ manual = np.array([B * np.exp(1j * (px * ns + py * ms))[None, :] for px, py in PHIS])
101
+ generic = transform_basis(B, hg, PHIS).numpy()
102
+ assert np.abs(generic - manual).max() < 1e-8
@@ -0,0 +1,521 @@
1
+ """
2
+ Equivalence tests: Astimator package vs the original script `estimate_pytorch.py`.
3
+
4
+ HOW TO RUN
5
+ ----------
6
+ 1. Copy `estimate_pytorch.py` to the repository root (temporary; or leave it in
7
+ PATH_FOLDER, it is searched there too).
8
+ 2. Set PATH_FOLDER below (or the environment variable PATH_FOLDER). It is the
9
+ folder that contains
10
+ data_cheat/<FOLDER>/random_modes_N*_f0.300_z*_theta*.h5
11
+ coefs_modes/random_modes_N*.h5
12
+ and also structured_optics.py, utils_lens.py, utils_img.py (the original
13
+ script's own imports).
14
+ 3. pytest tests/test_vs_reference.py -v -s
15
+
16
+ WHAT IS TESTED (each level isolates one possible source of mismatch)
17
+ --------------------------------------------------------------------
18
+ 1. test_hg_basis_matches_reference make_hg_basis vs Beam/hg_basis (norm1=True)
19
+ 2. test_transformed_basis_matches_... transform_hg_basis vs B * exp(i(phix*ni+phiy*mi))
20
+ (two different waists, like w1/w2 in the script)
21
+ 3. test_loss_and_gradient_match_reference TorchEngine loss/grad vs negative_log_likelihood_torch
22
+ 4. test_same_start_same_result Estimator vs the script's minimize(), SAME x0
23
+ 5. test_random_restarts_statistically_.. both with random restarts: best loss + fidelity to truth
24
+
25
+ The reference functions (loss, gradient, vector_to_matrix_np, ...) are NOT
26
+ re-typed here: they are extracted from the script with `ast`, so the test runs
27
+ the script's own code. Only the per-N setup block of the script (which lives
28
+ inside a `for N` loop and cannot be imported) is copied, in `build_reference`.
29
+
30
+ Mode ordering: the script uses a full (N+1)x(N+1) grid [i, j] and keeps the
31
+ entries with ni+mi<=N; the package uses a flat list of modes. The tests map
32
+ modes by their (n, m) values (taken from the script's own `hg_proj`), so
33
+ a different ordering is NOT a failure, but a different (n, m) meaning is.
34
+ """
35
+ import ast
36
+ import os
37
+ import sys
38
+ from functools import lru_cache
39
+ from pathlib import Path
40
+ from types import SimpleNamespace
41
+
42
+ import numpy as np
43
+ import pytest
44
+ import torch
45
+ from scipy.optimize import minimize
46
+
47
+ from Astimator import EstimationConfig, Estimator, make_hg_basis, transform_hg_basis
48
+ from Astimator.torch_engine import TorchEngine
49
+
50
+ # =============================================================================
51
+ # CONFIG -- edit here
52
+ # =============================================================================
53
+ # Folder with data_cheat/, coefs_modes/ and the reference modules
54
+ # (structured_optics, utils_lens, utils_img). Env var PATH_FOLDER overrides it.
55
+ PATH_FOLDER = os.environ.get("PATH_FOLDER", r"C:/Python/tilted_lens")
56
+
57
+ # Where the original script is. Default: repo root, then PATH_FOLDER.
58
+ REFERENCE_SCRIPT = os.environ.get("REFERENCE_SCRIPT", "")
59
+
60
+ # Same constants as in estimate_pytorch.py
61
+ FOLDER = "biconvex_stretched_gaussref_equalsize"
62
+ PIXEL_SIZE = 2.4e-6
63
+ RES = 230
64
+ Z1, THETA1 = 30e-2, 0
65
+ Z2, THETA2 = 22e-2, 20
66
+ LAMB, F_LENS, N_GLASS = 633e-9, 30e-2, 1.5151
67
+ WAISTX = WAISTY = 700e-6
68
+ Z0 = 118e-2
69
+ SIGMA = 1.0
70
+
71
+ # Test size (keep small: the script's N goes to 10, equivalence shows at small N)
72
+ N_VALUES = [1, 2, 3]
73
+ SAMPLES = [0, 1] # which images of each data file are fitted
74
+ NUM_REP = 5 # random restarts in the statistical test (script uses 5)
75
+ TEST_WAISTS = (1.2e-4, 1.5e-4) # waists for the data-free tests (differ on purpose)
76
+
77
+ # Conventions that make the package reproduce the script (found with the first run of
78
+ # these tests; test_diagnose_reference_grid re-checks them against your Beam class):
79
+ NORM = "hermite" # script's hg_basis(norm1=True) = H_n/sqrt(2^n n!)
80
+ GRID_PITCH = RES * PIXEL_SIZE / (RES - 1) # script's Beam grid: linspace(-nix, nix, res)
81
+ SWAP_AXES = True # script's `ni` (the index multiplying phix) is the order along image ROWS (y)
82
+
83
+ # Tolerances
84
+ BASIS_ATOL = 1e-8 # |s*basis_pkg - basis_script|, after removing one global scale s
85
+ SCALE_TOL = 1e-3 # allowed |s - 1|
86
+ LOSS_RTOL = 1e-9 # loss at the same parameters
87
+ GRAD_RTOL = 1e-8 # gradient, relative to max|grad|
88
+ OPT_LOSS_RTOL = 0.05 # final losses of two optimisations
89
+ OPT_LOSS_ATOL = 1e-6
90
+ FID_MIN = 0.999 # |<a,b>| of two unit-norm coefficient vectors
91
+
92
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
93
+
94
+
95
+ # =============================================================================
96
+ # Locating / loading the reference
97
+ # =============================================================================
98
+ def _find_reference_script():
99
+ candidates = []
100
+ if REFERENCE_SCRIPT:
101
+ candidates.append(Path(REFERENCE_SCRIPT))
102
+ candidates += [Path(__file__).resolve().parents[1] / "estimate_pytorch.py",
103
+ Path(PATH_FOLDER) / "estimate_pytorch.py"]
104
+ for c in candidates:
105
+ if c.is_file():
106
+ return c
107
+ pytest.skip("estimate_pytorch.py not found. Looked in: " + ", ".join(map(str, candidates)))
108
+
109
+
110
+ @pytest.fixture(scope="session")
111
+ def ref():
112
+ """The reference's own helper modules (structured_optics, utils_lens, utils_img)."""
113
+ if not Path(PATH_FOLDER).is_dir():
114
+ pytest.skip(f"PATH_FOLDER does not exist: {PATH_FOLDER}")
115
+ if PATH_FOLDER not in sys.path:
116
+ sys.path.append(str(PATH_FOLDER)) # append: never shadow the repo's modules
117
+ try:
118
+ import structured_optics as so
119
+ import utils_lens as ul
120
+ import utils_img as ui
121
+ except ImportError as e:
122
+ pytest.skip(f"cannot import reference modules from {PATH_FOLDER}: {e}")
123
+ return SimpleNamespace(Beam=so.Beam, hg_proj=so.hg_proj, hg_basis=so.hg_basis,
124
+ tilted_lens_focal_lengths=ul.tilted_lens_focal_lengths,
125
+ calculate_phix_phiy=ul.calculate_phix_phiy,
126
+ fit_beam_widths=ui.fit_beam_widths)
127
+
128
+
129
+ def _load_reference_functions():
130
+ """Execute ONLY the top-level `def`s of the script (loss, grad, vector_to_matrix_np...).
131
+ The `for N` loop is not executed. Globals the functions expect (n_valid, total,
132
+ valid_indices_t, valid, A_np) are injected by build_reference."""
133
+ path = _find_reference_script()
134
+ tree = ast.parse(path.read_text(encoding="utf-8"))
135
+ defs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]
136
+ ns = {"np": np, "torch": torch, "device": DEVICE, "dtype": torch.float64}
137
+ exec(compile(ast.Module(body=defs, type_ignores=[]), str(path), "exec"), ns)
138
+ return ns
139
+
140
+
141
+ def _reference_columns(ref, N, waist):
142
+ """Copy of the script's `build_Bmat` for ONE waist, restricted to valid modes.
143
+ Returns B_valid (n_pix, n_valid), valid_indices, ni_flat, mi_flat, ni, mi."""
144
+ beam = ref.Beam(nix=RES * PIXEL_SIZE / 2, niy=RES * PIXEL_SIZE / 2,
145
+ Dx=RES, Dy=RES, lamb=633e-9, waist=waist)
146
+ beam.hg(0, 0)
147
+ ni, mi, _overlap = ref.hg_proj(beam, N)
148
+ beam.waist = waist
149
+ basis = ref.hg_basis(beam, N, norm1=True)
150
+ Bmat = np.array([basis[i, j].ravel() for i in range(N + 1) for j in range(N + 1)]).T
151
+ valid = ni + mi <= N
152
+ valid_indices = np.where(valid.ravel())[0]
153
+ return (Bmat[:, valid_indices], valid_indices,
154
+ ni.ravel()[valid_indices], mi.ravel()[valid_indices], ni, mi, Bmat, valid)
155
+
156
+
157
+ _REF_CACHE = {}
158
+
159
+
160
+ def build_reference(ref, N, w1, w2):
161
+ """Copy of the per-N setup block of estimate_pytorch.py (plus_N = 0)."""
162
+ key = (N, w1, w2)
163
+ if key in _REF_CACHE:
164
+ return _REF_CACHE[key]
165
+
166
+ fx1, fy1 = ref.tilted_lens_focal_lengths('biconvex', THETA1, f0=F_LENS, n=N_GLASS)
167
+ phix1, phiy1 = ref.calculate_phix_phiy(fx1, fy1, WAISTX, WAISTY, LAMB, Z0, Z1)
168
+ fx2, fy2 = ref.tilted_lens_focal_lengths('biconvex', THETA2, f0=F_LENS, n=N_GLASS)
169
+ phix2, phiy2 = ref.calculate_phix_phiy(fx2, fy2, WAISTX, WAISTY, LAMB, Z0, Z2)
170
+
171
+ B1v, valid_indices, ni_flat, mi_flat, ni, mi, Bmat1, valid = _reference_columns(ref, N, w1)
172
+ B2v, *_rest, Bmat2, _ = _reference_columns(ref, N, w2)
173
+
174
+ n_valid = int(np.count_nonzero(valid))
175
+ forw = np.exp(1j * (phix1 * ni + phiy1 * mi)).ravel()
176
+ tilt = np.exp(1j * (phix2 * ni + phiy2 * mi)).ravel()
177
+ B_stacked_full = np.vstack([Bmat1 * forw[None, :], Bmat2 * tilt[None, :]]) # (2*n_pix, (N+1)^2)
178
+ B_valid = B_stacked_full[:, valid_indices].reshape(2, RES * RES, n_valid) # (2, n_pix, n_valid)
179
+
180
+ funcs = _load_reference_functions()
181
+ funcs.update(n_valid=n_valid, total=(N + 1) ** 2, valid=valid,
182
+ valid_indices_t=torch.tensor(valid_indices, dtype=torch.long, device=DEVICE),
183
+ A_np=np.zeros((N + 1, N + 1), dtype=complex))
184
+
185
+ out = SimpleNamespace(N=N, w1=w1, w2=w2, funcs=funcs, n_valid=n_valid,
186
+ valid_indices=valid_indices, ni_flat=ni_flat, mi_flat=mi_flat,
187
+ B_stacked_full=B_stacked_full, B_valid=B_valid,
188
+ phis=[(float(phix1), float(phiy1)), (float(phix2), float(phiy2))])
189
+ _REF_CACHE[key] = out
190
+ return out
191
+
192
+
193
+ def repo_phis(setup):
194
+ """The script's (phix, phiy) pairs in the PACKAGE's convention (n along x/columns, m along y/rows)."""
195
+ return [(py, px) if SWAP_AXES else (px, py) for px, py in setup.phis]
196
+
197
+
198
+ def build_repo(setup):
199
+ """The package's way: one HG basis per waist, phases from the same phis."""
200
+ hg1 = make_hg_basis(setup.N, (RES, RES), GRID_PITCH, setup.w1, norm=NORM, device="auto")
201
+ hg2 = make_hg_basis(setup.N, (RES, RES), GRID_PITCH, setup.w2, norm=NORM, device="auto")
202
+ phis = repo_phis(setup)
203
+ b1 = transform_hg_basis(hg1, [phis[0]])[0]
204
+ b2 = transform_hg_basis(hg2, [phis[1]])[0]
205
+ return hg1, torch.stack([b1, b2]) # (2, n_pix, n_modes)
206
+
207
+
208
+ # =============================================================================
209
+ # Mode bookkeeping (script: matrix [i, j] / valid order; package: flat (n, m) list)
210
+ # =============================================================================
211
+ def mode_permutation(setup, hg, swap=None):
212
+ """perm[p] = index (in the script's valid-mode order) of the package's mode p.
213
+ The script's label (ni, mi) is matched to the package's (n, m) -- or to (m, n) when
214
+ SWAP_AXES, because the script's `ni` runs along image rows and the package's `n` along columns."""
215
+ swap = SWAP_AXES if swap is None else swap
216
+ ns, ms = hg.ns.cpu().numpy(), hg.ms.cpu().numpy()
217
+ lookup = {(int(n), int(m)): q for q, (n, m) in enumerate(zip(setup.ni_flat, setup.mi_flat))}
218
+ assert len(ns) == setup.n_valid, (
219
+ f"number of modes differs: package {len(ns)} vs script {setup.n_valid}")
220
+ keys = [(int(m), int(n)) if swap else (int(n), int(m)) for n, m in zip(ns, ms)]
221
+ try:
222
+ perm = np.array([lookup[k] for k in keys])
223
+ except KeyError as e:
224
+ raise AssertionError(f"mode {e.args[0]} exists in the package but not in the script")
225
+ assert perm[0] == 0, "the first mode must be (0, 0) in both: it carries the fixed global phase"
226
+ return perm
227
+
228
+
229
+ def ref_vec_to_repo(v, perm, n):
230
+ """Parameter vector [real(n) | imag(n-1)] script order -> package order."""
231
+ imag = np.concatenate([[0.0], v[n:]])[perm]
232
+ return np.concatenate([v[:n][perm], imag[1:]])
233
+
234
+
235
+ def repo_vec_to_ref(v, perm, n):
236
+ real, imag = np.empty(n), np.empty(n)
237
+ real[perm] = v[:n]
238
+ imag[perm] = np.concatenate([[0.0], v[n:]])
239
+ return np.concatenate([real, imag[1:]])
240
+
241
+
242
+ def ref_matrix_to_repo_flat(A, setup, perm):
243
+ return np.asarray(A).ravel()[setup.valid_indices][perm]
244
+
245
+
246
+ def fidelity(a, b):
247
+ a, b = np.asarray(a).ravel(), np.asarray(b).ravel()
248
+ return float(abs(np.vdot(a, b)) / (np.linalg.norm(a) * np.linalg.norm(b)))
249
+
250
+
251
+ # =============================================================================
252
+ # Data
253
+ # =============================================================================
254
+ @lru_cache(maxsize=None)
255
+ def load_dataset(N):
256
+ h5py = pytest.importorskip("h5py")
257
+ root = Path(PATH_FOLDER)
258
+ p1 = root / "data_cheat" / FOLDER / f"random_modes_N{N}_f0.300_z{Z1:.3f}_theta{THETA1:.3f}.h5"
259
+ p2 = root / "data_cheat" / FOLDER / f"random_modes_N{N}_f0.300_z{Z2:.3f}_theta{THETA2:.3f}.h5"
260
+ pc = root / "coefs_modes" / f"random_modes_N{N}.h5"
261
+ for p in (p1, p2, pc):
262
+ if not p.is_file():
263
+ pytest.skip(f"data file not found: {p} (check PATH_FOLDER)")
264
+
265
+ def read(path, key):
266
+ with h5py.File(path, "r") as fh:
267
+ return np.asarray(fh[key])
268
+
269
+ imgs1, imgs2 = read(p1, "images"), read(p2, "images")
270
+ imgs1 = imgs1 / imgs1.max(axis=(1, 2), keepdims=True) # same as the script
271
+ imgs2 = imgs2 / imgs2.max(axis=(1, 2), keepdims=True)
272
+ return SimpleNamespace(combined=np.concatenate([imgs1, imgs2], axis=1), # (n, 2*RES, RES)
273
+ coefs=read(pc, "coefs"), imgs1=imgs1, imgs2=imgs2)
274
+
275
+
276
+ def fitted_waists(ref, data):
277
+ """Per-image waists exactly as in the script: sqrt(wx*wy) of image 0 of each file."""
278
+ wx1, wy1 = ref.fit_beam_widths(data.imgs1[0], pixel_size=PIXEL_SIZE)
279
+ wx2, wy2 = ref.fit_beam_widths(data.imgs2[0], pixel_size=PIXEL_SIZE)
280
+ return float(np.sqrt(wx1 * wy1)), float(np.sqrt(wx2 * wy2))
281
+
282
+
283
+ def run_reference_fit(setup, img_ravel, x0):
284
+ """The script's inner call: scipy L-BFGS-B with loss_np / grad_np."""
285
+ f = setup.funcs
286
+ B_t = f["_to_complex_tensor"](setup.B_stacked_full)
287
+ data_t = torch.tensor(img_ravel, dtype=torch.float64, device=DEVICE)
288
+ res = minimize(f["loss_np"], x0, args=(B_t, data_t, SIGMA), method="L-BFGS-B",
289
+ jac=f["grad_np"], tol=1e-12, options={"maxiter": 5000})
290
+ A = f["vector_to_matrix_np"](res.x, setup.N)
291
+ return float(res.fun), A / np.sqrt(np.sum(np.abs(A) ** 2))
292
+
293
+
294
+ def basis_for_estimator(source, setup, hg, perm):
295
+ if source == "repo":
296
+ return build_repo(setup)[1]
297
+ return torch.as_tensor(setup.B_valid[:, :, perm]) # script's own basis, package order
298
+
299
+
300
+ def close_enough(l_a, l_b):
301
+ return l_a <= l_b * (1 + OPT_LOSS_RTOL) + OPT_LOSS_ATOL
302
+
303
+
304
+ # =============================================================================
305
+ # 1. HG basis
306
+ # =============================================================================
307
+ def _fit_global_scale(B_new, B_ref):
308
+ """Best real s with s*B_new ~ B_ref (least squares); returns (s, max|s*B_new - B_ref|).
309
+ The script divides its whole basis by the sampled peak of the (0,0) mode, a constant
310
+ ~ exp(pitch^2/(2 waist^2)) = 1 + 2e-4 (even `res`: no pixel sits exactly at the centre).
311
+ A common factor on the basis cancels in the loss (I / I.max) and in the normalised
312
+ coefficients, so it is compared separately instead of counted as a mismatch."""
313
+ s = np.real(np.vdot(B_new, B_ref)) / np.real(np.vdot(B_new, B_new))
314
+ return float(s), float(np.abs(s * B_new - B_ref).max())
315
+
316
+
317
+ def _convention_table(B_valid, ni_flat, mi_flat, N, waist):
318
+ """max|diff| between the script's basis and the package's for every combination of the
319
+ usual suspects (axis swap, normalisation, grid pitch). The right one shows ~1e-12."""
320
+ setup = SimpleNamespace(ni_flat=ni_flat, mi_flat=mi_flat, n_valid=B_valid.shape[1])
321
+ rows = []
322
+ for swap in (False, True):
323
+ for norm in ("hermite", "max1"):
324
+ for label, pitch in (("pixel_size", PIXEL_SIZE), ("res*pixel/(res-1)", PIXEL_SIZE * RES / (RES - 1))):
325
+ hg = make_hg_basis(N, (RES, RES), pitch, waist, norm=norm, device="cpu")
326
+ d = _fit_global_scale(hg.basis.numpy(), B_valid[:, mode_permutation(setup, hg, swap=swap)])[1]
327
+ rows.append(f"swap_axes={swap!s:5} norm={norm:8} pitch={label:18} max|diff|={d:.3e}")
328
+ return "\n ".join(rows)
329
+
330
+
331
+ @pytest.mark.parametrize("waist", TEST_WAISTS)
332
+ @pytest.mark.parametrize("N", N_VALUES)
333
+ def test_hg_basis_matches_reference(ref, N, waist):
334
+ B_valid, _vi, ni_flat, mi_flat, *_ = _reference_columns(ref, N, waist)
335
+ hg = make_hg_basis(N, (RES, RES), GRID_PITCH, waist, norm=NORM, device="cpu")
336
+
337
+ setup = SimpleNamespace(ni_flat=ni_flat, mi_flat=mi_flat, n_valid=B_valid.shape[1])
338
+ perm = mode_permutation(setup, hg)
339
+ B_ref = B_valid[:, perm]
340
+ B_new = hg.basis.numpy()
341
+ assert B_new.shape == B_ref.shape, f"shape: package {B_new.shape} vs script {B_ref.shape}"
342
+
343
+ scale, diff = _fit_global_scale(B_new, B_ref)
344
+ expected = np.exp(GRID_PITCH ** 2 / (2 * waist ** 2)) - 1
345
+ print(f"\nN={N} waist={waist:g}: global scale s-1 = {scale - 1:.3e} (sampled-peak estimate {expected:.3e}), "
346
+ f"residual after scaling {diff:.2e}")
347
+ assert abs(scale - 1) < SCALE_TOL, f"global scale between the bases is {scale:.6f}, expected ~1"
348
+ assert diff < BASIS_ATOL, (
349
+ f"basis differs by {diff:.3e} (after a global scale) with NORM={NORM!r}, GRID_PITCH={GRID_PITCH:.6e}, "
350
+ f"SWAP_AXES={SWAP_AXES}.\nAll combinations (the matching one should be ~1e-12):\n "
351
+ + _convention_table(B_valid, ni_flat, mi_flat, N, waist))
352
+
353
+
354
+ def _fit_gaussian_1d(profile, waist):
355
+ """log|profile| = -(pitch/waist)^2 (k - k0)^2 + c -> (pitch, k0)."""
356
+ k = np.arange(len(profile))
357
+ ok = profile > 1e-8 * profile.max()
358
+ a2, a1, _ = np.polyfit(k[ok], np.log(profile[ok]), 2)
359
+ return waist * np.sqrt(-a2), -a1 / (2 * a2)
360
+
361
+
362
+ def test_diagnose_reference_grid(ref):
363
+ """Measures the script's Beam grid (pitch, centre) and the axis its `ni` runs along,
364
+ straight from its modes, and checks them against NORM / GRID_PITCH / SWAP_AXES."""
365
+ N, waist = 1, TEST_WAISTS[0]
366
+ B_valid, _vi, ni_flat, mi_flat, *_ = _reference_columns(ref, N, waist)
367
+ col = {(int(a), int(b)): q for q, (a, b) in enumerate(zip(ni_flat, mi_flat))}
368
+
369
+ G = np.abs(B_valid[:, col[(0, 0)]]).reshape(RES, RES)
370
+ pitch_x, c_x = _fit_gaussian_1d(G[RES // 2, :], waist) # along columns
371
+ pitch_y, c_y = _fit_gaussian_1d(G[:, RES // 2], waist) # along rows
372
+ G1 = B_valid[:, col[(1, 0)]].reshape(RES, RES)
373
+ odd_along_columns = np.abs(G1.sum(axis=1)).max() < np.abs(G1.sum(axis=0)).max()
374
+ ni_axis = "COLUMNS (x)" if odd_along_columns else "ROWS (y)"
375
+ print(f"\nscript grid: pitch along x = {pitch_x:.6e} ({pitch_x / PIXEL_SIZE:.6f} x pixel_size), "
376
+ f"along y = {pitch_y:.6e} ({pitch_y / PIXEL_SIZE:.6f} x pixel_size)")
377
+ print(f" centre (pixel index): x = {c_x:.4f}, y = {c_y:.4f} (package assumes {(RES - 1) / 2})")
378
+ print(f" res/(res-1) = {RES / (RES - 1):.6f}")
379
+ print(f"script mode ni (index multiplying phix) runs along image {ni_axis}")
380
+
381
+ assert pitch_x == pytest.approx(GRID_PITCH, rel=1e-4) and pitch_y == pytest.approx(GRID_PITCH, rel=1e-4), (
382
+ "script grid pitch != GRID_PITCH; use the fitted values printed above")
383
+ assert c_x == pytest.approx((RES - 1) / 2, abs=1e-3) and c_y == pytest.approx((RES - 1) / 2, abs=1e-3), (
384
+ "script grid centre differs from (RES-1)/2; pass center=(cy, cx) to make_hg_basis")
385
+ assert odd_along_columns == (not SWAP_AXES), f"ni runs along {ni_axis}: fix SWAP_AXES"
386
+
387
+
388
+ # =============================================================================
389
+ # 2. Transformed basis (phases + two waists)
390
+ # =============================================================================
391
+ @pytest.mark.parametrize("N", N_VALUES)
392
+ def test_transformed_basis_matches_reference(ref, N):
393
+ setup = build_reference(ref, N, *TEST_WAISTS)
394
+ hg, basis = build_repo(setup)
395
+ perm = mode_permutation(setup, hg)
396
+ B_ref = setup.B_valid[:, :, perm]
397
+ assert basis.shape == B_ref.shape
398
+ B_new = basis.cpu().numpy()
399
+ for k in range(B_new.shape[0]): # one global scale per image (each has its own waist)
400
+ scale, diff = _fit_global_scale(B_new[k], B_ref[k])
401
+ assert abs(scale - 1) < SCALE_TOL, f"image {k}: global scale {scale:.6f}, expected ~1"
402
+ assert diff < BASIS_ATOL, (
403
+ f"image {k}: transformed basis differs by {diff:.3e}; phis (script) {setup.phis}, "
404
+ f"used in package order {repo_phis(setup)}. If test_hg_basis_matches_reference passes, the "
405
+ f"phase convention or the image order is what differs.")
406
+
407
+
408
+ # =============================================================================
409
+ # 3. Loss and gradient at identical parameters
410
+ # =============================================================================
411
+ @pytest.mark.parametrize("sigma", [1.0, 0.5])
412
+ @pytest.mark.parametrize("N", N_VALUES)
413
+ def test_loss_and_gradient_match_reference(ref, N, sigma):
414
+ setup = build_reference(ref, N, *TEST_WAISTS)
415
+ hg, _ = build_repo(setup)
416
+ perm = mode_permutation(setup, hg)
417
+ n = setup.n_valid
418
+ f = setup.funcs
419
+
420
+ rng = np.random.default_rng(0)
421
+ data = rng.random((2, RES * RES))
422
+ data /= data.max(axis=1, keepdims=True)
423
+ v_ref = rng.standard_normal(2 * n - 1)
424
+
425
+ # script
426
+ B_t = f["_to_complex_tensor"](setup.B_stacked_full)
427
+ loss_ref, grad_ref = f["loss_and_grad_np"](
428
+ v_ref, B_t, torch.tensor(data.ravel(), dtype=torch.float64, device=DEVICE), sigma)
429
+
430
+ # package, fed with the SCRIPT's basis -> isolates the loss function itself
431
+ engine = TorchEngine("auto")
432
+ lg = engine.make_loss_and_grad(n)
433
+ basis = engine.to_basis_tensor(setup.B_valid[:, :, perm])
434
+ loss_new, grad_new = lg(ref_vec_to_repo(v_ref, perm, n), basis, engine.to_data_tensor(data), sigma)
435
+ grad_new = repo_vec_to_ref(grad_new, perm, n)
436
+
437
+ assert loss_new == pytest.approx(loss_ref, rel=LOSS_RTOL), f"loss: {loss_new} vs {loss_ref}"
438
+ gmax = np.abs(grad_ref).max()
439
+ assert np.abs(grad_new - grad_ref).max() <= GRAD_RTOL * gmax, (
440
+ f"gradient differs: max|diff|={np.abs(grad_new - grad_ref).max():.3e} (max|grad|={gmax:.3e})")
441
+
442
+
443
+ # =============================================================================
444
+ # 4. Same starting point -> same optimum
445
+ # =============================================================================
446
+ @pytest.mark.parametrize("basis_source", ["repo", "reference"])
447
+ @pytest.mark.parametrize("N", N_VALUES)
448
+ def test_same_start_same_result(ref, N, basis_source, monkeypatch):
449
+ """basis_source='reference' feeds the package the script's own basis: it isolates
450
+ Estimator/optimiser from the basis. 'repo' is the real end-to-end path."""
451
+ data = load_dataset(N)
452
+ setup = build_reference(ref, N, *fitted_waists(ref, data))
453
+ hg, _ = build_repo(setup)
454
+ perm = mode_permutation(setup, hg)
455
+ basis = basis_for_estimator(basis_source, setup, hg, perm)
456
+ n = setup.n_valid
457
+
458
+ rng = np.random.RandomState(0)
459
+ print()
460
+ for r in [s for s in SAMPLES if s < len(data.combined)]:
461
+ x0 = 0.01 * rng.randn(2 * n - 1)
462
+ x0 /= np.linalg.norm(x0) # the script normalises its initial guess
463
+ loss_ref, A_ref = run_reference_fit(setup, data.combined[r].ravel(), x0)
464
+
465
+ x0_repo = ref_vec_to_repo(x0, perm, n)
466
+ # Estimator draws x0 = 0.01 * np.random.randn(...): hand it exactly x0_repo
467
+ monkeypatch.setattr(np.random, "randn", lambda *a, _x=x0_repo: _x / 0.01)
468
+ out = Estimator(basis, EstimationConfig(device="auto", n_restarts=1, sigma=SIGMA)).estimate(
469
+ data.combined[r])
470
+ loss_new = float(out["results"][0])
471
+
472
+ fid = fidelity(out["best"], ref_matrix_to_repo_flat(A_ref, setup, perm))
473
+ print(f"N={N} sample={r} [{basis_source}] loss script={loss_ref:.3e} package={loss_new:.3e} fidelity={fid:.8f}")
474
+ assert close_enough(loss_new, loss_ref) and close_enough(loss_ref, loss_new), (
475
+ f"final losses differ: script {loss_ref:.4e} vs package {loss_new:.4e}")
476
+ assert fid > FID_MIN, f"estimated coefficients differ (fidelity {fid:.6f})"
477
+
478
+
479
+ # =============================================================================
480
+ # 5. Random restarts: best loss and recovery of the true coefficients
481
+ # =============================================================================
482
+ @pytest.mark.parametrize("basis_source", ["repo", "reference"])
483
+ @pytest.mark.parametrize("N", N_VALUES)
484
+ def test_random_restarts_statistically_equivalent(ref, N, basis_source):
485
+ data = load_dataset(N)
486
+ setup = build_reference(ref, N, *fitted_waists(ref, data))
487
+ hg, _ = build_repo(setup)
488
+ perm = mode_permutation(setup, hg)
489
+ basis = basis_for_estimator(basis_source, setup, hg, perm)
490
+ n = setup.n_valid
491
+
492
+ np.random.seed(123)
493
+ est = Estimator(basis, EstimationConfig(device="auto", n_restarts=NUM_REP, sigma=SIGMA))
494
+ rng = np.random.RandomState(321)
495
+ print()
496
+ for r in [s for s in SAMPLES if s < len(data.combined)]:
497
+ # script: NUM_REP restarts, keep the lowest loss
498
+ best_ref = (np.inf, None)
499
+ for _ in range(NUM_REP):
500
+ x0 = 0.01 * rng.randn(2 * n - 1)
501
+ x0 /= np.linalg.norm(x0)
502
+ l, A = run_reference_fit(setup, data.combined[r].ravel(), x0)
503
+ if l < best_ref[0]:
504
+ best_ref = (l, A)
505
+ loss_ref, A_ref = best_ref
506
+ out = est.estimate(data.combined[r])
507
+ loss_new = float(out["results"].min())
508
+
509
+ msg = f"N={N} sample={r} [{basis_source}] best loss script={loss_ref:.3e} package={loss_new:.3e}"
510
+ c_ref = ref_matrix_to_repo_flat(A_ref, setup, perm)
511
+ truth_r = np.asarray(data.coefs[r])
512
+ if truth_r.size == (N + 1) ** 2:
513
+ truth = ref_matrix_to_repo_flat(truth_r.reshape(N + 1, N + 1), setup, perm)
514
+ f_ref, f_new = fidelity(c_ref, truth), fidelity(out["best"], truth)
515
+ msg += f" fidelity to truth: script={f_ref:.6f} package={f_new:.6f}"
516
+ print(msg)
517
+ assert f_new >= f_ref - 0.01, "package recovers the true coefficients worse than the script"
518
+ else:
519
+ print(msg + f" (truth skipped: coefs[r] has shape {truth_r.shape}, expected ({N+1},{N+1}))")
520
+ assert close_enough(loss_new, loss_ref), (
521
+ f"package best loss {loss_new:.4e} is worse than script {loss_ref:.4e}")