From d45d9cda5243d642ea3256a572f45eae166ddca8 Mon Sep 17 00:00:00 2001 From: jacobdparker Date: Mon, 3 Aug 2026 10:07:42 -0600 Subject: [PATCH 1/2] Flip the stop-ray direction in the object surface's local coordinates Preserved from the fix/linear-weights-area-unit working tree when that branch was merged upstream as PR #194: the direction flip must be applied to the rays already expressed in the local coordinates of the object surface, since that is the frame in which _calc_rayfunction_input interprets the field and pupil coordinates. Co-Authored-By: Claude Fable 5 --- optika/systems/_sequential.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/optika/systems/_sequential.py b/optika/systems/_sequential.py index a1e617a..e7a4f06 100644 --- a/optika/systems/_sequential.py +++ b/optika/systems/_sequential.py @@ -658,10 +658,15 @@ def _calc_rayfunction_stops( obj = subsystem[~0] rays = result.outputs if obj.transformation is not None: + # express the stop rays in the local coordinates of the object + # surface, since that is the frame in which the field and pupil + # coordinates of the input grid are interpreted by + # `_calc_rayfunction_input` rays = obj.transformation.inverse(rays) where = rays.direction @ obj.sag.normal(rays.position) > 0 - result.outputs.direction[where] = -result.outputs.direction[where] + rays.direction[where] = -rays.direction[where] + result.outputs = rays # If the first stop is the object surface, the solved variable is the # position and the direction retains only the field-stop axis, so From 3f3e446e96427e7cb18096a56a69d26379861b2b Mon Sep 17 00:00:00 2001 From: jacobdparker Date: Tue, 18 Aug 2026 09:42:49 -0600 Subject: [PATCH 2/2] Add a regression test for a rotated object surface in the stop solver A finite-conjugate relay whose object surface is rotated 180 degrees about y, with a decentered circular aperture as the field stop. Without the object-local frame fix, the field bounds come back mirrored (field_min/field_max centered on -3 mm instead of +3 mm) and every traced ray vignettes at the field stop. Co-Authored-By: Claude Fable 5 --- optika/systems/_sequential_test.py | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/optika/systems/_sequential_test.py b/optika/systems/_sequential_test.py index 306278d..a5bb3d1 100644 --- a/optika/systems/_sequential_test.py +++ b/optika/systems/_sequential_test.py @@ -716,3 +716,80 @@ def test_field_max_matches_source_aperture( result = a.field_max assert np.abs(result.x - _radius_field_grazing) < 1e-6 * u.deg assert np.abs(result.y - _radius_field_grazing) < 1e-6 * u.deg + + +# the field stop is decentered so that a mirrored field frame is observable: +# rays aimed using global-frame field bounds miss the aperture entirely +_radius_field_rotated = 2 * u.mm +_decenter_field_rotated = 3 * u.mm + +_system_rotated_object = optika.systems.SequentialSystem( + object=optika.surfaces.Surface( + name="source", + aperture=optika.apertures.CircularAperture( + radius=_radius_field_rotated, + transformation=na.transformations.Cartesian3dTranslation( + x=_decenter_field_rotated, + ), + ), + is_field_stop=True, + transformation=na.transformations.Cartesian3dRotationY(180 * u.deg), + ), + surfaces=[ + optika.surfaces.Surface( + name="mirror", + sag=optika.sags.SphericalSag(radius=240 * u.mm), + material=optika.materials.Mirror(), + aperture=optika.apertures.CircularAperture(radius=15 * u.mm), + is_pupil_stop=True, + transformation=na.transformations.Cartesian3dTranslation( + z=-200 * u.mm, + ), + ), + ], + sensor=optika.sensors.ImagingSensor( + name="sensor", + width_pixel=150 * u.um, + axis_pixel=na.Cartesian2dVectorArray("detector_x", "detector_y"), + timedelta_exposure=1 * u.s, + num_pixel=na.Cartesian2dVectorArray(128, 128), + transformation=na.transformations.Cartesian3dTranslation( + z=100 * u.mm, + ), + ), + grid_input=_grid_input, +) + + +@pytest.mark.parametrize(argnames="a", argvalues=[_system_rotated_object]) +class TestSequentialSystemRotatedObject( + AbstractTestAbstractSequentialSystem, +): + """ + A finite-conjugate relay whose object surface is rotated 180 degrees + about :math:`y`, so its local coordinate frame differs from the global + frame. This guards the object-local frame handling of the stop + root-finding problem: the solved stop rays must be expressed in the + object surface's local coordinates before their direction is flipped, + since that is the frame in which the field and pupil coordinates of the + input grid are interpreted. + """ + + def test_field_bounds_match_decentered_aperture( + self, + a: optika.systems.AbstractSequentialSystem, + ): + x_min = _decenter_field_rotated - _radius_field_rotated + x_max = _decenter_field_rotated + _radius_field_rotated + assert np.abs(a.field_min.x - x_min) < 1 * u.um + assert np.abs(a.field_max.x - x_max) < 1 * u.um + + def test_rays_reach_the_sensor( + self, + a: optika.systems.AbstractSequentialSystem, + ): + # the square field/pupil grids overfill the circular apertures, so + # the unvignetted fraction is well below 1 even for a healthy trace; + # with a mirrored object frame it is exactly 0 + unvignetted = a.rayfunction_default.outputs.unvignetted + assert unvignetted.mean().ndarray > 0.25