File size: 14,775 Bytes
0ffbb74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
"""Known-answer tests for ISO 11146 beam widths, using the simulator."""

from dataclasses import replace

import numpy as np
import pytest

from beampilot.simulator import (
    BeamParams,
    CameraConfig,
    beam_diameter,
    dark_frame,
    render_frame,
)
from beampilot.widths import (
    Roi,
    mean_dark,
    measure_widths,
    second_moments,
    subtract_background,
    width_statistics,
)

# Astigmatic test beam: different waist sizes, positions and M² per axis.
BEAM = BeamParams(
    wavelength_m=1064e-9,
    d0x_m=0.5e-3,
    d0y_m=0.4e-3,
    z0x_m=0.10,
    z0y_m=0.12,
    m2x=1.2,
    m2y=1.5,
)
CAMERA = CameraConfig(
    pixel_size_m=5.5e-6,
    nx=512,
    ny=512,
    bit_depth=12,
    offset_counts=100.0,
    read_noise_counts=5.0,
    signal_counts_per_ms=1e6,
)


NOISELESS = replace(CAMERA, offset_counts=0.0, read_noise_counts=0.0, shot_noise=False)
PIXEL_M = CAMERA.pixel_size_m
Z_M = 0.25  # away from both waists: d_x ~ 698 um, d_y ~ 772 um
EXPOSURE_MS = 20.0  # peak ~ 2900 of 4095 counts at Z_M: unsaturated

# 4 sigma of the corner-patch mean: 4 patches of 18 x 18 px, ~5.2 counts noise each.
CORNER_MEAN_TOL = 4 * 5.2 / np.sqrt(4 * 18 * 18)


def _darks(camera: CameraConfig, n: int, seed: int) -> list[np.ndarray]:
    rng = np.random.default_rng(seed)
    return [dark_frame(camera, rng) for _ in range(n)]


def _true_diameters(beam: BeamParams, z_m: float) -> tuple[float, float]:
    d_x = beam_diameter(z_m, beam.d0x_m, beam.z0x_m, beam.m2x, beam.wavelength_m)
    d_y = beam_diameter(z_m, beam.d0y_m, beam.z0y_m, beam.m2y, beam.wavelength_m)
    return float(d_x), float(d_y)


def _rotated_ellipse(

    sigma_major_px: float, sigma_minor_px: float, angle_rad: float, n: int = 512

) -> np.ndarray:
    """Noiseless elliptical Gaussian, major axis at `angle_rad` from x."""
    y, x = np.indices((n, n)) - (n - 1) / 2.0
    u = x * np.cos(angle_rad) + y * np.sin(angle_rad)
    v = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
    return 1000.0 * np.exp(
        -0.5 * (u / sigma_major_px) ** 2 - 0.5 * (v / sigma_minor_px) ** 2
    )


@pytest.mark.parametrize(

    "roi", [Roi(0, 512, 0, 512), Roi(18, 453, 89, 408)], ids=["full", "8sigma"]

)
def test_second_moments_of_analytic_gaussian(roi: Roi) -> None:
    """Rotated, off-centre Gaussian sampled at pixel centres.



    Expected: centroid (x0, y0); sigma_x² = a² cos² + b² sin²,

    sigma_y² = a² sin² + b² cos², sigma_xy = (a² - b²) sin cos; sum = 2 pi a b A.

    Projected sigmas are 27.0 px (x) and 19.8 px (y); both ROIs reach at least

    8 sigma from the centroid, and sampling at >= 15 px sigma is exact for a

    Gaussian, so both effects stay far below 1e-9 relative.

    """
    n, peak = 512, 1000.0
    a_px, b_px, angle = 30.0, 15.0, np.radians(30.0)
    x0_px, y0_px = -20.3, -7.6  # relative to the image centre
    y, x = np.indices((n, n)) - (n - 1) / 2.0
    u = (x - x0_px) * np.cos(angle) + (y - y0_px) * np.sin(angle)
    v = -(x - x0_px) * np.sin(angle) + (y - y0_px) * np.cos(angle)
    image = peak * np.exp(-0.5 * (u / a_px) ** 2 - 0.5 * (v / b_px) ** 2)

    moments = second_moments(image, PIXEL_M, roi)

    c, s = np.cos(angle), np.sin(angle)
    p2 = PIXEL_M**2
    assert moments.x_c_m == pytest.approx(x0_px * PIXEL_M, rel=1e-9)
    assert moments.y_c_m == pytest.approx(y0_px * PIXEL_M, rel=1e-9)
    assert moments.var_x_m2 == pytest.approx((a_px**2 * c**2 + b_px**2 * s**2) * p2)
    assert moments.var_y_m2 == pytest.approx((a_px**2 * s**2 + b_px**2 * c**2) * p2)
    assert moments.cov_xy_m2 == pytest.approx((a_px**2 - b_px**2) * s * c * p2)
    assert moments.total_counts == pytest.approx(2 * np.pi * a_px * b_px * peak)


def test_noiseless_centred_beam() -> None:
    frame = render_frame(BEAM, NOISELESS, Z_M, EXPOSURE_MS, np.random.default_rng(0))
    result = measure_widths(frame, PIXEL_M)
    true_x, true_y = _true_diameters(BEAM, Z_M)
    assert result.converged
    assert result.d_x_m == pytest.approx(true_x, rel=0.005)
    assert result.d_y_m == pytest.approx(true_y, rel=0.005)


def test_centred_beam_roi_not_clipped_and_holds_the_signal() -> None:
    frame = render_frame(BEAM, NOISELESS, Z_M, EXPOSURE_MS, np.random.default_rng(0))
    result = measure_widths(frame, PIXEL_M)
    assert not result.roi_clipped
    roi = result.roi
    assert 0 < roi.x_start < roi.x_stop < NOISELESS.nx
    assert 0 < roi.y_start < roi.y_stop < NOISELESS.ny
    # The 3 x d rectangle spans +-6 sigma, so it holds practically all signal.
    total_counts = NOISELESS.signal_counts_per_ms * EXPOSURE_MS
    assert result.roi_sum_counts == pytest.approx(total_counts, rel=0.005)
    assert result.roi_sum_counts == pytest.approx(
        frame[roi.y_start : roi.y_stop, roi.x_start : roi.x_stop].sum()
    )


def test_beam_cut_by_sensor_edge_flags_roi_clipped() -> None:
    """A beam 1.35 mm off-centre on a 2.8 mm sensor converges ~34 % too small.



    Nothing else in the result reveals this, so roi_clipped must.

    """
    rng = np.random.default_rng(0)
    frame = render_frame(BEAM, NOISELESS, Z_M, EXPOSURE_MS, rng, x0_m=1.35e-3)
    result = measure_widths(frame, PIXEL_M)
    true_x, _ = _true_diameters(BEAM, Z_M)
    assert result.converged
    assert result.d_x_m < 0.8 * true_x  # the bias the flag warns about
    assert result.roi_clipped
    assert result.roi.x_stop == NOISELESS.nx


def test_off_centre_beam_gives_same_width_and_correct_centroid() -> None:
    x0_m, y0_m = 300e-6, -200e-6
    rng = np.random.default_rng(0)
    frame = render_frame(BEAM, NOISELESS, Z_M, EXPOSURE_MS, rng, x0_m=x0_m, y0_m=y0_m)
    result = measure_widths(frame, PIXEL_M)
    true_x, true_y = _true_diameters(BEAM, Z_M)
    assert result.d_x_m == pytest.approx(true_x, rel=0.005)
    assert result.d_y_m == pytest.approx(true_y, rel=0.005)
    assert result.x_c_m == pytest.approx(x0_m, abs=0.05 * PIXEL_M)
    assert result.y_c_m == pytest.approx(y0_m, abs=0.05 * PIXEL_M)


def test_astigmatic_beam_recovers_both_widths() -> None:
    beam = replace(
        BEAM, d0x_m=0.3e-3, d0y_m=0.6e-3, z0x_m=0.0, z0y_m=0.0, m2x=1.0, m2y=1.0
    )
    frame = render_frame(beam, NOISELESS, 0.0, 4.0, np.random.default_rng(0))
    result = measure_widths(frame, PIXEL_M)
    assert result.d_x_m == pytest.approx(0.3e-3, rel=0.005)
    assert result.d_y_m == pytest.approx(0.6e-3, rel=0.005)
    assert result.ellipticity == pytest.approx(0.5, rel=0.01)
    assert abs(result.azimuth_rad) == pytest.approx(np.pi / 2, abs=np.radians(0.5))


@pytest.mark.parametrize("angle_deg", [25.0, -60.0])
def test_rotated_ellipse_azimuth_recovered(angle_deg: float) -> None:
    angle = np.radians(angle_deg)
    sigma_major, sigma_minor = 40.0, 20.0
    image = _rotated_ellipse(sigma_major, sigma_minor, angle)
    result = measure_widths(image, PIXEL_M)

    assert result.azimuth_rad == pytest.approx(angle, abs=np.radians(0.5))
    assert result.ellipticity == pytest.approx(sigma_minor / sigma_major, rel=0.01)
    # Lab-axis width of a rotated ellipse: sigma_x² = a² cos² + b² sin².
    var_x_px = (sigma_major * np.cos(angle)) ** 2 + (sigma_minor * np.sin(angle)) ** 2
    assert result.d_x_m == pytest.approx(4 * np.sqrt(var_x_px) * PIXEL_M, rel=0.005)


def test_iteration_converges_before_max_iter() -> None:
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    frame = render_frame(BEAM, CAMERA, Z_M, EXPOSURE_MS, np.random.default_rng(1))
    image, _ = subtract_background(frame, dark)
    result = measure_widths(image, PIXEL_M, max_iter=25)
    assert result.converged
    assert result.iterations < 25


def test_dim_noisy_frames_all_converge() -> None:
    """At peak ~430 counts the 3 x d rectangle can flip by one pixel each pass.



    Each frame gets its own dark set, as in a real run. Before the

    repeated-rectangle stopping rule, 20 of the first 100 seeds hit max_iter.

    """
    dim_exposure_ms = 3.0  # peak ~ 430 counts at Z_M
    # Only convergence is checked: at this SNR single-frame widths scatter by
    # ~5.5 % (1 sigma, up to ~20 %), which a quality flag has to catch.
    for seed in range(50):
        dark = mean_dark(_darks(CAMERA, 10, seed=seed + 10_000))
        rng = np.random.default_rng(seed)
        frame = render_frame(BEAM, CAMERA, Z_M, dim_exposure_ms, rng)
        image, _ = subtract_background(frame, dark)
        result = measure_widths(image, PIXEL_M, max_iter=25)
        assert result.converged, f"seed {seed}"
        assert result.iterations < 25, f"seed {seed}"
        assert np.isfinite(result.d_x_m) and np.isfinite(result.d_y_m)


def test_small_beam_with_residual_offset_is_measured() -> None:
    """A 200 um beam holds only ~3e6 counts at 70 % of full scale.



    A residual offset of ~0.2 counts (the corner estimate's standard error is

    ~0.15) summed with r² weights over the full sensor outweighs the beam's

    sigma², so a full-image first pass gave negative second moments on

    several of these frames. The 1/e² start area avoids that.

    """
    small = replace(BEAM, d0x_m=0.2e-3, d0y_m=0.2e-3, z0x_m=0.0, z0y_m=0.0)
    small = replace(small, m2x=1.0, m2y=1.0)
    exposure_ms = 1.5  # peak ~ 2900 counts
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    rng = np.random.default_rng(7)
    results = []
    for _ in range(20):
        frame = render_frame(small, CAMERA, 0.0, exposure_ms, rng)
        image, _ = subtract_background(frame, dark)
        result = measure_widths(image, PIXEL_M)
        assert result.converged and not result.roi_clipped
        results.append(result)
    stats = width_statistics(results)
    assert stats.d_x_mean_m == pytest.approx(0.2e-3, rel=0.01)
    assert stats.d_y_mean_m == pytest.approx(0.2e-3, rel=0.01)


def test_realistic_frames_mean_of_five_repeats() -> None:
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    rng = np.random.default_rng(1)
    results = []
    for _ in range(5):
        frame = render_frame(BEAM, CAMERA, Z_M, EXPOSURE_MS, rng)
        image, _ = subtract_background(frame, dark)
        results.append(measure_widths(image, PIXEL_M))

    stats = width_statistics(results)
    true_x, true_y = _true_diameters(BEAM, Z_M)
    assert stats.n == 5
    assert stats.d_x_mean_m == pytest.approx(true_x, rel=0.02)
    assert stats.d_y_mean_m == pytest.approx(true_y, rel=0.02)
    assert 0 < stats.d_x_std_m < 0.02 * true_x
    assert 0 < stats.d_y_std_m < 0.02 * true_y


def test_skipping_fine_offset_makes_beam_look_larger() -> None:
    """Negative control: why the corner offset correction matters."""
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    # Background 3 counts higher than in the dark frames (0.1 % of the peak).
    brighter = replace(CAMERA, offset_counts=CAMERA.offset_counts + 3.0)
    frame = render_frame(BEAM, brighter, Z_M, EXPOSURE_MS, np.random.default_rng(1))
    true_x, true_y = _true_diameters(BEAM, Z_M)

    dark_only = measure_widths(frame - dark, PIXEL_M)
    corrected_image, _ = subtract_background(frame, dark)
    corrected = measure_widths(corrected_image, PIXEL_M)

    assert dark_only.d_x_m > 1.15 * true_x
    assert dark_only.d_y_m > 1.15 * true_y
    assert corrected.d_x_m == pytest.approx(true_x, rel=0.02)
    assert corrected.d_y_m == pytest.approx(true_y, rel=0.02)


def test_width_statistics_single_and_empty() -> None:
    image = _rotated_ellipse(40.0, 20.0, 0.0)
    single = width_statistics([measure_widths(image, PIXEL_M)])
    assert single.n == 1
    assert np.isnan(single.d_x_std_m)
    with pytest.raises(ValueError):
        width_statistics([])


@pytest.mark.parametrize("level", [0.0, -1.0])
def test_no_beam_gives_nan_not_crash(level: float) -> None:
    image = np.full((64, 64), level)
    result = measure_widths(image, PIXEL_M)
    assert np.isnan(result.d_x_m) and np.isnan(result.d_y_m)
    assert not result.converged


@pytest.mark.parametrize(

    "roi",

    [

        Roi(-1, 10, 0, 10),  # negative start

        Roi(0, 65, 0, 10),  # past the right edge

        Roi(0, 10, 0, 65),  # past the bottom edge

        Roi(10, 10, 0, 10),  # empty

        Roi(20, 10, 0, 10),  # reversed

    ],

)
def test_second_moments_rejects_bad_roi(roi: Roi) -> None:
    with pytest.raises(ValueError, match="roi"):
        second_moments(np.ones((64, 64)), PIXEL_M, roi)


@pytest.mark.parametrize("pixel_size_m", [0.0, -5.5e-6, np.nan])
def test_non_positive_pixel_size_raises(pixel_size_m: float) -> None:
    image = _rotated_ellipse(10.0, 10.0, 0.0, n=64)
    with pytest.raises(ValueError, match="pixel_size_m"):
        second_moments(image, pixel_size_m, Roi(0, 64, 0, 64))
    with pytest.raises(ValueError, match="pixel_size_m"):
        measure_widths(image, pixel_size_m)


def test_fewer_than_ten_dark_frames_raises() -> None:
    with pytest.raises(ValueError, match="at least 10"):
        mean_dark(_darks(CAMERA, 9, seed=0))


def test_mean_dark_averages_frames() -> None:
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    assert dark.shape == (CAMERA.ny, CAMERA.nx)
    assert dark.mean() == pytest.approx(CAMERA.offset_counts, abs=0.1)
    # Averaging 10 frames reduces the pixel noise by sqrt(10).
    assert dark.std() == pytest.approx(CAMERA.read_noise_counts / np.sqrt(10), rel=0.05)


def test_negative_values_survive_background_subtraction() -> None:
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    frame = dark_frame(CAMERA, np.random.default_rng(1))  # no beam: pure noise
    image, _ = subtract_background(frame, dark)
    assert image.dtype == np.float64
    assert (image < 0).sum() > 0.4 * image.size  # about half the pixels
    assert image.mean() == pytest.approx(0.0, abs=CORNER_MEAN_TOL)


def test_fine_offset_removes_residual_background() -> None:
    dark = mean_dark(_darks(CAMERA, 10, seed=0))
    extra_counts = 3.0  # e.g. room light switched on after the dark frames
    frame = dark_frame(CAMERA, np.random.default_rng(1)) + extra_counts
    image, info = subtract_background(frame, dark)
    assert info.offset_counts == pytest.approx(extra_counts, abs=CORNER_MEAN_TOL)
    assert info.std_counts == pytest.approx(
        CAMERA.read_noise_counts * np.sqrt(1 + 1 / 10), rel=0.1
    )
    # 4 corner patches of 18 x 18 px on a 512 x 512 image.
    n_corner = 4 * 18 * 18
    assert info.offset_std_err_counts == pytest.approx(
        info.std_counts / np.sqrt(n_corner)
    )
    assert len(info.corner_means_counts) == 4
    assert image.mean() == pytest.approx(0.0, abs=CORNER_MEAN_TOL)