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.
- astimator-0.0.1/Astimator/Astimator.egg-info/PKG-INFO +53 -0
- astimator-0.0.1/Astimator/Astimator.egg-info/SOURCES.txt +9 -0
- astimator-0.0.1/Astimator/Astimator.egg-info/dependency_links.txt +1 -0
- astimator-0.0.1/Astimator/Astimator.egg-info/requires.txt +3 -0
- astimator-0.0.1/Astimator/Astimator.egg-info/top_level.txt +1 -0
- astimator-0.0.1/PKG-INFO +53 -0
- astimator-0.0.1/README.md +43 -0
- astimator-0.0.1/pyproject.toml +17 -0
- astimator-0.0.1/setup.cfg +4 -0
- astimator-0.0.1/tests/test_synthetic.py +102 -0
- astimator-0.0.1/tests/test_vs_reference.py +521 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
astimator-0.0.1/PKG-INFO
ADDED
|
@@ -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,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}")
|