diff --git a/README.md b/README.md index d6c10756..ea002444 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Cherry might be for you if: - [ ] **Help Wanted** 3D views - [ ] **Help Wanted** Fuzzy search for materials - [ ] [Cardinal lens](https://opg.optica.org/ao/fulltext.cfm?uri=ao-63-4-1110) surface type for ideal objective models +- [ ] Cross-validate integration test results with Optiland ### Done diff --git a/crates/cherry-rs/src/core/sequential_model/solves/fno.rs b/crates/cherry-rs/src/core/sequential_model/solves/fno.rs index 8b784713..8597fd47 100644 --- a/crates/cherry-rs/src/core/sequential_model/solves/fno.rs +++ b/crates/cherry-rs/src/core/sequential_model/solves/fno.rs @@ -205,7 +205,7 @@ mod tests { let target = 4.0; let (_, pv) = build_with_fno_solve(target); let sub = pv.get(0, 0).unwrap(); - assert_abs_diff_eq!(sub.paraxial_fno(), target, epsilon = 1e-4); + assert_abs_diff_eq!(sub.paraxial_fno().abs(), target, epsilon = 1e-4); } #[test] @@ -315,7 +315,7 @@ mod tests { let sub = pv.get(0, 0).unwrap(); // F/# constraint satisfied. - assert_abs_diff_eq!(sub.paraxial_fno(), target_fno, epsilon = 1e-3); + assert_abs_diff_eq!(sub.paraxial_fno().abs(), target_fno, epsilon = 1e-3); // MarginalRaySolve constraint: marginal ray height at image ≈ 0. let bundle = marginal_ray_bundle(&model, 0).unwrap(); diff --git a/crates/cherry-rs/src/gui/app.rs b/crates/cherry-rs/src/gui/app.rs index d5bfb78f..194db9eb 100644 --- a/crates/cherry-rs/src/gui/app.rs +++ b/crates/cherry-rs/src/gui/app.rs @@ -515,6 +515,8 @@ impl eframe::App for CherryApp { self.load_specs(examples::f_theta_scan_lens()); ui.close(); } + ui.separator(); + ui.label("Systems"); if ui .button("Galvo and Scan Lens (Negrean and Mansvelder)") .clicked() @@ -522,6 +524,10 @@ impl eframe::App for CherryApp { self.load_specs(examples::galvo_scan_lens_negrean_mansvelder()); ui.close(); } + if ui.button("Widefield Epifluorescence Excitation").clicked() { + self.load_specs(examples::wf_epi_excitation()); + ui.close(); + } }); ui.add_space(16.0); diff --git a/crates/cherry-rs/src/gui/examples.rs b/crates/cherry-rs/src/gui/examples.rs index 581caf0a..03a6ff4e 100644 --- a/crates/cherry-rs/src/gui/examples.rs +++ b/crates/cherry-rs/src/gui/examples.rs @@ -546,6 +546,59 @@ pub fn galvo_scan_lens_negrean_mansvelder() -> SystemSpecs { } } +/// Reduced widefield epifluorescence microscope excitation path. +/// +/// An object, a thin relay lens (f = 40 mm), a flat fold mirror at 45°, and a +/// second thin lens (f = 3.3333 mm) that acts as the aperture stop, modelling +/// a microscope objective back aperture immersed in oil (n = 1.5). The fold +/// mirror contributes no power; only the unfolded track length of 150 mm +/// between the two lenses matters for paraxial calculations. +pub fn wf_epi_excitation() -> SystemSpecs { + SystemSpecs { + surfaces: vec![ + SurfaceRow::new_object("40.0"), + SurfaceRow::new_thin_lens("25.0", "40.0", "100.0", "1.0"), + SurfaceRow { + variant: SurfaceVariant::Sphere, + boundary_variant: BoundaryVariant::Reflecting, + refractive_index: "1.0".into(), + thickness: "50.0".into(), + semi_diameter: "36.0".into(), + radius_of_curvature: "Infinity".into(), + conic_constant: String::new(), + focal_length: String::new(), + theta: "45".into(), + psi: "0".into(), + material_key: None, + }, + SurfaceRow::new_thin_lens("4.25", "3.3333", "1.0", "1.5"), + SurfaceRow::new_image(), + ], + fields: vec![FieldRow { + chi: "1.5".into(), + phi: "90.0".into(), + x: "0.0".into(), + }], + aperture_semi_diameter: "1.5454".into(), + wavelengths: vec!["0.5876".into()], + field_mode: FieldMode::PointSource, + use_materials: false, + selected_materials: Vec::new(), + cross_section_n_rays: 11, + full_pupil_spacing: "0.1".into(), + n_fan_rays: 65, + background_n: "1.0".into(), + background_material_key: None, + stop_surface: Some(3), + solves: vec![SolveSpec::MarginalRayHeight { + gap_index: 3, + target_height: 0.0, + wavelength_id: 0, + }], + lens_groups: Vec::new(), + } +} + /// f = +100 mm concave mirror. pub fn concave_mirror() -> SystemSpecs { SystemSpecs { @@ -625,6 +678,18 @@ mod tests { .expect("model"); } + #[test] + fn wf_epi_excitation_example_converts_to_valid_model() { + let specs = wf_epi_excitation(); + let parsed = parse(&specs); + SequentialModelBuilder::new() + .gap_specs(parsed.gaps) + .surface_specs(parsed.surfaces) + .wavelengths(parsed.wavelengths) + .build() + .expect("model"); + } + /// The M solve on the lens-to-image gap must resolve to the lens's back /// focal distance (== focal length, for a thin lens in air with an /// object at infinity), demonstrating that the image plane tracks the diff --git a/crates/cherry-rs/src/gui/windows/paraxial.rs b/crates/cherry-rs/src/gui/windows/paraxial.rs index 16a72c97..2e6eb11b 100644 --- a/crates/cherry-rs/src/gui/windows/paraxial.rs +++ b/crates/cherry-rs/src/gui/windows/paraxial.rs @@ -130,59 +130,65 @@ fn render_table_body( tb_sep(body, n_cols); } - tb_row(body, row_h, "EFL", ids, pv, |sv| { + tb_row(body, row_h, "Effective focal length", ids, pv, |sv| { *sv.effective_focal_length() }); - tb_row(body, row_h, "BFD", ids, pv, |sv| *sv.back_focal_distance()); - tb_row(body, row_h, "FFD", ids, pv, |sv| *sv.front_focal_distance()); - tb_row(body, row_h, "Paraxial F/#", ids, pv, |sv| sv.paraxial_fno()); - tb_row(body, row_h, "Image space F/#", ids, pv, |sv| { - sv.image_space_fno() + tb_row(body, row_h, "Back focal distance", ids, pv, |sv| { + *sv.back_focal_distance() }); - - tb_sep(body, n_cols); - tb_row( body, row_h, - "Entrance pupil dist. from first surface", + "Back principal plane location", ids, pv, - |sv| sv.entrance_pupil().location, + |sv| *sv.back_principal_plane(), ); - tb_row(body, row_h, "Entrance pupil semi-diameter", ids, pv, |sv| { - sv.entrance_pupil().semi_diameter + tb_row(body, row_h, "Front focal length", ids, pv, |sv| { + *sv.front_focal_length() + }); + tb_row(body, row_h, "Front focal distance", ids, pv, |sv| { + *sv.front_focal_distance() }); tb_row( body, row_h, - "Exit pupil dist. from last surface", + "Front principal plane location", ids, pv, - |sv| sv.exit_pupil().location, + |sv| *sv.front_principal_plane(), ); - tb_row(body, row_h, "Exit pupil semi-diameter", ids, pv, |sv| { - sv.exit_pupil().semi_diameter + + tb_sep(body, n_cols); + + tb_row(body, row_h, "Paraxial F/#", ids, pv, |sv| sv.paraxial_fno()); + tb_row(body, row_h, "Image space F/#", ids, pv, |sv| { + sv.image_space_fno() + }); + + tb_row(body, row_h, "Lagrange invariant", ids, pv, |sv| { + let stop = *sv.aperture_stop(); + sv.lagrange_invariants() + .get(stop) + .copied() + .unwrap_or(f64::NAN) + .abs() }); tb_sep(body, n_cols); - tb_row( - body, - row_h, - "Front principal plane dist. from first surface", - ids, - pv, - |sv| *sv.front_principal_plane(), - ); - tb_row( - body, - row_h, - "Back principal plane dist. from last surface", - ids, - pv, - |sv| *sv.back_principal_plane(), - ); + tb_row(body, row_h, "Entrance pupil location", ids, pv, |sv| { + sv.entrance_pupil().location + }); + tb_row(body, row_h, "Entrance pupil semi-diameter", ids, pv, |sv| { + sv.entrance_pupil().semi_diameter + }); + tb_row(body, row_h, "Exit pupil location", ids, pv, |sv| { + sv.exit_pupil().location + }); + tb_row(body, row_h, "Exit pupil semi-diameter", ids, pv, |sv| { + sv.exit_pupil().semi_diameter + }); tb_sep(body, n_cols); @@ -331,9 +337,9 @@ mod tests { ParaxialWindow::show(ctx, &mut open, Some(&result)); }); harness.step(); - harness.get_by_label("EFL"); - harness.get_by_label("BFD"); - harness.get_by_label("FFD"); + harness.get_by_label("Effective focal length"); + harness.get_by_label("Back focal distance"); + harness.get_by_label("Front focal distance"); } #[test] @@ -388,6 +394,17 @@ mod tests { harness.get_by_label("Image space F/#"); } + #[test] + fn paraxial_data_shows_lagrange_invariant_row() { + let result = make_result(&["0.567"]); + let mut harness = Harness::new(|ctx| { + let mut open = true; + ParaxialWindow::show(ctx, &mut open, Some(&result)); + }); + harness.step(); + harness.get_by_label("Lagrange invariant"); + } + #[test] fn single_wavelength_no_primary_axial_color() { let result = make_result(&["0.567"]); diff --git a/crates/cherry-rs/src/views/paraxial.rs b/crates/cherry-rs/src/views/paraxial.rs index a99610e8..838710ad 100644 --- a/crates/cherry-rs/src/views/paraxial.rs +++ b/crates/cherry-rs/src/views/paraxial.rs @@ -139,8 +139,10 @@ pub struct ParaxialSubView { entrance_pupil: Pupil, exit_pupil: Pupil, front_focal_distance: Float, + front_focal_length: Float, front_principal_plane: Float, image_space_fno: Float, + lagrange_invariants: Vec, marginal_ray: ParaxialRayBundle, paraxial_fno: Float, paraxial_image_plane: ImagePlane, @@ -163,8 +165,10 @@ pub struct ParaxialSubViewDescription { entrance_pupil: Pupil, exit_pupil: Pupil, front_focal_distance: Float, + front_focal_length: Float, front_principal_plane: Float, image_space_fno: Float, + lagrange_invariants: Vec, marginal_ray: ParaxialRayBundle, paraxial_fno: Float, paraxial_image_plane: ImagePlane, @@ -569,11 +573,17 @@ impl ParaxialSubView { &marginal_ray, )?; let effective_focal_length = Self::calc_effective_focal_length(¶llel_ray); + let front_focal_length = Self::calc_front_focal_length( + sequential_sub_model, + surfaces, + surface_indices, + &reverse_parallel_ray, + )?; let back_principal_plane = Self::calc_back_principal_plane(back_focal_distance, effective_focal_length)?; let front_principal_plane = - Self::calc_front_principal_plane(front_focal_distance, effective_focal_length); + Self::calc_front_principal_plane(front_focal_distance, front_focal_length); let chief_ray = Self::calc_chief_ray( surfaces, @@ -586,13 +596,8 @@ impl ParaxialSubView { field_specs, &entrance_pupil, )?; - let paraxial_image_plane = Self::calc_paraxial_image_plane( - surfaces, - placements, - surface_indices, - &marginal_ray, - &chief_ray, - )?; + let paraxial_image_plane = + Self::calc_paraxial_image_plane(surfaces, surface_indices, &marginal_ray, &chief_ray)?; let last_phys_id = last_physical_step(surface_indices, surfaces) .ok_or_else(|| anyhow!("There are no physical surfaces"))?; @@ -603,8 +608,13 @@ impl ParaxialSubView { .refractive_index .n(); let u_last = marginal_ray.rays_at_surface(last_phys_id)[0].angle; - let paraxial_fno = 1.0 / (2.0 * n_image * u_last.abs()); + let paraxial_fno = 1.0 / (2.0 * n_image * u_last); let image_space_fno = effective_focal_length / (2.0 * entrance_pupil.semi_diameter); + let lagrange_invariants = (0..sequential_sub_model.gaps().len()) + .map(|i| { + Self::calc_lagrange_invariant(i, &marginal_ray, &chief_ray, sequential_sub_model) + }) + .collect::>>()?; Ok(Self { path_id, @@ -620,8 +630,10 @@ impl ParaxialSubView { entrance_pupil, exit_pupil, front_focal_distance, + front_focal_length, front_principal_plane, image_space_fno, + lagrange_invariants, marginal_ray, paraxial_fno, paraxial_image_plane, @@ -641,8 +653,10 @@ impl ParaxialSubView { entrance_pupil: self.entrance_pupil.clone(), exit_pupil: self.exit_pupil.clone(), front_focal_distance: self.front_focal_distance, + front_focal_length: self.front_focal_length, front_principal_plane: self.front_principal_plane, image_space_fno: self.image_space_fno, + lagrange_invariants: self.lagrange_invariants.clone(), marginal_ray: self.marginal_ray.clone(), paraxial_fno: self.paraxial_fno, paraxial_image_plane: self.paraxial_image_plane.clone(), @@ -693,6 +707,10 @@ impl ParaxialSubView { &self.front_focal_distance } + pub fn front_focal_length(&self) -> &Float { + &self.front_focal_length + } + pub fn front_principal_plane(&self) -> &Float { &self.front_principal_plane } @@ -717,6 +735,10 @@ impl ParaxialSubView { self.image_space_fno } + pub fn lagrange_invariants(&self) -> &[Float] { + &self.lagrange_invariants + } + fn calc_aperture_stop( surfaces: &[Box], placements: &[SurfacePlacement], @@ -751,8 +773,7 @@ impl ParaxialSubView { return Ok(Float::INFINITY); } - // Distance is always positive - Ok(bfd.abs()) + Ok(bfd) } fn calc_back_principal_plane( @@ -996,20 +1017,58 @@ impl ParaxialSubView { return Ok(Float::INFINITY); } - // Distance is always positive - Ok(ffd.abs()) + Ok(ffd) } - fn calc_front_principal_plane( - front_focal_distance: Float, - effective_focal_length: Float, - ) -> Float { + fn calc_front_focal_length( + sequential_sub_model: &dyn SequentialSubModel, + surfaces: &[Box], + surface_indices: &[usize], + reverse_parallel_ray: &ParaxialRayBundle, + ) -> Result { + // y_1: height at the first physical surface in the reverse trace + let last_physical_step_index = last_physical_step(surface_indices, surfaces) + .ok_or(anyhow!("There are no physical surfaces"))?; + let y_index = reversed_surface_id(sequential_sub_model.len() + 1, last_physical_step_index); + let y_1 = reverse_parallel_ray.rays_at_surface(y_index)[0].height; + + // u_final: angle at the last physical surface in the reverse trace + let first_physical_step_index = first_physical_step(surface_indices, surfaces) + .ok_or(anyhow!("There are no physical surfaces"))?; + let u_index = + reversed_surface_id(sequential_sub_model.len() + 1, first_physical_step_index); + let u_final = reverse_parallel_ray.rays_at_surface(u_index)[0].angle; + + if u_final == 0.0 { + return Ok(Float::INFINITY); + } + Ok(-y_1 / u_final) + } + + fn calc_front_principal_plane(front_focal_distance: Float, front_focal_length: Float) -> Float { // Principal planes make no sense for lenses without power if front_focal_distance.is_infinite() { return Float::NAN; } - effective_focal_length - front_focal_distance + front_focal_distance - front_focal_length + } + + fn calc_lagrange_invariant( + surface_id: usize, + marginal_ray: &ParaxialRayBundle, + chief_ray: &ParaxialRayBundle, + sequential_sub_model: &dyn SequentialSubModel, + ) -> Result { + let n = sequential_sub_model + .gaps() + .get(surface_id) + .ok_or_else(|| anyhow!("surface_id {surface_id} out of range"))? + .refractive_index + .n(); + let mr = marginal_ray.rays_at_surface(surface_id); + let cr = chief_ray.rays_at_surface(surface_id); + Ok(n * (mr[0].height * cr[0].angle - cr[0].height * mr[0].angle)) } fn calc_marginal_ray( @@ -1061,21 +1120,21 @@ impl ParaxialSubView { /// Compute the paraxial image plane. fn calc_paraxial_image_plane( surfaces: &[Box], - placements: &[SurfacePlacement], surface_indices: &[usize], marginal_ray: &ParaxialRayBundle, chief_ray: &ParaxialRayBundle, ) -> Result { let last_physical_step_id = last_physical_step(surface_indices, surfaces) .ok_or(anyhow!("There are no physical surfaces"))?; - let store_idx = surface_indices[last_physical_step_id]; let d_axis = axis_intercepts(marginal_ray.rays_at_surface(last_physical_step_id))?[0]; let location = if d_axis.is_infinite() { // Ensure positive infinity is returned for infinite image planes Float::INFINITY } else { - placements[store_idx].track + d_axis + // Compute the paraxial image plane location relative to the last physical + // surface + d_axis }; // Propagate the chief ray from the last physical surface to the image plane to @@ -1822,7 +1881,7 @@ mod test { let (sub, _) = setup(); // Last physical surface is index 2 (plano surface); n_image = 1.0 (air). let u_last = sub.marginal_ray().rays_at_surface(2)[0].angle; - let expected = 1.0 / (2.0 * u_last.abs()); + let expected = 1.0 / (2.0 * u_last); assert_abs_diff_eq!(sub.paraxial_fno(), expected, epsilon = 1e-6); } @@ -1953,4 +2012,16 @@ mod test { "path 1 stop should be BS (step 1)" ); } + + #[test] + fn test_lagrange_invariant_is_conserved() { + let (view, _) = setup(); + + // Convexplano lens has 3 gaps; invariant is stored for each (indices 0–2). + let invariants = view.lagrange_invariants(); + assert!(invariants.len() >= 2); + for &h in invariants { + assert_abs_diff_eq!(h, invariants[1], epsilon = 1e-4); + } + } } diff --git a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs index b957daef..66889085 100644 --- a/crates/cherry-rs/tests/biconvex_lens_finite_object.rs +++ b/crates/cherry-rs/tests/biconvex_lens_finite_object.rs @@ -23,11 +23,12 @@ const EXIT_PUPIL: Pupil = Pupil { location: -2.4019, semi_diameter: 12.8540, }; -const FRONT_FOCAL_DISTANCE: f64 = 98.4360; +const FRONT_FOCAL_DISTANCE: f64 = -98.4360; +const FRONT_FOCAL_LENGTH: f64 = -99.6297; const FRONT_PRINCIPAL_PLANE: f64 = 1.1937; const PARAXIAL_IMAGE_PLANE: ImagePlane = ImagePlane { - location: 199.7684, + location: 196.1684, semi_diameter: 4.9048, }; @@ -159,6 +160,19 @@ fn test_paraxial_view_front_focal_distance() { } } +#[test] +fn test_paraxial_view_front_focal_length() { + let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_length(); + + assert_abs_diff_eq!(FRONT_FOCAL_LENGTH, *result, epsilon = 1e-4) + } +} + #[test] fn test_paraxial_view_front_principal_plane() { let model = sequential_model(n!(1.0), n!(1.517), &WAVELENGTHS); diff --git a/crates/cherry-rs/tests/convexplano_lens_ri.rs b/crates/cherry-rs/tests/convexplano_lens_ri.rs index 42e3631a..c6249a01 100644 --- a/crates/cherry-rs/tests/convexplano_lens_ri.rs +++ b/crates/cherry-rs/tests/convexplano_lens_ri.rs @@ -28,12 +28,12 @@ const EXIT_PUPIL: Pupil = Pupil { location: -3.4983, semi_diameter: 12.5, }; -const FRONT_FOCAL_DISTANCE: f64 = EFFECTIVE_FOCAL_LENGTH; +const FRONT_FOCAL_DISTANCE: f64 = -EFFECTIVE_FOCAL_LENGTH; const FRONT_PRINCIPAL_PLANE: f64 = 0.0; // For a 5 degree field angle const PARAXIAL_IMAGE_PLANE: ImagePlane = ImagePlane { - location: 51.8987, + location: 46.5987, semi_diameter: 4.3829, }; diff --git a/crates/cherry-rs/tests/wf_epi_excitation.rs b/crates/cherry-rs/tests/wf_epi_excitation.rs index 0a8377b7..8d32129c 100644 --- a/crates/cherry-rs/tests/wf_epi_excitation.rs +++ b/crates/cherry-rs/tests/wf_epi_excitation.rs @@ -13,6 +13,14 @@ const BACK_PRINCIPAL_PLANE: f64 = 7.0312; const EFFECTIVE_FOCAL_LENGTH: f64 = -1.8750; const ENTRANCE_PUPIL_LOCATION: f64 = -54.5455; const ENTRANCE_PUPIL_SIZE: f64 = 1.5454; +const FRONT_FOCAL_DISTANCE: f64 = -55.0; +const FRONT_FOCAL_LENGTH: f64 = 1.25; +const FRONT_PRINCIPAL_PLANE: f64 = -56.25; +const IMAGE_LOCATION: f64 = 5.0000; +const IMAGE_SIZE: f64 = 0.125; +const IMAGE_SPACE_FNO: f64 = -0.6066; +const LAGRANGE_INVARIANT: f64 = -0.1594; +const PARAXIAL_FNO: f64 = -0.3922; #[test] fn wf_epi_excitation_paraxial_aperture_stop() { @@ -86,3 +94,107 @@ fn wf_epi_excitation_entrance_pupil_location() { ); } } + +#[test] +fn wf_epi_excitation_paraxial_front_focal_distance() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_distance(); + + assert_abs_diff_eq!(FRONT_FOCAL_DISTANCE, *result, epsilon = 1e-4) + } +} + +#[test] +fn wf_epi_excitation_paraxial_front_focal_length() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.front_focal_length(); + + assert_abs_diff_eq!(FRONT_FOCAL_LENGTH, *result, epsilon = 1e-4) + } +} + +#[test] +fn wf_epi_excitation_paraxial_front_principal_plane() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.front_principal_plane(); + + assert_abs_diff_eq!(FRONT_PRINCIPAL_PLANE, *result, epsilon = 1e-4) + } +} + +#[test] +fn wf_epi_excitation_paraxial_image_space_fno() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.image_space_fno(); + + assert_abs_diff_eq!(IMAGE_SPACE_FNO, result, epsilon = 1e-4) + } +} + +#[test] +fn wf_epi_excitation_paraxial_image_location() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_image_plane().location; + assert_abs_diff_eq!(IMAGE_LOCATION, result, epsilon = 1e-4); + } +} + +#[test] +fn wf_epi_excitation_paraxial_image_size() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_image_plane().semi_diameter; + assert_abs_diff_eq!(IMAGE_SIZE, result, epsilon = 1e-4); + } +} + +#[test] +fn wf_epi_excitation_paraxial_lagrange_invariant() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.lagrange_invariants(); + + for &h in result { + assert_abs_diff_eq!(LAGRANGE_INVARIANT, h, epsilon = 1e-4); + } + } +} + +#[test] +fn wf_epi_excitation_paraxial_fno() { + let model = sequential_model(n!(1.0), n!(1.5), &WAVELENGTHS); + let view = + ParaxialView::new(&model, &FIELD_SPECS, false).expect("Could not create paraxial view"); + + for sub_view in view.iter() { + let result = sub_view.paraxial_fno(); + + assert_abs_diff_eq!(PARAXIAL_FNO, result, epsilon = 1e-4) + } +}