Skip to main content

fpm_rs/
illumination_initialization.rs

1//! Bright-field circle initialization for physical planar LED arrays.
2//!
3//! This module estimates bright-field illumination wave vectors directly from
4//! measured intensity spectra, then fits those observations to the canonical
5//! [`PlanarLedArray`](crate::experiment::PlanarLedArray) geometry. It is an
6//! optional warm start for reconstruction and
7//! [`IlluminationCalibration`](crate::illumination_calibration::IlluminationCalibration),
8//! not a reconstruction algorithm or an independent per-source correction path.
9
10use std::{
11    fs::{self, File},
12    io::{BufReader, BufWriter, Read, Write},
13    path::{Path, PathBuf},
14    sync::Arc,
15    time::Instant,
16};
17
18use num_complex::Complex64;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use crate::{
23    Error, Result,
24    array_layout::checked_len_2d,
25    backend::{Backend, CpuBackend, FftDirection},
26    experiment::{Illumination, KVector, Optics, SourceGeometry},
27    illumination_calibration::{
28        CalibrationParameterSpec, PlanarArrayCalibrationParameters, PlanarArrayParameterValues,
29    },
30    measurements::MeasurementRead,
31    model::{ImagePlaneModel, ReconstructionShape, fftshift_copy},
32};
33
34/// Current JSON serialization format for planar-array initialization results.
35pub const INITIALIZATION_FORMAT_VERSION: u32 = 1;
36/// Current verified directory-bundle format for initialization results.
37pub const INITIALIZATION_BUNDLE_FORMAT_VERSION: u32 = 1;
38const TAU: f64 = std::f64::consts::TAU;
39const RESULT_ROLE: &str = "domain.initialization";
40const OBSERVATIONS_ROLE: &str = "tables.observations";
41const FIT_HISTORY_ROLE: &str = "tables.fit_history";
42
43/// Numerical and validation controls for bright-field circle initialization.
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct BrightfieldCircleOptions {
47    /// Optional acquisition-frame subset; `None` selects safe bright-field frames automatically.
48    pub frame_indices: Option<Vec<usize>>,
49    /// Maximum center displacement from the nominal source, in dimensionless NA.
50    pub center_search_radius_na: f64,
51    /// Additional positive distance retained inside the objective-NA boundary.
52    pub brightfield_margin_na: f64,
53    /// Half-width of the fitted pupil-radius search, in dimensionless NA.
54    pub pupil_radius_search_na: f64,
55    /// Gaussian smoothing standard deviation in Fourier-grid pixels.
56    pub gaussian_sigma_pixels: f64,
57    /// Number of uniformly spaced angles used for every circular score.
58    pub angular_samples: usize,
59    /// Radial finite-difference displacement in Fourier-grid pixels.
60    pub radial_derivative_step_pixels: f64,
61    /// Minimum fraction of angular samples required for a valid score.
62    pub minimum_arc_fraction: f64,
63    /// Minimum normalized first-derivative edge contrast for acceptance.
64    pub minimum_edge_contrast: f64,
65    /// Positive relative floor used when dividing by the mean magnitude spectrum.
66    pub mean_spectrum_floor: f64,
67    /// Huber transition scale for physical-fit residual components, in NA.
68    pub robust_residual_scale_na: f64,
69    /// Maximum bounded physical-fit steps.
70    pub maximum_fit_steps: usize,
71    /// Relative physical-fit objective improvement required to continue.
72    pub fit_relative_tolerance: f64,
73    /// Initial normalized physical-fit line-search step.
74    pub fit_initial_step_size: f64,
75    /// Smallest normalized line-search step attempted.
76    pub fit_minimum_step_size: f64,
77    /// Multiplicative line-search reduction in `(0, 1)`.
78    pub fit_step_reduction: f64,
79    /// Relative pivot threshold used for the observation-Jacobian rank test.
80    pub rank_tolerance: f64,
81    /// Maximum accepted difference between fitted and configured pupil NA.
82    pub pupil_radius_tolerance_na: f64,
83}
84
85impl Default for BrightfieldCircleOptions {
86    fn default() -> Self {
87        Self {
88            frame_indices: None,
89            center_search_radius_na: 0.02,
90            brightfield_margin_na: 0.002,
91            pupil_radius_search_na: 0.01,
92            gaussian_sigma_pixels: 2.0,
93            angular_samples: 180,
94            radial_derivative_step_pixels: 1.0,
95            minimum_arc_fraction: 0.2,
96            minimum_edge_contrast: 0.01,
97            mean_spectrum_floor: 1e-8,
98            robust_residual_scale_na: 0.002,
99            maximum_fit_steps: 100,
100            fit_relative_tolerance: 1e-8,
101            fit_initial_step_size: 0.5,
102            fit_minimum_step_size: 1e-6,
103            fit_step_reduction: 0.5,
104            rank_tolerance: 1e-8,
105            pupil_radius_tolerance_na: 0.02,
106        }
107    }
108}
109
110impl BrightfieldCircleOptions {
111    /// Validates finite ranges, search controls, and optimizer settings.
112    pub fn validate(&self) -> Result<()> {
113        for (name, value) in [
114            ("center_search_radius_na", self.center_search_radius_na),
115            ("brightfield_margin_na", self.brightfield_margin_na),
116            ("pupil_radius_search_na", self.pupil_radius_search_na),
117            ("gaussian_sigma_pixels", self.gaussian_sigma_pixels),
118            (
119                "radial_derivative_step_pixels",
120                self.radial_derivative_step_pixels,
121            ),
122            ("mean_spectrum_floor", self.mean_spectrum_floor),
123            ("robust_residual_scale_na", self.robust_residual_scale_na),
124            ("fit_initial_step_size", self.fit_initial_step_size),
125            ("fit_minimum_step_size", self.fit_minimum_step_size),
126            ("rank_tolerance", self.rank_tolerance),
127            ("pupil_radius_tolerance_na", self.pupil_radius_tolerance_na),
128        ] {
129            if !value.is_finite() || value <= 0.0 {
130                return Err(invalid(name, "must be finite and positive"));
131            }
132        }
133        if !self.minimum_arc_fraction.is_finite()
134            || !(0.0..=1.0).contains(&self.minimum_arc_fraction)
135            || self.minimum_arc_fraction == 0.0
136        {
137            return Err(invalid(
138                "minimum_arc_fraction",
139                "must be finite and in (0, 1]",
140            ));
141        }
142        if !self.minimum_edge_contrast.is_finite() || self.minimum_edge_contrast < 0.0 {
143            return Err(invalid(
144                "minimum_edge_contrast",
145                "must be finite and non-negative",
146            ));
147        }
148        if self.angular_samples < 16 {
149            return Err(invalid("angular_samples", "must be at least 16"));
150        }
151        if self.maximum_fit_steps == 0 {
152            return Err(invalid("maximum_fit_steps", "must be greater than zero"));
153        }
154        if !self.fit_relative_tolerance.is_finite() || self.fit_relative_tolerance < 0.0 {
155            return Err(invalid(
156                "fit_relative_tolerance",
157                "must be finite and non-negative",
158            ));
159        }
160        if self.fit_minimum_step_size > self.fit_initial_step_size {
161            return Err(invalid(
162                "fit_minimum_step_size",
163                "must not exceed fit_initial_step_size",
164            ));
165        }
166        if !self.fit_step_reduction.is_finite() || !(0.0..1.0).contains(&self.fit_step_reduction) {
167            return Err(invalid(
168                "fit_step_reduction",
169                "must be finite and strictly between zero and one",
170            ));
171        }
172        if let Some(frames) = &self.frame_indices {
173            if frames.is_empty() {
174                return Err(invalid(
175                    "frame_indices",
176                    "an explicit frame subset must not be empty",
177                ));
178            }
179            let mut sorted = frames.clone();
180            sorted.sort_unstable();
181            sorted.dedup();
182            if sorted.len() != frames.len() {
183                return Err(invalid(
184                    "frame_indices",
185                    "explicit frame indices must be unique",
186                ));
187            }
188        }
189        Ok(())
190    }
191}
192
193/// One considered acquisition frame and its circle-localization diagnostics.
194#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct BrightfieldCircleObservation {
197    /// Zero-based acquisition-frame index.
198    pub frame_index: usize,
199    /// Stable row-major physical source index.
200    pub source_index: usize,
201    /// Nominal transverse vector `(kx, ky)` in radians per metre.
202    pub nominal_k_rad_per_m: [f64; 2],
203    /// Detected transverse vector `(kx, ky)` in radians per metre, when accepted.
204    pub detected_k_rad_per_m: Option<[f64; 2]>,
205    /// Detected `(NA_x, NA_y)` components, when accepted.
206    pub detected_na: Option<[f64; 2]>,
207    /// Detected centered Fourier coordinates `(row, column)`, when accepted.
208    pub fourier_grid_position: Option<[f64; 2]>,
209    /// Best pupil radius for this frame, in dimensionless NA.
210    pub fitted_pupil_radius_na: f64,
211    /// Normalized first-radial-derivative score.
212    pub first_derivative_score: f64,
213    /// Normalized second-radial-derivative score.
214    pub second_derivative_score: f64,
215    /// Combined deterministic detector score.
216    pub combined_score: f64,
217    /// Score at the conjugate branch corresponding to `-k`.
218    pub conjugate_score: f64,
219    /// Fraction of requested angular samples contributing to the score.
220    pub usable_arc_fraction: f64,
221    /// Fixed confidence weight supplied to the physical fit.
222    pub confidence: f64,
223    /// Fraction of background-corrected spatial samples below zero.
224    pub negative_sample_fraction: f64,
225    /// Human-readable rejection reason, or `None` for an accepted observation.
226    pub rejection_reason: Option<String>,
227}
228
229impl BrightfieldCircleObservation {
230    /// Returns whether this observation contributes to physical fitting.
231    pub fn accepted(&self) -> bool {
232        self.detected_k_rad_per_m.is_some() && self.rejection_reason.is_none()
233    }
234}
235
236/// One accepted or rejected bounded physical-fit step.
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct PlanarArrayInitializationFitRecord {
240    /// One-based optimizer step.
241    pub step: usize,
242    /// Whether a bounded line-search candidate reduced the objective.
243    pub accepted: bool,
244    /// Accepted normalized step size, or zero after exhaustion.
245    pub step_size: f64,
246    /// Robust observation data loss.
247    pub data_loss: f64,
248    /// Quadratic-prior loss.
249    pub regularization_loss: f64,
250    /// `data_loss + regularization_loss`.
251    pub total_loss: f64,
252    /// Current normalized physical values in `parameter_names` order.
253    pub normalized_values: Vec<f64>,
254}
255
256/// Structural and numerical summary of circle detection and physical fitting.
257#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct PlanarArrayInitializationDiagnostics {
260    /// Number of acquisition frames considered by the detector.
261    pub candidate_frames: usize,
262    /// Number of accepted circle-center observations.
263    pub accepted_observations: usize,
264    /// Number of rejected circle-center observations.
265    pub rejected_observations: usize,
266    /// Configured objective pupil radius in NA.
267    pub configured_pupil_radius_na: f64,
268    /// Confidence-weighted detected pupil radius in NA.
269    pub fitted_pupil_radius_na: f64,
270    /// Number of independent data-Jacobian columns before applying priors.
271    pub jacobian_rank: usize,
272    /// Number of active physical parameters.
273    pub active_parameter_count: usize,
274    /// Squared largest-to-smallest accepted rank pivot ratio.
275    pub jacobian_condition_estimate: Option<f64>,
276    /// Confidence-weighted initial transverse-vector residual RMS, in NA.
277    pub initial_residual_rms_na: f64,
278    /// Confidence-weighted final transverse-vector residual RMS, in NA.
279    pub final_residual_rms_na: f64,
280    /// Additional inspectable warnings that do not invalidate the result.
281    pub warnings: Vec<String>,
282}
283
284/// Progress stage reported by a bright-field initializer.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum PlanarArrayInitializationStage {
288    /// Accumulating the mean measured magnitude spectrum.
289    MeanSpectrum,
290    /// Detecting a circle center in one frame.
291    CircleDetection,
292    /// Updating bounded physical parameters.
293    PhysicalFit,
294    /// Initialization completed successfully.
295    Complete,
296}
297
298/// Immutable progress record for initializer-specific callbacks.
299#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct PlanarArrayInitializationProgress {
302    /// Current stage.
303    pub stage: PlanarArrayInitializationStage,
304    /// Number of completed units in the current stage.
305    pub completed: usize,
306    /// Total units in the current stage.
307    pub total: usize,
308    /// Current acquisition frame, when the stage is frame-indexed.
309    pub frame_index: Option<usize>,
310}
311
312/// Action returned by a planar-array initialization callback.
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub enum PlanarArrayInitializationAction {
315    /// Continue initialization.
316    Continue,
317    /// Cancel before the next stage boundary without returning a partial result.
318    Cancel,
319}
320
321/// Callback interface dedicated to pre-reconstruction planar-array initialization.
322pub trait PlanarArrayInitializationCallback: Send {
323    /// Receives progress at deterministic frame, fit-step, and completion boundaries.
324    fn on_progress(
325        &mut self,
326        progress: &PlanarArrayInitializationProgress,
327    ) -> Result<PlanarArrayInitializationAction>;
328}
329
330/// Runtime metadata for one completed initialization.
331#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332#[serde(deny_unknown_fields)]
333pub struct PlanarArrayInitializationRuntime {
334    /// Wall-clock seconds spent detecting and fitting.
335    pub elapsed_seconds: f64,
336    /// Number of complete measurement passes.
337    pub measurement_passes: usize,
338    /// Number of physical objective evaluations.
339    pub physical_objective_evaluations: usize,
340}
341
342/// Complete serializable output of bright-field planar-array initialization.
343#[derive(Clone, Debug, Serialize, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct PlanarArrayInitializationResult {
346    /// Serialization format version.
347    pub format_version: u32,
348    /// Nominal illumination supplied to the initializer.
349    pub nominal_illumination: Illumination,
350    /// Reusable illumination containing the fitted physical geometry.
351    pub initialized_illumination: Illumination,
352    /// Reusable model atomically refreshed from [`Self::initialized_illumination`].
353    pub initialized_model: ImagePlaneModel,
354    /// Physical parameter selection and bounds used by the fit.
355    pub parameters: PlanarArrayCalibrationParameters,
356    /// Detection and optimizer controls used by this run.
357    pub options: BrightfieldCircleOptions,
358    /// Absolute nominal physical and multiplicative values.
359    pub initial_parameters: PlanarArrayParameterValues,
360    /// Absolute initialized physical and unchanged multiplicative values.
361    pub initialized_parameters: PlanarArrayParameterValues,
362    /// Stable active physical parameter names.
363    pub parameter_names: Vec<String>,
364    /// Detector result for every considered acquisition frame.
365    pub observations: Vec<BrightfieldCircleObservation>,
366    /// Accepted and rejected physical-fit steps.
367    pub fit_history: Vec<PlanarArrayInitializationFitRecord>,
368    /// Detection, rank, and residual diagnostics.
369    pub diagnostics: PlanarArrayInitializationDiagnostics,
370    /// Timing and evaluation counts.
371    pub runtime: PlanarArrayInitializationRuntime,
372}
373
374impl PlanarArrayInitializationResult {
375    /// Validates version, physical state, model consistency, and finite diagnostics.
376    pub fn validate(&self) -> Result<()> {
377        if self.format_version != INITIALIZATION_FORMAT_VERSION {
378            return Err(Error::InvalidModel(format!(
379                "planar-array initialization format version {} is unsupported",
380                self.format_version
381            )));
382        }
383        self.initialized_model.validate()?;
384        if PlanarArrayParameterValues::from_illumination(&self.nominal_illumination)?
385            != self.initial_parameters
386            || PlanarArrayParameterValues::from_illumination(&self.initialized_illumination)?
387                != self.initialized_parameters
388        {
389            return Err(Error::InvalidModel(
390                "initialization illumination and absolute parameter values disagree".into(),
391            ));
392        }
393        if self.parameter_names.is_empty()
394            || self
395                .observations
396                .iter()
397                .any(|value| !observation_is_finite(value))
398            || !diagnostics_are_finite(&self.diagnostics)
399            || !self.runtime.elapsed_seconds.is_finite()
400            || self.runtime.elapsed_seconds < 0.0
401        {
402            return Err(Error::InvalidModel(
403                "planar-array initialization result contains invalid diagnostics".into(),
404            ));
405        }
406        BrightfieldCircleInitializer {
407            parameters: self.parameters.clone(),
408            options: self.options.clone(),
409        }
410        .validate_for(&self.nominal_illumination)?;
411        let active = active_parameters(&self.parameters);
412        let expected_names: Vec<_> = active.iter().map(ActiveParameter::name).collect();
413        if self.parameter_names != expected_names
414            || self.diagnostics.active_parameter_count != active.len()
415            || self.diagnostics.jacobian_rank != active.len()
416            || self.runtime.measurement_passes != 2
417            || self.runtime.physical_objective_evaluations == 0
418        {
419            return Err(Error::InvalidModel(
420                "initialization parameter names or rank diagnostics disagree".into(),
421            ));
422        }
423        if self.diagnostics.candidate_frames != self.observations.len()
424            || self.diagnostics.accepted_observations
425                != self
426                    .observations
427                    .iter()
428                    .filter(|value| value.accepted())
429                    .count()
430            || self.diagnostics.rejected_observations
431                != self
432                    .observations
433                    .iter()
434                    .filter(|value| !value.accepted())
435                    .count()
436        {
437            return Err(Error::InvalidModel(
438                "planar-array initialization observation counts disagree".into(),
439            ));
440        }
441        if self.observations.iter().any(|observation| {
442            observation.frame_index >= self.initialized_model.frame_count()
443                || observation.source_index >= self.initialized_model.source_count()
444                || (observation.rejection_reason.is_none()
445                    != observation.detected_k_rad_per_m.is_some())
446                || (observation.detected_k_rad_per_m.is_some() != observation.detected_na.is_some())
447                || (observation.detected_na.is_some()
448                    != observation.fourier_grid_position.is_some())
449        }) {
450            return Err(Error::InvalidModel(
451                "initialization observation indices or acceptance fields disagree".into(),
452            ));
453        }
454        if self.fit_history.iter().enumerate().any(|(index, record)| {
455            let loss_scale = record
456                .total_loss
457                .abs()
458                .max(record.data_loss.abs())
459                .max(record.regularization_loss.abs())
460                .max(1.0);
461            record.step != index + 1
462                || record.normalized_values.len() != active.len()
463                || !record.step_size.is_finite()
464                || record.step_size < 0.0
465                || (record.accepted && record.step_size == 0.0)
466                || (!record.accepted && record.step_size != 0.0)
467                || !record.data_loss.is_finite()
468                || record.data_loss < 0.0
469                || !record.regularization_loss.is_finite()
470                || record.regularization_loss < 0.0
471                || !record.total_loss.is_finite()
472                || (record.total_loss - record.data_loss - record.regularization_loss).abs()
473                    > 16.0 * f64::EPSILON * loss_scale
474                || record
475                    .normalized_values
476                    .iter()
477                    .any(|value| !value.is_finite())
478        }) {
479            return Err(Error::InvalidModel(
480                "initialization fit history is inconsistent or non-finite".into(),
481            ));
482        }
483        if self.initial_parameters.position_offsets_m
484            != self.initialized_parameters.position_offsets_m
485            || self.initial_parameters.relative_source_power
486                != self.initialized_parameters.relative_source_power
487            || self.initial_parameters.frame_gains != self.initialized_parameters.frame_gains
488        {
489            return Err(Error::InvalidModel(
490                "circle initialization changed unsupported per-source or multiplicative values"
491                    .into(),
492            ));
493        }
494        if self.initialized_model.source_count()
495            != self.initialized_parameters.relative_source_power.len()
496            || self.initialized_model.frame_count() != self.initialized_parameters.frame_gains.len()
497        {
498            return Err(Error::InvalidModel(
499                "initialized model and illumination topology disagree".into(),
500            ));
501        }
502        Ok(())
503    }
504
505    /// Writes the complete initialization result as JSON.
506    pub fn save_json(&self, path: impl AsRef<Path>) -> Result<()> {
507        self.validate()?;
508        serde_json::to_writer(BufWriter::new(File::create(path)?), self)?;
509        Ok(())
510    }
511
512    /// Loads and validates a complete initialization result from JSON.
513    pub fn load_json(path: impl AsRef<Path>) -> Result<Self> {
514        let result: Self = serde_json::from_reader(BufReader::new(File::open(path)?))?;
515        result.validate()?;
516        Ok(result)
517    }
518
519    /// Atomically writes a verified directory bundle with JSON state and CSV tables.
520    ///
521    /// The complete JSON result is authoritative. The observation and fit-history
522    /// tables are normalized, analysis-friendly projections of the same state.
523    /// Existing destinations and incomplete sibling workspaces are never overwritten.
524    pub fn write_bundle(&self, path: impl AsRef<Path>) -> Result<InitializationBundle> {
525        write_initialization_bundle(path.as_ref(), self)
526    }
527}
528
529/// Manifest-verified file inside a planar-array initialization bundle.
530#[derive(Clone, Debug, PartialEq, Eq)]
531pub struct InitializationBundleArtifact {
532    /// Stable semantic role.
533    pub role: String,
534    /// Resolved local artifact path.
535    pub path: PathBuf,
536    /// Declared MIME media type.
537    pub media_type: String,
538    /// Exact byte size.
539    pub byte_size: u64,
540    /// Lowercase hexadecimal SHA-256 digest.
541    pub sha256: String,
542}
543
544/// Aggregate result of re-verifying an initialization bundle.
545#[derive(Clone, Debug, PartialEq, Eq)]
546pub struct InitializationBundleVerificationResult {
547    /// Number of manifest-declared artifacts verified.
548    pub artifact_count: usize,
549    /// Sum of verified artifact sizes in bytes.
550    pub total_bytes: u64,
551}
552
553/// Eagerly verified initialization bundle and its authoritative physical result.
554#[derive(Clone, Debug)]
555pub struct InitializationBundle {
556    /// Bundle root directory.
557    pub path: PathBuf,
558    /// Bundle manifest path.
559    pub manifest_path: PathBuf,
560    /// Complete initialization JSON artifact.
561    pub result_artifact: InitializationBundleArtifact,
562    /// Per-frame circle-observation CSV artifact.
563    pub observations_artifact: InitializationBundleArtifact,
564    /// Bounded physical-fit history CSV artifact.
565    pub fit_history_artifact: InitializationBundleArtifact,
566    /// Validated authoritative initialization result.
567    pub result: PlanarArrayInitializationResult,
568}
569
570impl InitializationBundle {
571    /// Opens a complete bundle, verifies every declared artifact, and loads its result.
572    pub fn read(path: impl AsRef<Path>) -> Result<Self> {
573        read_initialization_bundle(path)
574    }
575
576    /// Recomputes the size and SHA-256 digest of every declared artifact.
577    pub fn verify(&self) -> Result<InitializationBundleVerificationResult> {
578        let manifest = read_initialization_manifest(&self.path)?;
579        verify_initialization_artifacts(&self.path, &manifest)
580    }
581}
582
583/// Opens and verifies a planar-array initialization bundle.
584pub fn read_initialization_bundle(path: impl AsRef<Path>) -> Result<InitializationBundle> {
585    let path = path.as_ref();
586    if path
587        .file_name()
588        .and_then(|value| value.to_str())
589        .is_some_and(|value| value.ends_with(".inprogress"))
590    {
591        return Err(Error::IncompleteBundle(format!(
592            "{} is an in-progress initialization workspace",
593            path.display()
594        )));
595    }
596    let manifest = read_initialization_manifest(path)?;
597    verify_initialization_artifacts(path, &manifest)?;
598    let artifact = |role: &str| -> Result<InitializationBundleArtifact> {
599        let value = manifest
600            .artifacts
601            .iter()
602            .find(|artifact| artifact.role == role)
603            .ok_or_else(|| Error::MissingArtifact { role: role.into() })?;
604        Ok(InitializationBundleArtifact {
605            role: value.role.clone(),
606            path: path.join(&value.relative_path),
607            media_type: value.media_type.clone(),
608            byte_size: value.byte_size,
609            sha256: value.sha256.clone(),
610        })
611    };
612    let result_artifact = artifact(RESULT_ROLE)?;
613    let result = PlanarArrayInitializationResult::load_json(&result_artifact.path)?;
614    Ok(InitializationBundle {
615        path: path.to_owned(),
616        manifest_path: path.join("manifest.json"),
617        result_artifact,
618        observations_artifact: artifact(OBSERVATIONS_ROLE)?,
619        fit_history_artifact: artifact(FIT_HISTORY_ROLE)?,
620        result,
621    })
622}
623
624#[derive(Serialize, Deserialize)]
625#[serde(deny_unknown_fields)]
626struct InitializationBundleManifest {
627    initialization_bundle_format_version: u32,
628    crate_version: String,
629    artifacts: Vec<InitializationManifestArtifact>,
630}
631
632#[derive(Serialize, Deserialize)]
633#[serde(deny_unknown_fields)]
634struct InitializationManifestArtifact {
635    role: String,
636    relative_path: PathBuf,
637    media_type: String,
638    byte_size: u64,
639    sha256: String,
640}
641
642fn write_initialization_bundle(
643    requested: &Path,
644    result: &PlanarArrayInitializationResult,
645) -> Result<InitializationBundle> {
646    result.validate()?;
647    if requested.exists() {
648        return Err(invalid(
649            "initialization bundle path",
650            format!("{} already exists", requested.display()),
651        ));
652    }
653    let name = requested
654        .file_name()
655        .and_then(|value| value.to_str())
656        .ok_or_else(|| invalid("initialization bundle path", "must name a UTF-8 directory"))?;
657    let workspace = requested.with_file_name(format!("{name}.inprogress"));
658    if workspace.exists() {
659        return Err(Error::IncompleteBundle(format!(
660            "{} already exists",
661            workspace.display()
662        )));
663    }
664    if let Some(parent) = requested.parent() {
665        fs::create_dir_all(parent)?;
666    }
667    fs::create_dir(&workspace)?;
668    fs::create_dir(workspace.join("domain"))?;
669    fs::create_dir(workspace.join("tables"))?;
670
671    let result_path = workspace.join("domain/initialization.json");
672    result.save_json(&result_path)?;
673    write_observations_csv(
674        &workspace.join("tables/observations.csv"),
675        &result.observations,
676    )?;
677    write_fit_history_csv(
678        &workspace.join("tables/fit_history.csv"),
679        &result.fit_history,
680    )?;
681    let artifacts = vec![
682        describe_initialization_artifact(
683            &workspace,
684            RESULT_ROLE,
685            "domain/initialization.json",
686            "application/json",
687        )?,
688        describe_initialization_artifact(
689            &workspace,
690            OBSERVATIONS_ROLE,
691            "tables/observations.csv",
692            "text/csv",
693        )?,
694        describe_initialization_artifact(
695            &workspace,
696            FIT_HISTORY_ROLE,
697            "tables/fit_history.csv",
698            "text/csv",
699        )?,
700    ];
701    write_pretty_json(
702        &workspace.join("manifest.json"),
703        &InitializationBundleManifest {
704            initialization_bundle_format_version: INITIALIZATION_BUNDLE_FORMAT_VERSION,
705            crate_version: env!("CARGO_PKG_VERSION").into(),
706            artifacts,
707        },
708    )?;
709    fs::rename(&workspace, requested)?;
710    read_initialization_bundle(requested)
711}
712
713fn read_initialization_manifest(path: &Path) -> Result<InitializationBundleManifest> {
714    let manifest_path = path.join("manifest.json");
715    if !manifest_path.is_file() {
716        return Err(Error::MissingArtifact {
717            role: "manifest".into(),
718        });
719    }
720    let manifest: InitializationBundleManifest =
721        serde_json::from_reader(BufReader::new(File::open(manifest_path)?))
722            .map_err(|error| Error::InvalidManifest(error.to_string()))?;
723    if manifest.initialization_bundle_format_version != INITIALIZATION_BUNDLE_FORMAT_VERSION {
724        return Err(Error::UnsupportedBundleVersion {
725            actual: manifest.initialization_bundle_format_version,
726            supported: INITIALIZATION_BUNDLE_FORMAT_VERSION,
727        });
728    }
729    if manifest.crate_version.is_empty() || manifest.artifacts.len() != 3 {
730        return Err(Error::InvalidManifest(
731            "initialization bundle must declare a crate version and exactly three artifacts".into(),
732        ));
733    }
734    Ok(manifest)
735}
736
737fn verify_initialization_artifacts(
738    root: &Path,
739    manifest: &InitializationBundleManifest,
740) -> Result<InitializationBundleVerificationResult> {
741    let expected = [
742        (
743            RESULT_ROLE,
744            "domain/initialization.json",
745            "application/json",
746        ),
747        (OBSERVATIONS_ROLE, "tables/observations.csv", "text/csv"),
748        (FIT_HISTORY_ROLE, "tables/fit_history.csv", "text/csv"),
749    ];
750    let mut total_bytes = 0;
751    for (role, relative, media_type) in expected {
752        let artifact = manifest
753            .artifacts
754            .iter()
755            .find(|artifact| artifact.role == role)
756            .ok_or_else(|| Error::MissingArtifact { role: role.into() })?;
757        if artifact.relative_path != Path::new(relative)
758            || artifact.media_type != media_type
759            || artifact.sha256.len() != 64
760            || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
761        {
762            return Err(Error::InvalidManifest(format!(
763                "initialization artifact {role} has invalid path, media type, or digest metadata"
764            )));
765        }
766        let artifact_path = root.join(&artifact.relative_path);
767        let metadata = fs::metadata(&artifact_path).map_err(|error| {
768            if error.kind() == std::io::ErrorKind::NotFound {
769                Error::MissingArtifact { role: role.into() }
770            } else {
771                Error::Io(error)
772            }
773        })?;
774        if metadata.len() != artifact.byte_size {
775            return Err(Error::InvalidManifest(format!(
776                "initialization artifact {role} has the wrong byte size"
777            )));
778        }
779        if file_sha256(&artifact_path)? != artifact.sha256 {
780            return Err(Error::ArtifactHashMismatch { role: role.into() });
781        }
782        total_bytes += artifact.byte_size;
783    }
784    Ok(InitializationBundleVerificationResult {
785        artifact_count: expected.len(),
786        total_bytes,
787    })
788}
789
790fn describe_initialization_artifact(
791    root: &Path,
792    role: &str,
793    relative_path: &str,
794    media_type: &str,
795) -> Result<InitializationManifestArtifact> {
796    let path = root.join(relative_path);
797    Ok(InitializationManifestArtifact {
798        role: role.into(),
799        relative_path: relative_path.into(),
800        media_type: media_type.into(),
801        byte_size: fs::metadata(&path)?.len(),
802        sha256: file_sha256(&path)?,
803    })
804}
805
806fn write_observations_csv(
807    path: &Path,
808    observations: &[BrightfieldCircleObservation],
809) -> Result<()> {
810    let mut writer = csv::Writer::from_path(path)?;
811    writer.write_record([
812        "frame_index",
813        "source_index",
814        "nominal_kx_rad_per_m",
815        "nominal_ky_rad_per_m",
816        "detected_kx_rad_per_m",
817        "detected_ky_rad_per_m",
818        "detected_na_x",
819        "detected_na_y",
820        "fourier_row",
821        "fourier_column",
822        "fitted_pupil_radius_na",
823        "first_derivative_score",
824        "second_derivative_score",
825        "combined_score",
826        "conjugate_score",
827        "usable_arc_fraction",
828        "confidence",
829        "negative_sample_fraction",
830        "accepted",
831        "rejection_reason",
832    ])?;
833    for value in observations {
834        let optional = |pair: Option<[f64; 2]>, axis: usize| {
835            pair.map(|pair| pair[axis].to_string()).unwrap_or_default()
836        };
837        writer.write_record([
838            value.frame_index.to_string(),
839            value.source_index.to_string(),
840            value.nominal_k_rad_per_m[0].to_string(),
841            value.nominal_k_rad_per_m[1].to_string(),
842            optional(value.detected_k_rad_per_m, 0),
843            optional(value.detected_k_rad_per_m, 1),
844            optional(value.detected_na, 0),
845            optional(value.detected_na, 1),
846            optional(value.fourier_grid_position, 0),
847            optional(value.fourier_grid_position, 1),
848            value.fitted_pupil_radius_na.to_string(),
849            value.first_derivative_score.to_string(),
850            value.second_derivative_score.to_string(),
851            value.combined_score.to_string(),
852            value.conjugate_score.to_string(),
853            value.usable_arc_fraction.to_string(),
854            value.confidence.to_string(),
855            value.negative_sample_fraction.to_string(),
856            value.accepted().to_string(),
857            value.rejection_reason.clone().unwrap_or_default(),
858        ])?;
859    }
860    writer.flush()?;
861    Ok(())
862}
863
864fn write_fit_history_csv(
865    path: &Path,
866    history: &[PlanarArrayInitializationFitRecord],
867) -> Result<()> {
868    let mut writer = csv::Writer::from_path(path)?;
869    writer.write_record([
870        "step",
871        "accepted",
872        "step_size",
873        "data_loss",
874        "regularization_loss",
875        "total_loss",
876        "normalized_values_json",
877    ])?;
878    for value in history {
879        writer.write_record([
880            value.step.to_string(),
881            value.accepted.to_string(),
882            value.step_size.to_string(),
883            value.data_loss.to_string(),
884            value.regularization_loss.to_string(),
885            value.total_loss.to_string(),
886            serde_json::to_string(&value.normalized_values)?,
887        ])?;
888    }
889    writer.flush()?;
890    Ok(())
891}
892
893fn write_pretty_json(path: &Path, value: &impl Serialize) -> Result<()> {
894    let mut writer = BufWriter::new(File::create(path)?);
895    serde_json::to_writer_pretty(&mut writer, value)?;
896    writer.flush()?;
897    writer.get_ref().sync_all()?;
898    Ok(())
899}
900
901fn file_sha256(path: &Path) -> Result<String> {
902    let mut file = File::open(path)?;
903    let mut hasher = Sha256::new();
904    let mut buffer = [0_u8; 64 * 1024];
905    loop {
906        let read = file.read(&mut buffer)?;
907        if read == 0 {
908            break;
909        }
910        hasher.update(&buffer[..read]);
911    }
912    Ok(format!("{:x}", hasher.finalize()))
913}
914
915/// Bright-field detector and bounded physical planar-array fitter.
916///
917/// The detector uses circular pupil edges in centered intensity spectra only
918/// to obtain a physical warm start. It then fits those observed centers to the
919/// canonical [`PlanarLedArray`](crate::experiment::PlanarLedArray) mapping.
920/// It does not reconstruct an object and does not produce independent source
921/// shifts. Measurement-loss refinement remains the responsibility of
922/// [`IlluminationCalibration`](crate::illumination_calibration::IlluminationCalibration).
923///
924/// # Assumptions
925///
926/// Selected frames must be single-source, strictly bright-field exposures of
927/// a thin coherent specimen with enough reference interference and texture to
928/// expose the circular pupil edge. The compiled pupil support must be circular
929/// and shift invariant. The initializer uses two deterministic streaming
930/// measurement passes and preserves pupil values, backgrounds, source powers,
931/// frame gains, sampling, and acquisition topology in its returned model.
932///
933/// # Example
934///
935/// ```no_run
936/// use fpm_rs::{
937///     Result,
938///     experiment::{Illumination, Optics},
939///     illumination_calibration::{
940///         CalibrationParameterSpec, PlanarArrayCalibrationParameters,
941///     },
942///     illumination_initialization::BrightfieldCircleInitializer,
943///     measurements::MeasurementStack,
944///     model::ImagePlaneModel,
945/// };
946///
947/// # fn initialize(
948/// #     measurements: &MeasurementStack,
949/// #     optics: &Optics,
950/// #     nominal: &Illumination,
951/// #     model: &ImagePlaneModel,
952/// # ) -> Result<()> {
953/// let lateral = CalibrationParameterSpec::new(-1e-3, 1e-3, 0.2e-3)
954///     .finite_difference_step(1e-6);
955/// let parameters = PlanarArrayCalibrationParameters::builder()
956///     .translation_specs([Some(lateral.clone()), Some(lateral), None])
957///     .build()?;
958/// let initialized = BrightfieldCircleInitializer::new(parameters)
959///     .initialize(measurements, optics, nominal, model)?;
960/// let warm_model = initialized.initialized_model;
961/// # let _ = warm_model;
962/// # Ok(())
963/// # }
964/// ```
965///
966/// # References
967///
968/// - J. Sun, Q. Chen, Y. Zhang, and C. Zuo,
969///   [“Efficient positional misalignment correction method for Fourier
970///   ptychographic microscopy,”](https://doi.org/10.1364/BOE.7.001336)
971///   *Biomedical Optics Express* **7**(4), 1336–1350 (2016). Sun et al. search
972///   independent apertures during reconstruction and subsequently regress a
973///   planar misalignment model; this initializer instead fits detected circle
974///   centers directly to the crate's bounded physical geometry.
975/// - R. Eckert, Z. F. Phillips, and L. Waller,
976///   [“Efficient illumination angle self-calibration in Fourier
977///   ptychography,”](https://doi.org/10.1364/AO.57.005434) *Applied Optics*
978///   **57**(19), 5434–5442 (2018). This implementation adopts only the
979///   bright-field circular-edge initialization concept, not their iterative
980///   spectral-correlation stage or three-dimensional variants.
981#[derive(Clone, Debug)]
982pub struct BrightfieldCircleInitializer {
983    /// Global physical parameters selected for fitting.
984    pub parameters: PlanarArrayCalibrationParameters,
985    /// Detector, rank, and fit controls.
986    pub options: BrightfieldCircleOptions,
987}
988
989impl BrightfieldCircleInitializer {
990    /// Creates an initializer for explicitly selected global physical parameters.
991    pub fn new(parameters: PlanarArrayCalibrationParameters) -> Self {
992        Self {
993            parameters,
994            options: BrightfieldCircleOptions::default(),
995        }
996    }
997
998    /// Replaces circle-detection and physical-fit options.
999    pub fn options(mut self, options: BrightfieldCircleOptions) -> Self {
1000        self.options = options;
1001        self
1002    }
1003
1004    /// Validates this initializer for the nominal planar-array illumination.
1005    pub fn validate_for(&self, illumination: &Illumination) -> Result<()> {
1006        self.options.validate()?;
1007        self.parameters.validate_for(illumination)?;
1008        if !self.parameters.has_active_parameters() {
1009            return Err(invalid(
1010                "parameters",
1011                "at least one global physical parameter must be active",
1012            ));
1013        }
1014        if !self.parameters.position_offsets.is_empty() {
1015            return Err(Error::Unsupported(
1016                "bright-field circle initialization does not fit per-source position offsets"
1017                    .into(),
1018            ));
1019        }
1020        if self.parameters.relative_source_power.is_some() || self.parameters.frame_gains.is_some()
1021        {
1022            return Err(Error::Unsupported(
1023                "bright-field circle initialization does not fit source powers or frame gains"
1024                    .into(),
1025            ));
1026        }
1027        match illumination.geometry() {
1028            SourceGeometry::PlanarArray(_) => Ok(()),
1029            _ => Err(Error::Unsupported(
1030                "bright-field circle initialization supports only PlanarLedArray geometry".into(),
1031            )),
1032        }
1033    }
1034
1035    /// Detects bright-field circles and fits a physical warm start using the CPU backend.
1036    pub fn initialize<M: MeasurementRead>(
1037        &self,
1038        measurements: &M,
1039        optics: &Optics,
1040        nominal_illumination: &Illumination,
1041        model: &ImagePlaneModel,
1042    ) -> Result<PlanarArrayInitializationResult> {
1043        let backend: Arc<dyn Backend> = Arc::new(CpuBackend::new(
1044            model.image_shape(),
1045            model.reconstruction_shape(),
1046        )?);
1047        self.initialize_with_backend(measurements, optics, nominal_illumination, model, backend)
1048    }
1049
1050    /// Detects and fits using an explicit FFT backend.
1051    pub fn initialize_with_backend<M: MeasurementRead>(
1052        &self,
1053        measurements: &M,
1054        optics: &Optics,
1055        nominal_illumination: &Illumination,
1056        model: &ImagePlaneModel,
1057        backend: Arc<dyn Backend>,
1058    ) -> Result<PlanarArrayInitializationResult> {
1059        self.initialize_internal(
1060            measurements,
1061            optics,
1062            nominal_illumination,
1063            model,
1064            backend,
1065            None,
1066        )
1067    }
1068
1069    /// Detects and fits with an explicit backend and initializer-specific callback.
1070    pub fn initialize_with_callback<M: MeasurementRead>(
1071        &self,
1072        measurements: &M,
1073        optics: &Optics,
1074        nominal_illumination: &Illumination,
1075        model: &ImagePlaneModel,
1076        backend: Arc<dyn Backend>,
1077        callback: &mut dyn PlanarArrayInitializationCallback,
1078    ) -> Result<PlanarArrayInitializationResult> {
1079        self.initialize_internal(
1080            measurements,
1081            optics,
1082            nominal_illumination,
1083            model,
1084            backend,
1085            Some(callback),
1086        )
1087    }
1088
1089    fn initialize_internal<M: MeasurementRead>(
1090        &self,
1091        measurements: &M,
1092        optics: &Optics,
1093        nominal_illumination: &Illumination,
1094        model: &ImagePlaneModel,
1095        backend: Arc<dyn Backend>,
1096        mut callback: Option<&mut dyn PlanarArrayInitializationCallback>,
1097    ) -> Result<PlanarArrayInitializationResult> {
1098        let started = Instant::now();
1099        self.validate_for(nominal_illumination)?;
1100        validate_inputs(measurements, optics, nominal_illumination, model)?;
1101        let active = active_parameters(&self.parameters);
1102        let initial_parameters =
1103            PlanarArrayParameterValues::from_illumination(nominal_illumination)?;
1104        validate_initial_values(&active, &initial_parameters)?;
1105        let candidates = select_candidates(
1106            measurements,
1107            optics,
1108            nominal_illumination,
1109            model,
1110            &self.options,
1111        )?;
1112        if candidates.len().saturating_mul(2) < active.len() {
1113            return Err(Error::InvalidMeasurements(format!(
1114                "{} candidate circle centers provide fewer than {} scalar constraints",
1115                candidates.len(),
1116                active.len()
1117            )));
1118        }
1119        let image_len = checked_len_2d(model.image_shape())?;
1120        let mut mean_spectrum = vec![0.0; image_len];
1121        let mut negative_fractions = vec![0.0; candidates.len()];
1122        let mut spectrum = vec![Complex64::default(); image_len];
1123        let mut shifted = vec![Complex64::default(); image_len];
1124        let mut column = vec![Complex64::default(); model.image_shape().0];
1125        for (position, candidate) in candidates.iter().enumerate() {
1126            let negative_fraction = frame_spectrum(
1127                measurements,
1128                model,
1129                candidate.frame,
1130                candidate.scalar,
1131                backend.as_ref(),
1132                &mut spectrum,
1133                &mut shifted,
1134                &mut column,
1135            )?;
1136            negative_fractions[position] = negative_fraction;
1137            for (mean, value) in mean_spectrum.iter_mut().zip(&shifted) {
1138                *mean += value.norm();
1139            }
1140            emit_progress(
1141                &mut callback,
1142                PlanarArrayInitializationStage::MeanSpectrum,
1143                position + 1,
1144                candidates.len(),
1145                Some(candidate.frame),
1146            )?;
1147        }
1148        for value in &mut mean_spectrum {
1149            *value /= candidates.len() as f64;
1150        }
1151        let mean_max = mean_spectrum.iter().copied().fold(0.0, f64::max);
1152        if !mean_max.is_finite() || mean_max <= 0.0 {
1153            return Err(Error::InvalidMeasurements(
1154                "candidate frames have no finite positive Fourier magnitude".into(),
1155            ));
1156        }
1157        let mean_floor = self.options.mean_spectrum_floor * mean_max;
1158        let mut observations = Vec::with_capacity(candidates.len());
1159        let mut normalized = vec![0.0; image_len];
1160        for (position, candidate) in candidates.iter().enumerate() {
1161            frame_spectrum(
1162                measurements,
1163                model,
1164                candidate.frame,
1165                candidate.scalar,
1166                backend.as_ref(),
1167                &mut spectrum,
1168                &mut shifted,
1169                &mut column,
1170            )?;
1171            for pixel in 0..image_len {
1172                normalized[pixel] = shifted[pixel].norm() / mean_spectrum[pixel].max(mean_floor);
1173            }
1174            gaussian_blur_in_place(
1175                &mut normalized,
1176                model.image_shape(),
1177                self.options.gaussian_sigma_pixels,
1178            );
1179            observations.push(detect_circle(
1180                &normalized,
1181                model.image_shape(),
1182                model,
1183                optics,
1184                candidate,
1185                negative_fractions[position],
1186                &self.options,
1187            ));
1188            emit_progress(
1189                &mut callback,
1190                PlanarArrayInitializationStage::CircleDetection,
1191                position + 1,
1192                candidates.len(),
1193                Some(candidate.frame),
1194            )?;
1195        }
1196        let accepted: Vec<_> = observations
1197            .iter()
1198            .filter(|value| value.accepted())
1199            .collect();
1200        if accepted.len().saturating_mul(2) < active.len() {
1201            return Err(Error::InvalidMeasurements(format!(
1202                "{} accepted circle centers provide fewer than {} scalar constraints",
1203                accepted.len(),
1204                active.len()
1205            )));
1206        }
1207        let fitted_pupil_radius_na = weighted_radius(&accepted)?;
1208        if (fitted_pupil_radius_na - optics.objective_na).abs()
1209            > self.options.pupil_radius_tolerance_na
1210        {
1211            return Err(Error::InvalidMeasurements(format!(
1212                "fitted pupil radius {fitted_pupil_radius_na:.6e} NA differs from configured objective NA {:.6e} by more than {:.6e}",
1213                optics.objective_na, self.options.pupil_radius_tolerance_na
1214            )));
1215        }
1216        let (rank, condition) = jacobian_rank(
1217            optics,
1218            nominal_illumination,
1219            &initial_parameters,
1220            &active,
1221            &accepted,
1222            self.options.rank_tolerance,
1223        )?;
1224        if rank != active.len() {
1225            return Err(Error::InvalidParameter {
1226                name: "parameters",
1227                reason: format!(
1228                    "accepted circle observations give data-Jacobian rank {rank} for {} active physical parameters",
1229                    active.len()
1230                ),
1231            });
1232        }
1233        let initial_rms =
1234            residual_rms(optics, nominal_illumination, &initial_parameters, &accepted)?;
1235        let mut evaluations = 0;
1236        let (initialized_parameters, fit_history) = fit_parameters(
1237            optics,
1238            nominal_illumination,
1239            &initial_parameters,
1240            &active,
1241            &accepted,
1242            &self.options,
1243            &mut evaluations,
1244            &mut callback,
1245        )?;
1246        let initialized_illumination =
1247            initialized_parameters.to_illumination(nominal_illumination)?;
1248        let mut initialized_model = model.clone();
1249        initialized_model.update_illumination_geometry(optics, &initialized_illumination)?;
1250        validate_preserved_model_state(model, &initialized_model)?;
1251        let final_rms = residual_rms(
1252            optics,
1253            nominal_illumination,
1254            &initialized_parameters,
1255            &accepted,
1256        )?;
1257        let diagnostics = PlanarArrayInitializationDiagnostics {
1258            candidate_frames: observations.len(),
1259            accepted_observations: accepted.len(),
1260            rejected_observations: observations.len() - accepted.len(),
1261            configured_pupil_radius_na: optics.objective_na,
1262            fitted_pupil_radius_na,
1263            jacobian_rank: rank,
1264            active_parameter_count: active.len(),
1265            jacobian_condition_estimate: condition,
1266            initial_residual_rms_na: initial_rms,
1267            final_residual_rms_na: final_rms,
1268            warnings: Vec::new(),
1269        };
1270        emit_progress(
1271            &mut callback,
1272            PlanarArrayInitializationStage::Complete,
1273            1,
1274            1,
1275            None,
1276        )?;
1277        let result = PlanarArrayInitializationResult {
1278            format_version: INITIALIZATION_FORMAT_VERSION,
1279            nominal_illumination: nominal_illumination.clone(),
1280            initialized_illumination,
1281            initialized_model,
1282            parameters: self.parameters.clone(),
1283            options: self.options.clone(),
1284            initial_parameters,
1285            initialized_parameters,
1286            parameter_names: active.iter().map(ActiveParameter::name).collect(),
1287            observations,
1288            fit_history,
1289            diagnostics,
1290            runtime: PlanarArrayInitializationRuntime {
1291                elapsed_seconds: started.elapsed().as_secs_f64(),
1292                measurement_passes: 2,
1293                physical_objective_evaluations: evaluations,
1294            },
1295        };
1296        result.validate()?;
1297        Ok(result)
1298    }
1299}
1300
1301#[derive(Clone, Copy)]
1302struct CandidateFrame {
1303    frame: usize,
1304    source: usize,
1305    scalar: f64,
1306    nominal: KVector,
1307}
1308
1309fn select_candidates<M: MeasurementRead>(
1310    measurements: &M,
1311    optics: &Optics,
1312    illumination: &Illumination,
1313    model: &ImagePlaneModel,
1314    options: &BrightfieldCircleOptions,
1315) -> Result<Vec<CandidateFrame>> {
1316    let resolved = illumination.resolve(optics)?;
1317    let requested = options.frame_indices.as_ref().map(|values| {
1318        let mut values = values.clone();
1319        values.sort_unstable();
1320        values
1321    });
1322    let indices: Vec<_> = requested
1323        .clone()
1324        .unwrap_or_else(|| (0..resolved.frame_count()).collect());
1325    let mut candidates = Vec::new();
1326    for frame in indices {
1327        if frame >= resolved.frame_count() {
1328            return Err(Error::FrameOutOfRange {
1329                index: frame,
1330                frames: resolved.frame_count(),
1331            });
1332        }
1333        let explicit = requested.is_some();
1334        let weight = measurements.frame_weight(frame)?;
1335        let resolved_frame = &resolved.frames()[frame];
1336        let eligible = resolved_frame.contributions().len() == 1
1337            && weight > 0.0
1338            && resolved_frame.gain() > 0.0;
1339        if !eligible {
1340            if explicit {
1341                return Err(Error::InvalidMeasurements(format!(
1342                    "selected frame {frame} must have positive measurement weight and exactly one positive source contribution"
1343                )));
1344            }
1345            continue;
1346        }
1347        if measurements
1348            .frame_mask(frame)?
1349            .is_some_and(|mask| mask.contains(&0))
1350        {
1351            if explicit {
1352                return Err(Error::InvalidMeasurements(format!(
1353                    "selected frame {frame} has invalid pixels; circle detection requires an all-valid frame"
1354                )));
1355            }
1356            continue;
1357        }
1358        let contribution = resolved_frame.contributions()[0];
1359        let power = resolved.source_power()[contribution.source];
1360        let scalar = resolved_frame.gain() * contribution.intensity_weight * power;
1361        if !scalar.is_finite() || scalar <= 0.0 {
1362            if explicit {
1363                return Err(Error::InvalidMeasurements(format!(
1364                    "selected frame {frame} has a non-positive complete intensity factor"
1365                )));
1366            }
1367            continue;
1368        }
1369        let nominal = model.k_vectors()[contribution.source];
1370        let nominal_na = k_to_na(optics, nominal.kx.hypot(nominal.ky));
1371        if nominal_na + options.center_search_radius_na + options.brightfield_margin_na
1372            >= optics.objective_na
1373        {
1374            if explicit {
1375                return Err(Error::InvalidMeasurements(format!(
1376                    "selected frame {frame} is not safely inside the bright-field boundary"
1377                )));
1378            }
1379            continue;
1380        }
1381        candidates.push(CandidateFrame {
1382            frame,
1383            source: contribution.source,
1384            scalar,
1385            nominal,
1386        });
1387    }
1388    if candidates.is_empty() {
1389        return Err(Error::InvalidMeasurements(
1390            "no safe single-source bright-field frames are available".into(),
1391        ));
1392    }
1393    candidates.sort_by_key(|value| (value.source, value.frame));
1394    Ok(candidates)
1395}
1396
1397fn validate_inputs<M: MeasurementRead>(
1398    measurements: &M,
1399    optics: &Optics,
1400    illumination: &Illumination,
1401    model: &ImagePlaneModel,
1402) -> Result<()> {
1403    measurements.validate()?;
1404    optics.validate()?;
1405    model.validate()?;
1406    if measurements.frame_count() != model.frame_count()
1407        || measurements.image_shape() != model.image_shape()
1408    {
1409        return Err(Error::InvalidShape(
1410            "measurements and initialization model must have matching frame and image shapes"
1411                .into(),
1412        ));
1413    }
1414    let expected = ImagePlaneModel::from_experiment(
1415        optics,
1416        illumination,
1417        model.image_shape(),
1418        ReconstructionShape::Exact(model.reconstruction_shape()),
1419    )?;
1420    if expected.k_vectors() != model.k_vectors()
1421        || expected.crop_indices().as_slice() != model.crop_indices().as_slice()
1422        || expected.subpixel_offsets() != model.subpixel_offsets()
1423        || expected.multiplexing_matrix() != model.multiplexing_matrix()
1424        || expected.frame_gains() != model.frame_gains()
1425        || expected
1426            .pupil()
1427            .support()
1428            .iter()
1429            .ne(model.pupil().support().iter())
1430        || expected.sampling().dkx != model.sampling().dkx
1431        || expected.sampling().dky != model.sampling().dky
1432        || expected.sampling().low_res_pixel_size != model.sampling().low_res_pixel_size
1433    {
1434        return Err(Error::InvalidModel(
1435            "initialization model was not compiled from the supplied optics and nominal illumination"
1436                .into(),
1437        ));
1438    }
1439    Ok(())
1440}
1441
1442#[allow(clippy::too_many_arguments)]
1443fn frame_spectrum<M: MeasurementRead>(
1444    measurements: &M,
1445    model: &ImagePlaneModel,
1446    frame: usize,
1447    scalar: f64,
1448    backend: &dyn Backend,
1449    spectrum: &mut [Complex64],
1450    shifted: &mut [Complex64],
1451    column: &mut [Complex64],
1452) -> Result<f64> {
1453    let measured = measurements.frame(frame)?;
1454    let shape = model.image_shape();
1455    let mut negative = 0usize;
1456    for row in 0..shape.0 {
1457        let row_window = hann(row, shape.0);
1458        for col in 0..shape.1 {
1459            let pixel = row * shape.1 + col;
1460            let corrected = (measured[pixel] - model.background_value(frame, pixel)?) / scalar;
1461            if !corrected.is_finite() {
1462                return Err(Error::InvalidMeasurements(format!(
1463                    "frame {frame} has a non-finite corrected intensity at pixel {pixel}"
1464                )));
1465            }
1466            negative += usize::from(corrected < 0.0);
1467            spectrum[pixel] = Complex64::new(corrected * row_window * hann(col, shape.1), 0.0);
1468        }
1469    }
1470    backend.fft2(spectrum, shape, FftDirection::Forward, column)?;
1471    fftshift_copy(spectrum, shifted, shape);
1472    Ok(negative as f64 / measured.len() as f64)
1473}
1474
1475fn hann(index: usize, length: usize) -> f64 {
1476    if length <= 1 {
1477        1.0
1478    } else {
1479        0.5 - 0.5 * (TAU * index as f64 / (length - 1) as f64).cos()
1480    }
1481}
1482
1483fn gaussian_blur_in_place(values: &mut [f64], shape: (usize, usize), sigma: f64) {
1484    let radius = (3.0 * sigma).ceil() as isize;
1485    let mut kernel: Vec<_> = (-radius..=radius)
1486        .map(|offset| (-0.5 * (offset as f64 / sigma).powi(2)).exp())
1487        .collect();
1488    let sum = kernel.iter().sum::<f64>();
1489    for value in &mut kernel {
1490        *value /= sum;
1491    }
1492    let mut temporary = vec![0.0; values.len()];
1493    for row in 0..shape.0 {
1494        for col in 0..shape.1 {
1495            let mut total = 0.0;
1496            for (kernel_index, &weight) in kernel.iter().enumerate() {
1497                let offset = kernel_index as isize - radius;
1498                let source = (col as isize + offset).clamp(0, shape.1 as isize - 1) as usize;
1499                total += weight * values[row * shape.1 + source];
1500            }
1501            temporary[row * shape.1 + col] = total;
1502        }
1503    }
1504    for row in 0..shape.0 {
1505        for col in 0..shape.1 {
1506            let mut total = 0.0;
1507            for (kernel_index, &weight) in kernel.iter().enumerate() {
1508                let offset = kernel_index as isize - radius;
1509                let source = (row as isize + offset).clamp(0, shape.0 as isize - 1) as usize;
1510                total += weight * temporary[source * shape.1 + col];
1511            }
1512            values[row * shape.1 + col] = total;
1513        }
1514    }
1515}
1516
1517#[derive(Clone, Copy, Default)]
1518struct CircleScore {
1519    first: f64,
1520    second: f64,
1521    combined: f64,
1522    arc_fraction: f64,
1523}
1524
1525#[allow(clippy::too_many_arguments)]
1526fn detect_circle(
1527    normalized: &[f64],
1528    shape: (usize, usize),
1529    model: &ImagePlaneModel,
1530    optics: &Optics,
1531    candidate: &CandidateFrame,
1532    negative_fraction: f64,
1533    options: &BrightfieldCircleOptions,
1534) -> BrightfieldCircleObservation {
1535    let na_per_k = optics.wavelength_vacuum_m / TAU;
1536    let dkx_na = model.sampling().dkx * na_per_k;
1537    let dky_na = model.sampling().dky * na_per_k;
1538    let pixel_na = dkx_na.min(dky_na);
1539    let center_search = options.center_search_radius_na;
1540    let nominal_na = [
1541        candidate.nominal.kx * na_per_k,
1542        candidate.nominal.ky * na_per_k,
1543    ];
1544    let separation = 2.0 * nominal_na[0].hypot(nominal_na[1]);
1545    let conjugate_overlap = separation <= 2.0 * center_search;
1546    let radius_steps = (options.pupil_radius_search_na / pixel_na).ceil() as isize;
1547    let x_steps = (center_search / dkx_na).ceil() as isize;
1548    let y_steps = (center_search / dky_na).ceil() as isize;
1549    let mut best = None::<([f64; 2], f64, CircleScore)>;
1550    for radius_step in -radius_steps..=radius_steps {
1551        let radius_na = optics.objective_na + radius_step as f64 * pixel_na;
1552        if radius_na <= 0.0
1553            || (radius_na - optics.objective_na).abs() > options.pupil_radius_search_na
1554        {
1555            continue;
1556        }
1557        for row_step in -y_steps..=y_steps {
1558            for col_step in -x_steps..=x_steps {
1559                let center = [
1560                    nominal_na[0] + col_step as f64 * dkx_na,
1561                    nominal_na[1] + row_step as f64 * dky_na,
1562                ];
1563                if (center[0] - nominal_na[0]).hypot(center[1] - nominal_na[1]) > center_search {
1564                    continue;
1565                }
1566                let score = circle_score(
1567                    normalized, shape, center, radius_na, dkx_na, dky_na, options,
1568                );
1569                if best.is_none_or(|value| score.combined > value.2.combined) {
1570                    best = Some((center, radius_na, score));
1571                }
1572            }
1573        }
1574    }
1575    let Some((mut center, mut radius_na, mut score)) = best else {
1576        return rejected_observation(
1577            candidate,
1578            optics.objective_na,
1579            negative_fraction,
1580            "no finite circle candidate was available",
1581        );
1582    };
1583    for scale in [0.5, 0.25] {
1584        let origin = center;
1585        let origin_radius = radius_na;
1586        for radius_step in -1..=1 {
1587            for row_step in -1..=1 {
1588                for col_step in -1..=1 {
1589                    let trial_center = [
1590                        origin[0] + col_step as f64 * dkx_na * scale,
1591                        origin[1] + row_step as f64 * dky_na * scale,
1592                    ];
1593                    if (trial_center[0] - nominal_na[0]).hypot(trial_center[1] - nominal_na[1])
1594                        > center_search
1595                    {
1596                        continue;
1597                    }
1598                    let trial_radius = origin_radius + radius_step as f64 * pixel_na * scale;
1599                    if trial_radius <= 0.0
1600                        || (trial_radius - optics.objective_na).abs()
1601                            > options.pupil_radius_search_na
1602                    {
1603                        continue;
1604                    }
1605                    let trial_score = circle_score(
1606                        normalized,
1607                        shape,
1608                        trial_center,
1609                        trial_radius,
1610                        dkx_na,
1611                        dky_na,
1612                        options,
1613                    );
1614                    if trial_score.combined > score.combined {
1615                        center = trial_center;
1616                        radius_na = trial_radius;
1617                        score = trial_score;
1618                    }
1619                }
1620            }
1621        }
1622    }
1623    let conjugate_score = circle_score(
1624        normalized,
1625        shape,
1626        [-center[0], -center[1]],
1627        radius_na,
1628        dkx_na,
1629        dky_na,
1630        options,
1631    )
1632    .combined;
1633    let nominal_radius = nominal_na[0].hypot(nominal_na[1]);
1634    let rejection = if nominal_radius <= pixel_na {
1635        Some("on-axis magnitude spectra do not identify a signed source center".to_string())
1636    } else if conjugate_overlap {
1637        Some("nominal and conjugate center-search regions overlap".to_string())
1638    } else if score.arc_fraction < options.minimum_arc_fraction {
1639        Some("insufficient usable circular arc".to_string())
1640    } else if score.first < options.minimum_edge_contrast {
1641        Some("normalized circular-edge contrast is below the configured minimum".to_string())
1642    } else if !score.combined.is_finite() {
1643        Some("circle score is non-finite".to_string())
1644    } else {
1645        None
1646    };
1647    let accepted = rejection.is_none();
1648    let k = [center[0] / na_per_k, center[1] / na_per_k];
1649    let grid = [
1650        shape.0 as f64 / 2.0 + k[1] / model.sampling().dky,
1651        shape.1 as f64 / 2.0 + k[0] / model.sampling().dkx,
1652    ];
1653    BrightfieldCircleObservation {
1654        frame_index: candidate.frame,
1655        source_index: candidate.source,
1656        nominal_k_rad_per_m: [candidate.nominal.kx, candidate.nominal.ky],
1657        detected_k_rad_per_m: accepted.then_some(k),
1658        detected_na: accepted.then_some(center),
1659        fourier_grid_position: accepted.then_some(grid),
1660        fitted_pupil_radius_na: radius_na,
1661        first_derivative_score: score.first,
1662        second_derivative_score: score.second,
1663        combined_score: score.combined,
1664        conjugate_score,
1665        usable_arc_fraction: score.arc_fraction,
1666        confidence: if accepted {
1667            (score.first - options.minimum_edge_contrast).clamp(1e-6, 1.0)
1668        } else {
1669            0.0
1670        },
1671        negative_sample_fraction: negative_fraction,
1672        rejection_reason: rejection,
1673    }
1674}
1675
1676#[allow(clippy::too_many_arguments)]
1677fn circle_score(
1678    values: &[f64],
1679    shape: (usize, usize),
1680    center_na: [f64; 2],
1681    radius_na: f64,
1682    dkx_na: f64,
1683    dky_na: f64,
1684    options: &BrightfieldCircleOptions,
1685) -> CircleScore {
1686    let delta_na = options.radial_derivative_step_pixels * dkx_na.min(dky_na);
1687    let second_radius = radius_na + options.gaussian_sigma_pixels * dkx_na.min(dky_na);
1688    // A point-wise denominator makes tiny Gaussian tails look like perfect edges.
1689    // Normalize by one spectrum-wide scale so an aligned circumference is rewarded
1690    // for sustained contrast rather than isolated relative changes in the background.
1691    let intensity_scale = values.iter().copied().fold(0.0, f64::max).max(f64::EPSILON);
1692    let conjugate = [-center_na[0], -center_na[1]];
1693    let mut first = 0.0;
1694    let mut second = 0.0;
1695    let mut valid = 0usize;
1696    for sample in 0..options.angular_samples {
1697        let angle = TAU * sample as f64 / options.angular_samples as f64;
1698        let (sin, cos) = angle.sin_cos();
1699        let edge = [
1700            center_na[0] + radius_na * cos,
1701            center_na[1] + radius_na * sin,
1702        ];
1703        if (edge[0] - conjugate[0]).hypot(edge[1] - conjugate[1]) < radius_na
1704            && center_na[0].hypot(center_na[1]) > delta_na
1705        {
1706            continue;
1707        }
1708        let inner = sample_na(
1709            values,
1710            shape,
1711            [
1712                center_na[0] + (radius_na - delta_na) * cos,
1713                center_na[1] + (radius_na - delta_na) * sin,
1714            ],
1715            dkx_na,
1716            dky_na,
1717        );
1718        let outer = sample_na(
1719            values,
1720            shape,
1721            [
1722                center_na[0] + (radius_na + delta_na) * cos,
1723                center_na[1] + (radius_na + delta_na) * sin,
1724            ],
1725            dkx_na,
1726            dky_na,
1727        );
1728        let second_inner = sample_na(
1729            values,
1730            shape,
1731            [
1732                center_na[0] + (second_radius - delta_na) * cos,
1733                center_na[1] + (second_radius - delta_na) * sin,
1734            ],
1735            dkx_na,
1736            dky_na,
1737        );
1738        let second_mid = sample_na(
1739            values,
1740            shape,
1741            [
1742                center_na[0] + second_radius * cos,
1743                center_na[1] + second_radius * sin,
1744            ],
1745            dkx_na,
1746            dky_na,
1747        );
1748        let second_outer = sample_na(
1749            values,
1750            shape,
1751            [
1752                center_na[0] + (second_radius + delta_na) * cos,
1753                center_na[1] + (second_radius + delta_na) * sin,
1754            ],
1755            dkx_na,
1756            dky_na,
1757        );
1758        let (Some(inner), Some(outer), Some(second_inner), Some(second_mid), Some(second_outer)) =
1759            (inner, outer, second_inner, second_mid, second_outer)
1760        else {
1761            continue;
1762        };
1763        first += (inner - outer) / intensity_scale;
1764        second += (second_inner - 2.0 * second_mid + second_outer).abs() / (4.0 * intensity_scale);
1765        valid += 1;
1766    }
1767    if valid == 0 {
1768        return CircleScore::default();
1769    }
1770    let first = first / valid as f64;
1771    let second = second / valid as f64;
1772    CircleScore {
1773        first,
1774        second,
1775        combined: first.max(0.0) + 0.25 * second,
1776        arc_fraction: valid as f64 / options.angular_samples as f64,
1777    }
1778}
1779
1780fn sample_na(
1781    values: &[f64],
1782    shape: (usize, usize),
1783    coordinate_na: [f64; 2],
1784    dkx_na: f64,
1785    dky_na: f64,
1786) -> Option<f64> {
1787    let column = shape.1 as f64 / 2.0 + coordinate_na[0] / dkx_na;
1788    let row = shape.0 as f64 / 2.0 + coordinate_na[1] / dky_na;
1789    if row < 0.0 || column < 0.0 || row > (shape.0 - 1) as f64 || column > (shape.1 - 1) as f64 {
1790        return None;
1791    }
1792    let row0 = row.floor() as usize;
1793    let col0 = column.floor() as usize;
1794    let row1 = (row0 + 1).min(shape.0 - 1);
1795    let col1 = (col0 + 1).min(shape.1 - 1);
1796    let tr = row - row0 as f64;
1797    let tc = column - col0 as f64;
1798    let a = values[row0 * shape.1 + col0] * (1.0 - tc) + values[row0 * shape.1 + col1] * tc;
1799    let b = values[row1 * shape.1 + col0] * (1.0 - tc) + values[row1 * shape.1 + col1] * tc;
1800    Some(a * (1.0 - tr) + b * tr)
1801}
1802
1803fn rejected_observation(
1804    candidate: &CandidateFrame,
1805    radius_na: f64,
1806    negative_sample_fraction: f64,
1807    reason: impl Into<String>,
1808) -> BrightfieldCircleObservation {
1809    BrightfieldCircleObservation {
1810        frame_index: candidate.frame,
1811        source_index: candidate.source,
1812        nominal_k_rad_per_m: [candidate.nominal.kx, candidate.nominal.ky],
1813        detected_k_rad_per_m: None,
1814        detected_na: None,
1815        fourier_grid_position: None,
1816        fitted_pupil_radius_na: radius_na,
1817        first_derivative_score: 0.0,
1818        second_derivative_score: 0.0,
1819        combined_score: 0.0,
1820        conjugate_score: 0.0,
1821        usable_arc_fraction: 0.0,
1822        confidence: 0.0,
1823        negative_sample_fraction,
1824        rejection_reason: Some(reason.into()),
1825    }
1826}
1827
1828#[derive(Clone)]
1829struct ActiveParameter {
1830    kind: ParameterKind,
1831    spec: CalibrationParameterSpec,
1832}
1833
1834#[derive(Clone, Copy)]
1835enum ParameterKind {
1836    Translation(usize),
1837    Rotation(usize),
1838    Pitch(usize),
1839    Reference(usize),
1840}
1841
1842impl ActiveParameter {
1843    fn name(&self) -> String {
1844        match self.kind {
1845            ParameterKind::Translation(axis) => ["tx_m", "ty_m", "tz_m"][axis].into(),
1846            ParameterKind::Rotation(axis) => ["rx_rad", "ry_rad", "rz_rad"][axis].into(),
1847            ParameterKind::Pitch(axis) => ["pitch_x_m", "pitch_y_m"][axis].into(),
1848            ParameterKind::Reference(axis) => ["reference_column", "reference_row"][axis].into(),
1849        }
1850    }
1851
1852    fn value(&self, values: &PlanarArrayParameterValues) -> f64 {
1853        match self.kind {
1854            ParameterKind::Translation(axis) => values.translation_m[axis],
1855            ParameterKind::Rotation(axis) => values.rotation_rad[axis],
1856            ParameterKind::Pitch(axis) => values.pitch_m[axis],
1857            ParameterKind::Reference(axis) => values.reference_index[axis],
1858        }
1859    }
1860
1861    fn set(&self, values: &mut PlanarArrayParameterValues, value: f64) {
1862        match self.kind {
1863            ParameterKind::Translation(axis) => values.translation_m[axis] = value,
1864            ParameterKind::Rotation(axis) => values.rotation_rad[axis] = value,
1865            ParameterKind::Pitch(axis) => values.pitch_m[axis] = value,
1866            ParameterKind::Reference(axis) => values.reference_index[axis] = value,
1867        }
1868    }
1869}
1870
1871fn active_parameters(parameters: &PlanarArrayCalibrationParameters) -> Vec<ActiveParameter> {
1872    let mut active = Vec::new();
1873    for axis in 0..3 {
1874        if let Some(spec) = &parameters.translation[axis] {
1875            active.push(ActiveParameter {
1876                kind: ParameterKind::Translation(axis),
1877                spec: spec.clone(),
1878            });
1879        }
1880    }
1881    for axis in 0..3 {
1882        if let Some(spec) = &parameters.rotation[axis] {
1883            active.push(ActiveParameter {
1884                kind: ParameterKind::Rotation(axis),
1885                spec: spec.clone(),
1886            });
1887        }
1888    }
1889    for axis in 0..2 {
1890        if let Some(spec) = &parameters.pitch[axis] {
1891            active.push(ActiveParameter {
1892                kind: ParameterKind::Pitch(axis),
1893                spec: spec.clone(),
1894            });
1895        }
1896    }
1897    for axis in 0..2 {
1898        if let Some(spec) = &parameters.reference_index[axis] {
1899            active.push(ActiveParameter {
1900                kind: ParameterKind::Reference(axis),
1901                spec: spec.clone(),
1902            });
1903        }
1904    }
1905    active
1906}
1907
1908fn validate_initial_values(
1909    active: &[ActiveParameter],
1910    values: &PlanarArrayParameterValues,
1911) -> Result<()> {
1912    for parameter in active {
1913        let value = parameter.value(values);
1914        if value < parameter.spec.lower_bound || value > parameter.spec.upper_bound {
1915            return Err(Error::InvalidParameter {
1916                name: "parameters",
1917                reason: format!(
1918                    "initial {}={value:.6e} lies outside [{:.6e}, {:.6e}]",
1919                    parameter.name(),
1920                    parameter.spec.lower_bound,
1921                    parameter.spec.upper_bound
1922                ),
1923            });
1924        }
1925    }
1926    Ok(())
1927}
1928
1929fn predicted_vectors(
1930    optics: &Optics,
1931    template: &Illumination,
1932    values: &PlanarArrayParameterValues,
1933) -> Result<Vec<KVector>> {
1934    Ok(values
1935        .to_illumination(template)?
1936        .resolve(optics)?
1937        .k_vectors()
1938        .to_vec())
1939}
1940
1941#[allow(clippy::too_many_arguments)]
1942fn objective(
1943    optics: &Optics,
1944    template: &Illumination,
1945    initial: &PlanarArrayParameterValues,
1946    values: &PlanarArrayParameterValues,
1947    active: &[ActiveParameter],
1948    observations: &[&BrightfieldCircleObservation],
1949    robust_scale: f64,
1950    evaluations: &mut usize,
1951) -> Result<(f64, f64, f64)> {
1952    *evaluations += 1;
1953    let vectors = predicted_vectors(optics, template, values)?;
1954    let mut data_loss = 0.0;
1955    let mut weight_sum = 0.0;
1956    for observation in observations {
1957        let detected = observation.detected_na.ok_or_else(|| {
1958            Error::InvalidMeasurements("accepted observation has no detected center".into())
1959        })?;
1960        let predicted = vectors[observation.source_index];
1961        let residuals = [
1962            k_to_na(optics, predicted.kx) - detected[0],
1963            k_to_na(optics, predicted.ky) - detected[1],
1964        ];
1965        let weight = observation.confidence.max(1e-12);
1966        for residual in residuals {
1967            data_loss += weight * huber(residual, robust_scale);
1968            weight_sum += weight;
1969        }
1970    }
1971    data_loss /= weight_sum.max(f64::EPSILON);
1972    let mut regularization = 0.0;
1973    for parameter in active {
1974        if let Some(center) = parameter.spec.prior_center {
1975            let normalized = (parameter.value(values) - center) / parameter.spec.scale;
1976            regularization +=
1977                0.5 * parameter.spec.regularization_strength * normalized * normalized;
1978        }
1979        let initial_value = parameter.value(initial);
1980        if !initial_value.is_finite() {
1981            return Err(Error::InvalidModel(
1982                "initial physical parameter is non-finite".into(),
1983            ));
1984        }
1985    }
1986    Ok((data_loss, regularization, data_loss + regularization))
1987}
1988
1989fn huber(residual: f64, scale: f64) -> f64 {
1990    let magnitude = residual.abs();
1991    if magnitude <= scale {
1992        0.5 * residual * residual
1993    } else {
1994        scale * (magnitude - 0.5 * scale)
1995    }
1996}
1997
1998#[allow(clippy::too_many_arguments)]
1999fn fit_parameters(
2000    optics: &Optics,
2001    template: &Illumination,
2002    initial: &PlanarArrayParameterValues,
2003    active: &[ActiveParameter],
2004    observations: &[&BrightfieldCircleObservation],
2005    options: &BrightfieldCircleOptions,
2006    evaluations: &mut usize,
2007    callback: &mut Option<&mut dyn PlanarArrayInitializationCallback>,
2008) -> Result<(
2009    PlanarArrayParameterValues,
2010    Vec<PlanarArrayInitializationFitRecord>,
2011)> {
2012    let mut current = initial.clone();
2013    let mut current_objective = objective(
2014        optics,
2015        template,
2016        initial,
2017        &current,
2018        active,
2019        observations,
2020        options.robust_residual_scale_na,
2021        evaluations,
2022    )?;
2023    let mut history = Vec::new();
2024    for step in 1..=options.maximum_fit_steps {
2025        let mut gradient = vec![0.0; active.len()];
2026        for (index, parameter) in active.iter().enumerate() {
2027            let value = parameter.value(&current);
2028            let plus_value =
2029                (value + parameter.spec.finite_difference_step).min(parameter.spec.upper_bound);
2030            let minus_value =
2031                (value - parameter.spec.finite_difference_step).max(parameter.spec.lower_bound);
2032            let derivative = if plus_value > value && minus_value < value {
2033                let mut plus = current.clone();
2034                parameter.set(&mut plus, plus_value);
2035                let plus_loss = objective(
2036                    optics,
2037                    template,
2038                    initial,
2039                    &plus,
2040                    active,
2041                    observations,
2042                    options.robust_residual_scale_na,
2043                    evaluations,
2044                )?
2045                .2;
2046                let mut minus = current.clone();
2047                parameter.set(&mut minus, minus_value);
2048                let minus_loss = objective(
2049                    optics,
2050                    template,
2051                    initial,
2052                    &minus,
2053                    active,
2054                    observations,
2055                    options.robust_residual_scale_na,
2056                    evaluations,
2057                )?
2058                .2;
2059                (plus_loss - minus_loss) / (plus_value - minus_value)
2060            } else if plus_value > value {
2061                let mut plus = current.clone();
2062                parameter.set(&mut plus, plus_value);
2063                (objective(
2064                    optics,
2065                    template,
2066                    initial,
2067                    &plus,
2068                    active,
2069                    observations,
2070                    options.robust_residual_scale_na,
2071                    evaluations,
2072                )?
2073                .2 - current_objective.2)
2074                    / (plus_value - value)
2075            } else if minus_value < value {
2076                let mut minus = current.clone();
2077                parameter.set(&mut minus, minus_value);
2078                (current_objective.2
2079                    - objective(
2080                        optics,
2081                        template,
2082                        initial,
2083                        &minus,
2084                        active,
2085                        observations,
2086                        options.robust_residual_scale_na,
2087                        evaluations,
2088                    )?
2089                    .2)
2090                    / (value - minus_value)
2091            } else {
2092                0.0
2093            };
2094            gradient[index] = derivative * parameter.spec.scale;
2095        }
2096        let norm = gradient
2097            .iter()
2098            .map(|value| value * value)
2099            .sum::<f64>()
2100            .sqrt();
2101        if !norm.is_finite() {
2102            return Err(Error::Numerical(
2103                "planar-array initialization gradient is non-finite".into(),
2104            ));
2105        }
2106        if norm <= f64::EPSILON {
2107            break;
2108        }
2109        let mut step_size = options.fit_initial_step_size;
2110        let mut accepted = None;
2111        while step_size >= options.fit_minimum_step_size {
2112            let mut candidate = current.clone();
2113            for (parameter, &component) in active.iter().zip(&gradient) {
2114                let value = parameter.value(&candidate)
2115                    - step_size * parameter.spec.scale * component / norm;
2116                parameter.set(
2117                    &mut candidate,
2118                    value.clamp(parameter.spec.lower_bound, parameter.spec.upper_bound),
2119                );
2120            }
2121            if candidate == current {
2122                step_size *= options.fit_step_reduction;
2123                continue;
2124            }
2125            if let Ok(candidate_objective) = objective(
2126                optics,
2127                template,
2128                initial,
2129                &candidate,
2130                active,
2131                observations,
2132                options.robust_residual_scale_na,
2133                evaluations,
2134            ) && candidate_objective.2 < current_objective.2
2135            {
2136                accepted = Some((candidate, candidate_objective));
2137                break;
2138            }
2139            step_size *= options.fit_step_reduction;
2140        }
2141        if let Some((candidate, candidate_objective)) = accepted {
2142            let relative = (current_objective.2 - candidate_objective.2)
2143                / current_objective.2.abs().max(f64::EPSILON);
2144            current = candidate;
2145            current_objective = candidate_objective;
2146            history.push(PlanarArrayInitializationFitRecord {
2147                step,
2148                accepted: true,
2149                step_size,
2150                data_loss: current_objective.0,
2151                regularization_loss: current_objective.1,
2152                total_loss: current_objective.2,
2153                normalized_values: normalized_values(active, initial, &current),
2154            });
2155            emit_progress(
2156                callback,
2157                PlanarArrayInitializationStage::PhysicalFit,
2158                step,
2159                options.maximum_fit_steps,
2160                None,
2161            )?;
2162            if relative <= options.fit_relative_tolerance {
2163                break;
2164            }
2165        } else {
2166            history.push(PlanarArrayInitializationFitRecord {
2167                step,
2168                accepted: false,
2169                step_size: 0.0,
2170                data_loss: current_objective.0,
2171                regularization_loss: current_objective.1,
2172                total_loss: current_objective.2,
2173                normalized_values: normalized_values(active, initial, &current),
2174            });
2175            emit_progress(
2176                callback,
2177                PlanarArrayInitializationStage::PhysicalFit,
2178                step,
2179                options.maximum_fit_steps,
2180                None,
2181            )?;
2182            break;
2183        }
2184    }
2185    Ok((current, history))
2186}
2187
2188fn normalized_values(
2189    active: &[ActiveParameter],
2190    initial: &PlanarArrayParameterValues,
2191    current: &PlanarArrayParameterValues,
2192) -> Vec<f64> {
2193    active
2194        .iter()
2195        .map(|parameter| {
2196            (parameter.value(current) - parameter.value(initial)) / parameter.spec.scale
2197        })
2198        .collect()
2199}
2200
2201fn residual_rms(
2202    optics: &Optics,
2203    template: &Illumination,
2204    values: &PlanarArrayParameterValues,
2205    observations: &[&BrightfieldCircleObservation],
2206) -> Result<f64> {
2207    let vectors = predicted_vectors(optics, template, values)?;
2208    let mut total = 0.0;
2209    let mut weight = 0.0;
2210    for observation in observations {
2211        let detected = observation.detected_na.unwrap();
2212        let predicted = vectors[observation.source_index];
2213        let confidence = observation.confidence.max(1e-12);
2214        total += confidence
2215            * ((k_to_na(optics, predicted.kx) - detected[0]).powi(2)
2216                + (k_to_na(optics, predicted.ky) - detected[1]).powi(2));
2217        weight += 2.0 * confidence;
2218    }
2219    Ok((total / weight.max(f64::EPSILON)).sqrt())
2220}
2221
2222fn jacobian_rank(
2223    optics: &Optics,
2224    template: &Illumination,
2225    values: &PlanarArrayParameterValues,
2226    active: &[ActiveParameter],
2227    observations: &[&BrightfieldCircleObservation],
2228    tolerance: f64,
2229) -> Result<(usize, Option<f64>)> {
2230    let rows = observations.len() * 2;
2231    let mut columns = Vec::with_capacity(active.len());
2232    for parameter in active {
2233        let value = parameter.value(values);
2234        let plus_value =
2235            (value + parameter.spec.finite_difference_step).min(parameter.spec.upper_bound);
2236        let minus_value =
2237            (value - parameter.spec.finite_difference_step).max(parameter.spec.lower_bound);
2238        if plus_value == minus_value {
2239            columns.push(vec![0.0; rows]);
2240            continue;
2241        }
2242        let mut plus = values.clone();
2243        parameter.set(&mut plus, plus_value);
2244        let plus_vectors = predicted_vectors(optics, template, &plus)?;
2245        let mut minus = values.clone();
2246        parameter.set(&mut minus, minus_value);
2247        let minus_vectors = predicted_vectors(optics, template, &minus)?;
2248        let mut column = Vec::with_capacity(rows);
2249        for observation in observations {
2250            let weight = observation.confidence.max(1e-12).sqrt();
2251            let plus = plus_vectors[observation.source_index];
2252            let minus = minus_vectors[observation.source_index];
2253            let denominator = plus_value - minus_value;
2254            column.push(
2255                weight * parameter.spec.scale * k_to_na(optics, plus.kx - minus.kx) / denominator,
2256            );
2257            column.push(
2258                weight * parameter.spec.scale * k_to_na(optics, plus.ky - minus.ky) / denominator,
2259            );
2260        }
2261        columns.push(column);
2262    }
2263    let mut pivots = Vec::new();
2264    let mut remaining: Vec<_> = (0..columns.len()).collect();
2265    let mut basis: Vec<Vec<f64>> = Vec::new();
2266    while !remaining.is_empty() {
2267        let (position, residual, norm) = remaining
2268            .iter()
2269            .enumerate()
2270            .map(|(position, &column)| {
2271                let mut residual = columns[column].clone();
2272                for vector in &basis {
2273                    let projection = dot(&residual, vector);
2274                    for (value, &basis_value) in residual.iter_mut().zip(vector) {
2275                        *value -= projection * basis_value;
2276                    }
2277                }
2278                let norm = dot(&residual, &residual).sqrt();
2279                (position, residual, norm)
2280            })
2281            .max_by(|left, right| left.2.total_cmp(&right.2))
2282            .unwrap();
2283        let reference = pivots.first().copied().unwrap_or(norm);
2284        if !norm.is_finite() || norm <= tolerance * reference.max(f64::EPSILON) {
2285            break;
2286        }
2287        let mut normalized = residual;
2288        for value in &mut normalized {
2289            *value /= norm;
2290        }
2291        basis.push(normalized);
2292        pivots.push(norm);
2293        remaining.remove(position);
2294    }
2295    let condition = if pivots.is_empty() {
2296        None
2297    } else {
2298        Some((pivots[0] / pivots[pivots.len() - 1]).powi(2))
2299    };
2300    Ok((pivots.len(), condition))
2301}
2302
2303fn dot(left: &[f64], right: &[f64]) -> f64 {
2304    left.iter()
2305        .zip(right)
2306        .map(|(left, right)| left * right)
2307        .sum()
2308}
2309
2310fn validate_preserved_model_state(
2311    nominal: &ImagePlaneModel,
2312    initialized: &ImagePlaneModel,
2313) -> Result<()> {
2314    if nominal.image_shape() != initialized.image_shape()
2315        || nominal.reconstruction_shape() != initialized.reconstruction_shape()
2316        || nominal.pupil() != initialized.pupil()
2317        || nominal.background() != initialized.background()
2318        || nominal.frame_gains() != initialized.frame_gains()
2319        || nominal.multiplexing_matrix() != initialized.multiplexing_matrix()
2320    {
2321        return Err(Error::InvalidModel(
2322            "physical initialization changed non-geometric compiled model state".into(),
2323        ));
2324    }
2325    Ok(())
2326}
2327
2328fn weighted_radius(observations: &[&BrightfieldCircleObservation]) -> Result<f64> {
2329    let weight = observations
2330        .iter()
2331        .map(|value| value.confidence)
2332        .sum::<f64>();
2333    if !weight.is_finite() || weight <= 0.0 {
2334        return Err(Error::InvalidMeasurements(
2335            "accepted circle observations have no positive confidence".into(),
2336        ));
2337    }
2338    Ok(observations
2339        .iter()
2340        .map(|value| value.confidence * value.fitted_pupil_radius_na)
2341        .sum::<f64>()
2342        / weight)
2343}
2344
2345fn emit_progress(
2346    callback: &mut Option<&mut dyn PlanarArrayInitializationCallback>,
2347    stage: PlanarArrayInitializationStage,
2348    completed: usize,
2349    total: usize,
2350    frame_index: Option<usize>,
2351) -> Result<()> {
2352    if let Some(callback) = callback.as_deref_mut()
2353        && callback.on_progress(&PlanarArrayInitializationProgress {
2354            stage,
2355            completed,
2356            total,
2357            frame_index,
2358        })? == PlanarArrayInitializationAction::Cancel
2359    {
2360        return Err(Error::Numerical(
2361            "planar-array initialization was cancelled".into(),
2362        ));
2363    }
2364    Ok(())
2365}
2366
2367fn k_to_na(optics: &Optics, value: f64) -> f64 {
2368    value * optics.wavelength_vacuum_m / TAU
2369}
2370
2371fn observation_is_finite(value: &BrightfieldCircleObservation) -> bool {
2372    value
2373        .nominal_k_rad_per_m
2374        .iter()
2375        .chain(value.detected_k_rad_per_m.iter().flatten())
2376        .chain(value.detected_na.iter().flatten())
2377        .chain(value.fourier_grid_position.iter().flatten())
2378        .chain(
2379            [
2380                value.fitted_pupil_radius_na,
2381                value.first_derivative_score,
2382                value.second_derivative_score,
2383                value.combined_score,
2384                value.conjugate_score,
2385                value.usable_arc_fraction,
2386                value.confidence,
2387                value.negative_sample_fraction,
2388            ]
2389            .iter(),
2390        )
2391        .all(|value| value.is_finite())
2392}
2393
2394fn diagnostics_are_finite(value: &PlanarArrayInitializationDiagnostics) -> bool {
2395    [
2396        value.configured_pupil_radius_na,
2397        value.fitted_pupil_radius_na,
2398        value.initial_residual_rms_na,
2399        value.final_residual_rms_na,
2400    ]
2401    .iter()
2402    .all(|value| value.is_finite())
2403        && value
2404            .jacobian_condition_estimate
2405            .is_none_or(|condition| condition.is_finite() && condition >= 0.0)
2406}
2407
2408fn invalid(name: &'static str, reason: impl Into<String>) -> Error {
2409    Error::InvalidParameter {
2410        name,
2411        reason: reason.into(),
2412    }
2413}
2414
2415#[cfg(test)]
2416mod tests {
2417    use super::*;
2418    use crate::experiment::{ArrayPose, PlanarLedArray};
2419
2420    fn optics() -> Optics {
2421        Optics {
2422            wavelength_vacuum_m: 532e-9,
2423            objective_na: 0.1,
2424            magnification: 4.0,
2425            camera_pixel_size: 6.5e-6,
2426            illumination_refractive_index: 1.0,
2427            objective_medium_refractive_index: 1.0,
2428            defocus_distance: None,
2429            pupil_aberration: None,
2430        }
2431    }
2432
2433    fn illumination(translation: [f64; 3]) -> Illumination {
2434        Illumination::from_geometry(PlanarLedArray::new(
2435            (5, 5),
2436            (4e-3, 4e-3),
2437            (2.0, 2.0),
2438            ArrayPose::from_translation(translation),
2439        ))
2440        .unwrap()
2441    }
2442
2443    fn observations_for(
2444        optics: &Optics,
2445        illumination: &Illumination,
2446    ) -> Vec<BrightfieldCircleObservation> {
2447        illumination
2448            .resolve(optics)
2449            .unwrap()
2450            .k_vectors()
2451            .iter()
2452            .enumerate()
2453            .map(|(source, vector)| BrightfieldCircleObservation {
2454                frame_index: source,
2455                source_index: source,
2456                nominal_k_rad_per_m: [vector.kx, vector.ky],
2457                detected_k_rad_per_m: Some([vector.kx, vector.ky]),
2458                detected_na: Some([k_to_na(optics, vector.kx), k_to_na(optics, vector.ky)]),
2459                fourier_grid_position: Some([0.0, 0.0]),
2460                fitted_pupil_radius_na: optics.objective_na,
2461                first_derivative_score: 1.0,
2462                second_derivative_score: 1.0,
2463                combined_score: 1.0,
2464                conjugate_score: 1.0,
2465                usable_arc_fraction: 1.0,
2466                confidence: 1.0,
2467                negative_sample_fraction: 0.0,
2468                rejection_reason: None,
2469            })
2470            .collect()
2471    }
2472
2473    #[test]
2474    fn physical_fit_recovers_lateral_translation_from_detected_vectors() {
2475        let optics = optics();
2476        let nominal = illumination([0.0, 0.0, -80e-3]);
2477        let truth = illumination([0.8e-3, -0.6e-3, -80e-3]);
2478        let observations = observations_for(&optics, &truth);
2479        let accepted: Vec<_> = observations.iter().collect();
2480        let spec = CalibrationParameterSpec::new(-2e-3, 2e-3, 1e-3).finite_difference_step(1e-6);
2481        let parameters = PlanarArrayCalibrationParameters::builder()
2482            .translation_specs([Some(spec.clone()), Some(spec), None])
2483            .build()
2484            .unwrap();
2485        let active = active_parameters(&parameters);
2486        let initial = PlanarArrayParameterValues::from_illumination(&nominal).unwrap();
2487        let options = BrightfieldCircleOptions {
2488            maximum_fit_steps: 200,
2489            fit_initial_step_size: 0.25,
2490            ..Default::default()
2491        };
2492        let mut evaluations = 0;
2493        let mut callback = None;
2494        let (fitted, _) = fit_parameters(
2495            &optics,
2496            &nominal,
2497            &initial,
2498            &active,
2499            &accepted,
2500            &options,
2501            &mut evaluations,
2502            &mut callback,
2503        )
2504        .unwrap();
2505        assert!((fitted.translation_m[0] - 0.8e-3).abs() < 5e-5);
2506        assert!((fitted.translation_m[1] + 0.6e-3).abs() < 5e-5);
2507    }
2508
2509    #[test]
2510    fn rank_test_rejects_axial_distance_pitch_scale_gauge() {
2511        let optics = optics();
2512        let nominal = illumination([0.0, 0.0, -80e-3]);
2513        let observations = observations_for(&optics, &nominal);
2514        let accepted: Vec<_> = observations.iter().collect();
2515        let distance =
2516            CalibrationParameterSpec::new(-0.12, -0.04, 1e-3).finite_difference_step(1e-5);
2517        let pitch = CalibrationParameterSpec::new(3e-3, 5e-3, 1e-4).finite_difference_step(1e-6);
2518        let parameters = PlanarArrayCalibrationParameters::builder()
2519            .translation_specs([None, None, Some(distance)])
2520            .pitch_specs([Some(pitch.clone()), Some(pitch)])
2521            .build()
2522            .unwrap();
2523        let active = active_parameters(&parameters);
2524        let values = PlanarArrayParameterValues::from_illumination(&nominal).unwrap();
2525        let (rank, _) =
2526            jacobian_rank(&optics, &nominal, &values, &active, &accepted, 1e-7).unwrap();
2527        assert!(rank < active.len());
2528    }
2529
2530    #[test]
2531    fn detector_localizes_an_analytic_circular_edge_on_rectangular_sampling() {
2532        let shape = (64, 80);
2533        let dkx_na = 0.004;
2534        let dky_na = 0.005;
2535        let center = [0.02, -0.015];
2536        let radius = 0.1;
2537        let mut values = vec![0.0; shape.0 * shape.1];
2538        for row in 0..shape.0 {
2539            for col in 0..shape.1 {
2540                let coordinate = [
2541                    (col as f64 - shape.1 as f64 / 2.0) * dkx_na,
2542                    (row as f64 - shape.0 as f64 / 2.0) * dky_na,
2543                ];
2544                let positive = (coordinate[0] - center[0]).hypot(coordinate[1] - center[1]);
2545                let negative = (coordinate[0] + center[0]).hypot(coordinate[1] + center[1]);
2546                values[row * shape.1 + col] = if positive <= radius || negative <= radius {
2547                    1.0
2548                } else {
2549                    0.0
2550                };
2551            }
2552        }
2553        gaussian_blur_in_place(&mut values, shape, 1.0);
2554        let options = BrightfieldCircleOptions {
2555            gaussian_sigma_pixels: 1.0,
2556            ..Default::default()
2557        };
2558        let true_score = circle_score(&values, shape, center, radius, dkx_na, dky_na, &options);
2559        let wrong_score = circle_score(
2560            &values,
2561            shape,
2562            [center[0] + 0.02, center[1]],
2563            radius,
2564            dkx_na,
2565            dky_na,
2566            &options,
2567        );
2568        assert!(
2569            true_score.combined > wrong_score.combined,
2570            "true={:?}/{:?}/{:?}, wrong={:?}/{:?}/{:?}",
2571            true_score.first,
2572            true_score.second,
2573            true_score.combined,
2574            wrong_score.first,
2575            wrong_score.second,
2576            wrong_score.combined
2577        );
2578        assert!(true_score.arc_fraction >= options.minimum_arc_fraction);
2579    }
2580}