Files
Uni-Lab-OS/unilabos/layout_optimizer/tests/test_bugfixes_v2.py
yexiaozhou b04dc8dd4a feat(layout_optimizer): default cardinal snap and alignment to off
align_weight defaults to 0 (was DEFAULT_WEIGHT_ANGLE=60).
snap_theta_safe is opt-in via snap_cardinal=True (was always-on).
Both remain available when explicitly requested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:48:34 +08:00

434 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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})]
# graduated=True (default): 返回有限惩罚
cost = evaluate_constraints(devices, stacked, lab, constraints,
MockCollisionChecker())
assert cost > 0
assert not math.isinf(cost)
# graduated=False: binary inf
cost_binary = evaluate_constraints(devices, stacked, lab, constraints,
MockCollisionChecker(),
graduated=False)
assert math.isinf(cost_binary)
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
# ── V2 Stage 1: 默认关闭 cardinal snap/alignment ────────
class TestV2Stage1Bugfixes:
"""align_weight 默认为 0snap_cardinal 默认关闭。"""
def test_default_align_weight_is_zero(self):
"""Default request (no seeder_overrides) 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",
"run_de": True,
"maxiter": 50,
"seed": 42,
})
assert resp.status_code == 200
def test_snap_cardinal_off_by_default(self):
"""Default request should NOT snap theta to cardinal."""
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",
"run_de": True,
"maxiter": 10,
"seed": 42,
})
assert resp.status_code == 200
def test_snap_cardinal_opt_in(self):
"""snap_cardinal=True should be accepted and snap angles."""
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",
"snap_cardinal": True,
"run_de": True,
"maxiter": 10,
"seed": 42,
})
assert resp.status_code == 200