fpm_rs/experiment/optics.rs
1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5/// Physical microscope parameters in SI units.
6///
7/// The sample-plane detector pitch must satisfy the coherent-field sampling
8/// invariant
9///
10/// `camera_pixel_size / magnification < wavelength_vacuum_m / (2 * objective_na)`.
11///
12/// Equality is rejected because the circular pupil cutoff would lie on the
13/// one-sided discrete Nyquist boundary.
14///
15/// # References
16///
17/// - [G. Zheng, R. Horstmeyer, and C. Yang, “Wide-field, high-resolution
18/// Fourier ptychographic microscopy,” *Nature Photonics* **7**, 739–745
19/// (2013).](https://doi.org/10.1038/nphoton.2013.187)
20///
21/// # Example
22///
23/// ```
24/// use fpm_rs::experiment::Optics;
25///
26/// # fn main() -> fpm_rs::Result<()> {
27/// let optics = Optics {
28/// wavelength_vacuum_m: 532e-9,
29/// objective_na: 0.1,
30/// magnification: 4.0,
31/// camera_pixel_size: 6.5e-6,
32/// illumination_refractive_index: 1.0,
33/// objective_medium_refractive_index: 1.0,
34/// defocus_distance: None,
35/// pupil_aberration: None,
36/// };
37/// optics.validate()?;
38/// assert_eq!(optics.object_pixel_size(), 6.5e-6 / 4.0);
39/// # Ok(())
40/// # }
41/// ```
42#[derive(Clone, Debug, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Optics {
45 /// Illumination wavelength in vacuum, in metres.
46 pub wavelength_vacuum_m: f64,
47 /// Objective numerical aperture; must not exceed [`Self::objective_medium_refractive_index`].
48 pub objective_na: f64,
49 /// Lateral image magnification, as a positive dimensionless ratio.
50 pub magnification: f64,
51 /// Physical detector-pixel pitch in metres.
52 ///
53 /// After division by [`Self::magnification`], this must be strictly less
54 /// than `wavelength_vacuum_m / (2 * objective_na)`.
55 pub camera_pixel_size: f64,
56 /// Refractive index between illumination sources and the sample.
57 pub illumination_refractive_index: f64,
58 /// Refractive index in the objective-side medium.
59 pub objective_medium_refractive_index: f64,
60 /// Axial sample displacement from the focal plane in metres.
61 pub defocus_distance: Option<f64>,
62 /// Optional sampled-pupil aberration model.
63 ///
64 /// Coefficients are interpreted by [`crate::model::Pupil::circular`] as
65 /// crate-specific radial-polynomial weights in radians, not as normalized
66 /// Zernike coefficients.
67 pub pupil_aberration: Option<PupilAberration>,
68}
69
70/// Crate-specific pupil phase and amplitude perturbation.
71///
72/// For normalized pupil radius `rho` and polar angle `theta`, the sampled phase
73/// contribution is
74///
75/// `astigmatism * rho^2 * cos(2 theta)
76/// + coma * (3 rho^3 - 2 rho) * cos(theta)
77/// + spherical * (6 rho^4 - 6 rho^2 + 1)`.
78///
79/// These are direct radian coefficients used by [`crate::model::Pupil::circular`],
80/// not normalized Zernike coefficients. Defocus is stored separately on
81/// [`Optics`] as [`Optics::defocus_distance`].
82#[derive(Clone, Debug, Default, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct PupilAberration {
85 /// Radian coefficient multiplying `rho^2 * cos(2 theta)`.
86 pub astigmatism: f64,
87 /// Radian coefficient multiplying `(3 rho^3 - 2 rho) * cos(theta)`.
88 pub coma: f64,
89 /// Radian coefficient multiplying `6 rho^4 - 6 rho^2 + 1`.
90 pub spherical: f64,
91 /// Radial pupil-amplitude decay strength. The amplitude at the pupil edge
92 /// is `exp(-edge_apodization)`.
93 pub edge_apodization: f64,
94}
95
96impl PupilAberration {
97 /// Checks finite phase coefficients and a finite, non-negative edge-apodization strength.
98 pub fn validate(&self) -> Result<()> {
99 if [
100 self.astigmatism,
101 self.coma,
102 self.spherical,
103 self.edge_apodization,
104 ]
105 .iter()
106 .any(|value| !value.is_finite())
107 || self.edge_apodization < 0.0
108 {
109 return Err(Error::InvalidParameter {
110 name: "pupil_aberration",
111 reason: "phase coefficients must be finite and edge apodization must be finite and non-negative"
112 .into(),
113 });
114 }
115 Ok(())
116 }
117}
118
119impl Optics {
120 /// Validates positive finite physical parameters, coherent-field sampling,
121 /// objective-medium compatibility, finite optional defocus, and the optional
122 /// [`PupilAberration`].
123 ///
124 /// Coherent-field sampling requires
125 /// `camera_pixel_size / magnification < wavelength_vacuum_m / (2 * objective_na)`.
126 /// Equality is invalid because it places the pupil cutoff on the discrete
127 /// Nyquist boundary.
128 pub fn validate(&self) -> Result<()> {
129 for (name, value) in [
130 ("wavelength_vacuum_m", self.wavelength_vacuum_m),
131 ("objective_na", self.objective_na),
132 ("magnification", self.magnification),
133 ("camera_pixel_size", self.camera_pixel_size),
134 (
135 "illumination_refractive_index",
136 self.illumination_refractive_index,
137 ),
138 (
139 "objective_medium_refractive_index",
140 self.objective_medium_refractive_index,
141 ),
142 ] {
143 if !value.is_finite() || value <= 0.0 {
144 return Err(Error::InvalidParameter {
145 name,
146 reason: format!("must be finite and positive, got {value}"),
147 });
148 }
149 }
150 if self.objective_na > self.objective_medium_refractive_index {
151 return Err(Error::InvalidParameter {
152 name: "objective_na",
153 reason: "cannot exceed the medium refractive index".into(),
154 });
155 }
156 let object_pixel_size = self.object_pixel_size();
157 let coherent_sampling_limit = self.wavelength_vacuum_m / (2.0 * self.objective_na);
158 if !object_pixel_size.is_finite()
159 || !coherent_sampling_limit.is_finite()
160 || coherent_sampling_limit <= 0.0
161 {
162 return Err(Error::InvalidParameter {
163 name: "camera_pixel_size",
164 reason: "camera_pixel_size / magnification and wavelength_vacuum_m / (2 * objective_na) must produce finite positive sampling scales"
165 .into(),
166 });
167 }
168 if object_pixel_size >= coherent_sampling_limit {
169 return Err(Error::InvalidParameter {
170 name: "camera_pixel_size",
171 reason: format!(
172 "object-plane pitch camera_pixel_size / magnification ({object_pixel_size:.6e} m) must be strictly less than wavelength_vacuum_m / (2 * objective_na) ({coherent_sampling_limit:.6e} m) for coherent-field sampling; use a smaller detector pixel, greater magnification, or lower objective NA"
173 ),
174 });
175 }
176 if self
177 .defocus_distance
178 .is_some_and(|value| !value.is_finite())
179 {
180 return Err(Error::InvalidParameter {
181 name: "defocus_distance",
182 reason: "must be finite".into(),
183 });
184 }
185 if let Some(aberration) = &self.pupil_aberration {
186 aberration.validate()?;
187 }
188 Ok(())
189 }
190
191 /// Returns the sample-plane pixel pitch `camera_pixel_size / magnification`, in metres.
192 pub fn object_pixel_size(&self) -> f64 {
193 self.camera_pixel_size / self.magnification
194 }
195
196 /// Returns `2π n_illumination / wavelength_vacuum`, in radians per metre.
197 pub fn illumination_wavenumber(&self) -> f64 {
198 std::f64::consts::TAU * self.illumination_refractive_index / self.wavelength_vacuum_m
199 }
200
201 /// Returns the objective-medium angular wavenumber in radians per metre.
202 pub fn objective_medium_wavenumber(&self) -> f64 {
203 std::f64::consts::TAU * self.objective_medium_refractive_index / self.wavelength_vacuum_m
204 }
205}