feat: add layout_optimizer package for automatic layout of devices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yexiaozhou
2026-03-31 01:00:09 +08:00
parent 3f75ca4ea3
commit 64eeed56a1
37 changed files with 19226 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
"""Regression tests for V2 Stage 1 bugfixes.
Covers:
- Duplicate device ID stacking (uuid-based internal IDs)
- DE orientation preservation (prefer_orientation_mode constraint)
- prefer_aligned auto-injection and adjustability
- Preset switch reorientation
- min_spacing with duplicate catalog IDs
"""
import math
import pytest
from ..constraints import evaluate_constraints
from ..mock_checkers import MockCollisionChecker
from ..models import Constraint, Device, Lab, Opening, Placement
from ..obb import obb_corners, obb_overlap
from ..optimizer import (
_placements_to_vector,
_vector_to_placements,
optimize,
snap_theta,
)
from ..seeders import resolve_seeder_params, seed_layout
# ── Helpers ─────────────────────────────────────────────
def _ot(uid: str) -> Device:
return Device(
id=uid, name="Opentrons Liquid Handler",
bbox=(0.6243, 0.5672), openings=[Opening(direction=(0.0, -1.0))],
)
def _tecan(uid: str) -> Device:
return Device(
id=uid, name="Tecan EVO 100",
bbox=(0.8121, 0.8574), openings=[Opening(direction=(0.0, -1.0))],
)
def _facing_dot(p: Placement, device: Device, lab: Lab) -> float:
"""Dot product of rotated front vector with vector from center to device.
Positive = outward, negative = inward."""
cx, cy = lab.width / 2, lab.depth / 2
dx, dy = p.x - cx, p.y - cy
front = device.openings[0].direction if device.openings else (0.0, -1.0)
rf_x = math.cos(p.theta) * front[0] - math.sin(p.theta) * front[1]
rf_y = math.sin(p.theta) * front[0] + math.cos(p.theta) * front[1]
return rf_x * dx + rf_y * dy
def _has_collision(devices, placements):
for i in range(len(devices)):
for j in range(i + 1, len(devices)):
ci = obb_corners(placements[i].x, placements[i].y,
devices[i].bbox[0], devices[i].bbox[1], placements[i].theta)
cj = obb_corners(placements[j].x, placements[j].y,
devices[j].bbox[0], devices[j].bbox[1], placements[j].theta)
if obb_overlap(ci, cj):
return True
return False
# ── Bug 1: Duplicate device ID stacking ────────────────
class TestDuplicateDeviceIDs:
"""When two instances of the same catalog device are placed,
unique uuid-based IDs must prevent dict-key collisions."""
def test_vector_roundtrip_preserves_unique_positions(self):
"""_placements_to_vector → _vector_to_placements with unique IDs."""
devices = [_ot("uuid-a"), _ot("uuid-b")]
placements = [
Placement(device_id="uuid-a", x=0.5, y=0.5, theta=0.0),
Placement(device_id="uuid-b", x=1.5, y=1.5, theta=1.0),
]
vec = _placements_to_vector(placements, devices)
decoded = _vector_to_placements(vec, devices)
assert decoded[0].x == pytest.approx(0.5)
assert decoded[1].x == pytest.approx(1.5)
def test_min_spacing_detects_stacked_unique_ids(self):
"""min_spacing should detect two devices at the same position
when they have unique IDs."""
devices = [_ot("uuid-a"), _ot("uuid-b")]
stacked = [
Placement(device_id="uuid-a", x=1.0, y=1.0, theta=0.0),
Placement(device_id="uuid-b", x=1.0, y=1.0, theta=0.0),
]
lab = Lab(width=5, depth=5)
constraints = [Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 0.05})]
cost = evaluate_constraints(devices, stacked, lab, constraints,
MockCollisionChecker())
assert math.isinf(cost)
def test_create_devices_uses_uuid(self):
"""create_devices_from_list should use uuid as Device.id."""
from ..device_catalog import create_devices_from_list
specs = [
{"id": "opentrons_liquid_handler", "uuid": "abc-123"},
{"id": "opentrons_liquid_handler", "uuid": "def-456"},
]
devices = create_devices_from_list(specs)
assert devices[0].id == "abc-123"
assert devices[1].id == "def-456"
# Both should have the same bbox from footprints
assert devices[0].bbox == devices[1].bbox
def test_create_devices_fallback_no_uuid(self):
"""Without uuid, Device.id falls back to catalog id."""
from ..device_catalog import create_devices_from_list
specs = [{"id": "opentrons_liquid_handler"}]
devices = create_devices_from_list(specs)
assert devices[0].id == "opentrons_liquid_handler"
# ── Bug 2 & 4: DE orientation preservation ─────────────
class TestOrientationWithDE:
"""DE must preserve seeder orientation direction (outward/inward)
via the prefer_orientation_mode constraint."""
def _run_de_with_orientation(self, mode, seed_val=42):
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
lab = Lab(width=2.0, depth=2.0)
params = resolve_seeder_params(
"compact_outward" if mode == "outward" else "spread_inward"
)
seed = seed_layout(devices, lab, params)
constraints = [
Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 0.05}),
Constraint(type="soft", rule_name="prefer_orientation_mode",
params={"mode": mode}, weight=5.0),
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
]
result = optimize(devices, lab, constraints, seed_placements=seed,
maxiter=200, seed=seed_val)
result = snap_theta(result)
return devices, lab, result
def test_compact_outward_de_faces_outward(self):
devices, lab, result = self._run_de_with_orientation("outward")
for i, p in enumerate(result):
dot = _facing_dot(p, devices[i], lab)
assert dot > 0, (
f"{p.device_id} faces inward (dot={dot:.3f}) "
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
)
def test_spread_inward_de_faces_inward(self):
devices, lab, result = self._run_de_with_orientation("inward")
for i, p in enumerate(result):
dot = _facing_dot(p, devices[i], lab)
assert dot < 0, (
f"{p.device_id} faces outward (dot={dot:.3f}) "
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
)
def test_switching_preset_changes_orientation(self):
"""Switching from outward to inward should produce opposite facing."""
_, lab, out_result = self._run_de_with_orientation("outward")
devices_in, _, in_result = self._run_de_with_orientation("inward")
# At least one device should have different facing
out_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(out_result)]
in_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(in_result)]
# Outward: all positive; inward: all negative
assert all(d > 0 for d in out_dots), f"outward dots: {out_dots}"
assert all(d < 0 for d in in_dots), f"inward dots: {in_dots}"
def test_no_collision_after_de(self):
devices, lab, result = self._run_de_with_orientation("outward")
assert not _has_collision(devices, result)
# ── Bug 3: prefer_aligned & prefer_orientation_mode ────
class TestOrientationConstraints:
"""Test the new constraint rules directly."""
def test_prefer_orientation_mode_outward_zero_at_correct(self):
"""Zero cost when device faces outward from center."""
device = _ot("a")
# Device to the right of center, front pointing right
# front=(0,-1), theta=pi/2 → rotated front = (1, 0) = rightward
lab = Lab(width=4, depth=4)
placements = [Placement("a", 3.0, 2.0, math.pi / 2)]
constraint = Constraint(
type="soft", rule_name="prefer_orientation_mode",
params={"mode": "outward"}, weight=1.0,
)
cost = evaluate_constraints(
[device], placements, lab, [constraint], MockCollisionChecker(),
)
assert cost == pytest.approx(0.0, abs=0.01)
def test_prefer_orientation_mode_outward_penalty_at_inward(self):
"""High cost when device faces inward (opposite of outward)."""
device = _ot("a")
# Device to the right of center, front pointing left (inward)
# front=(0,-1), theta=3*pi/2 → rotated front = (-1, 0) = leftward
lab = Lab(width=4, depth=4)
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
constraint = Constraint(
type="soft", rule_name="prefer_orientation_mode",
params={"mode": "outward"}, weight=1.0,
)
cost = evaluate_constraints(
[device], placements, lab, [constraint], MockCollisionChecker(),
)
# 180° off → (1 - cos(pi)) / 2 = 1.0
assert cost == pytest.approx(1.0, abs=0.05)
def test_prefer_orientation_mode_inward(self):
"""Zero cost when device faces inward."""
device = _ot("a")
# Device to the right of center, front pointing left (inward)
lab = Lab(width=4, depth=4)
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
constraint = Constraint(
type="soft", rule_name="prefer_orientation_mode",
params={"mode": "inward"}, weight=1.0,
)
cost = evaluate_constraints(
[device], placements, lab, [constraint], MockCollisionChecker(),
)
assert cost == pytest.approx(0.0, abs=0.01)
def test_prefer_seeder_orientation_zero_at_target(self):
"""Zero cost when theta matches target."""
device = Device(id="a", name="A", bbox=(0.5, 0.5))
lab = Lab(width=4, depth=4)
placements = [Placement("a", 2, 2, 1.5)]
constraint = Constraint(
type="soft", rule_name="prefer_seeder_orientation",
params={"target_thetas": {"a": 1.5}}, weight=1.0,
)
cost = evaluate_constraints(
[device], placements, lab, [constraint], MockCollisionChecker(),
)
assert cost == pytest.approx(0.0, abs=1e-9)
def test_prefer_seeder_orientation_penalty_at_deviation(self):
"""Non-zero cost when theta deviates from target."""
device = Device(id="a", name="A", bbox=(0.5, 0.5))
lab = Lab(width=4, depth=4)
placements = [Placement("a", 2, 2, math.pi)] # pi away from 0
constraint = Constraint(
type="soft", rule_name="prefer_seeder_orientation",
params={"target_thetas": {"a": 0.0}}, weight=1.0,
)
cost = evaluate_constraints(
[device], placements, lab, [constraint], MockCollisionChecker(),
)
# (1 - cos(pi)) / 2 = 1.0
assert cost == pytest.approx(1.0)
# ── API endpoint regression ────────────────────────────
class TestEndpointOrientation:
"""Test that /optimize injects orientation constraints."""
def test_endpoint_with_de_injects_orientation(self):
from fastapi.testclient import TestClient
from ..server import app
client = TestClient(app)
resp = client.post("/optimize", json={
"devices": [
{"id": "opentrons_liquid_handler", "uuid": "u1"},
{"id": "opentrons_liquid_handler", "uuid": "u2"},
],
"lab": {"width": 3, "depth": 3},
"seeder": "compact_outward",
"run_de": True,
"maxiter": 50,
"seed": 42,
})
assert resp.status_code == 200
data = resp.json()
# Both devices should have unique uuids in response
uuids = [p["uuid"] for p in data["placements"]]
assert len(set(uuids)) == 2, f"Expected 2 unique uuids, got {uuids}"
def test_endpoint_orientation_weight_override(self):
from fastapi.testclient import TestClient
from ..server import app
client = TestClient(app)
resp = client.post("/optimize", json={
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
"lab": {"width": 3, "depth": 3},
"seeder": "compact_outward",
"seeder_overrides": {"orientation_weight": 10, "align_weight": 0},
"run_de": True,
"maxiter": 50,
"seed": 42,
})
assert resp.status_code == 200
def test_endpoint_align_weight_zero_disables(self):
"""Setting align_weight=0 should not inject prefer_aligned."""
from fastapi.testclient import TestClient
from ..server import app
client = TestClient(app)
resp = client.post("/optimize", json={
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
"lab": {"width": 3, "depth": 3},
"seeder": "compact_outward",
"seeder_overrides": {"align_weight": 0},
"run_de": True,
"maxiter": 50,
"seed": 42,
})
assert resp.status_code == 200
# ── Broader scenario tests ─────────────────────────────
class TestScenarios:
"""End-to-end scenarios similar to user's real usage."""
def test_user_scenario_2ot_1tecan_compact_outward(self):
"""User's exact scenario: 2 OT + 1 Tecan in 2m×2m, compact outward."""
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
lab = Lab(width=2.0, depth=2.0)
params = resolve_seeder_params("compact_outward")
seed = seed_layout(devices, lab, params)
constraints = [
Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 0.05}),
Constraint(type="soft", rule_name="prefer_orientation_mode",
params={"mode": "outward"}, weight=5.0),
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
]
result = optimize(devices, lab, constraints, seed_placements=seed,
maxiter=200, seed=42)
result = snap_theta(result)
# No stacking
assert not _has_collision(devices, result)
# All outward
for i, p in enumerate(result):
assert _facing_dot(p, devices[i], lab) > 0
def test_4_medium_devices_mixed_openings(self):
"""4 devices with different opening directions."""
devices = [
Device(id="d0", name="D0", bbox=(0.5, 0.3), openings=[Opening((1, 0))]),
Device(id="d1", name="D1", bbox=(0.5, 0.3), openings=[Opening((-1, 0))]),
Device(id="d2", name="D2", bbox=(0.5, 0.3), openings=[Opening((0, -1))]),
Device(id="d3", name="D3", bbox=(0.5, 0.3), openings=[Opening((0, 1))]),
]
lab = Lab(width=3.0, depth=3.0)
params = resolve_seeder_params("compact_outward")
seed = seed_layout(devices, lab, params)
constraints = [
Constraint(type="hard", rule_name="min_spacing",
params={"min_gap": 0.05}),
Constraint(type="soft", rule_name="prefer_orientation_mode",
params={"mode": "outward"}, weight=5.0),
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
]
result = optimize(devices, lab, constraints, seed_placements=seed,
maxiter=200, seed=42)
result = snap_theta(result)
assert not _has_collision(devices, result)
for i, p in enumerate(result):
assert _facing_dot(p, devices[i], lab) > 0