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