Files
Uni-Lab-OS/unilabos/layout_optimizer/tests/test_e2e_pcr_pipeline.py
yexiaozhou 9ef24b7768 feat(layout_optimizer): DE optimizer V2 — custom loop, graduated hard constraints, broad phase
Replace scipy differential_evolution with custom DE loop for per-device
crossover, circular θ wrapping, and configurable mutation strategy
(currenttobest1bin default, best1bin as turbo mode).

Key improvements:
- Graduate ALL hard constraints during DE (proportional penalty instead of
  flat inf), giving DE smooth gradient for reachability, min_spacing, etc.
  Binary inf preserved for final pass/fail reporting.
- 2-axis sweep-and-prune AABB broad phase for collision pair pruning
- Multi-seed injection from multiple seeder presets + Gaussian variants
- snap_theta_safe: collision-check after angle snapping, revert on violation
- Weight normalization (100 distance / 60 angle / 5× hard multiplier)
- Constraint priority field (critical/high/normal/low → weight multiplier)
  with LLM intent interpreter setting priority per constraint type
- Final success field now checks user hard constraints in binary mode
- arm_slider added to mock checker reach table (1.07m)

Tests: 202 passed, 24 new tests added (optimizer 7, constraints 6, broad_phase 11)

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

262 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 now includes arm_slider in the default reach table
(1.07m). Binary final evaluation checks all hard constraints including
user-defined reachability.
"""
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": 100,
"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