Skip to main content

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}