1use 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
34pub const INITIALIZATION_FORMAT_VERSION: u32 = 1;
36pub 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct BrightfieldCircleOptions {
47 pub frame_indices: Option<Vec<usize>>,
49 pub center_search_radius_na: f64,
51 pub brightfield_margin_na: f64,
53 pub pupil_radius_search_na: f64,
55 pub gaussian_sigma_pixels: f64,
57 pub angular_samples: usize,
59 pub radial_derivative_step_pixels: f64,
61 pub minimum_arc_fraction: f64,
63 pub minimum_edge_contrast: f64,
65 pub mean_spectrum_floor: f64,
67 pub robust_residual_scale_na: f64,
69 pub maximum_fit_steps: usize,
71 pub fit_relative_tolerance: f64,
73 pub fit_initial_step_size: f64,
75 pub fit_minimum_step_size: f64,
77 pub fit_step_reduction: f64,
79 pub rank_tolerance: f64,
81 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct BrightfieldCircleObservation {
197 pub frame_index: usize,
199 pub source_index: usize,
201 pub nominal_k_rad_per_m: [f64; 2],
203 pub detected_k_rad_per_m: Option<[f64; 2]>,
205 pub detected_na: Option<[f64; 2]>,
207 pub fourier_grid_position: Option<[f64; 2]>,
209 pub fitted_pupil_radius_na: f64,
211 pub first_derivative_score: f64,
213 pub second_derivative_score: f64,
215 pub combined_score: f64,
217 pub conjugate_score: f64,
219 pub usable_arc_fraction: f64,
221 pub confidence: f64,
223 pub negative_sample_fraction: f64,
225 pub rejection_reason: Option<String>,
227}
228
229impl BrightfieldCircleObservation {
230 pub fn accepted(&self) -> bool {
232 self.detected_k_rad_per_m.is_some() && self.rejection_reason.is_none()
233 }
234}
235
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct PlanarArrayInitializationFitRecord {
240 pub step: usize,
242 pub accepted: bool,
244 pub step_size: f64,
246 pub data_loss: f64,
248 pub regularization_loss: f64,
250 pub total_loss: f64,
252 pub normalized_values: Vec<f64>,
254}
255
256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct PlanarArrayInitializationDiagnostics {
260 pub candidate_frames: usize,
262 pub accepted_observations: usize,
264 pub rejected_observations: usize,
266 pub configured_pupil_radius_na: f64,
268 pub fitted_pupil_radius_na: f64,
270 pub jacobian_rank: usize,
272 pub active_parameter_count: usize,
274 pub jacobian_condition_estimate: Option<f64>,
276 pub initial_residual_rms_na: f64,
278 pub final_residual_rms_na: f64,
280 pub warnings: Vec<String>,
282}
283
284#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum PlanarArrayInitializationStage {
288 MeanSpectrum,
290 CircleDetection,
292 PhysicalFit,
294 Complete,
296}
297
298#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct PlanarArrayInitializationProgress {
302 pub stage: PlanarArrayInitializationStage,
304 pub completed: usize,
306 pub total: usize,
308 pub frame_index: Option<usize>,
310}
311
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub enum PlanarArrayInitializationAction {
315 Continue,
317 Cancel,
319}
320
321pub trait PlanarArrayInitializationCallback: Send {
323 fn on_progress(
325 &mut self,
326 progress: &PlanarArrayInitializationProgress,
327 ) -> Result<PlanarArrayInitializationAction>;
328}
329
330#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
332#[serde(deny_unknown_fields)]
333pub struct PlanarArrayInitializationRuntime {
334 pub elapsed_seconds: f64,
336 pub measurement_passes: usize,
338 pub physical_objective_evaluations: usize,
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct PlanarArrayInitializationResult {
346 pub format_version: u32,
348 pub nominal_illumination: Illumination,
350 pub initialized_illumination: Illumination,
352 pub initialized_model: ImagePlaneModel,
354 pub parameters: PlanarArrayCalibrationParameters,
356 pub options: BrightfieldCircleOptions,
358 pub initial_parameters: PlanarArrayParameterValues,
360 pub initialized_parameters: PlanarArrayParameterValues,
362 pub parameter_names: Vec<String>,
364 pub observations: Vec<BrightfieldCircleObservation>,
366 pub fit_history: Vec<PlanarArrayInitializationFitRecord>,
368 pub diagnostics: PlanarArrayInitializationDiagnostics,
370 pub runtime: PlanarArrayInitializationRuntime,
372}
373
374impl PlanarArrayInitializationResult {
375 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 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 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 pub fn write_bundle(&self, path: impl AsRef<Path>) -> Result<InitializationBundle> {
525 write_initialization_bundle(path.as_ref(), self)
526 }
527}
528
529#[derive(Clone, Debug, PartialEq, Eq)]
531pub struct InitializationBundleArtifact {
532 pub role: String,
534 pub path: PathBuf,
536 pub media_type: String,
538 pub byte_size: u64,
540 pub sha256: String,
542}
543
544#[derive(Clone, Debug, PartialEq, Eq)]
546pub struct InitializationBundleVerificationResult {
547 pub artifact_count: usize,
549 pub total_bytes: u64,
551}
552
553#[derive(Clone, Debug)]
555pub struct InitializationBundle {
556 pub path: PathBuf,
558 pub manifest_path: PathBuf,
560 pub result_artifact: InitializationBundleArtifact,
562 pub observations_artifact: InitializationBundleArtifact,
564 pub fit_history_artifact: InitializationBundleArtifact,
566 pub result: PlanarArrayInitializationResult,
568}
569
570impl InitializationBundle {
571 pub fn read(path: impl AsRef<Path>) -> Result<Self> {
573 read_initialization_bundle(path)
574 }
575
576 pub fn verify(&self) -> Result<InitializationBundleVerificationResult> {
578 let manifest = read_initialization_manifest(&self.path)?;
579 verify_initialization_artifacts(&self.path, &manifest)
580 }
581}
582
583pub 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#[derive(Clone, Debug)]
982pub struct BrightfieldCircleInitializer {
983 pub parameters: PlanarArrayCalibrationParameters,
985 pub options: BrightfieldCircleOptions,
987}
988
989impl BrightfieldCircleInitializer {
990 pub fn new(parameters: PlanarArrayCalibrationParameters) -> Self {
992 Self {
993 parameters,
994 options: BrightfieldCircleOptions::default(),
995 }
996 }
997
998 pub fn options(mut self, options: BrightfieldCircleOptions) -> Self {
1000 self.options = options;
1001 self
1002 }
1003
1004 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 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 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 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 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) = ¶meters.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) = ¶meters.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) = ¶meters.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) = ¶meters.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 ¤t,
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(¤t);
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, ¤t),
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, ¤t),
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(¶meters);
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(¶meters);
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}