mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 13:25:13 +00:00
338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""
|
||
PRCXI transfer_liquid 集成测试。
|
||
|
||
这些用例会启动 UniLiquidHandler RViz 仿真 backend,需要同时满足:
|
||
1. 安装 pylabrobot 依赖;
|
||
2. 设置环境变量 UNILAB_SIM_TEST=1;
|
||
3. 具备 ROS 运行环境(rviz_backend 会创建 ROS 节点)。
|
||
"""
|
||
import asyncio
|
||
import os
|
||
from dataclasses import dataclass
|
||
from typing import List, Sequence
|
||
|
||
import pytest
|
||
|
||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Deck, PRCXI9300Trash
|
||
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import (
|
||
PRCXI_300ul_Tips,
|
||
PRCXI_BioER_96_wellplate,
|
||
)
|
||
|
||
pytestmark = pytest.mark.slow
|
||
|
||
try:
|
||
from pylabrobot.resources import Coordinate, Deck, Plate, TipRack, Well
|
||
except ImportError: # pragma: no cover - 测试环境缺少 pylabrobot 时直接跳过
|
||
Coordinate = Deck = Plate = TipRack = Well = None # type: ignore[assignment]
|
||
PYLABROBOT_AVAILABLE = False
|
||
else:
|
||
PYLABROBOT_AVAILABLE = True
|
||
|
||
SIM_ENV_VAR = "UNILAB_SIM_TEST"
|
||
|
||
|
||
@dataclass
|
||
class SimulationContext:
|
||
handler: LiquidHandlerAbstract
|
||
deck: Deck
|
||
tip_rack: TipRack
|
||
source_plate: Plate
|
||
target_plate: Plate
|
||
waste_plate: Plate
|
||
channel_num: int
|
||
|
||
|
||
def run(coro):
|
||
return asyncio.run(coro)
|
||
|
||
|
||
def _ensure_unilabos_extra(well: Well) -> None:
|
||
if not hasattr(well, "unilabos_extra") or well.unilabos_extra is None:
|
||
well.unilabos_extra = {} # type: ignore[attr-defined]
|
||
|
||
|
||
def _assign_sample_uuid(well: Well, value: str) -> None:
|
||
_ensure_unilabos_extra(well)
|
||
well.unilabos_extra["sample_uuid"] = value # type: ignore[attr-defined]
|
||
|
||
|
||
def _zero_coordinate() -> Coordinate:
|
||
if hasattr(Coordinate, "zero"):
|
||
return Coordinate.zero()
|
||
return Coordinate(0, 0, 0)
|
||
|
||
|
||
def _zero_offsets(count: int) -> List[Coordinate]:
|
||
return [_zero_coordinate() for _ in range(count)]
|
||
|
||
|
||
def _build_simulation_deck() -> tuple[PRCXI9300Deck, TipRack, Plate, Plate, Plate, PRCXI9300Trash]:
|
||
deck = PRCXI9300Deck(name="PRCXI_Deck", size_x=542, size_y=374, size_z=50)
|
||
tip_rack = PRCXI_300ul_Tips("Tips")
|
||
source_plate = PRCXI_BioER_96_wellplate("SourcePlate")
|
||
target_plate = PRCXI_BioER_96_wellplate("TargetPlate")
|
||
waste_plate = PRCXI_BioER_96_wellplate("WastePlate")
|
||
trash = PRCXI9300Trash(name="trash", size_x=100, size_y=100, size_z=50)
|
||
deck.assign_child_resource(tip_rack, location=Coordinate(0, 0, 0))
|
||
deck.assign_child_resource(source_plate, location=Coordinate(150, 0, 0))
|
||
deck.assign_child_resource(target_plate, location=Coordinate(300, 0, 0))
|
||
deck.assign_child_resource(waste_plate, location=Coordinate(450, 0, 0))
|
||
deck.assign_child_resource(trash, location=Coordinate(150, -120, 0))
|
||
return deck, tip_rack, source_plate, target_plate, waste_plate, trash
|
||
|
||
|
||
def _stop_backend(handler: LiquidHandlerAbstract) -> None:
|
||
try:
|
||
run(handler.backend.stop())
|
||
except Exception: # pragma: no cover - 如果 backend 已经停止
|
||
pass
|
||
simulate_handler = getattr(handler, "_simulate_handler", None)
|
||
if simulate_handler is not None and getattr(simulate_handler, "backend", None) is not None:
|
||
try:
|
||
run(simulate_handler.backend.stop())
|
||
except Exception: # pragma: no cover
|
||
pass
|
||
|
||
|
||
@pytest.fixture(params=[1, 8])
|
||
def prcxi_simulation(request) -> SimulationContext:
|
||
if not PYLABROBOT_AVAILABLE:
|
||
pytest.skip("pylabrobot is required for PRCXI simulation tests.")
|
||
if os.environ.get(SIM_ENV_VAR) != "1":
|
||
pytest.skip(f"Set {SIM_ENV_VAR}=1 to run PRCXI simulation tests.")
|
||
|
||
channel_num = request.param
|
||
deck, tip_rack, source_plate, target_plate, waste_plate, _trash = _build_simulation_deck()
|
||
backend_cfg = {
|
||
"type": "unilabos.devices.liquid_handling.rviz_backend.UniLiquidHandlerRvizBackend",
|
||
"channel_num": channel_num,
|
||
"total_height": 310,
|
||
"lh_device_id": f"pytest_prcxi_{channel_num}",
|
||
}
|
||
handler = LiquidHandlerAbstract(
|
||
backend=backend_cfg,
|
||
deck=deck,
|
||
simulator=True,
|
||
channel_num=channel_num,
|
||
total_height=310,
|
||
)
|
||
run(handler.setup())
|
||
handler.set_tiprack([tip_rack])
|
||
handler.support_touch_tip = False
|
||
|
||
context = SimulationContext(
|
||
handler=handler,
|
||
deck=deck,
|
||
tip_rack=tip_rack,
|
||
source_plate=source_plate,
|
||
target_plate=target_plate,
|
||
waste_plate=waste_plate,
|
||
channel_num=channel_num,
|
||
)
|
||
|
||
yield context
|
||
|
||
_stop_backend(handler)
|
||
|
||
|
||
def _pick_wells(plate: Plate, start: int, count: int) -> List[Well]:
|
||
wells = plate.children[start : start + count]
|
||
for well in wells:
|
||
_ensure_unilabos_extra(well)
|
||
return wells
|
||
|
||
|
||
def _assert_samples_match(sources: Sequence[Well], targets: Sequence[Well]) -> None:
|
||
for src, tgt in zip(sources, targets):
|
||
src_uuid = getattr(src, "unilabos_extra", {}).get("sample_uuid")
|
||
tgt_uuid = getattr(tgt, "unilabos_extra", {}).get("sample_uuid")
|
||
assert tgt_uuid == src_uuid
|
||
|
||
|
||
def test_transfer_liquid_single_channel_one_to_one(prcxi_simulation: SimulationContext):
|
||
if prcxi_simulation.channel_num != 1:
|
||
pytest.skip("仅在单通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||
_ensure_unilabos_extra(well)
|
||
sources = prcxi_simulation.source_plate[0:3]
|
||
targets = prcxi_simulation.target_plate["A4:A6"]
|
||
for idx, src in enumerate(sources):
|
||
_assign_sample_uuid(src, f"single_{idx}")
|
||
offsets = _zero_offsets(max(len(sources), len(targets)))
|
||
|
||
result = run(
|
||
handler.transfer_liquid(
|
||
sources=sources,
|
||
targets=targets,
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=[0],
|
||
asp_vols=[5.0, 6.0, 7.0],
|
||
dis_vols=[10.0, 11.0, 12.0],
|
||
offsets=offsets,
|
||
mix_times=None,
|
||
)
|
||
)
|
||
|
||
# assert result == """"""
|
||
|
||
_assert_samples_match(sources, targets)
|
||
|
||
|
||
def test_transfer_liquid_single_channel_one_to_many(prcxi_simulation: SimulationContext):
|
||
if prcxi_simulation.channel_num != 1:
|
||
pytest.skip("仅在单通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||
_ensure_unilabos_extra(well)
|
||
source = prcxi_simulation.source_plate.children[0]
|
||
targets = prcxi_simulation.target_plate["A1:E1"]
|
||
_assign_sample_uuid(source, "one_to_many_source")
|
||
offsets = _zero_offsets(max(len(targets), 1))
|
||
|
||
run(
|
||
handler.transfer_liquid(
|
||
sources=[source],
|
||
targets=targets,
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=[0],
|
||
asp_vols=10.0,
|
||
dis_vols=[2.0, 2.0, 2.0, 2.0, 2.0],
|
||
offsets=offsets,
|
||
mix_times=0,
|
||
)
|
||
)
|
||
|
||
for target in targets:
|
||
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == "one_to_many_source"
|
||
|
||
|
||
def test_transfer_liquid_single_channel_many_to_one(prcxi_simulation: SimulationContext):
|
||
if prcxi_simulation.channel_num != 1:
|
||
pytest.skip("仅在单通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||
_ensure_unilabos_extra(well)
|
||
sources = prcxi_simulation.source_plate[0:3]
|
||
target = prcxi_simulation.target_plate.children[4]
|
||
for idx, src in enumerate(sources):
|
||
_assign_sample_uuid(src, f"many_to_one_{idx}")
|
||
offsets = _zero_offsets(max(len(sources), len([target])))
|
||
|
||
run(
|
||
handler.transfer_liquid(
|
||
sources=sources,
|
||
targets=[target],
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=[0],
|
||
asp_vols=[8.0, 9.0, 10.0],
|
||
dis_vols=1,
|
||
offsets=offsets,
|
||
mix_stage="after",
|
||
mix_times=1,
|
||
mix_vol=5,
|
||
)
|
||
)
|
||
|
||
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == "many_to_one_2"
|
||
|
||
|
||
def test_transfer_liquid_eight_channel_batches(prcxi_simulation: SimulationContext):
|
||
if prcxi_simulation.channel_num != 8:
|
||
pytest.skip("仅在八通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||
_ensure_unilabos_extra(well)
|
||
sources = prcxi_simulation.source_plate[0:8]
|
||
targets = prcxi_simulation.target_plate[16:24]
|
||
for idx, src in enumerate(sources):
|
||
_assign_sample_uuid(src, f"batch_{idx}")
|
||
offsets = _zero_offsets(len(targets))
|
||
|
||
use_channels = list(range(8))
|
||
asp_vols = [float(i + 1) * 2 for i in range(8)]
|
||
dis_vols = [float(i + 10) for i in range(8)]
|
||
|
||
run(
|
||
handler.transfer_liquid(
|
||
sources=sources,
|
||
targets=targets,
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=use_channels,
|
||
asp_vols=asp_vols,
|
||
dis_vols=dis_vols,
|
||
offsets=offsets,
|
||
mix_times=0,
|
||
)
|
||
)
|
||
|
||
_assert_samples_match(sources, targets)
|
||
|
||
|
||
@pytest.mark.parametrize("mix_stage", ["before", "after", "both"])
|
||
def test_transfer_liquid_mix_stages(prcxi_simulation: SimulationContext, mix_stage: str):
|
||
if prcxi_simulation.channel_num != 1:
|
||
pytest.skip("仅在单通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
for well in prcxi_simulation.source_plate.children + prcxi_simulation.target_plate.children:
|
||
_ensure_unilabos_extra(well)
|
||
target = prcxi_simulation.target_plate[70]
|
||
sources = prcxi_simulation.source_plate[80:82]
|
||
for idx, src in enumerate(sources):
|
||
_assign_sample_uuid(src, f"mix_stage_{mix_stage}_{idx}")
|
||
|
||
run(
|
||
handler.transfer_liquid(
|
||
sources=sources,
|
||
targets=[target],
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=[0],
|
||
asp_vols=[4.0, 5.0],
|
||
dis_vols=1,
|
||
offsets=_zero_offsets(len(sources)),
|
||
mix_stage=mix_stage,
|
||
mix_times=2,
|
||
mix_vol=3,
|
||
)
|
||
)
|
||
|
||
# mix_stage 前后都应该保留最新源的 sample_uuid
|
||
assert getattr(target, "unilabos_extra", {}).get("sample_uuid") == f"mix_stage_{mix_stage}_1"
|
||
if prcxi_simulation.channel_num != 8:
|
||
pytest.skip("仅在八通道配置下运行")
|
||
|
||
handler = prcxi_simulation.handler
|
||
sources = prcxi_simulation.source_plate[0:8]
|
||
targets = prcxi_simulation.target_plate[16:24]
|
||
for idx, src in enumerate(sources):
|
||
_assign_sample_uuid(src, f"batch_{idx}")
|
||
offsets = _zero_offsets(len(targets))
|
||
|
||
use_channels = list(range(8))
|
||
asp_vols = [float(i + 1) * 2 for i in range(8)]
|
||
dis_vols = [float(i + 10) for i in range(8)]
|
||
|
||
run(
|
||
handler.transfer_liquid(
|
||
sources=sources,
|
||
targets=targets,
|
||
tip_racks=[prcxi_simulation.tip_rack],
|
||
use_channels=use_channels,
|
||
asp_vols=asp_vols,
|
||
dis_vols=dis_vols,
|
||
offsets=offsets,
|
||
mix_stage="after",
|
||
mix_times=2,
|
||
mix_vol=3,
|
||
)
|
||
)
|
||
|
||
_assert_samples_match(sources, targets)
|