mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-24 19:51:27 +00:00
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:
0
unilabos/layout_optimizer/tests/__init__.py
Normal file
0
unilabos/layout_optimizer/tests/__init__.py
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_devices.json
vendored
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_devices.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"id": "arm_1", "name": "Elite CS66 Arm", "size": [0.20, 0.20], "device_type": "articulation"},
|
||||
{"id": "liquid_handler", "name": "Agilent Bravo", "size": [0.80, 0.65], "device_type": "static"},
|
||||
{"id": "centrifuge", "name": "Centrifuge", "size": [0.50, 0.50], "device_type": "static"},
|
||||
{"id": "plate_hotel", "name": "Thermo Orbitor RS2", "size": [0.45, 0.55], "device_type": "static"},
|
||||
{"id": "hplc", "name": "HPLC Station", "size": [0.60, 0.50], "device_type": "static"}
|
||||
]
|
||||
7
unilabos/layout_optimizer/tests/fixtures/sample_lab.json
vendored
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_lab.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"width": 5.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.5, "y": 0.0, "width": 0.1, "depth": 0.5}
|
||||
]
|
||||
}
|
||||
371
unilabos/layout_optimizer/tests/test_bugfixes_v2.py
Normal file
371
unilabos/layout_optimizer/tests/test_bugfixes_v2.py
Normal 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
|
||||
274
unilabos/layout_optimizer/tests/test_constraints.py
Normal file
274
unilabos/layout_optimizer/tests/test_constraints.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""约束体系测试。"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ..constraints import (
|
||||
evaluate_constraints,
|
||||
evaluate_default_hard_constraints,
|
||||
)
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
from ..models import Constraint, Device, Lab, Placement
|
||||
|
||||
|
||||
def _make_devices():
|
||||
return [
|
||||
Device(id="a", name="Device A", bbox=(0.5, 0.5)),
|
||||
Device(id="b", name="Device B", bbox=(0.5, 0.5)),
|
||||
]
|
||||
|
||||
|
||||
def _make_lab():
|
||||
return Lab(width=5.0, depth=4.0)
|
||||
|
||||
|
||||
class TestDefaultHardConstraints:
|
||||
def test_no_collision_passes(self):
|
||||
"""无碰撞的布局应返回 0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_collision_returns_graduated_penalty(self):
|
||||
"""碰撞布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_collision_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 碰撞布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_graduated_penalty(self):
|
||||
"""越界布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0), # 左下角越界
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 越界布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_worse_collision_higher_cost(self):
|
||||
"""Deeper penetration should produce higher cost."""
|
||||
devices = _make_devices()
|
||||
checker = MockCollisionChecker()
|
||||
lab = _make_lab()
|
||||
# Small overlap
|
||||
cost_small = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.4, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
# Large overlap
|
||||
cost_large = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.1, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
assert cost_large > cost_small > 0
|
||||
|
||||
|
||||
class TestUserConstraints:
|
||||
def test_distance_less_than_satisfied(self):
|
||||
"""距离约束满足时 cost=0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_distance_less_than_violated_hard(self):
|
||||
"""硬距离约束违反返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_minimize_distance_cost(self):
|
||||
"""minimize_distance 约束应返回正比于距离的 cost。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="soft", rule_name="minimize_distance",
|
||||
params={"device_a": "a", "device_b": "b"}, weight=2.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
# edge-to-edge distance = 2.0 - 0.25 - 0.25 = 1.5, weight = 2.0 → cost = 3.0
|
||||
assert abs(cost - 3.0) < 0.01
|
||||
|
||||
def test_reachability_constraint(self):
|
||||
"""可达性约束:目标在臂展内应通过(不返回 inf)。
|
||||
|
||||
Opening-faces-arm penalty may add a small soft cost when the
|
||||
target's opening doesn't face the arm, but it must not cause
|
||||
hard failure (inf).
|
||||
"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert not math.isinf(cost) # reachable → no hard failure
|
||||
|
||||
def test_reachability_constraint_violated(self):
|
||||
"""可达性约束:目标超出臂展应返回 inf。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
|
||||
def test_distance_less_than_uses_edge_to_edge():
|
||||
"""distance_less_than should measure edge-to-edge, not center-to-center.
|
||||
|
||||
Two devices: centers 3m apart, each 2m wide → edge gap = 1m.
|
||||
Constraint: distance_less_than 1.5m (edge-to-edge).
|
||||
Old center-to-center: 3m > 1.5m → violation.
|
||||
New edge-to-edge: 1m < 1.5m → satisfied.
|
||||
"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(2.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(2.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=1.0, y=1.0, theta=0.0),
|
||||
Placement(device_id="b", x=4.0, y=1.0, theta=0.0),
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.5},
|
||||
weight=1.0,
|
||||
)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_zero_at_cardinal():
|
||||
"""prefer_aligned cost = 0 when all devices at 0/90/180/270°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
lab = Lab(width=10, depth=10)
|
||||
checker = MockCollisionChecker()
|
||||
for angle in [0, math.pi / 2, math.pi, 3 * math.pi / 2]:
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=angle)]
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_prefer_aligned_max_at_45():
|
||||
"""prefer_aligned cost is maximum when device at 45°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=math.pi / 4)]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# (1 - cos(4 * pi/4)) / 2 = (1 - cos(pi)) / 2 = (1 - (-1)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_sums_over_devices():
|
||||
"""Cost sums across all devices."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(1.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(1.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=2, y=2, theta=math.pi / 4), # cost = 1.0
|
||||
Placement(device_id="b", x=7, y=7, theta=math.pi / 4), # cost = 1.0
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=2.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# 2 devices × 1.0 × weight 2.0 = 4.0
|
||||
assert cost == pytest.approx(4.0)
|
||||
222
unilabos/layout_optimizer/tests/test_device_catalog.py
Normal file
222
unilabos/layout_optimizer/tests/test_device_catalog.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""device_catalog 双源加载测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ..device_catalog import (
|
||||
_DEFAULT_FOOTPRINTS,
|
||||
create_devices_from_list,
|
||||
load_devices_from_assets,
|
||||
load_devices_from_registry,
|
||||
load_footprints,
|
||||
merge_device_lists,
|
||||
reset_footprints_cache,
|
||||
resolve_device,
|
||||
)
|
||||
|
||||
# ---------- fixtures ----------
|
||||
|
||||
# LeapLab/layout_optimizer/tests/ → LeapLab/ → DPTech/
|
||||
_LEAPLAB = Path(__file__).resolve().parent.parent.parent
|
||||
_DPTECH = _LEAPLAB.parent
|
||||
DATA_JSON = _DPTECH / "uni-lab-assets" / "data.json"
|
||||
REGISTRY_DIR = _LEAPLAB / "Uni-Lab-OS" / "unilabos" / "device_mesh" / "devices"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""每个测试前清除缓存。"""
|
||||
reset_footprints_cache()
|
||||
yield
|
||||
reset_footprints_cache()
|
||||
|
||||
|
||||
# ---------- footprints ----------
|
||||
|
||||
|
||||
class TestLoadFootprints:
|
||||
def test_load_footprints_exists(self):
|
||||
fp = load_footprints(_DEFAULT_FOOTPRINTS)
|
||||
assert isinstance(fp, dict)
|
||||
assert len(fp) > 0
|
||||
|
||||
def test_footprint_structure(self):
|
||||
fp = load_footprints()
|
||||
for dev_id, entry in fp.items():
|
||||
assert "bbox" in entry, f"{dev_id} missing bbox"
|
||||
assert len(entry["bbox"]) == 2
|
||||
assert "height" in entry
|
||||
assert "origin_offset" in entry
|
||||
assert "openings" in entry
|
||||
|
||||
def test_known_device_in_footprints(self):
|
||||
fp = load_footprints()
|
||||
assert "agilent_bravo" in fp
|
||||
bbox = fp["agilent_bravo"]["bbox"]
|
||||
assert 0.5 < bbox[0] < 1.0 # width ~0.65m
|
||||
assert 0.5 < bbox[1] < 1.0 # depth ~0.70m
|
||||
|
||||
def test_nonexistent_path_returns_empty(self):
|
||||
reset_footprints_cache()
|
||||
fp = load_footprints("/nonexistent/footprints.json")
|
||||
assert fp == {}
|
||||
|
||||
|
||||
# ---------- assets 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromAssets:
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_known_device_has_real_bbox(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
bravo = next((d for d in devices if d.id == "agilent_bravo"), None)
|
||||
assert bravo is not None
|
||||
assert bravo.bbox != (0.6, 0.4) # 不是默认值
|
||||
assert bravo.source == "assets"
|
||||
|
||||
def test_missing_data_json(self):
|
||||
devices = load_devices_from_assets("/nonexistent/data.json")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- registry 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromRegistry:
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_elite_robot_present(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
elite = next((d for d in devices if d.id == "elite_robot"), None)
|
||||
assert elite is not None
|
||||
assert elite.source == "registry"
|
||||
|
||||
def test_missing_dir(self):
|
||||
devices = load_devices_from_registry("/nonexistent/")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- 合并与去重 ----------
|
||||
|
||||
|
||||
class TestMergeDedup:
|
||||
def test_registry_wins_dedup(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2 Registry", bbox=(0.62, 0.50), source="registry")]
|
||||
asset = [Device(id="ot2", name="OT-2 Assets", bbox=(0.62, 0.50), source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.source == "registry"
|
||||
assert ot2.name == "OT-2 Registry"
|
||||
|
||||
def test_merge_preserves_unique(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="elite", name="Elite", source="registry")]
|
||||
asset = [Device(id="bravo", name="Bravo", source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ids = {d.id for d in merged}
|
||||
assert ids == {"elite", "bravo"}
|
||||
|
||||
def test_registry_inherits_asset_model(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2", source="registry", model_path="")]
|
||||
asset = [Device(id="ot2", name="OT-2", source="assets", model_path="/models/ot2/mesh.glb")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.model_path == "/models/ot2/mesh.glb"
|
||||
|
||||
|
||||
# ---------- resolve_device ----------
|
||||
|
||||
|
||||
class TestResolveDevice:
|
||||
def test_known_device(self):
|
||||
dev = resolve_device("agilent_bravo")
|
||||
assert dev is not None
|
||||
assert dev.id == "agilent_bravo"
|
||||
assert dev.bbox != (0.6, 0.4)
|
||||
|
||||
def test_fallback_known_sizes(self):
|
||||
dev = resolve_device("ot2")
|
||||
assert dev is not None
|
||||
assert dev.bbox == (0.62, 0.50)
|
||||
|
||||
def test_unknown_device_returns_none(self):
|
||||
dev = resolve_device("totally_unknown_device_xyz")
|
||||
assert dev is None
|
||||
|
||||
|
||||
# ---------- create_devices_from_list (向后兼容) ----------
|
||||
|
||||
|
||||
class TestCreateDevicesFromList:
|
||||
def test_basic(self):
|
||||
specs = [{"id": "test_dev", "name": "Test"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert len(devs) == 1
|
||||
assert devs[0].id == "test_dev"
|
||||
|
||||
def test_with_explicit_size(self):
|
||||
specs = [{"id": "custom", "name": "Custom", "size": [1.0, 0.5]}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox == (1.0, 0.5)
|
||||
|
||||
def test_footprint_size_used_when_no_explicit(self):
|
||||
specs = [{"id": "agilent_bravo", "name": "Bravo"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox != (0.6, 0.4) # 使用 footprints 中的真实尺寸
|
||||
|
||||
|
||||
# ---------- server endpoint (需要 httpx) ----------
|
||||
|
||||
|
||||
class TestDevicesEndpoint:
|
||||
def test_get_devices(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# 可能为空(取决于 uni-lab-assets 是否在预期路径)
|
||||
if len(data) > 0:
|
||||
first = data[0]
|
||||
assert "id" in first
|
||||
assert "bbox" in first
|
||||
assert "source" in first
|
||||
|
||||
def test_filter_by_source(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices?source=registry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for d in data:
|
||||
assert d["source"] == "registry"
|
||||
261
unilabos/layout_optimizer/tests/test_e2e_pcr_pipeline.py
Normal file
261
unilabos/layout_optimizer/tests/test_e2e_pcr_pipeline.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""End-to-end pipeline test: intents → interpret → optimize → verify.
|
||||
|
||||
Tests each stage boundary independently so failures are easy to localize.
|
||||
Uses real PCR workflow devices with footprints from the catalog.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# -- Scene: 5 PCR devices the user has already placed in the scene --
|
||||
|
||||
PCR_DEVICES = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
|
||||
PCR_LAB = {"width": 6.0, "depth": 4.0}
|
||||
|
||||
# -- Stage 1: simulated LLM output (what the LLM would produce from NL) --
|
||||
# User said: "take plate from hotel, prepare sample in opentrons,
|
||||
# seal plate then pcr cycle, arm_slider handles transfers"
|
||||
|
||||
LLM_INTENTS = [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "arm_slider must reach all workflow devices",
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "PCR order: hotel → liquid handler → sealer → thermal cycler",
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high",
|
||||
},
|
||||
"description": "Seal immediately after sample prep",
|
||||
},
|
||||
{
|
||||
"intent": "min_spacing",
|
||||
"params": {"min_gap": 0.15},
|
||||
"description": "Minimum 15cm gap for accessibility",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestStage1Interpret:
|
||||
"""Stage 1: /interpret translates intents → constraints."""
|
||||
|
||||
def test_interpret_returns_correct_constraint_count(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow minimize + 1 close minimize + 1 min_spacing = 9
|
||||
assert len(data["constraints"]) == 9
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
def test_interpret_has_translations_for_each_intent(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["translations"]) == len(LLM_INTENTS)
|
||||
# 每个 translation 都有 explanation
|
||||
for t in data["translations"]:
|
||||
assert t["explanation"] != ""
|
||||
|
||||
def test_interpret_extracts_workflow_edges(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert ["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"] in data["workflow_edges"]
|
||||
assert ["opentrons_liquid_handler", "agilent_plateloc"] in data["workflow_edges"]
|
||||
assert ["agilent_plateloc", "inheco_odtc_96xl"] in data["workflow_edges"]
|
||||
|
||||
def test_interpret_constraint_types_correct(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
constraints = data["constraints"]
|
||||
by_rule = {}
|
||||
for c in constraints:
|
||||
by_rule.setdefault(c["rule_name"], []).append(c)
|
||||
assert len(by_rule["reachability"]) == 4
|
||||
assert all(c["type"] == "hard" for c in by_rule["reachability"])
|
||||
assert len(by_rule["minimize_distance"]) == 4 # 3 workflow + 1 close
|
||||
assert all(c["type"] == "soft" for c in by_rule["minimize_distance"])
|
||||
assert len(by_rule["min_spacing"]) == 1
|
||||
assert by_rule["min_spacing"][0]["type"] == "hard"
|
||||
|
||||
|
||||
class TestStage2Optimize:
|
||||
"""Stage 2: pipe /interpret output into /optimize → placements."""
|
||||
|
||||
@pytest.fixture()
|
||||
def interpret_result(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
return resp.json()
|
||||
|
||||
def test_optimize_accepts_interpret_output(self, interpret_result):
|
||||
"""Constraints + workflow_edges from /interpret are valid /optimize input."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": False, # seeder only — fast
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["success"] is True
|
||||
|
||||
def test_optimize_with_de(self, interpret_result):
|
||||
"""Full DE optimization completes without error."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50, # reduced for test speed
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["de_ran"] is True
|
||||
|
||||
|
||||
class TestStage3VerifyPlacements:
|
||||
"""Stage 3: verify optimized placements satisfy constraint intent."""
|
||||
|
||||
@pytest.fixture()
|
||||
def placements(self):
|
||||
# Full pipeline: interpret → optimize (with DE), all intents including reachability
|
||||
# MockReachabilityChecker uses large fallback reach for unknown arms like arm_slider
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
return {p["device_id"]: p for p in optimize_resp.json()["placements"]}
|
||||
|
||||
def test_all_devices_placed(self, placements):
|
||||
expected_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert set(placements.keys()) == expected_ids
|
||||
|
||||
def test_all_within_lab_bounds(self, placements):
|
||||
for dev_id, p in placements.items():
|
||||
assert 0 <= p["position"]["x"] <= PCR_LAB["width"], f"{dev_id} x out of bounds"
|
||||
assert 0 <= p["position"]["y"] <= PCR_LAB["depth"], f"{dev_id} y out of bounds"
|
||||
|
||||
def test_no_hard_constraint_violation(self):
|
||||
"""Full pipeline with all intents including reachability converges cleanly.
|
||||
|
||||
MockReachabilityChecker uses large fallback reach for unknown arms,
|
||||
so arm_slider reachability constraints are satisfied in mock mode.
|
||||
When real ROS checkers replace mock, this test validates the same pipeline.
|
||||
"""
|
||||
interpret_data = client.post("/interpret", json={"intents": LLM_INTENTS}).json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
data = optimize_resp.json()
|
||||
assert data["success"] is True
|
||||
assert not math.isinf(data["cost"])
|
||||
|
||||
def test_workflow_neighbors_closer_than_diagonal(self, placements):
|
||||
"""Workflow-adjacent devices should be closer than lab diagonal (basic sanity)."""
|
||||
max_diagonal = math.sqrt(PCR_LAB["width"] ** 2 + PCR_LAB["depth"] ** 2)
|
||||
workflow_pairs = [
|
||||
("thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"),
|
||||
("opentrons_liquid_handler", "agilent_plateloc"),
|
||||
("agilent_plateloc", "inheco_odtc_96xl"),
|
||||
]
|
||||
for a_id, b_id in workflow_pairs:
|
||||
a, b = placements[a_id], placements[b_id]
|
||||
dist = math.sqrt(
|
||||
(a["position"]["x"] - b["position"]["x"]) ** 2
|
||||
+ (a["position"]["y"] - b["position"]["y"]) ** 2
|
||||
)
|
||||
# 应该远小于对角线(workflow minimize_distance 约束)
|
||||
assert dist < max_diagonal * 0.8, (
|
||||
f"Workflow pair {a_id}↔{b_id} distance {dist:.2f}m "
|
||||
f"exceeds 80% of diagonal {max_diagonal:.2f}m"
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineStageIsolation:
|
||||
"""Verify each stage's output format is valid input for the next stage."""
|
||||
|
||||
def test_interpret_output_schema_matches_optimize_input(self):
|
||||
"""constraints from /interpret have all fields /optimize expects."""
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
|
||||
for c in data["constraints"]:
|
||||
assert "type" in c
|
||||
assert "rule_name" in c
|
||||
assert "params" in c
|
||||
assert "weight" in c
|
||||
assert c["type"] in ("hard", "soft")
|
||||
|
||||
for edge in data["workflow_edges"]:
|
||||
assert isinstance(edge, list)
|
||||
assert len(edge) == 2
|
||||
|
||||
def test_round_trip_no_data_loss(self):
|
||||
"""Interpret → optimize → check that all device IDs survive the pipeline."""
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": False,
|
||||
})
|
||||
result_ids = {p["device_id"] for p in optimize_resp.json()["placements"]}
|
||||
input_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert result_ids == input_ids
|
||||
197
unilabos/layout_optimizer/tests/test_intent_interpreter.py
Normal file
197
unilabos/layout_optimizer/tests/test_intent_interpreter.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Intent interpreter tests — PCR workflow devices."""
|
||||
import pytest
|
||||
|
||||
from ..intent_interpreter import interpret_intents
|
||||
from ..models import Intent
|
||||
|
||||
|
||||
# --- reachable_by ---
|
||||
|
||||
def test_reachable_by_generates_hard_reachability():
|
||||
intents = [Intent(
|
||||
intent="reachable_by",
|
||||
params={"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]},
|
||||
description="Robot arm must reach liquid handler and thermal cycler",
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 2
|
||||
assert all(c.rule_name == "reachability" for c in result.constraints)
|
||||
assert all(c.type == "hard" for c in result.constraints)
|
||||
assert result.constraints[0].params == {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}
|
||||
assert result.constraints[1].params == {"arm_id": "arm_slider", "target_device_id": "inheco_odtc_96xl"}
|
||||
assert len(result.translations) == 1
|
||||
assert len(result.translations[0]["generated_constraints"]) == 2
|
||||
|
||||
|
||||
def test_reachable_by_missing_arm():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"targets": ["a"]})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "arm" in result.errors[0].lower()
|
||||
|
||||
|
||||
def test_reachable_by_empty_targets():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"arm": "arm_slider", "targets": []})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "targets" in result.errors[0].lower()
|
||||
|
||||
|
||||
# --- close_together ---
|
||||
|
||||
def test_close_together_generates_minimize_distance():
|
||||
intents = [Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl", "agilent_plateloc"],
|
||||
})]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # C(3,2) = 3 pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert all(c.type == "soft" for c in result.constraints)
|
||||
|
||||
|
||||
def test_close_together_priority_scales_weight():
|
||||
low = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "low"})])
|
||||
high = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "high"})])
|
||||
assert high.constraints[0].weight > low.constraints[0].weight
|
||||
|
||||
|
||||
def test_close_together_single_device_error():
|
||||
result = interpret_intents([Intent(intent="close_together", params={"devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- far_apart ---
|
||||
|
||||
def test_far_apart_generates_maximize_distance():
|
||||
result = interpret_intents([Intent(intent="far_apart", params={
|
||||
"devices": ["inheco_odtc_96xl", "thermo_orbitor_rs2_hotel"],
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert result.constraints[0].rule_name == "maximize_distance"
|
||||
|
||||
|
||||
# --- max_distance / min_distance ---
|
||||
|
||||
def test_max_distance_generates_distance_less_than():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "opentrons_liquid_handler", "device_b": "inheco_odtc_96xl", "distance": 1.5,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_less_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 1.5
|
||||
|
||||
|
||||
def test_min_distance_generates_distance_greater_than():
|
||||
result = interpret_intents([Intent(intent="min_distance", params={
|
||||
"device_a": "inheco_odtc_96xl", "device_b": "thermo_orbitor_rs2_hotel", "distance": 2.0,
|
||||
})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_greater_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 2.0
|
||||
|
||||
|
||||
def test_max_distance_zero_is_valid():
|
||||
"""distance=0 is falsy but valid — must not be rejected."""
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "a", "device_b": "b", "distance": 0,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
def test_max_distance_missing_param():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={"device_a": "a"})])
|
||||
assert len(result.errors) == 1
|
||||
assert len(result.constraints) == 0
|
||||
|
||||
|
||||
# --- orientation ---
|
||||
|
||||
def test_face_outward():
|
||||
result = interpret_intents([Intent(intent="face_outward")])
|
||||
assert result.constraints[0].rule_name == "prefer_orientation_mode"
|
||||
assert result.constraints[0].params["mode"] == "outward"
|
||||
|
||||
|
||||
def test_face_inward():
|
||||
result = interpret_intents([Intent(intent="face_inward")])
|
||||
assert result.constraints[0].params["mode"] == "inward"
|
||||
|
||||
|
||||
def test_align_cardinal():
|
||||
result = interpret_intents([Intent(intent="align_cardinal")])
|
||||
assert result.constraints[0].rule_name == "prefer_aligned"
|
||||
|
||||
|
||||
# --- min_spacing ---
|
||||
|
||||
def test_min_spacing():
|
||||
result = interpret_intents([Intent(intent="min_spacing", params={"min_gap": 0.3})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "min_spacing"
|
||||
assert c.type == "hard"
|
||||
assert c.params["min_gap"] == 0.3
|
||||
|
||||
|
||||
# --- workflow_hint (PCR scenario) ---
|
||||
|
||||
def test_workflow_hint_pcr():
|
||||
"""PCR workflow: pipette → thermal cycler → plate sealer → storage."""
|
||||
intents = [Intent(
|
||||
intent="workflow_hint",
|
||||
params={
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # 4 devices → 3 consecutive pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert len(result.workflow_edges) == 3
|
||||
assert ["opentrons_liquid_handler", "inheco_odtc_96xl"] in result.workflow_edges
|
||||
assert result.translations[0]["confidence"] == "low"
|
||||
|
||||
|
||||
def test_workflow_hint_single_device_error():
|
||||
result = interpret_intents([Intent(intent="workflow_hint", params={"workflow": "test", "devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- unknown intent ---
|
||||
|
||||
def test_unknown_intent():
|
||||
result = interpret_intents([Intent(intent="nonexistent")])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "nonexistent" in result.errors[0]
|
||||
|
||||
|
||||
# --- multi-intent combination ---
|
||||
|
||||
def test_full_pcr_scenario():
|
||||
"""Arm reachability + close together for full PCR setup."""
|
||||
intents = [
|
||||
Intent(intent="reachable_by", params={
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler", "inheco_odtc_96xl",
|
||||
"agilent_plateloc", "thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
}),
|
||||
Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
}),
|
||||
]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 5 # 4 reachability + 1 minimize_distance
|
||||
assert len(result.translations) == 2
|
||||
assert len(result.errors) == 0
|
||||
134
unilabos/layout_optimizer/tests/test_interpret_api.py
Normal file
134
unilabos/layout_optimizer/tests/test_interpret_api.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Tests for /interpret and /interpret/schema API endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_interpret_reachable_by():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
},
|
||||
"description": "Arm must reach liquid handler and thermal cycler",
|
||||
}
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) == 2
|
||||
assert all(c["rule_name"] == "reachability" for c in data["constraints"])
|
||||
assert len(data["translations"]) == 1
|
||||
assert data["translations"][0]["source_intent"] == "reachable_by"
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_pcr_workflow():
|
||||
"""Full PCR: reachability + workflow_hint + close_together."""
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow + 1 close = 8
|
||||
assert len(data["constraints"]) == 8
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert len(data["translations"]) == 3
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_returns_errors_for_bad_intents():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "reachable_by", "params": {}},
|
||||
{"intent": "nonexistent_intent"},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["errors"]) == 2
|
||||
assert len(data["constraints"]) == 0
|
||||
|
||||
|
||||
def test_interpret_empty_intents():
|
||||
resp = client.post("/interpret", json={"intents": []})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["constraints"] == []
|
||||
assert data["translations"] == []
|
||||
assert data["errors"] == []
|
||||
|
||||
|
||||
def test_interpret_schema_returns_all_intents():
|
||||
resp = client.get("/interpret/schema")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
intents = data["intents"]
|
||||
expected = {
|
||||
"reachable_by", "close_together", "far_apart",
|
||||
"max_distance", "min_distance", "min_spacing",
|
||||
"workflow_hint", "face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
assert set(intents.keys()) == expected
|
||||
|
||||
|
||||
def test_interpret_constraints_passable_to_optimize():
|
||||
"""Constraints from /interpret should be directly usable in /optimize."""
|
||||
# Step 1: interpret
|
||||
interpret_resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "close_together", "params": {"devices": ["dev_a", "dev_b"]}},
|
||||
]
|
||||
})
|
||||
constraints = interpret_resp.json()["constraints"]
|
||||
|
||||
# Step 2: pass to optimize (verify it accepts the format)
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": [
|
||||
{"id": "dev_a", "name": "Device A", "size": [0.5, 0.4]},
|
||||
{"id": "dev_b", "name": "Device B", "size": [0.5, 0.4]},
|
||||
],
|
||||
"lab": {"width": 4.0, "depth": 3.0},
|
||||
"constraints": constraints,
|
||||
"run_de": False,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
assert len(optimize_resp.json()["placements"]) == 2
|
||||
277
unilabos/layout_optimizer/tests/test_llm_skill.py
Normal file
277
unilabos/layout_optimizer/tests/test_llm_skill.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""LLM 技能文档测试:用真实 LLM 验证模糊用户输入 → 结构化意图的翻译质量。
|
||||
|
||||
需要 ANTHROPIC_API_KEY 环境变量。无 key 时自动跳过。
|
||||
测试覆盖:设备名模糊匹配、工作流顺序推理、约束类型选择、JSON 格式正确性。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
||||
pytestmark = pytest.mark.skipif(not HAS_API_KEY, reason="ANTHROPIC_API_KEY not set")
|
||||
|
||||
# 读取技能文档
|
||||
_SKILL_DOC_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "llm_skill", "layout_intent_translator.md"
|
||||
)
|
||||
|
||||
# PCR 场景设备列表(模拟用户场景中已有的设备)
|
||||
SCENE_DEVICE_LIST = """\
|
||||
Devices in scene:
|
||||
- thermo_orbitor_rs2_hotel: Thermo Orbitor RS2 Hotel (type: static, bbox: 0.68×0.52m)
|
||||
- arm_slider: Arm Slider (type: articulation, bbox: 1.20×0.30m)
|
||||
- opentrons_liquid_handler: Opentrons Liquid Handler (type: static, bbox: 0.65×0.60m)
|
||||
- agilent_plateloc: Agilent PlateLoc (type: static, bbox: 0.35×0.40m)
|
||||
- inheco_odtc_96xl: Inheco ODTC 96XL (type: static, bbox: 0.30×0.35m)
|
||||
"""
|
||||
|
||||
VALID_DEVICE_IDS = {
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"arm_slider",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
}
|
||||
|
||||
VALID_INTENT_TYPES = {
|
||||
"reachable_by", "close_together", "far_apart", "max_distance",
|
||||
"min_distance", "min_spacing", "workflow_hint",
|
||||
"face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
|
||||
|
||||
def _call_llm(user_message: str) -> dict:
|
||||
"""调用 LLM,使用技能文档作为 system prompt,返回解析后的 JSON。"""
|
||||
import anthropic
|
||||
|
||||
with open(_SKILL_DOC_PATH) as f:
|
||||
skill_doc = f.read()
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
system=skill_doc,
|
||||
messages=[
|
||||
{"role": "user", "content": f"{SCENE_DEVICE_LIST}\n\n{user_message}"},
|
||||
],
|
||||
)
|
||||
|
||||
# 从 response 中提取 JSON
|
||||
text = response.content[0].text
|
||||
# LLM 可能返回 ```json ... ``` 包裹的 JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0]
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0]
|
||||
|
||||
return json.loads(text.strip())
|
||||
|
||||
|
||||
def _extract_all_device_ids(intents: list[dict]) -> set[str]:
|
||||
"""从意图列表中提取所有引用的设备 ID。"""
|
||||
ids = set()
|
||||
for intent in intents:
|
||||
params = intent.get("params", {})
|
||||
if "arm" in params:
|
||||
ids.add(params["arm"])
|
||||
for key in ("targets", "devices"):
|
||||
if key in params:
|
||||
ids.update(params[key])
|
||||
for key in ("device_a", "device_b"):
|
||||
if key in params:
|
||||
ids.add(params[key])
|
||||
return ids
|
||||
|
||||
|
||||
class TestLLMFuzzyDeviceResolution:
|
||||
"""测试 LLM 能否将模糊设备名映射到精确 ID。"""
|
||||
|
||||
def test_pcr_machine_resolves_to_inheco(self):
|
||||
"""'PCR machine' 应解析为 inheco_odtc_96xl。"""
|
||||
result = _call_llm(
|
||||
"Keep the PCR machine close to the plate sealer"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "inheco_odtc_96xl" in all_ids, f"Expected inheco_odtc_96xl in {all_ids}"
|
||||
assert "agilent_plateloc" in all_ids, f"Expected agilent_plateloc in {all_ids}"
|
||||
|
||||
def test_robot_resolves_to_articulation_type(self):
|
||||
"""'the robot' / 'robot arm' 应解析为 arm_slider(唯一 articulation 类型)。"""
|
||||
result = _call_llm(
|
||||
"The robot should be able to reach the liquid handler and the storage hotel"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "arm_slider" in all_ids, f"Expected arm_slider in {all_ids}"
|
||||
assert "opentrons_liquid_handler" in all_ids
|
||||
assert "thermo_orbitor_rs2_hotel" in all_ids
|
||||
|
||||
def test_all_resolved_ids_are_valid(self):
|
||||
"""LLM 输出的所有设备 ID 必须来自场景设备列表。"""
|
||||
result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in the pipetting robot, "
|
||||
"seal it, then run thermal cycling. The arm handles all transfers."
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
invalid = all_ids - VALID_DEVICE_IDS
|
||||
assert not invalid, f"LLM produced invalid device IDs: {invalid}"
|
||||
|
||||
|
||||
class TestLLMWorkflowInterpretation:
|
||||
"""测试 LLM 对工作流描述的理解和翻译。"""
|
||||
|
||||
def test_pcr_workflow_full(self):
|
||||
"""完整 PCR 工作流描述应生成 reachable_by + workflow_hint + close_together。"""
|
||||
result = _call_llm(
|
||||
"I need to set up a PCR workflow: take plate from the hotel, "
|
||||
"prepare the sample in the liquid handler, seal the plate, "
|
||||
"then run the thermal cycler. The robot arm handles all plate transfers. "
|
||||
"Keep the liquid handler and sealer close together."
|
||||
)
|
||||
intents = result["intents"]
|
||||
intent_types = {i["intent"] for i in intents}
|
||||
|
||||
# 应包含核心意图类型
|
||||
assert "reachable_by" in intent_types, f"Missing reachable_by in {intent_types}"
|
||||
assert "workflow_hint" in intent_types, f"Missing workflow_hint in {intent_types}"
|
||||
|
||||
# reachable_by 应包含所有工作流设备作为 targets
|
||||
reach_intents = [i for i in intents if i["intent"] == "reachable_by"]
|
||||
assert len(reach_intents) >= 1
|
||||
reach_targets = set()
|
||||
for ri in reach_intents:
|
||||
reach_targets.update(ri["params"].get("targets", []))
|
||||
# 至少液体处理器和热循环仪应在可达范围内
|
||||
assert "opentrons_liquid_handler" in reach_targets
|
||||
assert "inheco_odtc_96xl" in reach_targets
|
||||
|
||||
def test_workflow_device_order(self):
|
||||
"""workflow_hint 的设备顺序应反映工作流步骤。"""
|
||||
result = _call_llm(
|
||||
"PCR process: first the hotel dispenses a plate, then the opentrons "
|
||||
"prepares the sample, next the plateloc seals it, finally the thermal "
|
||||
"cycler runs PCR. Generate a workflow hint."
|
||||
)
|
||||
intents = result["intents"]
|
||||
wf_intents = [i for i in intents if i["intent"] == "workflow_hint"]
|
||||
assert len(wf_intents) >= 1, f"No workflow_hint found in {[i['intent'] for i in intents]}"
|
||||
|
||||
devices = wf_intents[0]["params"]["devices"]
|
||||
# 验证顺序:hotel → liquid_handler → plateloc → thermal_cycler
|
||||
hotel_idx = devices.index("thermo_orbitor_rs2_hotel")
|
||||
lh_idx = devices.index("opentrons_liquid_handler")
|
||||
seal_idx = devices.index("agilent_plateloc")
|
||||
tc_idx = devices.index("inheco_odtc_96xl")
|
||||
assert hotel_idx < lh_idx < seal_idx < tc_idx, (
|
||||
f"Wrong workflow order: {devices}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMOutputFormat:
|
||||
"""测试 LLM 输出格式的正确性。"""
|
||||
|
||||
def test_output_has_intents_array(self):
|
||||
"""输出必须有 intents 数组。"""
|
||||
result = _call_llm("Keep all devices at least 30cm apart")
|
||||
assert "intents" in result
|
||||
assert isinstance(result["intents"], list)
|
||||
assert len(result["intents"]) > 0
|
||||
|
||||
def test_each_intent_has_required_fields(self):
|
||||
"""每个意图必须有 intent、params、description。"""
|
||||
result = _call_llm(
|
||||
"The robot arm should reach the liquid handler. "
|
||||
"Keep the thermal cycler away from the plate hotel."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert "intent" in intent, f"Missing 'intent' field: {intent}"
|
||||
assert "params" in intent, f"Missing 'params' field: {intent}"
|
||||
assert "description" in intent, f"Missing 'description' field: {intent}"
|
||||
|
||||
def test_intent_types_are_valid(self):
|
||||
"""所有意图类型必须是已知类型。"""
|
||||
result = _call_llm(
|
||||
"Set up a compact PCR line: hotel → liquid handler → sealer → thermal cycler. "
|
||||
"Robot arm handles transfers. Align everything neatly."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert intent["intent"] in VALID_INTENT_TYPES, (
|
||||
f"Unknown intent type: {intent['intent']}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMInterpretThenOptimize:
|
||||
"""端到端:LLM 翻译 → /interpret → /optimize → 验证布局。"""
|
||||
|
||||
def test_llm_output_accepted_by_interpret_endpoint(self):
|
||||
"""LLM 输出应能直接被 /interpret 端点接受。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
llm_result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in opentrons, "
|
||||
"seal plate then pcr cycle, arm_slider handles all transfers. "
|
||||
"Keep liquid handler and sealer close."
|
||||
)
|
||||
|
||||
# /interpret 应接受 LLM 输出
|
||||
resp = test_client.post("/interpret", json=llm_result)
|
||||
assert resp.status_code == 200, f"Interpret failed: {resp.text}"
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) > 0, "No constraints generated"
|
||||
assert len(data["errors"]) == 0, f"Interpretation errors: {data['errors']}"
|
||||
|
||||
def test_full_pipeline_llm_to_placement(self):
|
||||
"""LLM 翻译 → interpret → optimize → 所有设备有 placement。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
# Stage 1: LLM 翻译
|
||||
llm_result = _call_llm(
|
||||
"I want a PCR workflow lab. Take plate from the hotel, pipette in the "
|
||||
"liquid handler, seal with the plateloc, then thermal cycle. "
|
||||
"The robot arm does all transfers between devices. "
|
||||
"Minimum 15cm gap between everything."
|
||||
)
|
||||
|
||||
# Stage 2: interpret
|
||||
interpret_resp = test_client.post("/interpret", json=llm_result)
|
||||
assert interpret_resp.status_code == 200
|
||||
interpret_data = interpret_resp.json()
|
||||
assert len(interpret_data["errors"]) == 0
|
||||
|
||||
# Stage 3: optimize
|
||||
pcr_devices = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
optimize_resp = test_client.post("/optimize", json={
|
||||
"devices": pcr_devices,
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data.get("workflow_edges", []),
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
data = optimize_resp.json()
|
||||
|
||||
# Stage 4: 验证所有设备都有 placement
|
||||
placed_ids = {p["device_id"] for p in data["placements"]}
|
||||
expected_ids = {d["id"] for d in pcr_devices}
|
||||
assert placed_ids == expected_ids
|
||||
assert data["success"] is True
|
||||
138
unilabos/layout_optimizer/tests/test_mock_checkers.py
Normal file
138
unilabos/layout_optimizer/tests/test_mock_checkers.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""MockCollisionChecker 和 MockReachabilityChecker 测试。"""
|
||||
|
||||
import math
|
||||
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
|
||||
|
||||
class TestMockCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockCollisionChecker()
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_touching_edges(self):
|
||||
"""两设备恰好边缘接触,不算碰撞(< 而非 <=)。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的设备 OBB 可能导致碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)}, # closer: OBB overlap
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_no_collision_with_rotation(self):
|
||||
"""旋转后仍不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (2.0, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,两两碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
def test_obb_collision_rotated_no_false_positive():
|
||||
"""A rotated narrow device should NOT collide with a nearby device
|
||||
that the old AABB method would have flagged as colliding.
|
||||
|
||||
Old AABB expands footprint; OBB is precise.
|
||||
"""
|
||||
checker = MockCollisionChecker()
|
||||
# Narrow device (2.0 x 0.5) rotated 45°:
|
||||
# AABB would be ~1.77 x 1.77, OBB is the actual narrow rectangle
|
||||
placements = [
|
||||
{"id": "narrow", "bbox": (2.0, 0.5), "pos": (3.0, 3.0, math.pi / 4)},
|
||||
{"id": "nearby", "bbox": (0.5, 0.5), "pos": (4.5, 3.0, 0.0)},
|
||||
]
|
||||
collisions = checker.check(placements)
|
||||
# With OBB: no collision (the narrow rotated box doesn't reach)
|
||||
assert ("narrow", "nearby") not in collisions and ("nearby", "narrow") not in collisions
|
||||
|
||||
|
||||
class TestMockReachabilityChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockReachabilityChecker()
|
||||
|
||||
def test_reachable_within_radius(self):
|
||||
"""目标在臂展半径内。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.5, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_radius(self):
|
||||
"""目标超出臂展半径。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 2.0, "y": 2.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_reachable_at_boundary(self):
|
||||
"""目标恰好在臂展边界上(应可达)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.914, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_uses_default(self):
|
||||
"""未知型号使用 1.0m 回退臂展(realistic lab-scale default)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# Within 1.0m fallback reach
|
||||
target_near = {"x": 0.8, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("unknown_arm", arm_pose, target_near)
|
||||
# Beyond 1.0m fallback reach
|
||||
target_far = {"x": 1.5, "y": 0.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("unknown_arm", arm_pose, target_far)
|
||||
|
||||
def test_custom_arm_reach(self):
|
||||
"""自定义臂展参数。"""
|
||||
checker = MockReachabilityChecker(arm_reach={"custom_arm": 1.5})
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.4, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("custom_arm", arm_pose, target)
|
||||
117
unilabos/layout_optimizer/tests/test_obb.py
Normal file
117
unilabos/layout_optimizer/tests/test_obb.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Tests for OBB (Oriented Bounding Box) geometry utilities."""
|
||||
import math
|
||||
import pytest
|
||||
from ..obb import obb_corners, obb_overlap, obb_min_distance
|
||||
|
||||
|
||||
class TestObbCorners:
|
||||
"""obb_corners(cx, cy, w, h, theta) → 4 corner points of the rotated rectangle."""
|
||||
|
||||
def test_no_rotation(self):
|
||||
"""Axis-aligned box at origin: corners at ±half extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, 0.0)
|
||||
assert len(corners) == 4
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
assert ys == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
|
||||
def test_90_degree_rotation(self):
|
||||
"""90° rotation swaps width and height extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, math.pi / 2)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
assert ys == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
|
||||
def test_offset_center(self):
|
||||
"""Corners shift by (cx, cy)."""
|
||||
corners = obb_corners(3.0, 2.0, 2.0, 1.0, 0.0)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([2.0, 2.0, 4.0, 4.0])
|
||||
assert ys == pytest.approx([1.5, 1.5, 2.5, 2.5])
|
||||
|
||||
def test_45_degree_rotation(self):
|
||||
"""45° rotation: corners on diagonals."""
|
||||
corners = obb_corners(0, 0, 2.0, 2.0, math.pi / 4)
|
||||
for cx, cy in corners:
|
||||
dist = math.sqrt(cx**2 + cy**2)
|
||||
assert dist == pytest.approx(math.sqrt(2), abs=1e-9)
|
||||
|
||||
|
||||
class TestObbOverlap:
|
||||
"""obb_overlap(corners_a, corners_b) → True if the two OBBs overlap."""
|
||||
|
||||
def test_separated_boxes(self):
|
||||
"""Two boxes far apart: no overlap."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(5, 0, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_overlapping_boxes(self):
|
||||
"""Two boxes sharing space: overlap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_touching_edges_no_overlap(self):
|
||||
"""Boxes touching at edge: no overlap (strict <, not <=)."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_rotated_overlap(self):
|
||||
"""One box rotated 45°, overlapping the other."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1.0, 1.0, 2.0, 2.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_rotated_no_overlap(self):
|
||||
"""One box rotated 45°, separated from the other."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_identical_boxes(self):
|
||||
"""Same position and size: overlap."""
|
||||
a = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
|
||||
class TestObbMinDistance:
|
||||
"""obb_min_distance(corners_a, corners_b) → minimum edge-to-edge distance."""
|
||||
|
||||
def test_overlapping_returns_zero(self):
|
||||
"""Overlapping boxes: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
|
||||
def test_separated_axis_aligned(self):
|
||||
"""Two axis-aligned boxes with 2m gap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # edges at x=±1
|
||||
b = obb_corners(4, 0, 2.0, 2.0, 0.0) # edges at x=3,5
|
||||
# Gap = 3 - 1 = 2.0
|
||||
assert obb_min_distance(a, b) == pytest.approx(2.0)
|
||||
|
||||
def test_diagonal_separation(self):
|
||||
"""Boxes separated diagonally: distance to nearest corner."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # corners at (±1, ±1)
|
||||
b = obb_corners(4, 4, 2.0, 2.0, 0.0) # corners at (3..5, 3..5)
|
||||
# Nearest corners: (1,1) to (3,3) → sqrt(8) ≈ 2.828
|
||||
assert obb_min_distance(a, b) == pytest.approx(math.sqrt(8), abs=0.01)
|
||||
|
||||
def test_rotated_separation(self):
|
||||
"""One rotated box separated from axis-aligned box."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
dist = obb_min_distance(a, b)
|
||||
assert dist > 0
|
||||
|
||||
def test_touching_returns_zero(self):
|
||||
"""Touching edges: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
219
unilabos/layout_optimizer/tests/test_optimizer.py
Normal file
219
unilabos/layout_optimizer/tests/test_optimizer.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""差分进化优化器端到端测试。"""
|
||||
|
||||
import math
|
||||
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
from ..models import Device, Lab, Placement
|
||||
import pytest
|
||||
from ..optimizer import optimize, snap_theta
|
||||
|
||||
|
||||
def test_optimize_three_devices_no_collision():
|
||||
"""3 台设备在 5m×5m 实验室中优化,结果应无碰撞且在边界内。"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.8, 0.6)),
|
||||
Device(id="b", name="B", bbox=(0.6, 0.5)),
|
||||
Device(id="c", name="C", bbox=(0.5, 0.5)),
|
||||
]
|
||||
lab = Lab(width=5.0, depth=5.0)
|
||||
|
||||
placements = optimize(devices, lab, seed=42, maxiter=100, popsize=10)
|
||||
|
||||
assert len(placements) == 3
|
||||
|
||||
# 验证无碰撞
|
||||
checker = MockCollisionChecker()
|
||||
checker_placements = [
|
||||
{"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id),
|
||||
"pos": (p.x, p.y, p.theta)}
|
||||
for p in placements
|
||||
]
|
||||
collisions = checker.check(checker_placements)
|
||||
assert collisions == [], f"Unexpected collisions: {collisions}"
|
||||
|
||||
# 验证在边界内
|
||||
oob = checker.check_bounds(checker_placements, lab.width, lab.depth)
|
||||
assert oob == [], f"Devices out of bounds: {oob}"
|
||||
|
||||
|
||||
def test_optimize_single_device():
|
||||
"""单个设备应直接放置成功。"""
|
||||
devices = [Device(id="solo", name="Solo", bbox=(0.5, 0.5))]
|
||||
lab = Lab(width=3.0, depth=3.0)
|
||||
|
||||
placements = optimize(devices, lab, seed=42, maxiter=50)
|
||||
|
||||
assert len(placements) == 1
|
||||
p = placements[0]
|
||||
assert 0.25 <= p.x <= 2.75
|
||||
assert 0.25 <= p.y <= 2.75
|
||||
|
||||
|
||||
def test_optimize_tight_space():
|
||||
"""紧凑空间:2 台设备在刚好够大的实验室中。"""
|
||||
devices = [
|
||||
Device(id="x", name="X", bbox=(1.0, 1.0)),
|
||||
Device(id="y", name="Y", bbox=(1.0, 1.0)),
|
||||
]
|
||||
# 2.5m 宽足够放 2 个 1m 宽设备(加间距)
|
||||
lab = Lab(width=2.5, depth=2.0)
|
||||
|
||||
placements = optimize(devices, lab, seed=42, maxiter=100)
|
||||
|
||||
assert len(placements) == 2
|
||||
checker = MockCollisionChecker()
|
||||
checker_placements = [
|
||||
{"id": p.device_id, "bbox": next(d.bbox for d in devices if d.id == p.device_id),
|
||||
"pos": (p.x, p.y, p.theta)}
|
||||
for p in placements
|
||||
]
|
||||
collisions = checker.check(checker_placements)
|
||||
assert collisions == []
|
||||
|
||||
|
||||
def test_optimize_returns_valid_placement_ids():
|
||||
"""验证返回的 placement device_id 与输入设备一致。"""
|
||||
devices = [
|
||||
Device(id="dev_1", name="D1", bbox=(0.5, 0.5)),
|
||||
Device(id="dev_2", name="D2", bbox=(0.5, 0.5)),
|
||||
]
|
||||
lab = Lab(width=5.0, depth=5.0)
|
||||
|
||||
placements = optimize(devices, lab, seed=42, maxiter=50)
|
||||
|
||||
result_ids = {p.device_id for p in placements}
|
||||
expected_ids = {d.id for d in devices}
|
||||
assert result_ids == expected_ids
|
||||
|
||||
|
||||
def test_snap_theta_near_cardinal():
|
||||
"""Theta within 15° of 90° snaps to 90°."""
|
||||
placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(85))]
|
||||
result = snap_theta(placements, threshold_deg=15)
|
||||
assert result[0].theta == pytest.approx(math.pi / 2)
|
||||
|
||||
|
||||
def test_snap_theta_far_from_cardinal():
|
||||
"""Theta 30° away from nearest cardinal: no snap."""
|
||||
placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(60))]
|
||||
result = snap_theta(placements, threshold_deg=15)
|
||||
assert result[0].theta == pytest.approx(math.radians(60))
|
||||
|
||||
|
||||
def test_snap_theta_at_cardinal():
|
||||
"""Already at cardinal: unchanged."""
|
||||
placements = [Placement(device_id="a", x=1, y=1, theta=math.pi)]
|
||||
result = snap_theta(placements, threshold_deg=15)
|
||||
assert result[0].theta == pytest.approx(math.pi)
|
||||
|
||||
|
||||
def test_snap_theta_near_360():
|
||||
"""Theta near 360° (=0°) snaps correctly."""
|
||||
placements = [Placement(device_id="a", x=1, y=1, theta=math.radians(355))]
|
||||
result = snap_theta(placements, threshold_deg=15)
|
||||
snapped = result[0].theta % (2 * math.pi)
|
||||
assert snapped == pytest.approx(0.0, abs=0.01) or snapped == pytest.approx(2 * math.pi, abs=0.01)
|
||||
|
||||
|
||||
def test_optimize_endpoint_accepts_seeder_field():
|
||||
"""POST /optimize should accept seeder and run_de fields."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "test_device", "name": "Test"}],
|
||||
"lab": {"width": 5, "depth": 4},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["seeder_used"] == "compact_outward"
|
||||
assert data["de_ran"] is False
|
||||
assert len(data["placements"]) == 1
|
||||
|
||||
|
||||
def test_optimize_endpoint_unknown_seeder_returns_400():
|
||||
"""Unknown seeder preset should return 400."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "test_device", "name": "Test"}],
|
||||
"lab": {"width": 5, "depth": 4},
|
||||
"seeder": "nonexistent_preset",
|
||||
"run_de": False,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_optimize_endpoint_backward_compatible():
|
||||
"""Existing calls without seeder/run_de fields still work."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "test_device", "name": "Test"}],
|
||||
"lab": {"width": 5, "depth": 4},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "seeder_used" in data
|
||||
assert "de_ran" in data
|
||||
|
||||
|
||||
def test_full_pipeline_seed_only():
|
||||
"""Full pipeline: seeder → snap_theta → correct count and bounds.
|
||||
|
||||
compact_outward is tested for collision-free (devices clustered, not at walls).
|
||||
spread_inward pushes to walls where rotated AABB bounds may flag — tested separately.
|
||||
"""
|
||||
from ..seeders import seed_layout, PRESETS
|
||||
from ..constraints import evaluate_default_hard_constraints
|
||||
|
||||
devices = [
|
||||
Device(id=f"dev{i}", name=f"Device {i}", bbox=(0.6, 0.4))
|
||||
for i in range(6)
|
||||
]
|
||||
lab = Lab(width=6.0, depth=5.0)
|
||||
|
||||
# compact_outward: devices cluster toward center, should be collision-free
|
||||
placements = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
placements = snap_theta(placements)
|
||||
assert len(placements) == len(devices)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, lab, checker)
|
||||
assert cost < 1e17, f"compact_outward: hard constraint violation (cost={cost})"
|
||||
|
||||
# spread_inward: verify correct count + all positions within lab canvas
|
||||
placements = seed_layout(devices, lab, PRESETS["spread_inward"])
|
||||
placements = snap_theta(placements)
|
||||
assert len(placements) == len(devices)
|
||||
for p in placements:
|
||||
assert 0 <= p.x <= lab.width, f"spread_inward: x={p.x} out of bounds"
|
||||
assert 0 <= p.y <= lab.depth, f"spread_inward: y={p.y} out of bounds"
|
||||
|
||||
|
||||
def test_full_pipeline_with_de():
|
||||
"""Full pipeline: seeder → DE → snap_theta."""
|
||||
from ..seeders import seed_layout, PRESETS
|
||||
|
||||
devices = [
|
||||
Device(id=f"dev{i}", name=f"Device {i}", bbox=(0.6, 0.4))
|
||||
for i in range(4)
|
||||
]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
checker = MockCollisionChecker()
|
||||
|
||||
seed = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
result = optimize(devices, lab, seed_placements=seed, collision_checker=checker,
|
||||
maxiter=50, seed=42)
|
||||
result = snap_theta(result)
|
||||
|
||||
assert len(result) == len(devices)
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width
|
||||
assert 0 <= p.y <= lab.depth
|
||||
430
unilabos/layout_optimizer/tests/test_ros_checkers.py
Normal file
430
unilabos/layout_optimizer/tests/test_ros_checkers.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""MoveItCollisionChecker 和 IKFastReachabilityChecker 测试。
|
||||
|
||||
使用 unittest.mock 模拟 MoveIt2 实例,验证适配器逻辑,
|
||||
无需 ROS2 / MoveIt2 运行环境。
|
||||
"""
|
||||
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ..ros_checkers import (
|
||||
IKFastReachabilityChecker,
|
||||
MoveItCollisionChecker,
|
||||
_transform_to_arm_frame,
|
||||
_yaw_to_quat,
|
||||
_yaw_to_rotation_matrix,
|
||||
create_checkers,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 辅助函数测试 ----------
|
||||
|
||||
|
||||
class TestYawToQuat:
|
||||
def test_zero_rotation(self):
|
||||
"""零旋转 → 单位四元数。"""
|
||||
q = _yaw_to_quat(0.0)
|
||||
assert q == pytest.approx((0.0, 0.0, 0.0, 1.0))
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° → (0, 0, sin(π/4), cos(π/4))。"""
|
||||
q = _yaw_to_quat(math.pi / 2)
|
||||
expected = (0.0, 0.0, math.sin(math.pi / 4), math.cos(math.pi / 4))
|
||||
assert q == pytest.approx(expected)
|
||||
|
||||
def test_180_degrees(self):
|
||||
"""180° → (0, 0, 1, 0)。"""
|
||||
q = _yaw_to_quat(math.pi)
|
||||
assert q == pytest.approx((0.0, 0.0, 1.0, 0.0), abs=1e-10)
|
||||
|
||||
|
||||
class TestTransformToArmFrame:
|
||||
def test_identity_transform(self):
|
||||
"""臂在原点无旋转,目标在 (1, 0, 0.5)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.0, "y": 0.0, "z": 0.5}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 0.0, 0.5))
|
||||
|
||||
def test_translation_only(self):
|
||||
"""臂在 (2, 3) 无旋转,目标在 (3, 4, 0)。"""
|
||||
arm_pose = {"x": 2.0, "y": 3.0, "theta": 0.0}
|
||||
target = {"x": 3.0, "y": 4.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 1.0, 0.0))
|
||||
|
||||
def test_rotation_90(self):
|
||||
"""臂旋转 90°,目标在臂前方。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
target = {"x": 0.0, "y": 1.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
# 世界 Y+ 在臂坐标系中变成 X+
|
||||
assert local[0] == pytest.approx(1.0, abs=1e-10)
|
||||
assert local[1] == pytest.approx(0.0, abs=1e-10)
|
||||
|
||||
|
||||
class TestYawToRotationMatrix:
|
||||
def test_identity(self):
|
||||
"""零旋转 → 单位矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(0.0)
|
||||
np.testing.assert_allclose(R, np.eye(3), atol=1e-10)
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° 旋转矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(math.pi / 2)
|
||||
expected = np.array([
|
||||
[0.0, -1.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
])
|
||||
np.testing.assert_allclose(R, expected, atol=1e-10)
|
||||
|
||||
|
||||
# ---------- MoveItCollisionChecker 测试 ----------
|
||||
|
||||
|
||||
class TestMoveItCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.moveit2 = MagicMock()
|
||||
# 禁用 FCL,使用 OBB 回退(测试环境无需 python-fcl)
|
||||
self.checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=True,
|
||||
)
|
||||
self.checker._fcl_available = False
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的碰撞检测。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_syncs_collision_objects(self):
|
||||
"""验证 check() 调用 add_collision_box 同步到 MoveIt2。"""
|
||||
placements = [
|
||||
{"id": "dev_a", "bbox": (0.6, 0.8), "pos": (1.0, 2.0, 0.5)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
self.moveit2.add_collision_box.assert_called_once()
|
||||
call_kwargs = self.moveit2.add_collision_box.call_args
|
||||
# 验证使用 {device_id}_ 前缀
|
||||
assert call_kwargs.kwargs["id"] == "dev_a_"
|
||||
# 验证 size = (w, d, h)
|
||||
assert call_kwargs.kwargs["size"] == (0.6, 0.8, 0.4)
|
||||
|
||||
def test_device_id_prefix(self):
|
||||
"""碰撞对象名称使用 {device_id}_ 前缀。"""
|
||||
placements = [
|
||||
{"id": "robot_arm", "bbox": (0.3, 0.3), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "centrifuge", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
calls = self.moveit2.add_collision_box.call_args_list
|
||||
ids = [c.kwargs["id"] for c in calls]
|
||||
assert "robot_arm_" in ids
|
||||
assert "centrifuge_" in ids
|
||||
|
||||
def test_sync_failure_does_not_crash(self):
|
||||
"""add_collision_box 异常不影响碰撞检测结果。"""
|
||||
self.moveit2.add_collision_box.side_effect = RuntimeError("service unavailable")
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
# 不应抛异常
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_no_sync_mode(self):
|
||||
"""sync_to_scene=False 时不调用 add_collision_box。"""
|
||||
checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=False,
|
||||
)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
checker.check(placements)
|
||||
self.moveit2.add_collision_box.assert_not_called()
|
||||
|
||||
def test_touching_edges_no_collision(self):
|
||||
"""恰好边缘接触,不算碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,相邻碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
# ---------- IKFastReachabilityChecker 测试 ----------
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerVoxel:
|
||||
"""体素图模式测试。"""
|
||||
|
||||
def _create_voxel_dir(self, tmp_path: Path, arm_id: str = "elite_cs66") -> Path:
|
||||
"""创建包含体素图的临时目录。"""
|
||||
# 创建一个简单的体素网格:中心区域可达
|
||||
grid = np.zeros((100, 100, 50), dtype=bool)
|
||||
# 标记中心 60x60x30 区域为可达
|
||||
grid[20:80, 20:80, 10:40] = True
|
||||
|
||||
origin = np.array([-0.5, -0.5, 0.0])
|
||||
resolution = 0.01
|
||||
|
||||
npz_path = tmp_path / f"{arm_id}.npz"
|
||||
np.savez(str(npz_path), grid=grid, origin=origin, resolution=resolution)
|
||||
return tmp_path
|
||||
|
||||
def test_reachable_in_voxel(self, tmp_path):
|
||||
"""目标在体素图可达区域内。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 中心区域:local = (0.0, 0.0, 0.2) → ix=50, iy=50, iz=20 → 可达
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_voxel(self, tmp_path):
|
||||
"""目标在体素图不可达区域。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 边缘区域:local = (-0.45, -0.45, 0.0) → ix=5, iy=5, iz=0 → 不可达
|
||||
target = {"x": -0.45, "y": -0.45, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_out_of_bounds_not_reachable(self, tmp_path):
|
||||
"""目标超出体素图范围。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_arm_rotation_transforms_target(self, tmp_path):
|
||||
"""臂旋转后目标变换到臂坐标系。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
# 臂旋转 90°,目标在世界 Y+ 方向 → 臂坐标系 X+ 方向
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
# 世界 (0, 0.1, 0.2) → 臂坐标系 (0.1, 0, 0.2) → 在可达范围
|
||||
target = {"x": 0.0, "y": 0.1, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_no_voxel_no_moveit(self, tmp_path):
|
||||
"""未知臂型且无 MoveIt2,乐观返回 True。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("unknown_arm", arm_pose, target)
|
||||
|
||||
def test_missing_voxel_dir(self):
|
||||
"""体素目录不存在不报错。"""
|
||||
checker = IKFastReachabilityChecker(voxel_dir="/nonexistent/path")
|
||||
assert len(checker._voxel_maps) == 0
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerLiveIK:
|
||||
"""实时 IK 模式测试。"""
|
||||
|
||||
def test_reachable_via_ik(self):
|
||||
"""compute_ik 返回 JointState → 可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock() # 非 None → 成功
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.3}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_via_ik(self):
|
||||
"""compute_ik 返回 None → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = None
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_exception_returns_false(self):
|
||||
"""compute_ik 抛异常 → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.side_effect = RuntimeError("service timeout")
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_called_with_correct_position(self):
|
||||
"""验证 compute_ik 接收正确的臂坐标系位置。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock()
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 1.0, "y": 2.0, "theta": 0.0}
|
||||
target = {"x": 1.5, "y": 2.3, "z": 0.4}
|
||||
checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
call_kwargs = moveit2.compute_ik.call_args.kwargs
|
||||
assert call_kwargs["position"] == pytest.approx((0.5, 0.3, 0.4))
|
||||
|
||||
def test_voxel_takes_priority_over_live_ik(self, tmp_path):
|
||||
"""有体素图时优先使用体素查询,不调用 compute_ik。"""
|
||||
# 创建体素图
|
||||
grid = np.ones((10, 10, 10), dtype=bool)
|
||||
origin = np.array([-0.05, -0.05, 0.0])
|
||||
np.savez(
|
||||
str(tmp_path / "test_arm.npz"),
|
||||
grid=grid, origin=origin, resolution=0.01,
|
||||
)
|
||||
|
||||
moveit2 = MagicMock()
|
||||
checker = IKFastReachabilityChecker(moveit2, voxel_dir=tmp_path)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.05}
|
||||
checker.is_reachable("test_arm", arm_pose, target)
|
||||
|
||||
moveit2.compute_ik.assert_not_called()
|
||||
|
||||
|
||||
# ---------- create_checkers 工厂函数测试 ----------
|
||||
|
||||
|
||||
class TestCreateCheckers:
|
||||
def test_mock_mode(self):
|
||||
"""mock 模式返回 Mock 检测器。"""
|
||||
from ..mock_checkers import (
|
||||
MockCollisionChecker,
|
||||
MockReachabilityChecker,
|
||||
)
|
||||
|
||||
collision, reachability = create_checkers(mode="mock")
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
assert isinstance(reachability, MockReachabilityChecker)
|
||||
|
||||
def test_moveit_mode(self):
|
||||
"""moveit 模式返回 MoveIt2 检测器。"""
|
||||
moveit2 = MagicMock()
|
||||
collision, reachability = create_checkers(moveit2, mode="moveit")
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
assert isinstance(reachability, IKFastReachabilityChecker)
|
||||
|
||||
def test_moveit_mode_requires_instance(self):
|
||||
"""moveit 模式无实例时抛异常。"""
|
||||
with pytest.raises(ValueError, match="MoveIt2 instance required"):
|
||||
create_checkers(mode="moveit")
|
||||
|
||||
def test_default_mode_is_mock(self):
|
||||
"""默认使用 mock 模式。"""
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
|
||||
collision, _ = create_checkers()
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
|
||||
def test_env_var_override(self, monkeypatch):
|
||||
"""LAYOUT_CHECKER_MODE 环境变量覆盖默认值。"""
|
||||
moveit2 = MagicMock()
|
||||
monkeypatch.setenv("LAYOUT_CHECKER_MODE", "moveit")
|
||||
collision, _ = create_checkers(moveit2)
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
|
||||
|
||||
# ---------- Protocol 兼容性测试 ----------
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
"""验证适配器满足 Protocol 接口签名。"""
|
||||
|
||||
def test_collision_checker_has_check(self):
|
||||
"""MoveItCollisionChecker 实现 check(placements) 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check(placements)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_reachability_checker_has_is_reachable(self):
|
||||
"""IKFastReachabilityChecker 实现 is_reachable(arm_id, arm_pose, target) 方法。"""
|
||||
checker = IKFastReachabilityChecker()
|
||||
result = checker.is_reachable(
|
||||
"arm_id",
|
||||
{"x": 0.0, "y": 0.0, "theta": 0.0},
|
||||
{"x": 0.5, "y": 0.0, "z": 0.0},
|
||||
)
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_collision_checker_has_check_bounds(self):
|
||||
"""MoveItCollisionChecker 实现 check_bounds 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert isinstance(result, list)
|
||||
113
unilabos/layout_optimizer/tests/test_seeders.py
Normal file
113
unilabos/layout_optimizer/tests/test_seeders.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Tests for the force-directed seeder engine."""
|
||||
import math
|
||||
import pytest
|
||||
from ..seeders import SeederParams, PRESETS, seed_layout
|
||||
from ..models import Device, Lab, Placement
|
||||
|
||||
|
||||
class TestSeederParams:
|
||||
def test_presets_exist(self):
|
||||
assert "compact_outward" in PRESETS
|
||||
assert "spread_inward" in PRESETS
|
||||
assert "row_fallback" in PRESETS
|
||||
|
||||
def test_compact_has_negative_boundary(self):
|
||||
assert PRESETS["compact_outward"].boundary_attraction < 0
|
||||
|
||||
def test_spread_has_positive_boundary(self):
|
||||
assert PRESETS["spread_inward"].boundary_attraction > 0
|
||||
|
||||
|
||||
class TestSeedLayout:
|
||||
"""seed_layout must return valid placements: within bounds, one per device."""
|
||||
|
||||
def _make_devices(self, n: int) -> list[Device]:
|
||||
return [Device(id=f"d{i}", name=f"Device {i}", bbox=(0.6, 0.4)) for i in range(n)]
|
||||
|
||||
def test_returns_one_placement_per_device(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 5
|
||||
ids = {p.device_id for p in result}
|
||||
assert ids == {f"d{i}" for i in range(5)}
|
||||
|
||||
def test_placements_within_bounds(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
for preset_name in ["compact_outward", "spread_inward"]:
|
||||
result = seed_layout(devices, lab, PRESETS[preset_name])
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width, f"{preset_name}: x={p.x} out of bounds"
|
||||
assert 0 <= p.y <= lab.depth, f"{preset_name}: y={p.y} out of bounds"
|
||||
|
||||
def test_empty_devices(self):
|
||||
result = seed_layout([], Lab(width=5, depth=4), PRESETS["compact_outward"])
|
||||
assert result == []
|
||||
|
||||
def test_single_device(self):
|
||||
devices = self._make_devices(1)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 1
|
||||
assert 0 <= result[0].x <= lab.width
|
||||
assert 0 <= result[0].y <= lab.depth
|
||||
|
||||
def test_row_fallback_delegates(self):
|
||||
"""row_fallback preset uses generate_fallback, not force engine."""
|
||||
devices = self._make_devices(3)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
# row_fallback is None in PRESETS; seed_layout detects and delegates
|
||||
result = seed_layout(devices, lab, None) # None = row_fallback
|
||||
assert len(result) == 3
|
||||
|
||||
def test_lab_too_small_returns_results_not_crash(self):
|
||||
"""When space is insufficient, seeder still returns placements (may have collisions)."""
|
||||
devices = [Device(id=f"d{i}", name=f"D{i}", bbox=(1.0, 1.0)) for i in range(20)]
|
||||
lab = Lab(width=2.0, depth=2.0) # Way too small for 20 1m×1m devices
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 20 # All placed, even if overlapping
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width
|
||||
assert 0 <= p.y <= lab.depth
|
||||
|
||||
def test_compact_clusters_toward_center(self):
|
||||
"""compact_outward should place devices closer to center than spread_inward."""
|
||||
devices = self._make_devices(4)
|
||||
lab = Lab(width=8.0, depth=8.0)
|
||||
center_x, center_y = lab.width / 2, lab.depth / 2
|
||||
|
||||
compact = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
spread = seed_layout(devices, lab, PRESETS["spread_inward"])
|
||||
|
||||
avg_dist_compact = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in compact
|
||||
) / len(compact)
|
||||
avg_dist_spread = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in spread
|
||||
) / len(spread)
|
||||
|
||||
assert avg_dist_compact < avg_dist_spread
|
||||
|
||||
|
||||
class TestOrientation:
|
||||
"""Orientation modes should set theta based on position relative to center."""
|
||||
|
||||
def test_outward_orientation_sets_theta(self):
|
||||
"""compact_outward: devices should have non-zero theta."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.6, 0.4)),
|
||||
Device(id="b", name="B", bbox=(0.6, 0.4)),
|
||||
]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
thetas = [p.theta for p in result]
|
||||
assert any(t != 0.0 for t in thetas) or len(devices) == 1
|
||||
|
||||
def test_none_orientation_keeps_zero(self):
|
||||
"""orientation_mode='none': all thetas stay 0."""
|
||||
devices = [Device(id="a", name="A", bbox=(0.6, 0.4))]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
params = SeederParams(boundary_attraction=0.0, orientation_mode="none")
|
||||
result = seed_layout(devices, lab, params)
|
||||
assert result[0].theta == 0.0
|
||||
Reference in New Issue
Block a user