1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct CalibrationParameterSpec {
30 pub lower_bound: f64,
32 pub upper_bound: f64,
34 pub finite_difference_step: f64,
36 pub scale: f64,
38 pub prior_center: Option<f64>,
40 pub regularization_strength: f64,
42}
43
44impl CalibrationParameterSpec {
45 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 pub fn finite_difference_step(mut self, step: f64) -> Self {
59 self.finite_difference_step = step;
60 self
61 }
62
63 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct PlanarArrayCalibrationParameters {
110 pub translation: [Option<CalibrationParameterSpec>; 3],
112 pub rotation: [Option<CalibrationParameterSpec>; 3],
114 pub pitch: [Option<CalibrationParameterSpec>; 2],
116 pub reference_index: [Option<CalibrationParameterSpec>; 2],
118 pub position_offsets: BTreeMap<usize, [CalibrationParameterSpec; 3]>,
120 pub relative_source_power: Option<CalibrationParameterSpec>,
122 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 pub fn builder() -> PlanarArrayCalibrationParametersBuilder {
143 PlanarArrayCalibrationParametersBuilder::default()
144 }
145
146 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 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#[derive(Clone, Debug, Default)]
246pub struct PlanarArrayCalibrationParametersBuilder {
247 parameters: PlanarArrayCalibrationParameters,
248 position_offset_indices: Vec<usize>,
249}
250
251impl PlanarArrayCalibrationParametersBuilder {
252 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 pub fn translation_specs(mut self, specs: [Option<CalibrationParameterSpec>; 3]) -> Self {
261 self.parameters.translation = specs;
262 self
263 }
264
265 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 pub fn rotation_specs(mut self, specs: [Option<CalibrationParameterSpec>; 3]) -> Self {
274 self.parameters.rotation = specs;
275 self
276 }
277
278 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 pub fn pitch_specs(mut self, specs: [Option<CalibrationParameterSpec>; 2]) -> Self {
286 self.parameters.pitch = specs;
287 self
288 }
289
290 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 pub fn reference_index_specs(mut self, specs: [Option<CalibrationParameterSpec>; 2]) -> Self {
299 self.parameters.reference_index = specs;
300 self
301 }
302
303 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 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 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 pub fn relative_source_power_spec(mut self, spec: Option<CalibrationParameterSpec>) -> Self {
327 self.parameters.relative_source_power = spec;
328 self
329 }
330
331 pub fn frame_gains(mut self, active: bool) -> Self {
333 self.parameters.frame_gains = active.then(default_multiplicative_spec);
334 self
335 }
336
337 pub fn frame_gain_spec(mut self, spec: Option<CalibrationParameterSpec>) -> Self {
339 self.parameters.frame_gains = spec;
340 self
341 }
342
343 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
364#[serde(deny_unknown_fields)]
365pub struct BoundedFiniteDifferenceOptimizer {
366 pub max_steps: usize,
368 pub relative_tolerance: f64,
370 pub initial_step_size: f64,
372 pub minimum_step_size: f64,
374 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
426#[serde(deny_unknown_fields)]
427pub struct IlluminationCalibration {
428 pub parameters: PlanarArrayCalibrationParameters,
430 pub optimizer: BoundedFiniteDifferenceOptimizer,
432 pub loss_type: LossType,
434}
435
436impl IlluminationCalibration {
437 pub fn new(parameters: PlanarArrayCalibrationParameters) -> Self {
439 Self {
440 parameters,
441 optimizer: BoundedFiniteDifferenceOptimizer::default(),
442 loss_type: LossType::AmplitudeMse,
443 }
444 }
445
446 pub fn optimizer(mut self, optimizer: BoundedFiniteDifferenceOptimizer) -> Self {
448 self.optimizer = optimizer;
449 self
450 }
451
452 pub fn loss_type(mut self, loss_type: LossType) -> Self {
454 self.loss_type = loss_type;
455 self
456 }
457
458 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 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(¤t_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 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
788#[serde(deny_unknown_fields)]
789pub struct PlanarArrayParameterValues {
790 pub translation_m: [f64; 3],
792 pub rotation_rad: [f64; 3],
794 pub pitch_m: [f64; 2],
796 pub reference_index: [f64; 2],
798 pub position_offsets_m: Vec<[f64; 3]>,
800 pub relative_source_power: Vec<f64>,
802 pub frame_gains: Vec<f64>,
804}
805
806impl PlanarArrayParameterValues {
807 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
869#[serde(deny_unknown_fields)]
870pub struct CalibrationObjective {
871 pub data_loss: f64,
873 pub regularization_loss: f64,
875 pub total_loss: f64,
877}
878
879#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(rename_all = "snake_case")]
882pub enum CalibrationConvergenceReason {
883 MaximumSteps,
885 RelativeTolerance,
887 NegligibleGradient,
889 LineSearchFailed,
891}
892
893#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct CalibrationConditioning {
897 pub parameter_names: Vec<String>,
899 pub scaled_sensitivities: Vec<f64>,
901 pub scaled_diagonal_curvature: Vec<f64>,
903 pub diagonal_condition_estimate: Option<f64>,
905 pub parameters_at_bounds: Vec<String>,
907 pub rejected_steps: usize,
909 pub warnings: Vec<String>,
911}
912
913#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct CalibrationParameterHistoryEntry {
917 pub outer_iteration: usize,
919 pub optimizer_step: usize,
921 pub accepted: bool,
923 pub step_size: f64,
925 pub normalized_values: Vec<f64>,
927}
928
929#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
931#[serde(deny_unknown_fields)]
932pub struct CalibrationLossHistoryEntry {
933 pub outer_iteration: usize,
935 pub optimizer_step: usize,
937 pub total_loss: f64,
939 pub data_loss: f64,
941 pub regularization_loss: f64,
943 pub accepted: bool,
945}
946
947#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct IlluminationCalibrationState {
951 pub initial_illumination: Illumination,
953 pub current_illumination: Illumination,
955 pub initial_parameters: PlanarArrayParameterValues,
957 pub current_parameters: PlanarArrayParameterValues,
959 pub parameter_names: Vec<String>,
961 pub normalized_variables: Vec<f64>,
963 pub applied_constraints: Vec<String>,
965 pub parameter_history: Vec<CalibrationParameterHistoryEntry>,
967 pub loss_history: Vec<CalibrationLossHistoryEntry>,
969 pub convergence_reason: Option<CalibrationConvergenceReason>,
971 pub conditioning: CalibrationConditioning,
973 pub geometry_recompilations: usize,
975 pub multiplicative_updates: usize,
977 pub rejected_steps: usize,
979}
980
981impl IlluminationCalibrationState {
982 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) = ¶meters.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) = ¶meters.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) = ¶meters.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) = ¶meters.reference_index[axis] {
1479 active.push(ActiveParameter {
1480 kind: ParameterKind::Reference(axis),
1481 spec: spec.clone(),
1482 });
1483 }
1484 }
1485 for (&source, specs) in ¶meters.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) = ¶meters.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) = ¶meters.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}