Initial commit: he11lib mode-purity reconstruction library

Full implementation of Laguerre-Gauss modal reconstruction for gyrotron
beam diagnostics, per the approved design spec, plus tests, docs, and
a runnable end-to-end example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-07-02 21:47:40 +02:00
commit 03b63ba03a
31 changed files with 3341 additions and 0 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
import matplotlib
matplotlib.use("Agg")
+93
View File
@@ -0,0 +1,93 @@
import numpy as np
import pytest
from he11lib.data import MeasurementPlane, ReconstructionResult, validate_planes
def test_measurement_plane_stores_fields():
flux = np.ones((4, 4))
plane = MeasurementPlane(flux=flux, z=0.3)
assert plane.z == 0.3
assert np.array_equal(plane.flux, flux)
assert plane.pixel_scale is None
assert plane.viewing_angle_deg is None
assert plane.label is None
def test_measurement_plane_stores_optional_fields():
flux = np.ones((4, 4))
plane = MeasurementPlane(
flux=flux, z=0.4, pixel_scale=0.1, viewing_angle_deg=5.0, label="plane_40cm"
)
assert plane.pixel_scale == 0.1
assert plane.viewing_angle_deg == 5.0
assert plane.label == "plane_40cm"
def test_measurement_plane_rejects_non_2d_flux():
with pytest.raises(ValueError, match="2D"):
MeasurementPlane(flux=np.ones((4, 4, 3)), z=0.3)
def test_measurement_plane_rejects_non_positive_z():
with pytest.raises(ValueError, match="positive"):
MeasurementPlane(flux=np.ones((4, 4)), z=0.0)
with pytest.raises(ValueError, match="positive"):
MeasurementPlane(flux=np.ones((4, 4)), z=-0.1)
def test_validate_planes_rejects_fewer_than_three():
planes = [
MeasurementPlane(flux=np.ones((4, 4)), z=0.3),
MeasurementPlane(flux=np.ones((4, 4)), z=0.4),
]
with pytest.raises(ValueError, match="[Aa]t least 3"):
validate_planes(planes)
def test_validate_planes_rejects_mismatched_shapes():
planes = [
MeasurementPlane(flux=np.ones((4, 4)), z=0.3),
MeasurementPlane(flux=np.ones((5, 5)), z=0.4),
MeasurementPlane(flux=np.ones((4, 4)), z=0.5),
]
with pytest.raises(ValueError, match="same shape"):
validate_planes(planes)
def test_validate_planes_rejects_duplicate_z():
planes = [
MeasurementPlane(flux=np.ones((4, 4)), z=0.3),
MeasurementPlane(flux=np.ones((4, 4)), z=0.3),
MeasurementPlane(flux=np.ones((4, 4)), z=0.5),
]
with pytest.raises(ValueError, match="distinct"):
validate_planes(planes)
def test_validate_planes_accepts_valid_list():
planes = [
MeasurementPlane(flux=np.ones((4, 4)), z=0.3),
MeasurementPlane(flux=np.ones((4, 4)), z=0.4),
MeasurementPlane(flux=np.ones((4, 4)), z=0.5),
]
validate_planes(planes) # should not raise
def test_reconstruction_result_stores_fields():
result = ReconstructionResult(
purity={(0, 0): (1.0, 0.0)},
reconstructed_field=np.ones((4, 4), dtype=complex),
centers=[(0.0, 0.0)],
pointing_angle_deg=0.0,
geometry={"pixel_scale": 0.1, "viewing_angle_deg": 2.0},
residuals=[np.zeros((4, 4))],
coefficient_uncertainty={(0, 0): 0.01},
used_phase_retrieval=False,
)
assert result.purity[(0, 0)] == (1.0, 0.0)
assert result.used_phase_retrieval is False
+67
View File
@@ -0,0 +1,67 @@
import numpy as np
import pytest
from he11lib.deconvolution import DiffusionDeconvolver
def gaussian_bump(n, sigma_px):
coords = np.arange(n) - n // 2
xx, yy = np.meshgrid(coords, coords)
return np.exp(-(xx**2 + yy**2) / (2 * sigma_px**2))
def profile_std(image):
n = image.shape[0]
coords = np.arange(n) - n // 2
weights = image[n // 2, :]
weights = np.clip(weights, 0, None)
mean = np.sum(coords * weights) / np.sum(weights)
var = np.sum(weights * (coords - mean) ** 2) / np.sum(weights)
return np.sqrt(var)
def test_blur_sigma_m_from_diffusivity_and_dwell_time():
diffusivity = 1e-6 # m^2/s
dwell_time = 0.5 # s
deconvolver = DiffusionDeconvolver(thermal_diffusivity=diffusivity, dwell_time=dwell_time)
expected_sigma = np.sqrt(2 * diffusivity * dwell_time)
assert deconvolver.blur_sigma_m() == pytest.approx(expected_sigma)
def test_blur_widens_a_sharp_peak():
deconvolver = DiffusionDeconvolver(thermal_diffusivity=1e-6, dwell_time=0.5)
pixel_scale = 2e-4
sharp = gaussian_bump(101, sigma_px=3)
blurred = deconvolver.blur(sharp, pixel_scale=pixel_scale)
assert profile_std(blurred) > profile_std(sharp)
def test_deconvolve_reduces_error_relative_to_blurred():
deconvolver = DiffusionDeconvolver(thermal_diffusivity=1e-6, dwell_time=0.3)
pixel_scale = 2e-4
sharp = gaussian_bump(101, sigma_px=4)
blurred = deconvolver.blur(sharp, pixel_scale=pixel_scale)
deconvolved = deconvolver.deconvolve(blurred, pixel_scale=pixel_scale)
error_blurred = np.sum((blurred - sharp) ** 2)
error_deconvolved = np.sum((deconvolved - sharp) ** 2)
assert error_deconvolved < error_blurred
def test_deconvolve_narrows_width_back_toward_original():
deconvolver = DiffusionDeconvolver(thermal_diffusivity=1e-6, dwell_time=0.3)
pixel_scale = 2e-4
sharp = gaussian_bump(101, sigma_px=4)
blurred = deconvolver.blur(sharp, pixel_scale=pixel_scale)
deconvolved = deconvolver.deconvolve(blurred, pixel_scale=pixel_scale)
std_sharp = profile_std(sharp)
std_blurred = profile_std(blurred)
std_deconvolved = profile_std(deconvolved)
assert std_sharp < std_deconvolved < std_blurred
+134
View File
@@ -0,0 +1,134 @@
import numpy as np
import pytest
from he11lib.data import validate_planes
from he11lib.fitting import ModalFitter, generate_mode_shells
from he11lib.modes import LGBasis
from he11lib.synthetic import SyntheticBeamGenerator
W0 = 5e-3
Z0 = 0.5
WAVELENGTH = 1.76e-3
PIXEL_SCALE = 4e-4
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_generator(basis):
return SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE)
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()
gen = make_generator(basis)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=0
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)])
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()
gen = make_generator(basis)
true_coeffs = {(0, 0): 0.9 + 0j, (1, 0): 0.3 + 0.1j}
planes = gen.generate(coefficients=true_coeffs, z_list=Z_LIST, noise_std=1e-4, seed=1)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=list(true_coeffs.keys()))
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()
gen = make_generator(basis)
true_center = (10 * PIXEL_SCALE, -5 * PIXEL_SCALE)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j},
z_list=Z_LIST,
center=true_center,
noise_std=1e-4,
seed=2,
)
fitter = ModalFitter(basis)
result = fitter.fit(planes, modes=[(0, 0)], 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_unknown_pixel_scale():
# Use a coarser pixel scale so the (much wider, far-field) beam at the
# outer z distances still fits within the frame -- otherwise pixel scale
# becomes unobservable from clipped images.
basis = make_basis()
local_pixel_scale = 1.5e-3
gen = SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=local_pixel_scale)
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=3)
# hide the known calibration to force the fitter to solve for it
for plane in planes:
plane.pixel_scale = None
plane.viewing_angle_deg = None
fitter = ModalFitter(basis)
result = fitter.fit(
planes,
modes=[(0, 0)],
initial_pixel_scale=local_pixel_scale * 1.1,
initial_viewing_angle_deg=0.0,
)
fitted_scales = [result.geometry[f"pixel_scale_{i}"] for i in range(len(planes))]
for scale in fitted_scales:
assert scale == pytest.approx(local_pixel_scale, rel=0.05)
def test_fit_auto_does_not_add_modes_for_pure_fundamental():
basis = make_basis()
gen = make_generator(basis)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=4
)
fitter = ModalFitter(basis)
result = fitter.fit_auto(planes, max_order=2)
assert set(result.purity.keys()) == {(0, 0)}
def test_fit_auto_grows_to_include_second_mode():
basis = make_basis()
gen = make_generator(basis)
true_coeffs = {(0, 0): 0.9 + 0j, (0, 1): 0.4 + 0j}
planes = gen.generate(coefficients=true_coeffs, z_list=Z_LIST, noise_std=1e-4, seed=5)
fitter = ModalFitter(basis)
result = fitter.fit_auto(planes, max_order=2)
assert (0, 1) in result.purity or (0, -1) in result.purity
+77
View File
@@ -0,0 +1,77 @@
import numpy as np
import pytest
from he11lib.data import MeasurementPlane
from he11lib.geometry import GeometryCalibration
def test_pixel_scale_known_reflects_plane():
plane_known = MeasurementPlane(flux=np.ones((5, 5)), z=0.3, pixel_scale=1e-4)
plane_unknown = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
assert GeometryCalibration(plane_known).pixel_scale_known is True
assert GeometryCalibration(plane_unknown).pixel_scale_known is False
def test_viewing_angle_known_reflects_plane():
plane_known = MeasurementPlane(flux=np.ones((5, 5)), z=0.3, viewing_angle_deg=10.0)
plane_unknown = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
assert GeometryCalibration(plane_known).viewing_angle_known is True
assert GeometryCalibration(plane_unknown).viewing_angle_known is False
def test_physical_coordinates_uses_known_calibration():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=0.0
)
calib = GeometryCalibration(plane)
x, y = calib.physical_coordinates()
row_idx = np.arange(5) - 2
col_idx = np.arange(5) - 2
expected_x = col_idx * 2e-4
expected_y = row_idx * 2e-4
np.testing.assert_allclose(x[2, :], expected_x)
np.testing.assert_allclose(y[:, 2], expected_y)
def test_physical_coordinates_compresses_x_for_viewing_angle():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=60.0
)
calib = GeometryCalibration(plane)
x, y = calib.physical_coordinates()
col_idx = np.arange(5) - 2
expected_x = col_idx * 2e-4 / np.cos(np.deg2rad(60.0))
np.testing.assert_allclose(x[2, :], expected_x)
def test_physical_coordinates_raises_without_calibration_or_override():
plane = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
calib = GeometryCalibration(plane)
with pytest.raises(ValueError, match="pixel_scale"):
calib.physical_coordinates()
def test_physical_coordinates_accepts_override_for_unknown_values():
plane = MeasurementPlane(flux=np.ones((5, 5)), z=0.3)
calib = GeometryCalibration(plane)
x, y = calib.physical_coordinates(pixel_scale=1e-4, viewing_angle_deg=0.0)
col_idx = np.arange(5) - 2
np.testing.assert_allclose(x[2, :], col_idx * 1e-4)
def test_known_calibration_takes_precedence_over_override():
plane = MeasurementPlane(
flux=np.ones((5, 5)), z=0.3, pixel_scale=2e-4, viewing_angle_deg=0.0
)
calib = GeometryCalibration(plane)
# override should be ignored since plane already specifies calibration
x, _ = calib.physical_coordinates(pixel_scale=999.0, viewing_angle_deg=45.0)
col_idx = np.arange(5) - 2
np.testing.assert_allclose(x[2, :], col_idx * 2e-4)
+103
View File
@@ -0,0 +1,103 @@
import numpy as np
import pytest
from he11lib.modes import LGBasis
W0 = 5e-3 # 5 mm waist
Z0 = 0.5 # waist at 0.5 m
WAVELENGTH = 1.76e-3 # ~170 GHz gyrotron
def make_grid(w, half_widths_in_w=6.0, n=300):
extent = half_widths_in_w * w
coords = np.linspace(-extent, extent, n)
dx = coords[1] - coords[0]
x, y = np.meshgrid(coords, coords)
return x, y, dx
def test_beam_radius_at_waist_equals_w0():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
assert basis.beam_radius(Z0) == pytest.approx(W0)
def test_beam_radius_at_one_rayleigh_range():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
zR = np.pi * W0**2 / WAVELENGTH
assert basis.beam_radius(Z0 + zR) == pytest.approx(W0 * np.sqrt(2))
def test_gouy_phase_zero_at_waist():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
assert basis.gouy_phase(Z0, p=0, l=0) == pytest.approx(0.0)
assert basis.gouy_phase(Z0, p=1, l=2) == pytest.approx(0.0)
def test_gouy_phase_at_one_rayleigh_range_for_fundamental():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
zR = np.pi * W0**2 / WAVELENGTH
# order (2p+|l|+1) = 1 for p=0,l=0; atan(1) = pi/4
assert basis.gouy_phase(Z0 + zR, p=0, l=0) == pytest.approx(np.pi / 4)
def test_fundamental_mode_matches_analytic_gaussian_at_waist():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
x, y, _ = make_grid(W0, n=50)
r2 = x**2 + y**2
field = basis.field(x, y, Z0, p=0, l=0)
expected_intensity_shape = np.exp(-2 * r2 / W0**2)
intensity = np.abs(field) ** 2
# shapes should match up to a constant normalization factor
ratio = intensity / expected_intensity_shape
ratio_center = ratio[len(ratio) // 2, len(ratio) // 2]
np.testing.assert_allclose(ratio / ratio_center, 1.0, atol=1e-6)
def test_mode_is_normalized_at_waist():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
x, y, dx = make_grid(W0, n=400)
field = basis.field(x, y, Z0, p=0, l=0)
total_power = np.sum(np.abs(field) ** 2) * dx**2
assert total_power == pytest.approx(1.0, rel=2e-3)
def test_mode_is_normalized_away_from_waist():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
z = Z0 + 0.2
w_z = basis.beam_radius(z)
x, y, dx = make_grid(w_z, n=400)
field = basis.field(x, y, z, p=1, l=2)
total_power = np.sum(np.abs(field) ** 2) * dx**2
assert total_power == pytest.approx(1.0, rel=2e-3)
def test_modes_are_orthogonal():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
z = Z0 + 0.1
w_z = basis.beam_radius(z)
x, y, dx = make_grid(w_z, n=400)
field_a = basis.field(x, y, z, p=0, l=0)
field_b = basis.field(x, y, z, p=1, l=0)
inner_product = np.sum(field_a * np.conj(field_b)) * dx**2
assert abs(inner_product) < 1e-3
def test_project_recovers_known_coefficients():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
z = Z0 + 0.15
w_z = basis.beam_radius(z)
x, y, dx = make_grid(w_z, n=400)
true_coeffs = {(0, 0): 0.8 + 0.1j, (1, 0): 0.2 - 0.3j}
field = basis.field_superposition(x, y, z, true_coeffs)
recovered = basis.project(field, x, y, dx, z, modes=list(true_coeffs.keys()))
for mode, coeff in true_coeffs.items():
assert recovered[mode] == pytest.approx(coeff, abs=5e-3)
+46
View File
@@ -0,0 +1,46 @@
import numpy as np
import pytest
from he11lib.noise import NoiseEstimator
def test_estimate_std_recovers_known_noise_on_flat_image():
rng = np.random.default_rng(0)
true_std = 0.05
image = np.ones((200, 200)) * 10.0 + rng.normal(0, true_std, size=(200, 200))
estimated = NoiseEstimator().estimate_std(image)
assert estimated == pytest.approx(true_std, rel=0.15)
def test_estimate_std_recovers_known_noise_on_smooth_bump():
rng = np.random.default_rng(1)
x = np.linspace(-3, 3, 200)
xx, yy = np.meshgrid(x, x)
smooth = np.exp(-(xx**2 + yy**2))
true_std = 0.01
image = smooth + rng.normal(0, true_std, size=smooth.shape)
estimated = NoiseEstimator().estimate_std(image)
assert estimated == pytest.approx(true_std, rel=0.35)
def test_estimate_std_near_zero_for_noise_free_image():
x = np.linspace(-3, 3, 100)
xx, yy = np.meshgrid(x, x)
smooth = np.exp(-(xx**2 + yy**2))
estimated = NoiseEstimator().estimate_std(smooth)
assert estimated < 1e-6
def test_weights_are_uniform_and_match_shape():
rng = np.random.default_rng(2)
image = np.ones((50, 50)) * 5.0 + rng.normal(0, 0.1, size=(50, 50))
weights = NoiseEstimator().weights(image)
assert weights.shape == image.shape
assert np.allclose(weights, weights.flat[0])
expected_std = NoiseEstimator().estimate_std(image)
assert weights.flat[0] == pytest.approx(1.0 / expected_std**2, rel=1e-6)
+84
View File
@@ -0,0 +1,84 @@
import numpy as np
import pytest
from he11lib.modes import LGBasis
from he11lib.phase_retrieval import PhaseRetriever, propagate_angular_spectrum
from he11lib.synthetic import SyntheticBeamGenerator
W0 = 5e-3
Z0 = 0.5
WAVELENGTH = 1.76e-3
PIXEL_SCALE = 3e-4
IMAGE_SHAPE = (121, 121)
def make_basis():
return LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
def make_grid():
coords = (np.arange(IMAGE_SHAPE[0]) - IMAGE_SHAPE[0] // 2) * PIXEL_SCALE
x, y = np.meshgrid(coords, coords)
return x, y
def test_propagate_round_trip_recovers_original_field():
basis = make_basis()
x, y = make_grid()
field = basis.field(x, y, Z0, p=0, l=0)
forward = propagate_angular_spectrum(field, PIXEL_SCALE, dz=0.05, wavelength=WAVELENGTH)
back = propagate_angular_spectrum(forward, PIXEL_SCALE, dz=-0.05, wavelength=WAVELENGTH)
np.testing.assert_allclose(back, field, atol=1e-3 * np.max(np.abs(field)))
def test_propagate_matches_lgbasis_analytic_evolution():
basis = make_basis()
x, y = make_grid()
field_at_waist = basis.field(x, y, Z0, p=0, l=0)
dz = 0.05
propagated = propagate_angular_spectrum(field_at_waist, PIXEL_SCALE, dz=dz, wavelength=WAVELENGTH)
analytic = basis.field(x, y, Z0 + dz, p=0, l=0)
# compare intensity profiles (phase reference/global constant may differ)
np.testing.assert_allclose(
np.abs(propagated) ** 2, np.abs(analytic) ** 2, atol=1e-2 * np.max(np.abs(analytic) ** 2)
)
def test_retrieve_recovers_pure_mode_purity():
# Keep z distances close to the waist so the (widening) beam stays well
# within the frame -- otherwise FFT wraparound/clipping at the edges
# degrades angular-spectrum propagation accuracy.
basis = make_basis()
gen = SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE)
z_list = [0.47, 0.5, 0.53]
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=z_list, noise_std=1e-5, seed=0)
retriever = PhaseRetriever(wavelength=WAVELENGTH)
result = retriever.retrieve(planes, viewing_angle_deg=0.0, max_iterations=100)
coeffs = basis.project(
result.field, result.x, result.y, PIXEL_SCALE, result.z, modes=[(0, 0), (1, 0), (0, 1)]
)
total_power = sum(abs(c) ** 2 for c in coeffs.values())
purity_00 = abs(coeffs[(0, 0)]) ** 2 / total_power
assert purity_00 > 0.9
def test_retrieve_estimates_beam_center():
basis = make_basis()
gen = SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE)
z_list = [0.47, 0.5, 0.53]
true_center = (15 * PIXEL_SCALE, -8 * PIXEL_SCALE)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=z_list, center=true_center, noise_std=1e-5, seed=1
)
retriever = PhaseRetriever(wavelength=WAVELENGTH)
result = retriever.retrieve(planes, viewing_angle_deg=0.0, max_iterations=100)
assert result.center[0] == pytest.approx(true_center[0], abs=3 * PIXEL_SCALE)
assert result.center[1] == pytest.approx(true_center[1], abs=3 * PIXEL_SCALE)
+64
View File
@@ -0,0 +1,64 @@
import matplotlib.figure
import numpy as np
import pytest
from he11lib.data import MeasurementPlane, ReconstructionResult
from he11lib.plotting import plot_center_trace, plot_mode_purity, plot_residuals
def make_result(**overrides):
defaults = dict(
purity={(0, 0): (0.9, 0.1), (1, 0): (0.1, -0.2)},
reconstructed_field=np.zeros((5, 5), dtype=complex),
centers=[(0.0, 0.0), (1e-4, -1e-4), (2e-4, -2e-4)],
pointing_angle_deg=0.5,
residuals=[np.ones((5, 5)), np.ones((5, 5)) * 2, np.ones((5, 5)) * 3],
)
defaults.update(overrides)
return ReconstructionResult(**defaults)
def make_planes():
return [
MeasurementPlane(flux=np.zeros((5, 5)), z=0.3),
MeasurementPlane(flux=np.zeros((5, 5)), z=0.5),
MeasurementPlane(flux=np.zeros((5, 5)), z=0.7),
]
def test_plot_mode_purity_draws_one_bar_per_mode():
result = make_result()
fig = plot_mode_purity(result)
assert isinstance(fig, matplotlib.figure.Figure)
ax = fig.axes[0]
assert len(ax.patches) == len(result.purity)
def test_plot_center_trace_plots_one_point_per_plane():
planes = make_planes()
result = make_result()
fig = plot_center_trace(planes, result)
assert isinstance(fig, matplotlib.figure.Figure)
ax = fig.axes[0]
line = ax.lines[0]
assert len(line.get_xdata()) == len(planes)
def test_plot_residuals_draws_one_axes_per_plane():
planes = make_planes()
result = make_result()
fig = plot_residuals(planes, result)
assert isinstance(fig, matplotlib.figure.Figure)
image_axes = [ax for ax in fig.axes if ax.images]
assert len(image_axes) == len(planes)
def test_plot_residuals_raises_when_phase_retrieval_used_without_residuals():
planes = make_planes()
result = make_result(residuals=[], used_phase_retrieval=True)
with pytest.raises(ValueError, match="residuals"):
plot_residuals(planes, result)
+112
View File
@@ -0,0 +1,112 @@
from dataclasses import replace
import pytest
from he11lib.deconvolution import DiffusionDeconvolver
from he11lib.fitting import ModalFitter
from he11lib.modes import LGBasis
from he11lib.reconstruct import BeamReconstructor
from he11lib.synthetic import SyntheticBeamGenerator
W0 = 5e-3
Z0 = 0.5
WAVELENGTH = 1.76e-3
PIXEL_SCALE = 4e-4
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_generator(basis):
return SyntheticBeamGenerator(basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE)
def test_reconstruct_recovers_pure_mode_purity():
basis = make_basis()
gen = make_generator(basis)
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=0)
reconstructor = BeamReconstructor(w0=W0, z0=Z0, wavelength=WAVELENGTH, max_order=2)
result = reconstructor.reconstruct(planes)
power_fraction, _ = result.purity[(0, 0)]
assert power_fraction == pytest.approx(1.0, abs=1e-3)
assert result.used_phase_retrieval is False
def test_reconstruct_recovers_center_offset():
basis = make_basis()
gen = make_generator(basis)
true_center = (10 * PIXEL_SCALE, -5 * PIXEL_SCALE)
planes = gen.generate(
coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, center=true_center, noise_std=1e-4, seed=1
)
reconstructor = BeamReconstructor(w0=W0, z0=Z0, wavelength=WAVELENGTH, max_order=2)
result = reconstructor.reconstruct(planes)
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_reconstruct_with_deconvolution_corrects_blur():
basis = make_basis()
gen = make_generator(basis)
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=Z_LIST, noise_std=1e-4, seed=2)
deconvolver = DiffusionDeconvolver(thermal_diffusivity=1e-6, dwell_time=30.0)
blurred_planes = [
replace(p, flux=deconvolver.blur(p.flux, p.pixel_scale)) for p in planes
]
# Without deconvolution, blur should measurably hurt purity recovery.
fitter = ModalFitter(basis)
result_no_deconv = fitter.fit(blurred_planes, modes=[(0, 0), (1, 0), (0, 1)])
purity_no_deconv, _ = result_no_deconv.purity[(0, 0)]
reconstructor = BeamReconstructor(
w0=W0, z0=Z0, wavelength=WAVELENGTH, max_order=2, deconvolver=deconvolver
)
result = reconstructor.reconstruct(blurred_planes)
purity_with_deconv, _ = result.purity[(0, 0)]
assert purity_with_deconv > purity_no_deconv
assert purity_with_deconv > 0.9
def test_reconstruct_forces_phase_retrieval_fallback():
basis = make_basis()
gen = SyntheticBeamGenerator(basis=basis, image_shape=(121, 121), pixel_scale=3e-4)
z_list = [0.47, 0.5, 0.53]
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=z_list, noise_std=1e-5, seed=3)
reconstructor = BeamReconstructor(
w0=W0, z0=Z0, wavelength=WAVELENGTH, max_order=2, force_phase_retrieval=True
)
result = reconstructor.reconstruct(planes)
assert result.used_phase_retrieval is True
power_fraction, _ = result.purity[(0, 0)]
assert power_fraction > 0.9
def test_reconstruct_falls_back_automatically_on_high_residual():
basis = make_basis()
gen = SyntheticBeamGenerator(basis=basis, image_shape=(121, 121), pixel_scale=3e-4)
z_list = [0.47, 0.5, 0.53]
planes = gen.generate(coefficients={(0, 0): 1.0 + 0j}, z_list=z_list, noise_std=1e-5, seed=4)
reconstructor = BeamReconstructor(
w0=W0,
z0=Z0,
wavelength=WAVELENGTH,
max_order=2,
phase_retrieval_residual_threshold=1e-8,
)
result = reconstructor.reconstruct(planes)
assert result.used_phase_retrieval is True
+123
View File
@@ -0,0 +1,123 @@
import numpy as np
import pytest
from he11lib.modes import LGBasis
from he11lib.synthetic import SyntheticBeamGenerator
W0 = 5e-3
Z0 = 0.5
WAVELENGTH = 1.76e-3
PIXEL_SCALE = 2e-4 # 0.2 mm/px
IMAGE_SHAPE = (161, 161) # odd so there's a well-defined center pixel
def make_generator():
basis = LGBasis(w0=W0, z0=Z0, wavelength=WAVELENGTH)
return SyntheticBeamGenerator(
basis=basis, image_shape=IMAGE_SHAPE, pixel_scale=PIXEL_SCALE
)
def test_generate_returns_planes_with_requested_z():
gen = make_generator()
z_list = [0.3, 0.4, 0.5]
planes = gen.generate(coefficients={(0, 0): 1 + 0j}, z_list=z_list)
assert [p.z for p in planes] == z_list
assert all(p.flux.shape == IMAGE_SHAPE for p in planes)
def test_generate_pure_mode_peak_at_image_center_when_centered():
gen = make_generator()
planes = gen.generate(coefficients={(0, 0): 1 + 0j}, z_list=[Z0], center=(0.0, 0.0))
flux = planes[0].flux
peak_idx = np.unravel_index(np.argmax(flux), flux.shape)
center_idx = (IMAGE_SHAPE[0] // 2, IMAGE_SHAPE[1] // 2)
assert peak_idx == center_idx
def test_generate_applies_center_offset():
gen = make_generator()
offset_m = 20 * PIXEL_SCALE # 20 pixels
planes = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], center=(offset_m, 0.0)
)
flux = planes[0].flux
peak_idx = np.unravel_index(np.argmax(flux), flux.shape)
center_row = IMAGE_SHAPE[0] // 2
center_col = IMAGE_SHAPE[1] // 2
assert peak_idx[0] == center_row
assert peak_idx[1] == pytest.approx(center_col + 20, abs=1)
def test_generate_applies_pointing_angle_as_linear_drift():
gen = make_generator()
pointing_angle_deg = 1.0 # small tilt
z_list = [Z0, Z0 + 0.2]
planes = gen.generate(
coefficients={(0, 0): 1 + 0j},
z_list=z_list,
center=(0.0, 0.0),
pointing_angle_deg=pointing_angle_deg,
)
peaks_col = []
for plane in planes:
peak_idx = np.unravel_index(np.argmax(plane.flux), plane.flux.shape)
peaks_col.append(peak_idx[1])
expected_shift_m = 0.2 * np.tan(np.deg2rad(pointing_angle_deg))
expected_shift_px = expected_shift_m / PIXEL_SCALE
actual_shift_px = peaks_col[1] - peaks_col[0]
assert actual_shift_px == pytest.approx(expected_shift_px, abs=1)
def test_generate_noise_is_reproducible_with_seed():
gen = make_generator()
planes_a = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], noise_std=0.01, seed=42
)
planes_b = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], noise_std=0.01, seed=42
)
np.testing.assert_array_equal(planes_a[0].flux, planes_b[0].flux)
def test_generate_noise_std_matches_requested_level():
gen = make_generator()
noise_std = 0.02
planes_noisy = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], noise_std=noise_std, seed=1
)
planes_clean = gen.generate(coefficients={(0, 0): 1 + 0j}, z_list=[Z0], noise_std=0.0)
diff = planes_noisy[0].flux - planes_clean[0].flux
assert np.std(diff) == pytest.approx(noise_std, rel=0.15)
def test_generate_viewing_angle_compresses_tilt_axis():
gen = make_generator()
planes_straight = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], viewing_angle_deg=0.0
)
planes_tilted = gen.generate(
coefficients={(0, 0): 1 + 0j}, z_list=[Z0], viewing_angle_deg=60.0
)
def width_along_axis(flux, axis):
profile = flux[flux.shape[0] // 2, :] if axis == 1 else flux[:, flux.shape[1] // 2]
half_max = profile.max() / 2
above = np.where(profile >= half_max)[0]
return above[-1] - above[0]
width_straight_x = width_along_axis(planes_straight[0].flux, axis=1)
width_tilted_x = width_along_axis(planes_tilted[0].flux, axis=1)
width_straight_y = width_along_axis(planes_straight[0].flux, axis=0)
width_tilted_y = width_along_axis(planes_tilted[0].flux, axis=0)
# tilt compresses the viewed beam along the tilt (x) axis, y unaffected
assert width_tilted_x < width_straight_x
assert width_tilted_y == pytest.approx(width_straight_y, abs=1)