"""Tests for the optical stack: validation, Snell's law, and the slab oracle.""" from __future__ import annotations import math from pathlib import Path import pytest np = pytest.importorskip("numpy") from orisys_sensor_design.optics import ( AIR_INDEX, Interface, OpticsConfigError, OpticsLayer, OpticalStack, intersect_mesh, load_optics_block, quantify, refract, resolve_optics, surfaces_coincide, trace_ray, validate_optics_block, ) from orisys_sensor_design.manifest import RevisionPaths, repo_root REPO = repo_root() def _block(**overrides) -> dict: base = { "marker_surface": "sensing_surface", "layers": [ { "name": "clear_rubber", "outer": "rubber-2", "inner": "rubber-1", "ior": 1.43, "ior_source": "assumed_silicone_literature", } ], } base.update(overrides) return base def _quad(z: float, half: float = 10.0, name: str = "q") -> Interface: """A horizontal quad facing -Z, as two triangles.""" vertices = np.array( [ [-half, -half, z], [half, -half, z], [half, half, z], [-half, half, z], ], dtype=np.float64, ) faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64) return Interface(name=name, vertices=vertices, faces=faces) # ------------------------------------------------------------- validation def test_validate_accepts_a_complete_block() -> None: assert validate_optics_block(_block()) == [] def test_validate_rejects_unknown_keys() -> None: errors = validate_optics_block(_block(dpi=600)) assert any("unknown key" in message for message in errors) def test_validate_requires_ior_source() -> None: block = _block() del block["layers"][0]["ior_source"] errors = validate_optics_block(block) assert any("ior_source" in message for message in errors) def test_validate_rejects_an_index_at_or_below_one() -> None: for value in (1.0, 0.9): block = _block() block["layers"][0]["ior"] = value errors = validate_optics_block(block) assert any("ior" in message for message in errors), value def test_validate_rejects_duplicate_layer_names() -> None: block = _block() block["layers"].append(dict(block["layers"][0])) errors = validate_optics_block(block) assert any("duplicate" in message for message in errors) def test_validate_rejects_empty_layers() -> None: assert any("non-empty" in m for m in validate_optics_block(_block(layers=[]))) def test_validate_rejects_a_shared_interface_between_layers() -> None: """The exact mistake that made every ray miss: rubber-2 as exit and entry.""" block = _block( layers=[ { "name": "plastic_shell", "outer": "shell", "inner": "rubber-2", "ior": 1.59, "ior_source": "assumed", }, { "name": "clear_rubber", "outer": "rubber-2", "inner": "rubber-1", "ior": 1.43, "ior_source": "assumed", }, ] ) errors = validate_optics_block(block) assert any("share an interface" in message for message in errors) def test_resolve_optics_returns_none_when_absent(tmp_path: Path) -> None: revision = tmp_path / "sensors" / "finger" / "r99" revision.mkdir(parents=True) (revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8") paths = RevisionPaths.from_revision_dir(revision) assert load_optics_block(paths) is None assert resolve_optics(paths) is None def test_r01_design_yaml_declares_a_valid_stack() -> None: paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01") stack = resolve_optics(paths) assert stack is not None assert stack.marker_surface == "sensing_surface" # The stack is camera > shell > rubber-1 > sensing_surface > rubber-2, so the # two refracting solids are shell and rubber-1; rubber-2 sits behind the # markers and does not bend camera light. assert [layer.name for layer in stack.layers] == ["plastic_shell", "clear_rubber"] assert [layer.solid for layer in stack.layers] == ["shell", "rubber-1"] assert all(layer.is_solid for layer in stack.layers) assert all(layer.ior_source for layer in stack.layers), ( "an assumed index must record its source" ) # ------------------------------------------------------------------- optics def test_refract_obeys_snells_law() -> None: normal = np.array([0.0, 0.0, 1.0]) angle_in = math.radians(40.0) direction = np.array([math.sin(angle_in), 0.0, -math.cos(angle_in)]) out = refract(direction, normal, 1.0, 1.5) assert out is not None angle_out = math.acos(abs(float(out[2]))) assert math.sin(angle_in) == pytest.approx(1.5 * math.sin(angle_out), rel=1e-9) def test_refract_returns_none_under_total_internal_reflection() -> None: normal = np.array([0.0, 0.0, 1.0]) steep = math.radians(70.0) direction = np.array([math.sin(steep), 0.0, math.cos(steep)]) assert refract(direction, normal, 1.5, 1.0) is None def test_intersect_mesh_finds_the_plane_and_reports_a_miss() -> None: plane = _quad(0.0) hit = intersect_mesh(np.array([0.0, 0.0, -5.0]), np.array([0.0, 0.0, 1.0]), plane) assert hit is not None distance, point, normal = hit assert distance == pytest.approx(5.0) assert point[2] == pytest.approx(0.0) assert abs(float(normal[2])) == pytest.approx(1.0) miss = intersect_mesh(np.array([0.0, 0.0, -5.0]), np.array([0.0, 1.0, 0.0]), plane) assert miss is None def test_surfaces_coincide_is_asymmetric_by_design() -> None: dense = np.array([[x, 0.0, 0.0] for x in np.linspace(0.0, 1.0, 20)]) sparse = dense[::4] # Every sparse point lies on the dense set, but not the reverse. assert surfaces_coincide(sparse, dense, 1e-6) is True assert surfaces_coincide(dense, sparse, 1e-6) is False def test_slab_displacement_matches_the_closed_form() -> None: """The tracer against an independent analytic oracle, not against itself. A plane-parallel slab of index ``n`` and thickness ``t`` shifts a ray of incidence ``theta1`` *along the interface* by ``t * (tan(theta1) - tan(theta2))`` with ``sin(theta2) = sin(theta1)/n``. That in-plane shift is the one that moves a marker's apparent position; the perpendicular gap between the incident and exit rays is smaller by a factor of ``cos(theta1)``, and conflating the two understates the error by 15% at 30 degrees. """ thickness = 2.0 index = 1.5 layer = OpticsLayer( name="slab", outer="top", inner="bottom", ior=index, ior_source="test" ) layers = [(layer, _quad(0.0, name="top"), _quad(-thickness, name="bottom"))] # Wide enough that a 40 degree ray still lands on it after 15 units of travel. marker = _quad(-10.0, half=60.0, name="marker") for theta_deg in (10.0, 20.0, 30.0, 40.0): theta = math.radians(theta_deg) direction = np.array([math.sin(theta), 0.0, -math.cos(theta)]) origin = np.array([0.0, 0.0, 5.0]) straight = intersect_mesh(origin, direction, marker) traced = trace_ray(origin, direction, layers, marker) assert straight is not None and traced.status == "ok", theta_deg _, straight_point, _ = straight measured = float(np.linalg.norm(traced.point - straight_point)) theta_out = math.asin(math.sin(theta) / index) expected = thickness * (math.tan(theta) - math.tan(theta_out)) assert measured == pytest.approx(expected, rel=1e-3), theta_deg def test_index_one_reproduces_the_pinhole_result() -> None: """With no refraction the trace must agree with the straight ray exactly.""" layer = OpticsLayer(name="air", outer="top", inner="bottom", ior=1.0, ior_source="test") layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))] marker = _quad(-10.0, name="marker") origin = np.array([0.0, 0.0, 5.0]) direction = np.array([0.3, 0.1, -1.0]) direction = direction / np.linalg.norm(direction) straight = intersect_mesh(origin, direction, marker) traced = trace_ray(origin, direction, layers, marker) assert straight is not None and traced.status == "ok" _, straight_point, _ = straight assert np.allclose(traced.point, straight_point, atol=1e-9) def test_marker_at_last_exit_uses_the_exit_point() -> None: """Markers printed on the inner face: the exit point is the marker hit.""" layer = OpticsLayer(name="rubber", outer="top", inner="bottom", ior=1.43, ior_source="t") layers = [(layer, _quad(0.0, name="top"), _quad(-1.0, name="bottom"))] marker = _quad(-1.0, name="marker") origin = np.array([0.0, 0.0, 5.0]) direction = np.array([0.1, 0.0, -1.0]) direction = direction / np.linalg.norm(direction) without = trace_ray(origin, direction, layers, marker) with_flag = trace_ray(origin, direction, layers, marker, marker_at_last_exit=True) assert with_flag.status == "ok" assert with_flag.point[2] == pytest.approx(-1.0, abs=1e-6) # Without the flag the ray sits on the marker already and finds nothing. assert without.status == "miss" def test_quantify_reports_zero_error_for_an_air_only_stack() -> None: layer = OpticsLayer(name="air", outer="top", inner="bottom", ior=1.0, ior_source="t") layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))] marker = _quad(-10.0, name="marker") stack = OpticalStack(marker_surface="marker", layers=(layer,)) directions = np.array([[x, 0.0, -1.0] for x in (-0.2, 0.0, 0.2)]) directions = directions / np.linalg.norm(directions, axis=1, keepdims=True) result = quantify( stack=stack, layers=layers, marker=marker, origin=np.array([0.0, 0.0, 5.0]), directions=directions, pitch_mm=0.25, measured_thickness=[2.0], ) assert result["statuses"]["ok"] == 3 assert result["error_mm"]["max"] == pytest.approx(0.0, abs=1e-9) assert result["error_cells"]["max"] == pytest.approx(0.0, abs=1e-9) assert result["layers"][0]["thickness_mm"] == pytest.approx(2.0) def test_quantify_reports_measured_thickness_when_not_declared() -> None: layer = OpticsLayer(name="slab", outer="top", inner="bottom", ior=1.5, ior_source="t") layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))] marker = _quad(-10.0, name="marker") stack = OpticalStack(marker_surface="marker", layers=(layer,)) result = quantify( stack=stack, layers=layers, marker=marker, origin=np.array([0.0, 0.0, 5.0]), directions=np.array([[0.0, 0.0, -1.0]]), pitch_mm=0.25, measured_thickness=[1.75], ) assert result["layers"][0]["thickness_mm"] == pytest.approx(1.75) assert result["total_thickness_mm"] == pytest.approx(1.75) def test_load_optics_block_rejects_a_non_mapping(tmp_path: Path) -> None: revision = tmp_path / "sensors" / "finger" / "r99" revision.mkdir(parents=True) (revision / "design.yaml").write_text( "sensor: finger\nrevision: r99\noptics: 3\n", encoding="utf-8" ) paths = RevisionPaths.from_revision_dir(revision) with pytest.raises(OpticsConfigError, match="must be a mapping"): load_optics_block(paths)