Rewrite ModalFitter.fit around the CameraModel tolerance mechanism

The optimizer's parameter vector is now built dynamically: LG
coefficients, per-plane center, and both pointing angles stay always
free; each CameraModel field and each plane's z join the fit (bounded to
its +/- tolerance) only when its paired tolerance is nonzero, and are
otherwise substituted as fixed constants.

Also seeds the first mode's coefficient with a small imaginary offset
(1.0 + 0.05j instead of 1.0 + 0j) rather than exactly on the real axis:
for a single mode, intensity depends only on |c| (phase is unobservable),
so Im=0 sits exactly on that flat/degenerate valley, aligned with a
coordinate axis, giving a zero-gradient Jacobian column that destabilized
trf's trust-region step and prevented pointing-angle recovery from a
default (0, 0) initial guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-07-03 12:04:47 +02:00
parent c2ae95e01f
commit ef57ec81e4
2 changed files with 265 additions and 85 deletions
+104 -57
View File
@@ -8,7 +8,14 @@ import numpy as np
from scipy.optimize import least_squares
from .data import MeasurementPlane, ReconstructionResult, validate_planes
from .geometry import GeometryCalibration
from .geometry import (
CameraModel,
CameraModelTolerance,
GeometryCalibration,
camera_from_values,
camera_to_values,
tolerance_to_values,
)
from .modes import LGBasis
from .noise import NoiseEstimator
@@ -35,19 +42,31 @@ class ModalFitter:
self,
planes: list[MeasurementPlane],
modes: list[tuple[int, int]],
camera: CameraModel,
camera_tolerance: CameraModelTolerance,
initial_coefficients: dict[tuple[int, int], complex] | None = None,
initial_center: tuple[float, float] = (0.0, 0.0),
initial_tilt_deg: tuple[float, float] = (0.0, 0.0),
initial_pixel_scale: float | None = None,
initial_viewing_angle_deg: float = 0.0,
initial_pointing_deg: tuple[float, float] = (0.0, 0.0),
) -> ReconstructionResult:
"""Jointly fit complex coefficients for `modes` plus center/tilt/geometry."""
validate_planes(planes)
"""Jointly fit complex coefficients for `modes` plus center/pointing/geometry.
unknown_scale_idx = [i for i, p in enumerate(planes) if p.pixel_scale is None]
unknown_angle_idx = [i for i, p in enumerate(planes) if p.viewing_angle_deg is None]
Every `CameraModel` field with a nonzero `camera_tolerance` entry,
and every plane whose `z_tolerance` is nonzero, is refined within
`[nominal - tolerance, nominal + tolerance]`; zero-tolerance fields
are held fixed at their nominal value.
"""
validate_planes(planes)
weights = [np.sqrt(self.noise_estimator.weights(p.flux)) for p in planes]
camera_nominal = camera_to_values(camera)
camera_tol = tolerance_to_values(camera_tolerance)
free_camera_idx = [i for i, t in enumerate(camera_tol) if t > 0]
free_z_idx = [i for i, p in enumerate(planes) if p.z_tolerance > 0]
n_modes = len(modes)
n_always_free = 2 * n_modes + 4 # coefficients + center(2) + pointing(2)
def pack_initial() -> np.ndarray:
x: list[float] = []
for i, mode in enumerate(modes):
@@ -56,98 +75,126 @@ class ModalFitter:
# Nonzero seed for every mode: starting a coefficient at
# exactly 0+0j sits at a flat/degenerate point for the
# optimizer and can prevent it from ever leaving zero.
c = 1.0 + 0j if i == 0 else 0.1 + 0.05j
# A purely-real seed (Im=0) sits exactly on the flat/
# degenerate valley of a single mode's phase (for one
# mode alone, |c|^2 -- not arg(c) -- is all that's
# observable in intensity), giving a zero-gradient
# column that destabilizes trf's trust-region step;
# a small imaginary offset avoids landing on that axis.
c = 1.0 + 0.05j if i == 0 else 0.1 + 0.05j
x += [c.real, c.imag]
x += [initial_center[0], initial_center[1], initial_tilt_deg[0], initial_tilt_deg[1]]
for _ in unknown_scale_idx:
x.append(initial_pixel_scale if initial_pixel_scale is not None else 1e-4)
for _ in unknown_angle_idx:
x.append(initial_viewing_angle_deg)
x += [initial_center[0], initial_center[1], initial_pointing_deg[0], initial_pointing_deg[1]]
for i in free_camera_idx:
x.append(camera_nominal[i])
for i in free_z_idx:
x.append(planes[i].z)
return np.array(x, dtype=float)
n_modes = len(modes)
def pack_bounds() -> tuple[np.ndarray, np.ndarray]:
lower = [-np.inf] * n_always_free
upper = [np.inf] * n_always_free
for i in free_camera_idx:
lower.append(camera_nominal[i] - camera_tol[i])
upper.append(camera_nominal[i] + camera_tol[i])
for i in free_z_idx:
lower.append(planes[i].z - planes[i].z_tolerance)
upper.append(planes[i].z + planes[i].z_tolerance)
return np.array(lower), np.array(upper)
def unpack(x: np.ndarray):
coeffs = {mode: complex(x[2 * i], x[2 * i + 1]) for i, mode in enumerate(modes)}
offset = 2 * n_modes
x0, y0, tilt_x_deg, tilt_y_deg = x[offset : offset + 4]
x0, y0, tilt_h_deg, tilt_v_deg = x[offset : offset + 4]
offset += 4
scales = {}
for idx in unknown_scale_idx:
scales[idx] = x[offset]
offset += 1
angles = {}
for idx in unknown_angle_idx:
angles[idx] = x[offset]
offset += 1
return coeffs, (x0, y0), (tilt_x_deg, tilt_y_deg), scales, angles
def plane_center(x0: float, y0: float, tilt_deg: tuple[float, float], z: float):
drift_x = (z - self.basis.z0) * np.tan(np.deg2rad(tilt_deg[0]))
drift_y = (z - self.basis.z0) * np.tan(np.deg2rad(tilt_deg[1]))
camera_values = list(camera_nominal)
for i in free_camera_idx:
camera_values[i] = x[offset]
offset += 1
fitted_camera = camera_from_values(camera_values)
z_values = [p.z for p in planes]
for i in free_z_idx:
z_values[i] = x[offset]
offset += 1
return coeffs, (x0, y0), (tilt_h_deg, tilt_v_deg), fitted_camera, z_values
def plane_center(x0: float, y0: float, pointing_deg: tuple[float, float], z: float):
drift_x = (z - self.basis.z0) * np.tan(np.deg2rad(pointing_deg[0]))
drift_y = (z - self.basis.z0) * np.tan(np.deg2rad(pointing_deg[1]))
return x0 + drift_x, y0 + drift_y
def model_flux_for_plane(i: int, plane: MeasurementPlane, coeffs, center0, tilt_deg, scales, angles):
scale = plane.pixel_scale if plane.pixel_scale is not None else scales[i]
angle = plane.viewing_angle_deg if plane.viewing_angle_deg is not None else angles[i]
calib = GeometryCalibration(plane)
x_grid, y_grid = calib.physical_coordinates(pixel_scale=scale, viewing_angle_deg=angle)
cx, cy = plane_center(center0[0], center0[1], tilt_deg, plane.z)
field = self.basis.field_superposition(x_grid - cx, y_grid - cy, plane.z, coeffs)
def model_flux_for_plane(plane, fitted_camera, z, coeffs, center0, pointing_deg):
calib = GeometryCalibration(fitted_camera)
x_grid, y_grid = calib.physical_coordinates(plane.flux.shape, z)
cx, cy = plane_center(center0[0], center0[1], pointing_deg, z)
field = self.basis.field_superposition(x_grid - cx, y_grid - cy, z, coeffs)
return np.abs(field) ** 2
def residuals(x: np.ndarray) -> np.ndarray:
coeffs, center0, tilt_deg, scales, angles = unpack(x)
coeffs, center0, pointing_deg, fitted_camera, z_values = unpack(x)
parts = []
for i, plane in enumerate(planes):
model_flux = model_flux_for_plane(i, plane, coeffs, center0, tilt_deg, scales, angles)
model_flux = model_flux_for_plane(
plane, fitted_camera, z_values[i], coeffs, center0, pointing_deg
)
parts.append(((plane.flux - model_flux) * weights[i]).ravel())
return np.concatenate(parts)
x0_vec = pack_initial()
# 'trf' + x_scale='jac' handles the very different natural magnitudes
# of these parameters (coefficients ~O(1), pixel_scale ~O(1e-3),
# angles ~O(1-90)); plain 'lm' can terminate prematurely on 'xtol'
# because its unscaled step-size test is dominated by the largest
# parameters.
lower, upper = pack_bounds()
# 'trf' + x_scale='jac' handles the very different natural
# magnitudes of these parameters (coefficients ~O(1), focal length
# ~O(1e3-1e4), angles ~O(1-90), z ~O(0.1-1)); plain 'lm' can
# terminate prematurely on 'xtol' because its unscaled step-size
# test is dominated by the largest parameters. 'lm' also doesn't
# support bounds, which the tolerance mechanism requires.
opt_result = least_squares(
residuals, x0_vec, method="trf", x_scale="jac", max_nfev=5000
residuals, x0_vec, method="trf", x_scale="jac", bounds=(lower, upper), max_nfev=5000
)
coeffs, center0, tilt_deg, scales, angles = unpack(opt_result.x)
coeffs, center0, pointing_deg, fitted_camera, z_values = unpack(opt_result.x)
total_power = sum(abs(c) ** 2 for c in coeffs.values())
if total_power == 0:
total_power = 1.0
purity = {mode: (abs(c) ** 2 / total_power, float(np.angle(c))) for mode, c in coeffs.items()}
centers = [plane_center(center0[0], center0[1], tilt_deg, p.z) for p in planes]
pointing_angle_deg = float(np.hypot(tilt_deg[0], tilt_deg[1]))
centers = [
plane_center(center0[0], center0[1], pointing_deg, z_values[i])
for i in range(len(planes))
]
geometry: dict[str, float] = {}
geometry: dict[str, float] = dict(zip(
(
"focal_length_px", "position_x", "position_y", "position_z",
"yaw_deg", "pitch_deg", "roll_deg",
"principal_point_x", "principal_point_y",
),
camera_to_values(fitted_camera),
))
for i in range(len(planes)):
geometry[f"pixel_scale_{i}"] = (
planes[i].pixel_scale if planes[i].pixel_scale is not None else scales[i]
)
geometry[f"viewing_angle_deg_{i}"] = (
planes[i].viewing_angle_deg if planes[i].viewing_angle_deg is not None else angles[i]
)
geometry[f"z_{i}"] = z_values[i]
residual_maps = []
for i, plane in enumerate(planes):
model_flux = model_flux_for_plane(i, plane, coeffs, center0, tilt_deg, scales, angles)
model_flux = model_flux_for_plane(
plane, fitted_camera, z_values[i], coeffs, center0, pointing_deg
)
residual_maps.append(plane.flux - model_flux)
coefficient_uncertainty = self._estimate_uncertainty(opt_result, modes, coeffs, total_power)
reference_z = min(planes, key=lambda p: abs(p.z - self.basis.z0)).z
field_at_reference = self._field_on_default_grid(coeffs, reference_z)
reference_idx = min(range(len(planes)), key=lambda i: abs(z_values[i] - self.basis.z0))
field_at_reference = self._field_on_default_grid(coeffs, z_values[reference_idx])
return ReconstructionResult(
purity=purity,
reconstructed_field=field_at_reference,
centers=centers,
pointing_angle_deg=pointing_angle_deg,
pointing_angle_horizontal_deg=pointing_deg[0],
pointing_angle_vertical_deg=pointing_deg[1],
geometry=geometry,
residuals=residual_maps,
coefficient_uncertainty=coefficient_uncertainty,