Files
orisys-sensor-design/tests/test_optics.py
T
2026-09-23 10:01:08 +08:00

309 lines
11 KiB
Python

"""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)