Files
he11lib/he11lib/synthetic.py
T
Martino Ferrari c2ae95e01f Rewrite SyntheticBeamGenerator around CameraModel and 2D beam pointing
Renders each plane via true pinhole projection through a shared
CameraModel instead of the old cosine-compression formula, adds
independent horizontal/vertical pointing drift, and supports generating
a deliberately-offset nominal z (vs. true z) per plane for tolerance-
recovery testing in later tasks.

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

80 lines
2.9 KiB
Python

"""Forward model: synthetic thermal (flux) images from known ground truth.
Used to validate the reconstruction pipeline (recover known mode content)
and to help users evaluate experimental design (e.g. whether a given set of
measurement distances will separate candidate modes).
"""
from __future__ import annotations
import numpy as np
from .data import MeasurementPlane
from .geometry import CameraModel, GeometryCalibration
from .modes import LGBasis
class SyntheticBeamGenerator:
"""Generates synthetic multi-plane flux images for a known ground-truth beam.
Parameters
----------
basis : LGBasis defining the reference w0, z0, wavelength.
camera : ground-truth CameraModel (position/orientation/intrinsics) used
to render each plane via true perspective projection.
"""
def __init__(self, basis: LGBasis, camera: CameraModel):
self.basis = basis
self.camera = camera
self.calibration = GeometryCalibration(camera)
def generate(
self,
coefficients: dict[tuple[int, int], complex],
z_list: list[float],
image_shape: tuple[int, int],
*,
center: tuple[float, float] = (0.0, 0.0),
pointing_angle_horizontal_deg: float = 0.0,
pointing_angle_vertical_deg: float = 0.0,
z_tolerance: float = 0.0,
nominal_z_offsets: dict[float, float] | None = None,
noise_std: float = 0.0,
seed: int | None = None,
) -> list[MeasurementPlane]:
"""Generate one MeasurementPlane per requested (true) z distance.
The beam transverse center drifts linearly with z according to the
two pointing angles, starting from `center` at the basis's
reference z0. `nominal_z_offsets`, if given, maps a true z (as
given in z_list) to an offset applied to the *nominal* z stored on
the resulting MeasurementPlane -- letting tests verify a fit
recovers the true z despite a deliberately-offset nominal input.
Every resulting plane shares `z_tolerance`.
"""
rng = np.random.default_rng(seed)
tilt_h_rad = np.deg2rad(pointing_angle_horizontal_deg)
tilt_v_rad = np.deg2rad(pointing_angle_vertical_deg)
offsets = nominal_z_offsets or {}
planes = []
for z in z_list:
drift_x = (z - self.basis.z0) * np.tan(tilt_h_rad)
drift_y = (z - self.basis.z0) * np.tan(tilt_v_rad)
cx = center[0] + drift_x
cy = center[1] + drift_y
x, y = self.calibration.physical_coordinates(image_shape, z)
field = self.basis.field_superposition(x - cx, y - cy, z, coefficients)
flux = np.abs(field) ** 2
if noise_std > 0:
flux = flux + rng.normal(0.0, noise_std, size=flux.shape)
nominal_z = z + offsets.get(z, 0.0)
planes.append(
MeasurementPlane(flux=flux, z=nominal_z, z_tolerance=z_tolerance)
)
return planes