use serde::{Deserialize, Serialize};
use tracing::trace;
use crate::error::{PrakashError, Result};
#[must_use = "returns the computed image distance"]
#[inline]
pub fn thin_lens_image_distance(focal_length: f64, object_distance: f64) -> Result<f64> {
let denom = object_distance - focal_length;
if denom.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "object at focal point".into(),
});
}
Ok(focal_length * object_distance / denom)
}
#[must_use]
#[inline]
pub fn magnification(object_distance: f64, image_distance: f64) -> f64 {
-image_distance / object_distance
}
#[must_use = "returns the computed focal length"]
#[inline]
pub fn lensmaker_focal_length(n: f64, r1: f64, r2: f64) -> Result<f64> {
let power = (n - 1.0) * (1.0 / r1 - 1.0 / r2);
if power.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "flat surfaces produce no focusing".into(),
});
}
Ok(1.0 / power)
}
#[must_use = "returns the computed optical power"]
#[inline]
pub fn optical_power(focal_length_m: f64) -> Result<f64> {
if focal_length_m.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "optical power requires non-zero focal length".into(),
});
}
Ok(1.0 / focal_length_m)
}
#[must_use]
#[inline]
pub fn mirror_focal_length(radius: f64) -> f64 {
radius / 2.0
}
#[must_use = "returns the computed image distance"]
#[inline]
pub fn mirror_image_distance(focal_length: f64, object_distance: f64) -> Result<f64> {
thin_lens_image_distance(focal_length, object_distance)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LensType {
Converging,
Diverging,
}
#[must_use]
#[inline]
pub fn classify_lens(focal_length: f64) -> LensType {
if focal_length > 0.0 {
LensType::Converging
} else {
LensType::Diverging
}
}
#[must_use = "returns the combined focal length"]
#[inline]
pub fn combined_focal_length(f1: f64, f2: f64) -> Result<f64> {
let sum = f1 + f2;
if sum.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "equal and opposite focal lengths produce zero combined power".into(),
});
}
Ok((f1 * f2) / sum)
}
#[must_use]
#[inline]
pub fn depth_of_field(focal_length: f64, f_number: f64, coc: f64, subject_dist: f64) -> (f64, f64) {
let h = (focal_length * focal_length) / (f_number * coc); let near = (h * subject_dist) / (h + (subject_dist - focal_length));
let far_denom = h - (subject_dist - focal_length);
let far = if far_denom <= 0.0 {
f64::INFINITY } else {
(h * subject_dist) / far_denom
};
(near, far)
}
#[must_use = "returns the computed focal length"]
#[inline]
pub fn thick_lens_focal_length(n: f64, r1: f64, r2: f64, thickness: f64) -> Result<f64> {
let nm1 = n - 1.0;
let power = nm1 * (1.0 / r1 - 1.0 / r2 + nm1 * thickness / (n * r1 * r2));
if power.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "thick lens has zero optical power".into(),
});
}
Ok(1.0 / power)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CardinalPoints {
pub focal_length: f64,
pub ffd: f64,
pub bfd: f64,
pub front_principal: f64,
pub back_principal: f64,
}
#[must_use = "returns the computed cardinal points"]
#[inline]
pub fn cardinal_points(n: f64, r1: f64, r2: f64, thickness: f64) -> Result<CardinalPoints> {
trace!(n, r1, r2, thickness, "cardinal_points");
let f = thick_lens_focal_length(n, r1, r2, thickness)?;
let nm1 = n - 1.0;
let phi1 = nm1 / r1;
let phi2 = -nm1 / r2;
let bfd = f * (1.0 - phi1 * thickness / n);
let ffd = -f * (1.0 - phi2 * thickness / n);
let back_principal = f - bfd;
let front_principal = -f - ffd;
Ok(CardinalPoints {
focal_length: f,
ffd,
bfd,
front_principal,
back_principal,
})
}
#[must_use]
#[inline]
pub fn f_number(focal_length: f64, aperture_diameter: f64) -> f64 {
focal_length / aperture_diameter
}
#[must_use]
#[inline]
pub fn aperture_from_f_number(focal_length: f64, f_num: f64) -> f64 {
focal_length / f_num
}
#[must_use]
#[inline]
pub fn numerical_aperture(n: f64, half_angle: f64) -> f64 {
n * half_angle.sin()
}
#[must_use]
#[inline]
pub fn na_from_f_number(f_num: f64) -> f64 {
1.0 / (2.0 * f_num)
}
#[must_use]
#[inline]
pub fn diffraction_limit(wavelength: f64, aperture_diameter: f64) -> f64 {
1.22 * wavelength / aperture_diameter
}
#[must_use]
#[inline]
pub fn airy_disk_radius(wavelength: f64, f_num: f64) -> f64 {
1.22 * wavelength * f_num
}
#[must_use]
#[inline]
pub fn field_of_view(sensor_size: f64, focal_length: f64) -> f64 {
2.0 * (sensor_size / (2.0 * focal_length)).atan()
}
#[must_use]
#[inline]
pub fn field_of_view_diagonal(width: f64, height: f64, focal_length: f64) -> f64 {
field_of_view(width.hypot(height), focal_length)
}
#[must_use]
#[inline]
pub fn mtf_cutoff_frequency(wavelength: f64, f_num: f64) -> f64 {
1.0 / (wavelength * f_num)
}
#[must_use]
#[inline]
pub fn mtf_diffraction_limited(spatial_freq: f64, cutoff_freq: f64) -> f64 {
let v = spatial_freq / cutoff_freq;
if v >= 1.0 {
return 0.0;
}
if v <= 0.0 {
return 1.0;
}
const TWO_OVER_PI: f64 = 2.0 / std::f64::consts::PI;
TWO_OVER_PI * (v.acos() - v * (1.0 - v * v).sqrt())
}
#[must_use]
#[inline]
pub fn mtf_polychromatic(
wavelengths: &[f64],
weights: &[f64],
f_num: f64,
spatial_freq: f64,
) -> f64 {
let mut sum_w = 0.0;
let mut sum_mtf = 0.0;
for (i, &wl) in wavelengths.iter().enumerate() {
let w = weights.get(i).copied().unwrap_or(1.0);
let cutoff = mtf_cutoff_frequency(wl, f_num);
sum_mtf += w * mtf_diffraction_limited(spatial_freq, cutoff);
sum_w += w;
}
if sum_w < 1e-15 {
return 0.0;
}
sum_mtf / sum_w
}
#[must_use]
pub fn mtf_through_focus(
wavelength: f64,
f_num: f64,
spatial_freq: f64,
defocus_range: &[f64],
) -> Vec<(f64, f64)> {
let cutoff = mtf_cutoff_frequency(wavelength, f_num);
let v = spatial_freq / cutoff;
defocus_range
.iter()
.map(|&defocus| {
let w20 = defocus / (8.0 * f_num * f_num * wavelength);
let attenuation = if w20.abs() < 1e-15 {
1.0
} else {
let phi = std::f64::consts::PI * w20 * v;
if phi.abs() < 1e-15 {
1.0
} else {
(phi.sin() / phi).abs()
}
};
let mtf = mtf_diffraction_limited(spatial_freq, cutoff) * attenuation;
(defocus, mtf)
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SeidelCoefficients {
pub spherical: f64,
pub coma: f64,
pub astigmatism: f64,
pub field_curvature: f64,
pub distortion: f64,
}
#[must_use]
#[inline]
pub fn shape_factor(r1: f64, r2: f64) -> f64 {
let denom = r2 - r1;
if denom.abs() < 1e-15 {
return 0.0; }
(r2 + r1) / denom
}
#[must_use]
#[inline]
pub fn conjugate_factor(object_distance: f64, image_distance: f64) -> f64 {
let sum = image_distance + object_distance;
if sum.abs() < 1e-15 {
return 0.0;
}
(image_distance - object_distance) / sum
}
#[must_use]
#[inline]
pub fn seidel_coefficients(n: f64, focal_length: f64, q: f64, p: f64) -> SeidelCoefficients {
trace!(n, focal_length, q, p, "seidel_coefficients");
let phi = 1.0 / focal_length;
let spherical = phi.powi(3)
* (n / (4.0 * (n - 1.0).powi(2)))
* ((n + 2.0) / (n * (n - 1.0)) * q * q
+ (3.0 * n + 2.0) * (n - 1.0) / n * p * p
+ 4.0 * (n + 1.0) / (n * (n - 1.0)) * q * p);
let coma = phi.powi(2)
* (1.0 / (2.0 * (n - 1.0)))
* ((2.0 * (n + 1.0)) / (n * (n - 1.0)) * q + (3.0 * n + 1.0) / n * p);
let astigmatism = phi;
let field_curvature = phi / n;
let distortion = 0.0;
SeidelCoefficients {
spherical,
coma,
astigmatism,
field_curvature,
distortion,
}
}
#[must_use]
#[inline]
pub fn longitudinal_spherical_aberration(ray_height: f64, focal_length: f64, n: f64) -> f64 {
let phi = 1.0 / focal_length;
let s1 = phi.powi(3) * n / (4.0 * (n - 1.0).powi(2)) * ((3.0 * n + 2.0) * (n - 1.0) / n);
ray_height * ray_height * s1 / (2.0 * phi)
}
#[must_use]
#[inline]
pub fn chromatic_aberration(focal_length: f64, abbe_v: f64) -> f64 {
focal_length / abbe_v
}
#[must_use]
#[inline]
pub fn petzval_sum(elements: &[(f64, f64)]) -> f64 {
elements.iter().map(|(n, f)| 1.0 / (n * f)).sum()
}
#[must_use]
#[inline]
pub fn petzval_radius(petzval_sum: f64) -> Option<f64> {
if petzval_sum.abs() < 1e-15 {
None } else {
Some(-1.0 / petzval_sum)
}
}
#[must_use = "returns the effective focal length"]
#[inline]
pub fn separated_lenses_focal_length(f1: f64, f2: f64, separation: f64) -> Result<f64> {
let power = 1.0 / f1 + 1.0 / f2 - separation / (f1 * f2);
if power.abs() < 1e-15 {
return Err(PrakashError::DivisionByZero {
context: "separated lens system has zero power".into(),
});
}
Ok(1.0 / power)
}
#[must_use = "returns the back focal distance"]
#[inline]
pub fn separated_lenses_bfd(f1: f64, f2: f64, separation: f64) -> Result<f64> {
let f = separated_lenses_focal_length(f1, f2, separation)?;
Ok(f * (1.0 - separation / f1))
}
#[must_use]
#[inline]
pub fn system_magnification(magnifications: &[f64]) -> f64 {
magnifications.iter().product()
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-6;
#[test]
fn test_thin_lens_real_image() {
let di = thin_lens_image_distance(50.0, 100.0).unwrap();
assert!((di - 100.0).abs() < EPS);
}
#[test]
fn test_thin_lens_virtual_image() {
let di = thin_lens_image_distance(50.0, 30.0).unwrap();
assert!(di < 0.0);
}
#[test]
fn test_thin_lens_at_focal() {
assert!(thin_lens_image_distance(50.0, 50.0).is_err());
}
#[test]
fn test_thin_lens_far_object() {
let di = thin_lens_image_distance(50.0, 1e6).unwrap();
assert!((di - 50.0).abs() < 0.01);
}
#[test]
fn test_thin_lens_diverging() {
let di = thin_lens_image_distance(-50.0, 100.0).unwrap();
assert!(di < 0.0, "Diverging lens should produce virtual image");
}
#[test]
fn test_thin_lens_satisfies_equation() {
let f = 75.0;
let do_ = 200.0;
let di = thin_lens_image_distance(f, do_).unwrap();
let lhs = 1.0 / f;
let rhs = 1.0 / do_ + 1.0 / di;
assert!((lhs - rhs).abs() < EPS);
}
#[test]
fn test_magnification_at_2f() {
let m = magnification(100.0, 100.0);
assert!((m - (-1.0)).abs() < EPS);
}
#[test]
fn test_magnification_magnified() {
let m = magnification(60.0, 120.0);
assert!(m.abs() > 1.0);
assert!(m < 0.0); }
#[test]
fn test_magnification_diminished() {
let m = magnification(200.0, 50.0);
assert!(m.abs() < 1.0);
}
#[test]
fn test_magnification_virtual_upright() {
let m = magnification(30.0, -60.0);
assert!(m > 0.0, "Virtual image should be upright (positive mag)");
}
#[test]
fn test_lensmaker() {
let f = lensmaker_focal_length(1.5, 100.0, -100.0).unwrap();
assert!((f - 100.0).abs() < EPS);
}
#[test]
fn test_lensmaker_planoconvex() {
let f = lensmaker_focal_length(1.5, 100.0, f64::INFINITY).unwrap();
assert!((f - 200.0).abs() < EPS); }
#[test]
fn test_lensmaker_flat_surface_error() {
assert!(lensmaker_focal_length(1.5, f64::INFINITY, f64::INFINITY).is_err());
}
#[test]
fn test_lensmaker_diverging() {
let f = lensmaker_focal_length(1.5, -100.0, 100.0).unwrap();
assert!(f < 0.0, "Biconcave lens should have negative focal length");
}
#[test]
fn test_optical_power() {
assert!((optical_power(0.5).unwrap() - 2.0).abs() < EPS);
}
#[test]
fn test_optical_power_negative() {
assert!((optical_power(-0.5).unwrap() - (-2.0)).abs() < EPS);
}
#[test]
fn test_optical_power_zero() {
assert!(optical_power(0.0).is_err());
}
#[test]
fn test_optical_power_roundtrip() {
let f = 0.25;
let d = optical_power(f).unwrap();
assert!((1.0 / d - f).abs() < EPS);
}
#[test]
fn test_mirror_focal() {
assert!((mirror_focal_length(200.0) - 100.0).abs() < EPS);
}
#[test]
fn test_mirror_focal_concave() {
assert!(mirror_focal_length(100.0) > 0.0);
}
#[test]
fn test_mirror_focal_convex() {
assert!(mirror_focal_length(-100.0) < 0.0);
}
#[test]
fn test_mirror_image_real() {
let di = mirror_image_distance(100.0, 200.0).unwrap();
assert!((di - 200.0).abs() < EPS);
}
#[test]
fn test_mirror_image_at_focal() {
assert!(mirror_image_distance(100.0, 100.0).is_err());
}
#[test]
fn test_classify_lens() {
assert_eq!(classify_lens(50.0), LensType::Converging);
assert_eq!(classify_lens(-50.0), LensType::Diverging);
}
#[test]
fn test_classify_lens_zero() {
assert_eq!(classify_lens(0.0), LensType::Diverging);
}
#[test]
fn test_lens_type_serde_roundtrip() {
let lt = LensType::Converging;
let json = serde_json::to_string(<).unwrap();
let back: LensType = serde_json::from_str(&json).unwrap();
assert_eq!(back, lt);
}
#[test]
fn test_combined_focal() {
let f = combined_focal_length(100.0, 100.0).unwrap();
assert!((f - 50.0).abs() < EPS);
}
#[test]
fn test_combined_focal_converging_diverging() {
let f = combined_focal_length(50.0, -100.0).unwrap();
assert!((f - 100.0).abs() < EPS);
}
#[test]
fn test_combined_focal_equal_opposite() {
assert!(combined_focal_length(100.0, -100.0).is_err());
}
#[test]
fn test_depth_of_field() {
let (near, far) = depth_of_field(50.0, 2.8, 0.03, 2000.0);
assert!(near < 2000.0);
assert!(far > 2000.0);
assert!(near > 0.0);
}
#[test]
fn test_dof_wider_aperture_shallower() {
let (near1, far1) = depth_of_field(50.0, 1.4, 0.03, 2000.0);
let (near2, far2) = depth_of_field(50.0, 8.0, 0.03, 2000.0);
let dof1 = far1 - near1;
let dof2 = far2 - near2;
assert!(
dof1 < dof2,
"Wider aperture (f/1.4) should have shallower DoF"
);
}
#[test]
fn test_dof_longer_lens_shallower() {
let (near1, far1) = depth_of_field(85.0, 2.8, 0.03, 2000.0);
let (near2, far2) = depth_of_field(35.0, 2.8, 0.03, 2000.0);
let dof1 = far1 - near1;
let dof2 = far2 - near2;
assert!(dof1 < dof2, "Longer focal length should have shallower DoF");
}
#[test]
fn test_dof_symmetric_around_subject() {
let (near, far) = depth_of_field(50.0, 5.6, 0.03, 5000.0);
assert!(near < 5000.0);
assert!(far > 5000.0);
}
#[test]
fn test_thick_lens_reduces_to_thin_at_zero_thickness() {
let n = 1.5;
let r1 = 100.0;
let r2 = -100.0;
let f_thin = lensmaker_focal_length(n, r1, r2).unwrap();
let f_thick = thick_lens_focal_length(n, r1, r2, 0.0).unwrap();
assert!(
(f_thin - f_thick).abs() < EPS,
"Zero thickness should match thin lens: thin={f_thin}, thick={f_thick}"
);
}
#[test]
fn test_thick_lens_biconvex() {
let f = thick_lens_focal_length(1.5, 100.0, -100.0, 10.0).unwrap();
assert!(f > 0.0, "Biconvex thick lens should be converging");
let f_thin = lensmaker_focal_length(1.5, 100.0, -100.0).unwrap();
assert!((f - f_thin).abs() > 0.01, "Thick and thin should differ");
}
#[test]
fn test_thick_lens_zero_power() {
assert!(thick_lens_focal_length(1.5, f64::INFINITY, f64::INFINITY, 10.0).is_err());
}
#[test]
fn test_cardinal_points_biconvex() {
let cp = cardinal_points(1.5, 100.0, -100.0, 10.0).unwrap();
assert!(cp.focal_length > 0.0);
assert!(cp.bfd > 0.0, "BFD should be positive for biconvex");
assert!(cp.ffd < 0.0, "FFD should be negative for biconvex");
}
#[test]
fn test_cardinal_points_symmetric_lens() {
let cp = cardinal_points(1.5, 100.0, -100.0, 10.0).unwrap();
assert!(
(cp.front_principal.abs() - cp.back_principal.abs()).abs() < 0.1,
"Symmetric lens should have symmetric principal planes"
);
}
#[test]
fn test_f_number() {
assert!((f_number(50.0, 25.0) - 2.0).abs() < EPS);
assert!((f_number(100.0, 50.0) - 2.0).abs() < EPS);
}
#[test]
fn test_aperture_from_f_number() {
assert!((aperture_from_f_number(50.0, 2.0) - 25.0).abs() < EPS);
}
#[test]
fn test_f_number_aperture_roundtrip() {
let f = 85.0;
let d = 30.357;
let n = f_number(f, d);
let d_back = aperture_from_f_number(f, n);
assert!((d_back - d).abs() < EPS);
}
#[test]
fn test_numerical_aperture() {
use std::f64::consts::FRAC_PI_6;
let na = numerical_aperture(1.0, FRAC_PI_6);
assert!((na - 0.5).abs() < EPS); }
#[test]
fn test_na_from_f_number() {
let na = na_from_f_number(2.0);
assert!((na - 0.25).abs() < EPS);
}
#[test]
fn test_na_immersion_oil() {
use std::f64::consts::FRAC_PI_3;
let na = numerical_aperture(1.515, FRAC_PI_3);
assert!(na > 1.0, "Oil immersion NA can exceed 1.0");
}
#[test]
fn test_diffraction_limit() {
let theta = diffraction_limit(550e-6, 10.0); assert!(theta > 0.0);
assert!(theta < 0.001); }
#[test]
fn test_larger_aperture_better_resolution() {
let theta_small = diffraction_limit(550e-6, 10.0);
let theta_large = diffraction_limit(550e-6, 50.0);
assert!(theta_large < theta_small);
}
#[test]
fn test_airy_disk_radius() {
let r = airy_disk_radius(0.00055, 2.8); assert!(r > 0.0);
assert!((r - 0.001_88).abs() < 0.001);
}
#[test]
fn test_field_of_view_50mm_fullframe() {
let fov = field_of_view(36.0, 50.0);
let fov_deg = fov.to_degrees();
assert!(
(fov_deg - 39.6).abs() < 1.0,
"50mm on FF should be ≈39.6° FOV, got {fov_deg}"
);
}
#[test]
fn test_field_of_view_longer_lens_narrower() {
let fov_50 = field_of_view(36.0, 50.0);
let fov_200 = field_of_view(36.0, 200.0);
assert!(fov_200 < fov_50);
}
#[test]
fn test_field_of_view_diagonal() {
let fov_diag = field_of_view_diagonal(36.0, 24.0, 50.0);
let fov_horiz = field_of_view(36.0, 50.0);
assert!(fov_diag > fov_horiz, "Diagonal FOV > horizontal FOV");
}
#[test]
fn test_mtf_cutoff() {
let fc = mtf_cutoff_frequency(0.00055, 2.8);
assert!((fc - 649.0).abs() < 5.0, "Cutoff ≈ 649 cy/mm, got {fc}");
}
#[test]
fn test_mtf_at_zero_frequency() {
let mtf = mtf_diffraction_limited(0.0, 1000.0);
assert!((mtf - 1.0).abs() < EPS, "MTF(0) should be 1.0");
}
#[test]
fn test_mtf_at_cutoff() {
let mtf = mtf_diffraction_limited(1000.0, 1000.0);
assert!(mtf.abs() < EPS, "MTF at cutoff should be 0.0");
}
#[test]
fn test_mtf_above_cutoff() {
let mtf = mtf_diffraction_limited(1500.0, 1000.0);
assert!(mtf.abs() < EPS, "MTF above cutoff should be 0.0");
}
#[test]
fn test_mtf_monotonic_decrease() {
let fc = 1000.0;
let mut prev = 1.0;
for i in 1..=10 {
let freq = fc * (i as f64) / 10.0;
let m = mtf_diffraction_limited(freq, fc);
assert!(
m <= prev + EPS,
"MTF should decrease monotonically: at {freq} got {m} > {prev}"
);
prev = m;
}
}
#[test]
fn test_mtf_range() {
let fc = 1000.0;
for i in 0..=20 {
let freq = fc * (i as f64) / 20.0;
let m = mtf_diffraction_limited(freq, fc);
assert!((0.0..=1.0).contains(&m), "MTF out of range at {freq}: {m}");
}
}
#[test]
fn test_shape_factor_equiconvex() {
let q = shape_factor(100.0, -100.0);
assert!(q.abs() < EPS, "Equi-convex should have q=0, got {q}");
}
#[test]
fn test_shape_factor_planoconvex() {
let q = shape_factor(100.0, 1e15);
assert!((q - 1.0).abs() < 0.01, "Plano-convex q ≈ 1, got {q}");
}
#[test]
fn test_conjugate_factor_infinity() {
let p = conjugate_factor(1e10, 50.0);
assert!((p - (-1.0)).abs() < 0.001);
}
#[test]
fn test_conjugate_factor_symmetric() {
let p = conjugate_factor(100.0, 100.0);
assert!(p.abs() < EPS);
}
#[test]
fn test_seidel_equiconvex_at_infinity() {
let sc = seidel_coefficients(1.5, 100.0, 0.0, -1.0);
assert!(
sc.spherical != 0.0,
"Spherical aberration should be nonzero"
);
assert!(sc.coma != 0.0, "Coma should be nonzero");
assert!(
sc.field_curvature > 0.0,
"Field curvature should be positive"
);
}
#[test]
fn test_seidel_best_form_reduces_spherical() {
let n = 1.5;
let f = 100.0;
let p = -1.0;
let sc_equi = seidel_coefficients(n, f, 0.0, p);
let mut min_sa = f64::MAX;
let mut best_q = 0.0;
for i in -20..=20 {
let q = i as f64 * 0.1;
let sc = seidel_coefficients(n, f, q, p);
if sc.spherical.abs() < min_sa {
min_sa = sc.spherical.abs();
best_q = q;
}
}
let sc_best = seidel_coefficients(n, f, best_q, p);
assert!(
sc_best.spherical.abs() <= sc_equi.spherical.abs(),
"Best-form (q={best_q}) should have ≤ spherical aberration than equi-convex"
);
}
#[test]
fn test_longitudinal_spherical_increases_with_height() {
let lsa_low = longitudinal_spherical_aberration(5.0, 100.0, 1.5);
let lsa_high = longitudinal_spherical_aberration(10.0, 100.0, 1.5);
assert!(
lsa_high.abs() > lsa_low.abs(),
"LSA should increase with ray height"
);
}
#[test]
fn test_chromatic_aberration() {
let tca = chromatic_aberration(100.0, 64.0);
assert!(tca > 0.0);
assert!((tca - 1.5625).abs() < EPS);
}
#[test]
fn test_low_dispersion_less_chromatic() {
let tca_crown = chromatic_aberration(100.0, 64.0); let tca_flint = chromatic_aberration(100.0, 26.0); assert!(tca_crown < tca_flint, "Crown should have less TCA");
}
#[test]
fn test_petzval_sum_single_lens() {
let ps = petzval_sum(&[(1.5, 100.0)]);
assert!((ps - 1.0 / 150.0).abs() < EPS);
}
#[test]
fn test_petzval_sum_doublet() {
let ps = petzval_sum(&[(1.5, 60.0), (1.7, -90.0)]);
assert!(ps > 0.0 && ps < 0.01, "Doublet reduces Petzval sum");
}
#[test]
fn test_petzval_radius_from_sum() {
let r = petzval_radius(0.01).unwrap();
assert!((r - (-100.0)).abs() < EPS);
}
#[test]
fn test_petzval_radius_flat_field() {
assert!(petzval_radius(0.0).is_none());
}
#[test]
fn test_separated_lenses_contact() {
let f_sep = separated_lenses_focal_length(100.0, 100.0, 0.0).unwrap();
let f_contact = combined_focal_length(100.0, 100.0).unwrap();
assert!((f_sep - f_contact).abs() < EPS);
}
#[test]
fn test_separated_lenses_telephoto() {
let f = separated_lenses_focal_length(50.0, -80.0, 30.0).unwrap();
assert!(
f > 50.0,
"Telephoto should have longer effective focal length"
);
}
#[test]
fn test_separated_lenses_bfd() {
let bfd = separated_lenses_bfd(50.0, -80.0, 30.0).unwrap();
let f = separated_lenses_focal_length(50.0, -80.0, 30.0).unwrap();
assert!(bfd < f, "Telephoto BFD should be shorter than focal length");
}
#[test]
fn test_system_magnification() {
let m = system_magnification(&[-0.5, -2.0, -0.3]);
assert!((m - (-0.3)).abs() < EPS); }
#[test]
fn test_system_magnification_single() {
let m = system_magnification(&[-1.0]);
assert!((m - (-1.0)).abs() < EPS);
}
#[test]
fn test_system_magnification_empty() {
let m = system_magnification(&[]);
assert!((m - 1.0).abs() < EPS); }
}