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

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

264 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,
"snap_cardinal": True,
"seeder_overrides": {"align_weight": 60},
})
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