Files
he11lib/tests/test_fitting.py
T
Martino Ferrari b81964fbad Update fit_auto for CameraModel and warn on underdetermined geometry fits
fit_auto now threads camera/camera_tolerance through to fit and _bic
(whose parameter count must include any free camera/z unknowns). Emits a
UserWarning, not an error, when free camera+z geometry parameters exceed
the number of measurement planes -- a new documented degeneracy pitfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 12:14:07 +02:00

315 lines
10 KiB
Python

import warnings
import numpy as np
import pytest
from he11lib.data import validate_planes
from he11lib.fitting import ModalFitter, generate_mode_shells
from he11lib.geometry import CameraModel, CameraModelTolerance
from he11lib.modes import LGBasis
from he11lib.synthetic import SyntheticBeamGenerator
W0 = 5e-3
Z0 = 0.5
WAVELENGTH = 1.76e-3
PIXEL_SCALE = 4e-4
CAMERA_DISTANCE = 5.0
IMAGE_SHAPE = (61, 61)
Z_LIST = [0.35, 0.5, 0.65, 0.8]
def make_basis():
return LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
def make_camera(pixel_scale=PIXEL_SCALE, position=(0.0, 0.0, -CAMERA_DISTANCE), orientation_deg=(0.0, 0.0, 0.0)):
focal_length_px = (CAMERA_DISTANCE + Z0) / pixel_scale
return CameraModel(
focal_length_px=focal_length_px, position=position, orientation_deg=orientation_deg
)
def zero_tolerance():
return CameraModelTolerance(
focal_length_px=0.0, position=(0.0, 0.0, 0.0), orientation_deg=(0.0, 0.0, 0.0)
)
def make_generator(basis, camera):
return SyntheticBeamGenerator(basis=basis, camera=camera)
def test_generate_mode_shells_orders_by_2p_plus_abs_l():
shells = generate_mode_shells(max_order=2)
assert shells[0] == [(0, 0)]
assert set(shells[1]) == {(0, 1), (0, -1)}
assert set(shells[2]) == {(0, 2), (0, -2), (1, 0)}
def test_fit_recovers_pure_fundamental_mode():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=0
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
power_fraction, _ = result.purity[(0, 0)]
assert power_fraction == pytest.approx(1.0, abs=1e-6)
for cx, cy in result.centers:
assert cx == pytest.approx(0.0, abs=2 * PIXEL_SCALE)
assert cy == pytest.approx(0.0, abs=2 * PIXEL_SCALE)
def test_fit_recovers_two_mode_purity_ratio():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
true_coeffs = {(0, 0): 0.9 + 0j, (1, 0): 0.3 + 0.1j}
planes = gen.generate(
coefficients=true_coeffs, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=1
)
fitter = ModalFitter(basis)
result = fitter.fit(
planes, modes=list(true_coeffs.keys()), camera=camera, camera_tolerance=zero_tolerance()
)
true_total = sum(abs(c) ** 2 for c in true_coeffs.values())
for mode, c in true_coeffs.items():
expected_fraction = abs(c) ** 2 / true_total
recovered_fraction, _ = result.purity[mode]
assert recovered_fraction == pytest.approx(expected_fraction, abs=0.03)
def test_fit_recovers_center_offset():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
true_center = (10 * PIXEL_SCALE, -5 * PIXEL_SCALE)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j},
z_list=Z_LIST,
image_shape=IMAGE_SHAPE,
center=true_center,
noise_std=1e-4,
seed=2,
)
fitter = ModalFitter(basis)
result = fitter.fit(
planes,
modes=[(0, 0)],
camera=camera,
camera_tolerance=zero_tolerance(),
initial_center=true_center,
)
for cx, cy in result.centers:
assert cx == pytest.approx(true_center[0], abs=2 * PIXEL_SCALE)
assert cy == pytest.approx(true_center[1], abs=2 * PIXEL_SCALE)
def test_fit_recovers_pointing_angles_independently():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j},
z_list=Z_LIST,
image_shape=IMAGE_SHAPE,
pointing_angle_horizontal_deg=0.3,
pointing_angle_vertical_deg=-0.15,
noise_std=1e-4,
seed=6,
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
assert result.pointing_angle_horizontal_deg == pytest.approx(0.3, abs=0.05)
assert result.pointing_angle_vertical_deg == pytest.approx(-0.15, abs=0.05)
def test_fit_holds_zero_tolerance_camera_field_fixed_at_wrong_nominal():
# A tolerance=0 field must stay exactly at its (deliberately wrong)
# nominal value rather than being corrected.
basis = make_basis()
true_camera = make_camera()
gen = make_generator(basis, true_camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=7
)
wrong_focal_length = true_camera.focal_length_px * 1.2
nominal_camera = CameraModel(
focal_length_px=wrong_focal_length,
position=true_camera.position,
orientation_deg=true_camera.orientation_deg,
)
fitter = ModalFitter(basis)
result = fitter.fit(
planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=zero_tolerance()
)
assert result.geometry["focal_length_px"] == wrong_focal_length
def test_fit_recovers_offset_camera_field_within_tolerance():
# A tolerance>0 field recovers a ground-truth offset from nominal, but
# within its band.
basis = make_basis()
true_camera = make_camera()
gen = make_generator(basis, true_camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=8
)
offset = true_camera.focal_length_px * 0.02 # 2% off nominal
nominal_camera = CameraModel(
focal_length_px=true_camera.focal_length_px + offset,
position=true_camera.position,
orientation_deg=true_camera.orientation_deg,
)
tolerance = CameraModelTolerance(
focal_length_px=true_camera.focal_length_px * 0.05, # +/-5% band
position=(0.0, 0.0, 0.0),
orientation_deg=(0.0, 0.0, 0.0),
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=tolerance)
assert result.geometry["focal_length_px"] == pytest.approx(
true_camera.focal_length_px, rel=0.02
)
def test_fit_clips_out_of_band_ground_truth_to_bound():
# A ground truth placed outside a deliberately too-tight band is
# clipped to the bound rather than escaping it.
basis = make_basis()
true_camera = make_camera()
gen = make_generator(basis, true_camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=9
)
# nominal is 10% off true, but the band only allows +/-1%.
nominal_focal_length = true_camera.focal_length_px * 1.10
nominal_camera = CameraModel(
focal_length_px=nominal_focal_length,
position=true_camera.position,
orientation_deg=true_camera.orientation_deg,
)
tight_tolerance = CameraModelTolerance(
focal_length_px=nominal_focal_length * 0.01,
position=(0.0, 0.0, 0.0),
orientation_deg=(0.0, 0.0, 0.0),
)
fitter = ModalFitter(basis)
result = fitter.fit(
planes, modes=[(0, 0)], camera=nominal_camera, camera_tolerance=tight_tolerance
)
lower_bound = nominal_focal_length - tight_tolerance.focal_length_px
assert result.geometry["focal_length_px"] == pytest.approx(lower_bound, rel=1e-3)
def test_fit_recovers_offset_z_within_tolerance():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
true_z_list = [0.35, 0.5, 0.65, 0.8]
offsets = {z: 0.01 for z in true_z_list} # nominal is 1 cm off true
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j},
z_list=true_z_list,
image_shape=IMAGE_SHAPE,
nominal_z_offsets=offsets,
z_tolerance=0.03,
noise_std=1e-4,
seed=10,
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)], camera=camera, camera_tolerance=zero_tolerance())
for i, true_z in enumerate(true_z_list):
assert result.geometry[f"z_{i}"] == pytest.approx(true_z, abs=0.005)
def test_fit_auto_does_not_add_modes_for_pure_fundamental():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=4
)
fitter = ModalFitter(basis)
result = fitter.fit_auto(planes, camera=camera, camera_tolerance=zero_tolerance(), max_order=2)
assert set(result.purity.keys()) == {(0, 0)}
def test_fit_auto_grows_to_include_second_mode():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
true_coeffs = {(0, 0): 0.9 + 0j, (0, 1): 0.4 + 0j}
planes = gen.generate(
coefficients=true_coeffs, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=5
)
fitter = ModalFitter(basis)
result = fitter.fit_auto(planes, camera=camera, camera_tolerance=zero_tolerance(), max_order=2)
assert (0, 1) in result.purity or (0, -1) in result.purity
def test_fit_auto_warns_when_free_geometry_params_exceed_plane_count():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j},
z_list=Z_LIST, # 4 planes
image_shape=IMAGE_SHAPE,
z_tolerance=0.05, # +4 free z params
noise_std=1e-4,
seed=11,
)
# +7 free camera params (all but the 2 principal_point components) +
# 4 free z params = 11 free geometry params > 4 planes.
generous_tolerance = CameraModelTolerance(
focal_length_px=camera.focal_length_px * 0.05,
position=(0.01, 0.01, 0.01),
orientation_deg=(2.0, 2.0, 2.0),
principal_point=(0.0, 0.0),
)
fitter = ModalFitter(basis)
with pytest.warns(UserWarning, match="free camera/z geometry parameters"):
fitter.fit_auto(planes, camera=camera, camera_tolerance=generous_tolerance, max_order=1)
def test_fit_auto_does_not_warn_when_geometry_fully_fixed():
basis = make_basis()
camera = make_camera()
gen = make_generator(basis, camera)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, image_shape=IMAGE_SHAPE, noise_std=1e-4, seed=12
)
fitter = ModalFitter(basis)
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
fitter.fit_auto(planes, camera=camera, camera_tolerance=zero_tolerance(), max_order=1)