Skip to main content

fpm_rs/
illumination_calibration.rs

1//! Bounded physical calibration of planar LED-array illumination.
2//!
3//! This module optimizes realizable [`PlanarLedArray`](crate::experiment::PlanarLedArray)
4//! parameters against the same image-plane forward model and measurement losses used by
5//! reconstruction. It is deliberately separate from the generic per-source Fourier-grid
6//! corrections implemented by [`crate::algorithms::GradientDescent`].
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use ndarray::ArrayView2;
11use num_complex::Complex64;
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    Result,
16    algorithms::objective::{LossType, point_loss},
17    error::Error,
18    experiment::{
19        AcquisitionPlan, ArrayPose, Illumination, IlluminationFrame, Optics, PlanarLedArray,
20        SourceCalibration, SourceGeometry,
21    },
22    measurements::MeasurementRead,
23    model::{ForwardModel, ImagePlaneModel, Pupil},
24};
25
26/// Bounds, differencing scale, optimization scale, and optional quadratic prior.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct CalibrationParameterSpec {
30    /// Inclusive lower bound in the parameter's documented physical unit.
31    pub lower_bound: f64,
32    /// Inclusive upper bound in the parameter's documented physical unit.
33    pub upper_bound: f64,
34    /// Positive finite-difference displacement in the physical unit.
35    pub finite_difference_step: f64,
36    /// Positive scale mapping an absolute value to a dimensionless optimizer variable.
37    pub scale: f64,
38    /// Optional absolute center of a quadratic prior, in the physical unit.
39    pub prior_center: Option<f64>,
40    /// Non-negative coefficient of the dimensionless quadratic prior.
41    pub regularization_strength: f64,
42}
43
44impl CalibrationParameterSpec {
45    /// Creates a bounded parameter specification with scale-aware default differencing.
46    pub fn new(lower_bound: f64, upper_bound: f64, scale: f64) -> Self {
47        Self {
48            lower_bound,
49            upper_bound,
50            finite_difference_step: scale * 1e-3,
51            scale,
52            prior_center: None,
53            regularization_strength: 0.0,
54        }
55    }
56
57    /// Replaces the positive physical finite-difference displacement.
58    pub fn finite_difference_step(mut self, step: f64) -> Self {
59        self.finite_difference_step = step;
60        self
61    }
62
63    /// Adds a quadratic prior centered at `center` with non-negative `strength`.
64    pub fn prior(mut self, center: f64, strength: f64) -> Self {
65        self.prior_center = Some(center);
66        self.regularization_strength = strength;
67        self
68    }
69
70    /// Validates bounds, scale, finite-difference step, and prior values.
71    pub fn validate(&self, name: &'static str) -> Result<()> {
72        if !self.lower_bound.is_finite()
73            || !self.upper_bound.is_finite()
74            || self.lower_bound >= self.upper_bound
75        {
76            return Err(invalid(name, "bounds must be finite and strictly ordered"));
77        }
78        if !self.scale.is_finite() || self.scale <= 0.0 {
79            return Err(invalid(
80                name,
81                "optimization scale must be finite and positive",
82            ));
83        }
84        if !self.finite_difference_step.is_finite() || self.finite_difference_step <= 0.0 {
85            return Err(invalid(
86                name,
87                "finite-difference step must be finite and positive",
88            ));
89        }
90        if self.prior_center.is_some_and(|value| !value.is_finite())
91            || !self.regularization_strength.is_finite()
92            || self.regularization_strength < 0.0
93        {
94            return Err(invalid(
95                name,
96                "prior center must be finite and regularization non-negative",
97            ));
98        }
99        Ok(())
100    }
101}
102
103/// Explicit selection and numerical configuration of planar-array parameters.
104///
105/// `None` means inactive. Position-offset entries are keyed by stable row-major
106/// physical source index and contain `(x, y, z)` specifications in metres.
107#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct PlanarArrayCalibrationParameters {
110    /// Optional `(tx, ty, tz)` specifications in metres.
111    pub translation: [Option<CalibrationParameterSpec>; 3],
112    /// Optional active-extrinsic `(rx, ry, rz)` specifications in radians.
113    pub rotation: [Option<CalibrationParameterSpec>; 3],
114    /// Optional `(pitch_x, pitch_y)` specifications in metres.
115    pub pitch: [Option<CalibrationParameterSpec>; 2],
116    /// Optional fractional `(reference_column, reference_row)` specifications.
117    pub reference_index: [Option<CalibrationParameterSpec>; 2],
118    /// Explicit source-indexed local `(offset_x, offset_y, offset_z)` specifications.
119    pub position_offsets: BTreeMap<usize, [CalibrationParameterSpec; 3]>,
120    /// One optional specification shared by all stable relative source powers.
121    pub relative_source_power: Option<CalibrationParameterSpec>,
122    /// One optional specification shared by all acquisition-frame gains.
123    pub frame_gains: Option<CalibrationParameterSpec>,
124}
125
126impl Default for PlanarArrayCalibrationParameters {
127    fn default() -> Self {
128        Self {
129            translation: std::array::from_fn(|_| None),
130            rotation: std::array::from_fn(|_| None),
131            pitch: std::array::from_fn(|_| None),
132            reference_index: std::array::from_fn(|_| None),
133            position_offsets: BTreeMap::new(),
134            relative_source_power: None,
135            frame_gains: None,
136        }
137    }
138}
139
140impl PlanarArrayCalibrationParameters {
141    /// Starts a builder with every parameter inactive.
142    pub fn builder() -> PlanarArrayCalibrationParametersBuilder {
143        PlanarArrayCalibrationParametersBuilder::default()
144    }
145
146    /// Returns whether at least one physical or multiplicative parameter is active.
147    pub fn has_active_parameters(&self) -> bool {
148        self.translation.iter().any(Option::is_some)
149            || self.rotation.iter().any(Option::is_some)
150            || self.pitch.iter().any(Option::is_some)
151            || self.reference_index.iter().any(Option::is_some)
152            || !self.position_offsets.is_empty()
153            || self.relative_source_power.is_some()
154            || self.frame_gains.is_some()
155    }
156
157    /// Validates specifications, selected sources, supported geometry, and gauges.
158    pub fn validate_for(&self, illumination: &Illumination) -> Result<()> {
159        let geometry = planar_geometry(illumination)?;
160        for spec in self
161            .translation
162            .iter()
163            .chain(&self.rotation)
164            .chain(&self.pitch)
165            .chain(&self.reference_index)
166            .flatten()
167        {
168            spec.validate("calibration parameter")?;
169        }
170        for (&source, specs) in &self.position_offsets {
171            if source >= geometry.source_count() {
172                return Err(invalid(
173                    "position_offsets",
174                    format!(
175                        "source index {source} must be less than {}",
176                        geometry.source_count()
177                    ),
178                ));
179            }
180            for spec in specs {
181                spec.validate("position offset")?;
182            }
183        }
184        if self
185            .pitch
186            .iter()
187            .flatten()
188            .any(|spec| spec.lower_bound <= 0.0)
189        {
190            return Err(invalid(
191                "pitch",
192                "pitch lower bounds must be strictly positive",
193            ));
194        }
195        if let Some(spec) = &self.relative_source_power {
196            spec.validate("relative_source_power")?;
197            if spec.lower_bound <= 0.0 {
198                return Err(invalid(
199                    "relative_source_power",
200                    "the lower bound must be strictly positive",
201                ));
202            }
203        }
204        if let Some(spec) = &self.frame_gains {
205            spec.validate("frame_gains")?;
206            if spec.lower_bound <= 0.0 {
207                return Err(invalid(
208                    "frame_gains",
209                    "the lower bound must be strictly positive",
210                ));
211            }
212        }
213        if self.translation[0].is_some() && self.reference_index[0].is_some() {
214            return Err(invalid(
215                "calibration parameters",
216                "tx and reference_column cannot be optimized together because they describe the same lateral gauge",
217            ));
218        }
219        if self.translation[1].is_some() && self.reference_index[1].is_some() {
220            return Err(invalid(
221                "calibration parameters",
222                "ty and reference_row cannot be optimized together because they describe the same lateral gauge",
223            ));
224        }
225        if self.relative_source_power.is_some() && self.frame_gains.is_some() {
226            return Err(invalid(
227                "calibration parameters",
228                "relative source power and frame gains cannot be optimized together because their product has an unresolved scale gauge",
229            ));
230        }
231        if !self.position_offsets.is_empty()
232            && self.translation.iter().any(Option::is_some)
233            && self.position_offsets.len() < 2
234        {
235            return Err(invalid(
236                "position_offsets",
237                "at least two selected source offsets are required with global translation so their mean can be constrained to zero",
238            ));
239        }
240        Ok(())
241    }
242}
243
244/// Builder for explicit planar-array calibration selection.
245#[derive(Clone, Debug, Default)]
246pub struct PlanarArrayCalibrationParametersBuilder {
247    parameters: PlanarArrayCalibrationParameters,
248    position_offset_indices: Vec<usize>,
249}
250
251impl PlanarArrayCalibrationParametersBuilder {
252    /// Activates selected `(tx, ty, tz)` components with metre-scale defaults.
253    pub fn translation(mut self, active: [bool; 3]) -> Self {
254        self.parameters.translation =
255            std::array::from_fn(|axis| active[axis].then(|| default_translation_spec(axis)));
256        self
257    }
258
259    /// Replaces optional translation specifications directly.
260    pub fn translation_specs(mut self, specs: [Option<CalibrationParameterSpec>; 3]) -> Self {
261        self.parameters.translation = specs;
262        self
263    }
264
265    /// Activates selected `(rx, ry, rz)` components with radian defaults.
266    pub fn rotation(mut self, active: [bool; 3]) -> Self {
267        self.parameters.rotation =
268            std::array::from_fn(|axis| active[axis].then(default_rotation_spec));
269        self
270    }
271
272    /// Replaces optional rotation specifications directly.
273    pub fn rotation_specs(mut self, specs: [Option<CalibrationParameterSpec>; 3]) -> Self {
274        self.parameters.rotation = specs;
275        self
276    }
277
278    /// Activates selected `(pitch_x, pitch_y)` components with metre defaults.
279    pub fn pitch(mut self, active: [bool; 2]) -> Self {
280        self.parameters.pitch = std::array::from_fn(|axis| active[axis].then(default_pitch_spec));
281        self
282    }
283
284    /// Replaces optional pitch specifications directly.
285    pub fn pitch_specs(mut self, specs: [Option<CalibrationParameterSpec>; 2]) -> Self {
286        self.parameters.pitch = specs;
287        self
288    }
289
290    /// Activates selected fractional `(reference_column, reference_row)` components.
291    pub fn reference_index(mut self, active: [bool; 2]) -> Self {
292        self.parameters.reference_index =
293            std::array::from_fn(|axis| active[axis].then(default_reference_spec));
294        self
295    }
296
297    /// Replaces optional reference-index specifications directly.
298    pub fn reference_index_specs(mut self, specs: [Option<CalibrationParameterSpec>; 2]) -> Self {
299        self.parameters.reference_index = specs;
300        self
301    }
302
303    /// Selects stable source indices whose local XYZ offsets are all optimized.
304    pub fn position_offsets(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
305        self.position_offset_indices = indices.into_iter().collect();
306        self
307    }
308
309    /// Replaces explicit source-indexed XYZ offset specifications.
310    pub fn position_offset_specs(
311        mut self,
312        specs: BTreeMap<usize, [CalibrationParameterSpec; 3]>,
313    ) -> Self {
314        self.parameters.position_offsets = specs;
315        self.position_offset_indices.clear();
316        self
317    }
318
319    /// Enables or disables optimization of all stable relative source powers.
320    pub fn relative_source_power(mut self, active: bool) -> Self {
321        self.parameters.relative_source_power = active.then(default_multiplicative_spec);
322        self
323    }
324
325    /// Replaces the optional source-power specification directly.
326    pub fn relative_source_power_spec(mut self, spec: Option<CalibrationParameterSpec>) -> Self {
327        self.parameters.relative_source_power = spec;
328        self
329    }
330
331    /// Enables or disables optimization of all acquisition-frame gains.
332    pub fn frame_gains(mut self, active: bool) -> Self {
333        self.parameters.frame_gains = active.then(default_multiplicative_spec);
334        self
335    }
336
337    /// Replaces the optional frame-gain specification directly.
338    pub fn frame_gain_spec(mut self, spec: Option<CalibrationParameterSpec>) -> Self {
339        self.parameters.frame_gains = spec;
340        self
341    }
342
343    /// Validates duplicate selections and returns the inspectable configuration.
344    pub fn build(mut self) -> Result<PlanarArrayCalibrationParameters> {
345        if !self.position_offset_indices.is_empty() {
346            let unique: BTreeSet<_> = self.position_offset_indices.iter().copied().collect();
347            if unique.len() != self.position_offset_indices.len() {
348                return Err(invalid(
349                    "position_offsets",
350                    "selected source indices must be unique",
351                ));
352            }
353            self.parameters.position_offsets = unique
354                .into_iter()
355                .map(|index| (index, std::array::from_fn(|_| default_offset_spec())))
356                .collect();
357        }
358        Ok(self.parameters)
359    }
360}
361
362/// Settings for the deterministic bounded finite-difference optimizer.
363#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
364#[serde(deny_unknown_fields)]
365pub struct BoundedFiniteDifferenceOptimizer {
366    /// Maximum accepted or rejected trial steps per illumination phase.
367    pub max_steps: usize,
368    /// Relative objective improvement below which a phase is converged.
369    pub relative_tolerance: f64,
370    /// Initial dimensionless step length in scaled parameter space.
371    pub initial_step_size: f64,
372    /// Smallest line-search step considered before reporting rejection.
373    pub minimum_step_size: f64,
374    /// Multiplicative line-search reduction in the open interval `(0, 1)`.
375    pub step_reduction: f64,
376}
377
378impl Default for BoundedFiniteDifferenceOptimizer {
379    fn default() -> Self {
380        Self {
381            max_steps: 2,
382            relative_tolerance: 1e-6,
383            initial_step_size: 0.25,
384            minimum_step_size: 1e-6,
385            step_reduction: 0.5,
386        }
387    }
388}
389
390impl BoundedFiniteDifferenceOptimizer {
391    /// Validates step counts, tolerances, and line-search controls.
392    pub fn validate(&self) -> Result<()> {
393        if self.max_steps == 0 {
394            return Err(invalid("max_steps", "must be greater than zero"));
395        }
396        if !self.relative_tolerance.is_finite() || self.relative_tolerance < 0.0 {
397            return Err(invalid(
398                "relative_tolerance",
399                "must be finite and non-negative",
400            ));
401        }
402        if !self.initial_step_size.is_finite() || self.initial_step_size <= 0.0 {
403            return Err(invalid("initial_step_size", "must be finite and positive"));
404        }
405        if !self.minimum_step_size.is_finite()
406            || self.minimum_step_size <= 0.0
407            || self.minimum_step_size > self.initial_step_size
408        {
409            return Err(invalid(
410                "minimum_step_size",
411                "must be positive and no larger than initial_step_size",
412            ));
413        }
414        if !self.step_reduction.is_finite() || !(0.0..1.0).contains(&self.step_reduction) {
415            return Err(invalid(
416                "step_reduction",
417                "must be finite and strictly between zero and one",
418            ));
419        }
420        Ok(())
421    }
422}
423
424/// Serializable physical illumination-calibration configuration.
425#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
426#[serde(deny_unknown_fields)]
427pub struct IlluminationCalibration {
428    /// Explicit physical and multiplicative parameter selection.
429    pub parameters: PlanarArrayCalibrationParameters,
430    /// Narrow deterministic bounded optimizer settings.
431    pub optimizer: BoundedFiniteDifferenceOptimizer,
432    /// Canonical measurement-domain data loss; amplitude MSE is the default.
433    pub loss_type: LossType,
434}
435
436impl IlluminationCalibration {
437    /// Creates a calibrator using bounded finite differences and amplitude MSE.
438    pub fn new(parameters: PlanarArrayCalibrationParameters) -> Self {
439        Self {
440            parameters,
441            optimizer: BoundedFiniteDifferenceOptimizer::default(),
442            loss_type: LossType::AmplitudeMse,
443        }
444    }
445
446    /// Replaces bounded optimizer settings.
447    pub fn optimizer(mut self, optimizer: BoundedFiniteDifferenceOptimizer) -> Self {
448        self.optimizer = optimizer;
449        self
450    }
451
452    /// Replaces the reused reconstruction measurement-domain loss.
453    pub fn loss_type(mut self, loss_type: LossType) -> Self {
454        self.loss_type = loss_type;
455        self
456    }
457
458    /// Validates configuration against an initial illumination.
459    pub fn validate_for(&self, illumination: &Illumination) -> Result<()> {
460        self.optimizer.validate()?;
461        self.parameters.validate_for(illumination)?;
462        if !self.parameters.has_active_parameters() {
463            return Err(invalid(
464                "parameters",
465                "at least one calibration parameter must be active",
466            ));
467        }
468        Ok(())
469    }
470
471    /// Creates checkpointable absolute and normalized state for `illumination`.
472    pub fn initialize(
473        &self,
474        illumination: &Illumination,
475        optics: &Optics,
476    ) -> Result<IlluminationCalibrationState> {
477        self.validate_for(illumination)?;
478        let mut values = PlanarArrayParameterValues::from_illumination(illumination)?;
479        apply_gauge_constraints(&self.parameters, &mut values)?;
480        validate_values(&self.parameters, &values)?;
481        let current_illumination = values.to_illumination(illumination)?;
482        validate_physical_sources(&current_illumination, optics)?;
483        let active = active_parameters(&self.parameters, &values);
484        let names = active.iter().map(|value| value.name()).collect();
485        let normalized_variables = vec![0.0; active.len()];
486        let constraints = applied_constraints(&self.parameters);
487        let mut initial_conditioning = CalibrationConditioning::default();
488        if self.parameters.translation[2].is_some()
489            && self.parameters.pitch.iter().any(Option::is_some)
490        {
491            initial_conditioning.warnings.push(
492                "pitch and axial translation are jointly active and may be weakly identifiable"
493                    .into(),
494            );
495        }
496        Ok(IlluminationCalibrationState {
497            initial_illumination: current_illumination.clone(),
498            current_illumination,
499            initial_parameters: values.clone(),
500            current_parameters: values,
501            parameter_names: names,
502            normalized_variables,
503            applied_constraints: constraints,
504            parameter_history: Vec::new(),
505            loss_history: Vec::new(),
506            convergence_reason: None,
507            conditioning: initial_conditioning,
508            geometry_recompilations: 0,
509            multiplicative_updates: 0,
510            rejected_steps: 0,
511        })
512    }
513
514    /// Calibrates physical illumination for a fixed reconstructed object and pupil.
515    ///
516    /// `model` must have been compiled from `initial_illumination`; it is refreshed in
517    /// place and can be reused immediately. `outer_steps` repeats the configured bounded
518    /// phase and must be positive. Joint object/illumination reconstruction is provided by
519    /// [`crate::algorithms::JointReconstruction`].
520    #[allow(clippy::too_many_arguments)]
521    pub fn calibrate<M: MeasurementRead>(
522        &self,
523        measurements: &M,
524        optics: &Optics,
525        initial_illumination: &Illumination,
526        object_spectrum: ArrayView2<'_, Complex64>,
527        pupil: &Pupil,
528        model: &mut ImagePlaneModel,
529        outer_steps: usize,
530    ) -> Result<IlluminationCalibrationState> {
531        if outer_steps == 0 {
532            return Err(invalid("outer_steps", "must be greater than zero"));
533        }
534        let expected = ImagePlaneModel::from_experiment(
535            optics,
536            initial_illumination,
537            model.image_shape(),
538            crate::model::ReconstructionShape::Exact(model.reconstruction_shape()),
539        )?;
540        if expected.k_vectors() != model.k_vectors()
541            || expected.source_count() != model.source_count()
542            || expected.frame_count() != model.frame_count()
543        {
544            return Err(Error::InvalidModel(
545                "calibration model was not compiled from the supplied initial illumination".into(),
546            ));
547        }
548        let mut state = self.initialize(initial_illumination, optics)?;
549        self.synchronize_model(optics, model, initial_illumination, &state)?;
550        for outer_iteration in 1..=outer_steps {
551            self.optimize(
552                measurements,
553                optics,
554                object_spectrum,
555                pupil,
556                model,
557                &mut state,
558                outer_iteration,
559            )?;
560        }
561        Ok(state)
562    }
563
564    pub(crate) fn synchronize_model(
565        &self,
566        optics: &Optics,
567        model: &mut ImagePlaneModel,
568        original_illumination: &Illumination,
569        state: &IlluminationCalibrationState,
570    ) -> Result<()> {
571        let original = PlanarArrayParameterValues::from_illumination(original_illumination)?;
572        if geometry_values_equal(&original, &state.current_parameters) {
573            model.update_intensity_calibration(
574                &state.current_parameters.relative_source_power,
575                state.current_illumination.acquisition(),
576            )
577        } else {
578            model.update_illumination_geometry(optics, &state.current_illumination)
579        }
580    }
581
582    #[allow(
583        clippy::too_many_arguments,
584        reason = "the calibration step deliberately receives each canonical model input explicitly"
585    )]
586    pub(crate) fn optimize<M: MeasurementRead>(
587        &self,
588        measurements: &M,
589        optics: &Optics,
590        object_spectrum: ArrayView2<'_, Complex64>,
591        pupil: &Pupil,
592        model: &mut ImagePlaneModel,
593        state: &mut IlluminationCalibrationState,
594        outer_iteration: usize,
595    ) -> Result<CalibrationObjective> {
596        self.validate_for(&state.initial_illumination)?;
597        state.validate()?;
598        let active = active_parameters(&self.parameters, &state.current_parameters);
599        if active.is_empty() {
600            return Err(invalid("parameters", "no active parameters remain"));
601        }
602        let active_names: Vec<_> = active.iter().map(ActiveParameter::name).collect();
603        if state.parameter_names != active_names {
604            return Err(Error::InvalidModel(
605                "checkpoint physical parameter selection differs from the joint algorithm".into(),
606            ));
607        }
608        let mut current = calibration_objective(
609            measurements,
610            object_spectrum,
611            pupil,
612            model,
613            &self.parameters,
614            &state.initial_parameters,
615            &state.current_parameters,
616            &active,
617            self.loss_type,
618        )?;
619        let mut latest_sensitivities = vec![0.0; active.len()];
620        let mut latest_curvatures = vec![0.0; active.len()];
621        let mut final_reason = CalibrationConvergenceReason::MaximumSteps;
622        let mut update_counters = UpdateCounters::default();
623
624        for optimizer_step in 0..self.optimizer.max_steps {
625            let mut scaled_gradient = vec![0.0; active.len()];
626            for (parameter_index, parameter) in active.iter().enumerate() {
627                let finite_difference = finite_difference_parameter(
628                    measurements,
629                    optics,
630                    object_spectrum,
631                    pupil,
632                    model,
633                    state,
634                    &self.parameters,
635                    &active,
636                    parameter_index,
637                    current.total_loss,
638                    self.loss_type,
639                    &mut update_counters,
640                )?;
641                scaled_gradient[parameter_index] =
642                    finite_difference.gradient * parameter.spec.scale;
643                latest_sensitivities[parameter_index] = scaled_gradient[parameter_index].abs();
644                latest_curvatures[parameter_index] =
645                    finite_difference.curvature * parameter.spec.scale * parameter.spec.scale;
646            }
647            let gradient_norm = scaled_gradient
648                .iter()
649                .map(|value| value * value)
650                .sum::<f64>()
651                .sqrt();
652            if !gradient_norm.is_finite() {
653                return Err(Error::Numerical(
654                    "physical calibration gradient is non-finite".into(),
655                ));
656            }
657            if gradient_norm <= f64::EPSILON {
658                final_reason = CalibrationConvergenceReason::NegligibleGradient;
659                break;
660            }
661
662            let mut step_size = self.optimizer.initial_step_size;
663            let mut accepted = None;
664            while step_size >= self.optimizer.minimum_step_size {
665                let mut candidate = state.current_parameters.clone();
666                for (parameter, gradient) in active.iter().zip(&scaled_gradient) {
667                    let delta = -step_size * parameter.spec.scale * gradient / gradient_norm;
668                    let value = (parameter.value(&candidate) + delta)
669                        .clamp(parameter.spec.lower_bound, parameter.spec.upper_bound);
670                    parameter.set(&mut candidate, value);
671                }
672                apply_gauge_constraints(&self.parameters, &mut candidate)?;
673                if candidate == state.current_parameters {
674                    step_size *= self.optimizer.step_reduction;
675                    continue;
676                }
677                if let Ok((candidate_model, candidate_illumination, candidate_objective)) =
678                    evaluate_candidate(
679                        measurements,
680                        optics,
681                        object_spectrum,
682                        pupil,
683                        model,
684                        &state.initial_illumination,
685                        &self.parameters,
686                        &state.current_parameters,
687                        &state.initial_parameters,
688                        &candidate,
689                        &active,
690                        self.loss_type,
691                        &mut update_counters,
692                    )
693                    && candidate_objective.total_loss < current.total_loss
694                {
695                    accepted = Some((
696                        candidate,
697                        candidate_model,
698                        candidate_illumination,
699                        candidate_objective,
700                    ));
701                    break;
702                }
703                step_size *= self.optimizer.step_reduction;
704            }
705
706            if let Some((candidate, candidate_model, illumination, objective)) = accepted {
707                let previous_loss = current.total_loss;
708                state.current_parameters = candidate;
709                state.current_illumination = illumination;
710                *model = candidate_model;
711                current = objective;
712                state
713                    .parameter_history
714                    .push(CalibrationParameterHistoryEntry {
715                        outer_iteration,
716                        optimizer_step: optimizer_step + 1,
717                        accepted: true,
718                        step_size,
719                        normalized_values: normalized_values(
720                            &active,
721                            &state.initial_parameters,
722                            &state.current_parameters,
723                        ),
724                    });
725                state.loss_history.push(CalibrationLossHistoryEntry {
726                    outer_iteration,
727                    optimizer_step: optimizer_step + 1,
728                    total_loss: current.total_loss,
729                    data_loss: current.data_loss,
730                    regularization_loss: current.regularization_loss,
731                    accepted: true,
732                });
733                let improvement =
734                    (previous_loss - current.total_loss) / previous_loss.abs().max(f64::EPSILON);
735                if improvement <= self.optimizer.relative_tolerance {
736                    final_reason = CalibrationConvergenceReason::RelativeTolerance;
737                    break;
738                }
739            } else {
740                state.rejected_steps += 1;
741                state
742                    .parameter_history
743                    .push(CalibrationParameterHistoryEntry {
744                        outer_iteration,
745                        optimizer_step: optimizer_step + 1,
746                        accepted: false,
747                        step_size: 0.0,
748                        normalized_values: normalized_values(
749                            &active,
750                            &state.initial_parameters,
751                            &state.current_parameters,
752                        ),
753                    });
754                state.loss_history.push(CalibrationLossHistoryEntry {
755                    outer_iteration,
756                    optimizer_step: optimizer_step + 1,
757                    total_loss: current.total_loss,
758                    data_loss: current.data_loss,
759                    regularization_loss: current.regularization_loss,
760                    accepted: false,
761                });
762                final_reason = CalibrationConvergenceReason::LineSearchFailed;
763                break;
764            }
765        }
766        state.normalized_variables = normalized_values(
767            &active,
768            &state.initial_parameters,
769            &state.current_parameters,
770        );
771        state.geometry_recompilations += update_counters.geometry;
772        state.multiplicative_updates += update_counters.multiplicative;
773        state.conditioning = conditioning(
774            &active,
775            &state.current_parameters,
776            &latest_sensitivities,
777            &latest_curvatures,
778            &self.parameters,
779            state.rejected_steps,
780        );
781        state.convergence_reason = Some(final_reason);
782        Ok(current)
783    }
784}
785
786/// Absolute physical and multiplicative parameter values in canonical units.
787#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
788#[serde(deny_unknown_fields)]
789pub struct PlanarArrayParameterValues {
790    /// Array-pose translation `(tx, ty, tz)` in metres.
791    pub translation_m: [f64; 3],
792    /// Active-extrinsic fixed-axis `(rx, ry, rz)` rotation in radians.
793    pub rotation_rad: [f64; 3],
794    /// Lattice `(pitch_x, pitch_y)` in metres.
795    pub pitch_m: [f64; 2],
796    /// Fractional `(reference_column, reference_row)` coordinate.
797    pub reference_index: [f64; 2],
798    /// Full row-major source-offset array in local XYZ metres.
799    pub position_offsets_m: Vec<[f64; 3]>,
800    /// Stable dimensionless relative source powers in source order.
801    pub relative_source_power: Vec<f64>,
802    /// Dimensionless frame gains in acquisition order.
803    pub frame_gains: Vec<f64>,
804}
805
806impl PlanarArrayParameterValues {
807    /// Extracts explicit absolute values, expanding implicit unit powers and zero offsets.
808    pub fn from_illumination(illumination: &Illumination) -> Result<Self> {
809        let geometry = planar_geometry(illumination)?;
810        geometry.validate()?;
811        let source_count = geometry.source_count();
812        let mut offsets = geometry.position_offsets_m().to_vec();
813        if offsets.is_empty() {
814            offsets.resize(source_count, [0.0; 3]);
815        }
816        let relative_source_power = illumination
817            .calibration()
818            .relative_power()
819            .map_or_else(|| vec![1.0; source_count], <[f64]>::to_vec);
820        let frame_gains = illumination
821            .acquisition()
822            .frames()
823            .iter()
824            .map(|frame| frame.gain)
825            .collect();
826        Ok(Self {
827            translation_m: geometry.pose().translation_m,
828            rotation_rad: geometry.pose().rotation_rad,
829            pitch_m: [geometry.pitch_m().0, geometry.pitch_m().1],
830            reference_index: [geometry.reference_index().0, geometry.reference_index().1],
831            position_offsets_m: offsets,
832            relative_source_power,
833            frame_gains,
834        })
835    }
836
837    /// Applies these values to the topology and sparse weights of `template`.
838    pub fn to_illumination(&self, template: &Illumination) -> Result<Illumination> {
839        let geometry = planar_geometry(template)?;
840        let pose = ArrayPose::from_translation_and_extrinsic_xyz_radians(
841            self.translation_m,
842            self.rotation_rad,
843        );
844        let calibrated_geometry = PlanarLedArray::new(
845            geometry.shape(),
846            (self.pitch_m[0], self.pitch_m[1]),
847            (self.reference_index[0], self.reference_index[1]),
848            pose,
849        )
850        .with_position_offsets_m(self.position_offsets_m.clone());
851        let frames = template
852            .acquisition()
853            .frames()
854            .iter()
855            .zip(&self.frame_gains)
856            .map(|(frame, &gain)| IlluminationFrame::new(frame.contributions.clone(), gain))
857            .collect();
858        let acquisition = AcquisitionPlan::from_sparse(frames)?;
859        Ok(Illumination::new(
860            calibrated_geometry.into(),
861            SourceCalibration::new(Some(self.relative_source_power.clone())),
862            acquisition,
863        ))
864    }
865}
866
867/// Scalar decomposition of one physical-calibration objective evaluation.
868#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
869#[serde(deny_unknown_fields)]
870pub struct CalibrationObjective {
871    /// Weighted measurement-domain loss.
872    pub data_loss: f64,
873    /// Sum of configured dimensionless quadratic priors.
874    pub regularization_loss: f64,
875    /// `data_loss + regularization_loss`.
876    pub total_loss: f64,
877}
878
879/// Why the most recent illumination-update phase stopped.
880#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(rename_all = "snake_case")]
882pub enum CalibrationConvergenceReason {
883    /// Configured optimizer-step limit was reached.
884    MaximumSteps,
885    /// Relative objective improvement met the configured tolerance.
886    RelativeTolerance,
887    /// Every scaled finite-difference sensitivity was negligible.
888    NegligibleGradient,
889    /// No bounded line-search candidate improved the objective.
890    LineSearchFailed,
891}
892
893/// Practical finite-difference conditioning indicators, not statistical uncertainty.
894#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct CalibrationConditioning {
897    /// Active parameter names in the same order as sensitivity arrays.
898    pub parameter_names: Vec<String>,
899    /// Absolute derivatives with respect to normalized optimizer variables.
900    pub scaled_sensitivities: Vec<f64>,
901    /// Approximate diagonal curvature in normalized variables.
902    pub scaled_diagonal_curvature: Vec<f64>,
903    /// Ratio of largest to smallest useful positive diagonal curvature, if defined.
904    pub diagonal_condition_estimate: Option<f64>,
905    /// Parameters within a numerical tolerance of either configured bound.
906    pub parameters_at_bounds: Vec<String>,
907    /// Number of trial steps rejected over the complete joint run.
908    pub rejected_steps: usize,
909    /// Human-readable weak-identifiability or negligible-influence warnings.
910    pub warnings: Vec<String>,
911}
912
913/// One accepted or rejected bounded optimizer trial.
914#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct CalibrationParameterHistoryEntry {
917    /// One-based joint outer iteration.
918    pub outer_iteration: usize,
919    /// One-based optimizer step inside the illumination phase.
920    pub optimizer_step: usize,
921    /// Whether the bounded trial decreased the complete objective.
922    pub accepted: bool,
923    /// Accepted dimensionless line-search length, or zero for rejection.
924    pub step_size: f64,
925    /// Normalized absolute parameter values in [`IlluminationCalibrationState::parameter_names`] order.
926    pub normalized_values: Vec<f64>,
927}
928
929/// Objective history for one physical illumination trial.
930#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
931#[serde(deny_unknown_fields)]
932pub struct CalibrationLossHistoryEntry {
933    /// One-based joint outer iteration.
934    pub outer_iteration: usize,
935    /// One-based optimizer step inside the illumination phase.
936    pub optimizer_step: usize,
937    /// Complete data-plus-regularization objective.
938    pub total_loss: f64,
939    /// Measurement-domain component.
940    pub data_loss: f64,
941    /// Quadratic-prior component.
942    pub regularization_loss: f64,
943    /// Whether the associated bounded trial was accepted.
944    pub accepted: bool,
945}
946
947/// Checkpointable physical calibration values, histories, constraints, and diagnostics.
948#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct IlluminationCalibrationState {
951    /// Gauge-normalized illumination at calibration initialization.
952    pub initial_illumination: Illumination,
953    /// Current normal reusable calibrated illumination.
954    pub current_illumination: Illumination,
955    /// Immutable initial absolute parameter values.
956    pub initial_parameters: PlanarArrayParameterValues,
957    /// Current absolute parameter values.
958    pub current_parameters: PlanarArrayParameterValues,
959    /// Stable active parameter names, including source and frame indices.
960    pub parameter_names: Vec<String>,
961    /// Current `(value - initial) / scale` values in parameter-name order.
962    pub normalized_variables: Vec<f64>,
963    /// Gauge and bound constraints applied to the parameterization.
964    pub applied_constraints: Vec<String>,
965    /// Accepted and rejected normalized parameter trials.
966    pub parameter_history: Vec<CalibrationParameterHistoryEntry>,
967    /// Data, regularization, and total loss history.
968    pub loss_history: Vec<CalibrationLossHistoryEntry>,
969    /// Stop reason for the most recent illumination phase.
970    pub convergence_reason: Option<CalibrationConvergenceReason>,
971    /// Practical finite-difference conditioning diagnostics.
972    pub conditioning: CalibrationConditioning,
973    /// Geometry trial evaluations that recompiled positions, vectors, and crops.
974    pub geometry_recompilations: usize,
975    /// Source-power or frame-gain trial evaluations that retained geometry and crops.
976    pub multiplicative_updates: usize,
977    /// Total bounded line-search failures or rejected optimizer steps.
978    pub rejected_steps: usize,
979}
980
981impl IlluminationCalibrationState {
982    /// Validates serialized physical values, illumination consistency, and history shapes.
983    pub fn validate(&self) -> Result<()> {
984        let initial_geometry = planar_geometry(&self.initial_illumination)?;
985        let current_geometry = planar_geometry(&self.current_illumination)?;
986        initial_geometry.validate()?;
987        current_geometry.validate()?;
988        if initial_geometry.shape() != current_geometry.shape()
989            || self.initial_illumination.acquisition().frame_count()
990                != self.current_illumination.acquisition().frame_count()
991        {
992            return Err(Error::InvalidModel(
993                "physical calibration changed source or acquisition topology".into(),
994            ));
995        }
996        let source_count = current_geometry.source_count();
997        let frame_count = self.current_illumination.acquisition().frame_count();
998        for (name, values) in [
999            (
1000                "initial relative source power",
1001                self.initial_parameters.relative_source_power.as_slice(),
1002            ),
1003            (
1004                "current relative source power",
1005                self.current_parameters.relative_source_power.as_slice(),
1006            ),
1007        ] {
1008            if values.len() != source_count
1009                || values
1010                    .iter()
1011                    .any(|value| !value.is_finite() || *value < 0.0)
1012            {
1013                return Err(Error::InvalidModel(format!(
1014                    "{name} must contain {source_count} finite non-negative values"
1015                )));
1016            }
1017        }
1018        for (name, values) in [
1019            (
1020                "initial frame gains",
1021                self.initial_parameters.frame_gains.as_slice(),
1022            ),
1023            (
1024                "current frame gains",
1025                self.current_parameters.frame_gains.as_slice(),
1026            ),
1027        ] {
1028            if values.len() != frame_count
1029                || values
1030                    .iter()
1031                    .any(|value| !value.is_finite() || *value < 0.0)
1032            {
1033                return Err(Error::InvalidModel(format!(
1034                    "{name} must contain {frame_count} finite non-negative values"
1035                )));
1036            }
1037        }
1038        if [
1039            &self.initial_parameters.position_offsets_m,
1040            &self.current_parameters.position_offsets_m,
1041        ]
1042        .into_iter()
1043        .any(|values| {
1044            values.len() != source_count || values.iter().flatten().any(|value| !value.is_finite())
1045        }) {
1046            return Err(Error::InvalidModel(format!(
1047                "physical position offsets must contain {source_count} finite XYZ values"
1048            )));
1049        }
1050        let all_absolute_values = |values: &PlanarArrayParameterValues| {
1051            values
1052                .translation_m
1053                .iter()
1054                .chain(&values.rotation_rad)
1055                .chain(&values.pitch_m)
1056                .chain(&values.reference_index)
1057                .all(|value| value.is_finite())
1058                && values.pitch_m.iter().all(|value| *value > 0.0)
1059        };
1060        if !all_absolute_values(&self.initial_parameters)
1061            || !all_absolute_values(&self.current_parameters)
1062        {
1063            return Err(Error::InvalidModel(
1064                "physical calibration parameters must be finite with positive pitch".into(),
1065            ));
1066        }
1067        if PlanarArrayParameterValues::from_illumination(&self.initial_illumination)?
1068            != self.initial_parameters
1069            || PlanarArrayParameterValues::from_illumination(&self.current_illumination)?
1070                != self.current_parameters
1071        {
1072            return Err(Error::InvalidModel(
1073                "physical calibration illumination and absolute values disagree".into(),
1074            ));
1075        }
1076        if self.parameter_names.len() != self.normalized_variables.len()
1077            || self
1078                .normalized_variables
1079                .iter()
1080                .any(|value| !value.is_finite())
1081        {
1082            return Err(Error::InvalidModel(
1083                "physical calibration normalized variables do not match parameter names".into(),
1084            ));
1085        }
1086        if self.parameter_history.iter().any(|entry| {
1087            entry.outer_iteration == 0
1088                || entry.optimizer_step == 0
1089                || !entry.step_size.is_finite()
1090                || entry.step_size < 0.0
1091                || entry.normalized_values.len() != self.parameter_names.len()
1092                || entry
1093                    .normalized_values
1094                    .iter()
1095                    .any(|value| !value.is_finite())
1096        }) {
1097            return Err(Error::InvalidModel(
1098                "physical calibration parameter history is malformed".into(),
1099            ));
1100        }
1101        if self.loss_history.iter().any(|entry| {
1102            entry.outer_iteration == 0
1103                || entry.optimizer_step == 0
1104                || [entry.total_loss, entry.data_loss, entry.regularization_loss]
1105                    .iter()
1106                    .any(|value| !value.is_finite())
1107        }) || self.loss_history.len() != self.parameter_history.len()
1108        {
1109            return Err(Error::InvalidModel(
1110                "physical calibration loss history is malformed".into(),
1111            ));
1112        }
1113        let conditioning_len = self.conditioning.parameter_names.len();
1114        if self.conditioning.scaled_sensitivities.len() != conditioning_len
1115            || self.conditioning.scaled_diagonal_curvature.len() != conditioning_len
1116            || self
1117                .conditioning
1118                .scaled_sensitivities
1119                .iter()
1120                .chain(&self.conditioning.scaled_diagonal_curvature)
1121                .any(|value| !value.is_finite())
1122            || self
1123                .conditioning
1124                .diagonal_condition_estimate
1125                .is_some_and(|value| !value.is_finite() || value < 0.0)
1126            || (conditioning_len != 0 && self.conditioning.parameter_names != self.parameter_names)
1127        {
1128            return Err(Error::InvalidModel(
1129                "physical calibration conditioning diagnostics are malformed".into(),
1130            ));
1131        }
1132        Ok(())
1133    }
1134}
1135
1136#[derive(Clone, Debug)]
1137struct ActiveParameter {
1138    kind: ParameterKind,
1139    spec: CalibrationParameterSpec,
1140}
1141
1142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1143enum ParameterKind {
1144    Translation(usize),
1145    Rotation(usize),
1146    Pitch(usize),
1147    Reference(usize),
1148    Offset(usize, usize),
1149    SourcePower(usize),
1150    FrameGain(usize),
1151}
1152
1153impl ActiveParameter {
1154    fn name(&self) -> String {
1155        match self.kind {
1156            ParameterKind::Translation(axis) => ["tx_m", "ty_m", "tz_m"][axis].into(),
1157            ParameterKind::Rotation(axis) => ["rx_rad", "ry_rad", "rz_rad"][axis].into(),
1158            ParameterKind::Pitch(axis) => ["pitch_x_m", "pitch_y_m"][axis].into(),
1159            ParameterKind::Reference(axis) => ["reference_column", "reference_row"][axis].into(),
1160            ParameterKind::Offset(source, axis) => {
1161                format!("position_offset_{}_m[{source}]", ["x", "y", "z"][axis])
1162            }
1163            ParameterKind::SourcePower(source) => format!("relative_source_power[{source}]"),
1164            ParameterKind::FrameGain(frame) => format!("frame_gain[{frame}]"),
1165        }
1166    }
1167
1168    fn value(&self, values: &PlanarArrayParameterValues) -> f64 {
1169        match self.kind {
1170            ParameterKind::Translation(axis) => values.translation_m[axis],
1171            ParameterKind::Rotation(axis) => values.rotation_rad[axis],
1172            ParameterKind::Pitch(axis) => values.pitch_m[axis],
1173            ParameterKind::Reference(axis) => values.reference_index[axis],
1174            ParameterKind::Offset(source, axis) => values.position_offsets_m[source][axis],
1175            ParameterKind::SourcePower(source) => values.relative_source_power[source],
1176            ParameterKind::FrameGain(frame) => values.frame_gains[frame],
1177        }
1178    }
1179
1180    fn set(&self, values: &mut PlanarArrayParameterValues, value: f64) {
1181        match self.kind {
1182            ParameterKind::Translation(axis) => values.translation_m[axis] = value,
1183            ParameterKind::Rotation(axis) => values.rotation_rad[axis] = value,
1184            ParameterKind::Pitch(axis) => values.pitch_m[axis] = value,
1185            ParameterKind::Reference(axis) => values.reference_index[axis] = value,
1186            ParameterKind::Offset(source, axis) => values.position_offsets_m[source][axis] = value,
1187            ParameterKind::SourcePower(source) => values.relative_source_power[source] = value,
1188            ParameterKind::FrameGain(frame) => values.frame_gains[frame] = value,
1189        }
1190    }
1191
1192    fn update_class(&self) -> UpdateClass {
1193        match self.kind {
1194            ParameterKind::SourcePower(_) | ParameterKind::FrameGain(_) => {
1195                UpdateClass::Multiplicative
1196            }
1197            _ => UpdateClass::Geometry,
1198        }
1199    }
1200}
1201
1202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1203enum UpdateClass {
1204    Geometry,
1205    Multiplicative,
1206}
1207
1208#[derive(Clone, Copy, Debug, Default)]
1209struct FiniteDifference {
1210    gradient: f64,
1211    curvature: f64,
1212}
1213
1214#[derive(Clone, Copy, Debug, Default)]
1215struct UpdateCounters {
1216    geometry: usize,
1217    multiplicative: usize,
1218}
1219
1220impl UpdateCounters {
1221    fn record(&mut self, update: UpdateClass) {
1222        match update {
1223            UpdateClass::Geometry => self.geometry += 1,
1224            UpdateClass::Multiplicative => self.multiplicative += 1,
1225        }
1226    }
1227}
1228
1229#[allow(clippy::too_many_arguments)]
1230fn finite_difference_parameter<M: MeasurementRead>(
1231    measurements: &M,
1232    optics: &Optics,
1233    object_spectrum: ArrayView2<'_, Complex64>,
1234    pupil: &Pupil,
1235    model: &ImagePlaneModel,
1236    state: &IlluminationCalibrationState,
1237    parameters: &PlanarArrayCalibrationParameters,
1238    active: &[ActiveParameter],
1239    parameter_index: usize,
1240    center_loss: f64,
1241    loss_type: LossType,
1242    update_counters: &mut UpdateCounters,
1243) -> Result<FiniteDifference> {
1244    let parameter = &active[parameter_index];
1245    let center_value = parameter.value(&state.current_parameters);
1246    let requested = parameter.spec.finite_difference_step;
1247    let plus_value = (center_value + requested).min(parameter.spec.upper_bound);
1248    let minus_value = (center_value - requested).max(parameter.spec.lower_bound);
1249    let mut evaluate = |value: f64| -> Option<(f64, f64)> {
1250        if value == center_value {
1251            return None;
1252        }
1253        let mut candidate = state.current_parameters.clone();
1254        parameter.set(&mut candidate, value);
1255        apply_gauge_constraints(parameters, &mut candidate).ok()?;
1256        let actual = parameter.value(&candidate);
1257        let (_, _, objective) = evaluate_candidate(
1258            measurements,
1259            optics,
1260            object_spectrum,
1261            pupil,
1262            model,
1263            &state.initial_illumination,
1264            parameters,
1265            &state.current_parameters,
1266            &state.initial_parameters,
1267            &candidate,
1268            active,
1269            loss_type,
1270            update_counters,
1271        )
1272        .ok()?;
1273        Some((actual - center_value, objective.total_loss))
1274    };
1275    let plus = evaluate(plus_value);
1276    let minus = evaluate(minus_value);
1277    match (plus, minus) {
1278        (Some((hp, fp)), Some((negative_hm, fm))) if hp > 0.0 && negative_hm < 0.0 => {
1279            let hm = -negative_hm;
1280            let denominator = hp * hm * (hp + hm);
1281            Ok(FiniteDifference {
1282                gradient: (hm * hm * (fp - center_loss) + hp * hp * (center_loss - fm))
1283                    / denominator,
1284                curvature: 2.0 * (hm * fp - (hp + hm) * center_loss + hp * fm) / denominator,
1285            })
1286        }
1287        (Some((h, value)), _) if h != 0.0 => Ok(FiniteDifference {
1288            gradient: (value - center_loss) / h,
1289            curvature: 0.0,
1290        }),
1291        (_, Some((h, value))) if h != 0.0 => Ok(FiniteDifference {
1292            gradient: (value - center_loss) / h,
1293            curvature: 0.0,
1294        }),
1295        _ => Ok(FiniteDifference::default()),
1296    }
1297}
1298
1299#[allow(clippy::too_many_arguments)]
1300fn evaluate_candidate<M: MeasurementRead>(
1301    measurements: &M,
1302    optics: &Optics,
1303    object_spectrum: ArrayView2<'_, Complex64>,
1304    pupil: &Pupil,
1305    base_model: &ImagePlaneModel,
1306    template: &Illumination,
1307    parameters: &PlanarArrayCalibrationParameters,
1308    base_values: &PlanarArrayParameterValues,
1309    initial_values: &PlanarArrayParameterValues,
1310    candidate_values: &PlanarArrayParameterValues,
1311    active: &[ActiveParameter],
1312    loss_type: LossType,
1313    update_counters: &mut UpdateCounters,
1314) -> Result<(ImagePlaneModel, Illumination, CalibrationObjective)> {
1315    validate_values(parameters, candidate_values)?;
1316    let illumination = candidate_values.to_illumination(template)?;
1317    validate_physical_sources(&illumination, optics)?;
1318    let mut model = base_model.clone();
1319    let update = changed_update_class(active, base_values, candidate_values);
1320    update_counters.record(update);
1321    match update {
1322        UpdateClass::Geometry => model.update_illumination_geometry(optics, &illumination)?,
1323        UpdateClass::Multiplicative => model.update_intensity_calibration(
1324            &candidate_values.relative_source_power,
1325            illumination.acquisition(),
1326        )?,
1327    }
1328    let objective = calibration_objective(
1329        measurements,
1330        object_spectrum,
1331        pupil,
1332        &model,
1333        parameters,
1334        initial_values,
1335        candidate_values,
1336        active,
1337        loss_type,
1338    )?;
1339    Ok((model, illumination, objective))
1340}
1341
1342fn changed_update_class(
1343    active: &[ActiveParameter],
1344    previous: &PlanarArrayParameterValues,
1345    candidate: &PlanarArrayParameterValues,
1346) -> UpdateClass {
1347    if active.iter().any(|parameter| {
1348        parameter.update_class() == UpdateClass::Geometry
1349            && parameter.value(previous) != parameter.value(candidate)
1350    }) {
1351        UpdateClass::Geometry
1352    } else {
1353        UpdateClass::Multiplicative
1354    }
1355}
1356
1357fn geometry_values_equal(
1358    left: &PlanarArrayParameterValues,
1359    right: &PlanarArrayParameterValues,
1360) -> bool {
1361    left.translation_m == right.translation_m
1362        && left.rotation_rad == right.rotation_rad
1363        && left.pitch_m == right.pitch_m
1364        && left.reference_index == right.reference_index
1365        && left.position_offsets_m == right.position_offsets_m
1366}
1367
1368#[allow(clippy::too_many_arguments)]
1369fn calibration_objective<M: MeasurementRead>(
1370    measurements: &M,
1371    object_spectrum: ArrayView2<'_, Complex64>,
1372    pupil: &Pupil,
1373    model: &ImagePlaneModel,
1374    parameters: &PlanarArrayCalibrationParameters,
1375    initial_values: &PlanarArrayParameterValues,
1376    values: &PlanarArrayParameterValues,
1377    active: &[ActiveParameter],
1378    loss_type: LossType,
1379) -> Result<CalibrationObjective> {
1380    let forward = ForwardModel::new(model)?;
1381    let mut workspace = forward.workspace()?;
1382    let mut predicted = vec![0.0; measurements.frame_len()];
1383    let mut objective_sum = 0.0;
1384    let mut weight_sum = 0.0;
1385    for frame in 0..measurements.frame_count() {
1386        let weight = measurements.frame_weight(frame)?;
1387        if weight == 0.0 {
1388            continue;
1389        }
1390        forward.forward_intensity_into(
1391            object_spectrum,
1392            pupil,
1393            frame,
1394            &mut workspace,
1395            &mut predicted,
1396        )?;
1397        let measured = measurements.frame(frame)?;
1398        let mask = measurements.frame_mask(frame)?;
1399        let mut loss_sum = 0.0;
1400        let mut valid = 0;
1401        for pixel in 0..predicted.len() {
1402            if mask.is_none_or(|values| values[pixel] != 0) {
1403                loss_sum += point_loss(predicted[pixel], measured[pixel], loss_type);
1404                valid += 1;
1405            }
1406        }
1407        if valid == 0 {
1408            return Err(Error::InvalidMeasurements(format!(
1409                "frame {frame} has no unmasked pixels"
1410            )));
1411        }
1412        objective_sum += weight * loss_sum / valid as f64;
1413        weight_sum += weight;
1414    }
1415    if weight_sum == 0.0 {
1416        return Err(Error::InvalidMeasurements(
1417            "physical calibration has no positive-weight frames".into(),
1418        ));
1419    }
1420    let data_loss = objective_sum / weight_sum;
1421    let regularization_loss = regularization(parameters, initial_values, values, active);
1422    Ok(CalibrationObjective {
1423        data_loss,
1424        regularization_loss,
1425        total_loss: data_loss + regularization_loss,
1426    })
1427}
1428
1429fn regularization(
1430    _parameters: &PlanarArrayCalibrationParameters,
1431    initial: &PlanarArrayParameterValues,
1432    values: &PlanarArrayParameterValues,
1433    active: &[ActiveParameter],
1434) -> f64 {
1435    active
1436        .iter()
1437        .map(|parameter| {
1438            let center = parameter
1439                .spec
1440                .prior_center
1441                .unwrap_or_else(|| parameter.value(initial));
1442            let residual = (parameter.value(values) - center) / parameter.spec.scale;
1443            0.5 * parameter.spec.regularization_strength * residual * residual
1444        })
1445        .sum()
1446}
1447
1448fn active_parameters(
1449    parameters: &PlanarArrayCalibrationParameters,
1450    values: &PlanarArrayParameterValues,
1451) -> Vec<ActiveParameter> {
1452    let mut active = Vec::new();
1453    for axis in 0..3 {
1454        if let Some(spec) = &parameters.translation[axis] {
1455            active.push(ActiveParameter {
1456                kind: ParameterKind::Translation(axis),
1457                spec: spec.clone(),
1458            });
1459        }
1460    }
1461    for axis in 0..3 {
1462        if let Some(spec) = &parameters.rotation[axis] {
1463            active.push(ActiveParameter {
1464                kind: ParameterKind::Rotation(axis),
1465                spec: spec.clone(),
1466            });
1467        }
1468    }
1469    for axis in 0..2 {
1470        if let Some(spec) = &parameters.pitch[axis] {
1471            active.push(ActiveParameter {
1472                kind: ParameterKind::Pitch(axis),
1473                spec: spec.clone(),
1474            });
1475        }
1476    }
1477    for axis in 0..2 {
1478        if let Some(spec) = &parameters.reference_index[axis] {
1479            active.push(ActiveParameter {
1480                kind: ParameterKind::Reference(axis),
1481                spec: spec.clone(),
1482            });
1483        }
1484    }
1485    for (&source, specs) in &parameters.position_offsets {
1486        for (axis, spec) in specs.iter().enumerate() {
1487            active.push(ActiveParameter {
1488                kind: ParameterKind::Offset(source, axis),
1489                spec: spec.clone(),
1490            });
1491        }
1492    }
1493    if let Some(spec) = &parameters.relative_source_power {
1494        for source in 0..values.relative_source_power.len() {
1495            active.push(ActiveParameter {
1496                kind: ParameterKind::SourcePower(source),
1497                spec: spec.clone(),
1498            });
1499        }
1500    }
1501    if let Some(spec) = &parameters.frame_gains {
1502        for frame in 0..values.frame_gains.len() {
1503            active.push(ActiveParameter {
1504                kind: ParameterKind::FrameGain(frame),
1505                spec: spec.clone(),
1506            });
1507        }
1508    }
1509    active
1510}
1511
1512fn apply_gauge_constraints(
1513    parameters: &PlanarArrayCalibrationParameters,
1514    values: &mut PlanarArrayParameterValues,
1515) -> Result<()> {
1516    if parameters.relative_source_power.is_some() {
1517        normalize_mean_one(&mut values.relative_source_power, "relative_source_power")?;
1518    }
1519    if parameters.frame_gains.is_some() {
1520        normalize_mean_one(&mut values.frame_gains, "frame_gains")?;
1521    }
1522    if !parameters.position_offsets.is_empty() && parameters.translation.iter().any(Option::is_some)
1523    {
1524        for axis in 0..3 {
1525            let mean = parameters
1526                .position_offsets
1527                .keys()
1528                .map(|&source| values.position_offsets_m[source][axis])
1529                .sum::<f64>()
1530                / parameters.position_offsets.len() as f64;
1531            for &source in parameters.position_offsets.keys() {
1532                values.position_offsets_m[source][axis] -= mean;
1533            }
1534        }
1535    }
1536    Ok(())
1537}
1538
1539fn normalize_mean_one(values: &mut [f64], name: &'static str) -> Result<()> {
1540    let mean = values.iter().sum::<f64>() / values.len() as f64;
1541    if !mean.is_finite() || mean <= 0.0 {
1542        return Err(invalid(name, "positive finite mean is required"));
1543    }
1544    for value in values {
1545        *value /= mean;
1546    }
1547    Ok(())
1548}
1549
1550fn validate_values(
1551    parameters: &PlanarArrayCalibrationParameters,
1552    values: &PlanarArrayParameterValues,
1553) -> Result<()> {
1554    for parameter in active_parameters(parameters, values) {
1555        let value = parameter.value(values);
1556        if !value.is_finite()
1557            || value < parameter.spec.lower_bound - f64::EPSILON
1558            || value > parameter.spec.upper_bound + f64::EPSILON
1559        {
1560            return Err(invalid(
1561                "calibration parameter",
1562                format!(
1563                    "{}={value} lies outside [{}, {}]",
1564                    parameter.name(),
1565                    parameter.spec.lower_bound,
1566                    parameter.spec.upper_bound
1567                ),
1568            ));
1569        }
1570    }
1571    Ok(())
1572}
1573
1574fn validate_physical_sources(illumination: &Illumination, optics: &Optics) -> Result<()> {
1575    let resolved = illumination.resolve(optics)?;
1576    if resolved
1577        .positions_m()
1578        .is_some_and(|positions| positions.iter().any(|position| position[2].abs() <= 1e-9))
1579    {
1580        return Err(invalid(
1581            "illumination geometry",
1582            "calibration cannot place a source on the sample plane",
1583        ));
1584    }
1585    Ok(())
1586}
1587
1588fn normalized_values(
1589    active: &[ActiveParameter],
1590    initial: &PlanarArrayParameterValues,
1591    current: &PlanarArrayParameterValues,
1592) -> Vec<f64> {
1593    active
1594        .iter()
1595        .map(|parameter| {
1596            (parameter.value(current) - parameter.value(initial)) / parameter.spec.scale
1597        })
1598        .collect()
1599}
1600
1601fn conditioning(
1602    active: &[ActiveParameter],
1603    values: &PlanarArrayParameterValues,
1604    sensitivities: &[f64],
1605    curvatures: &[f64],
1606    parameters: &PlanarArrayCalibrationParameters,
1607    rejected_steps: usize,
1608) -> CalibrationConditioning {
1609    let useful: Vec<_> = curvatures
1610        .iter()
1611        .copied()
1612        .filter(|value| value.is_finite() && *value > 1e-14)
1613        .collect();
1614    let diagonal_condition_estimate = (!useful.is_empty()).then(|| {
1615        useful.iter().copied().fold(f64::NEG_INFINITY, f64::max)
1616            / useful.iter().copied().fold(f64::INFINITY, f64::min)
1617    });
1618    let parameters_at_bounds = active
1619        .iter()
1620        .filter(|parameter| {
1621            let value = parameter.value(values);
1622            let tolerance = 1e-9 * parameter.spec.scale.max(1.0);
1623            (value - parameter.spec.lower_bound).abs() <= tolerance
1624                || (value - parameter.spec.upper_bound).abs() <= tolerance
1625        })
1626        .map(ActiveParameter::name)
1627        .collect();
1628    let mut warnings = Vec::new();
1629    if parameters.translation[2].is_some() && parameters.pitch.iter().any(Option::is_some) {
1630        warnings.push(
1631            "pitch and axial translation are jointly active and may be weakly identifiable".into(),
1632        );
1633    }
1634    if diagonal_condition_estimate.is_some_and(|value| value > 1e8) {
1635        warnings.push("scaled diagonal curvature condition estimate exceeds 1e8".into());
1636    }
1637    for (parameter, &sensitivity) in active.iter().zip(sensitivities) {
1638        if sensitivity <= 1e-12 {
1639            warnings.push(format!(
1640                "{} has negligible finite-difference influence",
1641                parameter.name()
1642            ));
1643        }
1644    }
1645    CalibrationConditioning {
1646        parameter_names: active.iter().map(ActiveParameter::name).collect(),
1647        scaled_sensitivities: sensitivities.to_vec(),
1648        scaled_diagonal_curvature: curvatures.to_vec(),
1649        diagonal_condition_estimate,
1650        parameters_at_bounds,
1651        rejected_steps,
1652        warnings,
1653    }
1654}
1655
1656fn applied_constraints(parameters: &PlanarArrayCalibrationParameters) -> Vec<String> {
1657    let mut constraints = vec!["inclusive parameter bounds".into()];
1658    if parameters.relative_source_power.is_some() {
1659        constraints.push("mean(relative_source_power) = 1".into());
1660    }
1661    if parameters.frame_gains.is_some() {
1662        constraints.push("mean(frame_gain) = 1".into());
1663    }
1664    if !parameters.position_offsets.is_empty() && parameters.translation.iter().any(Option::is_some)
1665    {
1666        constraints.push("mean(selected position offset) = 0 on every axis".into());
1667    }
1668    constraints
1669}
1670
1671fn planar_geometry(illumination: &Illumination) -> Result<&PlanarLedArray> {
1672    match illumination.geometry() {
1673        SourceGeometry::PlanarArray(value) => Ok(value),
1674        _ => Err(Error::Unsupported(
1675            "physical illumination calibration supports only PlanarLedArray geometry; use generic k-vector correction for other geometries".into(),
1676        )),
1677    }
1678}
1679
1680fn default_translation_spec(axis: usize) -> CalibrationParameterSpec {
1681    if axis == 2 {
1682        CalibrationParameterSpec::new(-1.0, -1e-5, 1e-3).finite_difference_step(1e-5)
1683    } else {
1684        CalibrationParameterSpec::new(-0.1, 0.1, 1e-3).finite_difference_step(1e-5)
1685    }
1686}
1687
1688fn default_rotation_spec() -> CalibrationParameterSpec {
1689    CalibrationParameterSpec::new(
1690        -std::f64::consts::FRAC_PI_4,
1691        std::f64::consts::FRAC_PI_4,
1692        0.01,
1693    )
1694    .finite_difference_step(1e-4)
1695}
1696
1697fn default_pitch_spec() -> CalibrationParameterSpec {
1698    CalibrationParameterSpec::new(1e-6, 0.1, 1e-4).finite_difference_step(1e-6)
1699}
1700
1701fn default_reference_spec() -> CalibrationParameterSpec {
1702    CalibrationParameterSpec::new(-10_000.0, 10_000.0, 0.1).finite_difference_step(1e-3)
1703}
1704
1705fn default_offset_spec() -> CalibrationParameterSpec {
1706    CalibrationParameterSpec::new(-0.01, 0.01, 1e-4)
1707        .finite_difference_step(1e-6)
1708        .prior(0.0, 1e-6)
1709}
1710
1711fn default_multiplicative_spec() -> CalibrationParameterSpec {
1712    CalibrationParameterSpec::new(1e-3, 1e3, 0.1)
1713        .finite_difference_step(1e-3)
1714        .prior(1.0, 1e-6)
1715}
1716
1717fn invalid(name: &'static str, reason: impl Into<String>) -> Error {
1718    Error::InvalidParameter {
1719        name,
1720        reason: reason.into(),
1721    }
1722}