added optics test (undergoing)
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user