mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 03:19:55 +00:00
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>
264 lines
10 KiB
Python
264 lines
10 KiB
Python
"""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
|