mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-01 01:53:10 +00:00
Add PRCXI 9300 (3x2) deck layout support via model parameter
PRCXI9300Deck now accepts model="9300"|"9320" to auto-select 6-slot or 16-slot layout. DefaultLayout gains default_layout for 9300 with T6 as trash. PRCXI9300Handler auto-derives is_9320 from deck.model when not explicitly passed. Includes 9300 slim experiment JSON and test fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
539
test/devices/test_prcxi.py
Normal file
539
test/devices/test_prcxi.py
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from pylabrobot.resources import Coordinate
|
||||||
|
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
||||||
|
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||||
|
|
||||||
|
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
||||||
|
PRCXI9300Deck,
|
||||||
|
PRCXI9300Container,
|
||||||
|
PRCXI9300Trash,
|
||||||
|
PRCXI9300Handler,
|
||||||
|
PRCXI9300Backend,
|
||||||
|
DefaultLayout,
|
||||||
|
Material,
|
||||||
|
WorkTablets,
|
||||||
|
MatrixInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prcxi_materials() -> Dict[str, Any]:
|
||||||
|
"""加载 PRCXI 物料数据"""
|
||||||
|
print("加载 PRCXI 物料数据...")
|
||||||
|
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
||||||
|
with open(material_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print(f"加载了 {len(data)} 条物料数据")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prcxi_9300_deck() -> PRCXI9300Deck:
|
||||||
|
"""创建 PRCXI 9300 工作台"""
|
||||||
|
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prcxi_9320_deck() -> PRCXI9300Deck:
|
||||||
|
"""创建 PRCXI 9320 工作台"""
|
||||||
|
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
||||||
|
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
||||||
|
return PRCXI9300Handler(
|
||||||
|
deck=prcxi_9300_deck,
|
||||||
|
host="192.168.1.201",
|
||||||
|
port=9999,
|
||||||
|
timeout=10.0,
|
||||||
|
channel_num=8,
|
||||||
|
axis="Left",
|
||||||
|
setup=False,
|
||||||
|
debug=True,
|
||||||
|
simulator=True,
|
||||||
|
matrix_id="test-matrix-9300"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
||||||
|
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
||||||
|
return PRCXI9300Handler(
|
||||||
|
deck=prcxi_9320_deck,
|
||||||
|
host="192.168.1.201",
|
||||||
|
port=9999,
|
||||||
|
timeout=10.0,
|
||||||
|
channel_num=1,
|
||||||
|
axis="Right",
|
||||||
|
setup=False,
|
||||||
|
debug=True,
|
||||||
|
simulator=True,
|
||||||
|
matrix_id="test-matrix-9320",
|
||||||
|
is_9320=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
||||||
|
"""创建 300μL 枪头盒"""
|
||||||
|
tip_rack = PRCXI9300Container(
|
||||||
|
name="tip_rack_300ul",
|
||||||
|
size_x=50,
|
||||||
|
size_y=50,
|
||||||
|
size_z=10,
|
||||||
|
category="tip_rack",
|
||||||
|
ordering=collections.OrderedDict()
|
||||||
|
)
|
||||||
|
tip_rack.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
||||||
|
"Code": "ZX-001-300",
|
||||||
|
"Name": "300μL Tip头"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tip_rack
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
||||||
|
"""创建 10μL 枪头盒"""
|
||||||
|
tip_rack = PRCXI9300Container(
|
||||||
|
name="tip_rack_10ul",
|
||||||
|
size_x=50,
|
||||||
|
size_y=50,
|
||||||
|
size_z=10,
|
||||||
|
category="tip_rack",
|
||||||
|
ordering=collections.OrderedDict()
|
||||||
|
)
|
||||||
|
tip_rack.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
||||||
|
"Code": "ZX-001-10+",
|
||||||
|
"Name": "10μL加长 Tip头"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tip_rack
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
||||||
|
"""创建 96 孔板"""
|
||||||
|
plate = PRCXI9300Container(
|
||||||
|
name="well_plate_96",
|
||||||
|
size_x=50,
|
||||||
|
size_y=50,
|
||||||
|
size_z=10,
|
||||||
|
category="plate",
|
||||||
|
ordering=collections.OrderedDict()
|
||||||
|
)
|
||||||
|
plate.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||||
|
"Code": "ZX-019-2.2",
|
||||||
|
"Name": "96深孔板"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return plate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
||||||
|
"""创建深孔板"""
|
||||||
|
plate = PRCXI9300Container(
|
||||||
|
name="deep_well_plate",
|
||||||
|
size_x=50,
|
||||||
|
size_y=50,
|
||||||
|
size_z=10,
|
||||||
|
category="plate",
|
||||||
|
ordering=collections.OrderedDict()
|
||||||
|
)
|
||||||
|
plate.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||||
|
"Code": "ZX-019-2.2",
|
||||||
|
"Name": "96深孔板"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return plate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
||||||
|
"""创建垃圾桶"""
|
||||||
|
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||||
|
trash.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return trash
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_layout_9300() -> DefaultLayout:
|
||||||
|
"""创建 PRCXI 9300 默认布局"""
|
||||||
|
return DefaultLayout("PRCXI9300")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_layout_9320() -> DefaultLayout:
|
||||||
|
"""创建 PRCXI 9320 默认布局"""
|
||||||
|
return DefaultLayout("PRCXI9320")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPRCXIDeckSetup:
|
||||||
|
"""测试 PRCXI 工作台设置功能"""
|
||||||
|
|
||||||
|
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
||||||
|
"""测试 PRCXI 9300 工作台创建"""
|
||||||
|
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
||||||
|
assert len(prcxi_9300_deck.sites) == 6
|
||||||
|
assert prcxi_9300_deck._size_x == 100
|
||||||
|
assert prcxi_9300_deck._size_y == 100
|
||||||
|
assert prcxi_9300_deck._size_z == 100
|
||||||
|
|
||||||
|
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
||||||
|
"""测试 PRCXI 9320 工作台创建"""
|
||||||
|
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
||||||
|
assert len(prcxi_9320_deck.sites) == 16
|
||||||
|
assert prcxi_9320_deck._size_x == 100
|
||||||
|
assert prcxi_9320_deck._size_y == 100
|
||||||
|
assert prcxi_9320_deck._size_z == 100
|
||||||
|
|
||||||
|
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
||||||
|
"""测试容器分配到工作台"""
|
||||||
|
# 分配枪头盒
|
||||||
|
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
||||||
|
assert tip_rack_300ul in prcxi_9300_deck.children
|
||||||
|
|
||||||
|
# 分配孔板
|
||||||
|
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
||||||
|
assert well_plate_96 in prcxi_9300_deck.children
|
||||||
|
|
||||||
|
# 分配垃圾桶
|
||||||
|
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
||||||
|
assert trash_container in prcxi_9300_deck.children
|
||||||
|
|
||||||
|
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
||||||
|
"""测试容器物料信息加载"""
|
||||||
|
# 测试枪头盒物料信息
|
||||||
|
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
||||||
|
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
||||||
|
assert tip_material["Name"] == "300μL Tip头"
|
||||||
|
|
||||||
|
# 测试孔板物料信息
|
||||||
|
plate_material = well_plate_96._unilabos_state["Material"]
|
||||||
|
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
||||||
|
assert plate_material["Name"] == "96深孔板"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPRCXISingleStepOperations:
|
||||||
|
"""测试 PRCXI 单步操作功能"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
||||||
|
"""测试单通道拾取枪头"""
|
||||||
|
# 将枪头盒添加到工作台
|
||||||
|
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
||||||
|
|
||||||
|
# 初始化处理器
|
||||||
|
await prcxi_9320_handler.setup()
|
||||||
|
|
||||||
|
# 设置枪头盒
|
||||||
|
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||||
|
|
||||||
|
# 创建模拟的枪头位置
|
||||||
|
from pylabrobot.resources import TipSpot, Tip
|
||||||
|
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
||||||
|
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
||||||
|
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
||||||
|
|
||||||
|
# 直接测试后端方法
|
||||||
|
from pylabrobot.liquid_handling import Pickup
|
||||||
|
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
||||||
|
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "Load"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
||||||
|
"""测试多通道拾取枪头"""
|
||||||
|
# 设置枪头盒
|
||||||
|
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
||||||
|
|
||||||
|
# 拾取8个枪头
|
||||||
|
tip_spots = tip_rack_300ul.children[:8]
|
||||||
|
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "Load"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||||
|
"""测试单通道吸取液体"""
|
||||||
|
# 设置液体
|
||||||
|
well = well_plate_96.get_item("A1")
|
||||||
|
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
||||||
|
|
||||||
|
# 吸取液体
|
||||||
|
await prcxi_9320_handler.aspirate([well], [50], [0])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "Imbibing"
|
||||||
|
assert step["DosageNum"] == 50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||||
|
"""测试单通道分配液体"""
|
||||||
|
# 分配液体
|
||||||
|
well = well_plate_96.get_item("A1")
|
||||||
|
await prcxi_9320_handler.dispense([well], [25], [0])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "Tapping"
|
||||||
|
assert step["DosageNum"] == 25
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||||
|
"""测试单通道混合液体"""
|
||||||
|
# 混合液体
|
||||||
|
well = well_plate_96.get_item("A1")
|
||||||
|
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "Blending"
|
||||||
|
assert step["BlendingTimes"] == 3
|
||||||
|
assert step["DosageNum"] == 50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
||||||
|
"""测试丢弃枪头到垃圾桶"""
|
||||||
|
# 丢弃枪头
|
||||||
|
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "UnLoad"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discard_tips(self, prcxi_9320_handler):
|
||||||
|
"""测试丢弃枪头"""
|
||||||
|
# 丢弃枪头
|
||||||
|
await prcxi_9320_handler.discard_tips([0])
|
||||||
|
|
||||||
|
# 验证步骤已添加到待办列表
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||||
|
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||||
|
assert step["Function"] == "UnLoad"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
||||||
|
"""测试完整的液体转移工作流程"""
|
||||||
|
# 设置枪头盒和液体
|
||||||
|
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||||
|
source_well = well_plate_96.get_item("A1")
|
||||||
|
target_well = well_plate_96.get_item("B1")
|
||||||
|
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
||||||
|
|
||||||
|
# 创建协议
|
||||||
|
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
||||||
|
|
||||||
|
# 执行转移流程
|
||||||
|
tip_spot = tip_rack_10ul.get_item("A1")
|
||||||
|
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
||||||
|
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
||||||
|
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
||||||
|
await prcxi_9320_handler.discard_tips([0])
|
||||||
|
|
||||||
|
# 验证所有步骤都已添加
|
||||||
|
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
||||||
|
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
||||||
|
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPRCXILayoutRecommendation:
|
||||||
|
"""测试 PRCXI 板位推荐功能"""
|
||||||
|
|
||||||
|
def test_9300_layout_creation(self, default_layout_9300):
|
||||||
|
"""测试 PRCXI 9300 布局创建"""
|
||||||
|
layout_info = default_layout_9300.get_layout()
|
||||||
|
assert layout_info["rows"] == 2
|
||||||
|
assert layout_info["columns"] == 3
|
||||||
|
assert len(layout_info["layout"]) == 6
|
||||||
|
assert layout_info["trash_slot"] == 6
|
||||||
|
assert "waste_liquid_slot" not in layout_info
|
||||||
|
|
||||||
|
def test_9320_layout_creation(self, default_layout_9320):
|
||||||
|
"""测试 PRCXI 9320 布局创建"""
|
||||||
|
layout_info = default_layout_9320.get_layout()
|
||||||
|
assert layout_info["rows"] == 4
|
||||||
|
assert layout_info["columns"] == 4
|
||||||
|
assert len(layout_info["layout"]) == 16
|
||||||
|
assert layout_info["trash_slot"] == 16
|
||||||
|
assert layout_info["waste_liquid_slot"] == 12
|
||||||
|
|
||||||
|
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
||||||
|
"""测试 PRCXI 9320 板位推荐功能"""
|
||||||
|
# 添加物料信息
|
||||||
|
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||||
|
|
||||||
|
# 推荐布局
|
||||||
|
needs = [
|
||||||
|
("reagent_1", "96 细胞培养皿", 3),
|
||||||
|
("reagent_2", "12道储液槽", 1),
|
||||||
|
("reagent_3", "200μL Tip头", 7),
|
||||||
|
("reagent_4", "10μL加长 Tip头", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
||||||
|
|
||||||
|
# 验证返回结果
|
||||||
|
assert "MatrixId" in matrix_layout
|
||||||
|
assert "MatrixName" in matrix_layout
|
||||||
|
assert "MatrixCount" in matrix_layout
|
||||||
|
assert "WorkTablets" in matrix_layout
|
||||||
|
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
||||||
|
|
||||||
|
# 验证推荐的位置不包含预留位置
|
||||||
|
reserved_positions = {12, 16}
|
||||||
|
recommended_positions = [item["positions"] for item in layout_list]
|
||||||
|
for pos in recommended_positions:
|
||||||
|
assert pos not in reserved_positions
|
||||||
|
|
||||||
|
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
||||||
|
"""测试板位推荐空间不足的情况"""
|
||||||
|
# 添加物料信息
|
||||||
|
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||||
|
|
||||||
|
# 尝试推荐超过可用空间的布局
|
||||||
|
needs = [
|
||||||
|
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
||||||
|
default_layout_9320.recommend_layout(needs)
|
||||||
|
|
||||||
|
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
||||||
|
"""测试板位推荐物料不存在的情况"""
|
||||||
|
# 添加物料信息
|
||||||
|
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||||
|
|
||||||
|
# 尝试推荐不存在的物料
|
||||||
|
needs = [
|
||||||
|
("reagent_1", "不存在的物料", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
||||||
|
default_layout_9320.recommend_layout(needs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPRCXIBackendOperations:
|
||||||
|
"""测试 PRCXI 后端操作功能"""
|
||||||
|
|
||||||
|
def test_backend_initialization(self, prcxi_9300_handler):
|
||||||
|
"""测试后端初始化"""
|
||||||
|
backend = prcxi_9300_handler._unilabos_backend
|
||||||
|
assert isinstance(backend, PRCXI9300Backend)
|
||||||
|
assert backend._num_channels == 8
|
||||||
|
assert backend.debug is True
|
||||||
|
|
||||||
|
def test_protocol_creation(self, prcxi_9300_handler):
|
||||||
|
"""测试协议创建"""
|
||||||
|
backend = prcxi_9300_handler._unilabos_backend
|
||||||
|
backend.create_protocol("Test Protocol")
|
||||||
|
assert backend.protocol_name == "Test Protocol"
|
||||||
|
assert len(backend.steps_todo_list) == 0
|
||||||
|
|
||||||
|
def test_channel_validation(self):
|
||||||
|
"""测试通道验证"""
|
||||||
|
# 测试正确的8通道配置
|
||||||
|
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
|
result = PRCXI9300Backend.check_channels(valid_channels)
|
||||||
|
assert result == valid_channels
|
||||||
|
|
||||||
|
# 测试错误的通道配置
|
||||||
|
invalid_channels = [0, 1, 2, 3]
|
||||||
|
result = PRCXI9300Backend.check_channels(invalid_channels)
|
||||||
|
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
|
|
||||||
|
def test_matrix_info_creation(self, prcxi_9300_handler):
|
||||||
|
"""测试矩阵信息创建"""
|
||||||
|
backend = prcxi_9300_handler._unilabos_backend
|
||||||
|
backend.create_protocol("Test Protocol")
|
||||||
|
|
||||||
|
# 模拟运行协议时的矩阵信息创建
|
||||||
|
run_time = 1234567890
|
||||||
|
matrix_info = MatrixInfo(
|
||||||
|
MatrixId=f"{int(run_time)}",
|
||||||
|
MatrixName=f"protocol_{run_time}",
|
||||||
|
MatrixCount=len(backend.tablets_info),
|
||||||
|
WorkTablets=backend.tablets_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matrix_info["MatrixId"] == str(int(run_time))
|
||||||
|
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
||||||
|
assert "WorkTablets" in matrix_info
|
||||||
|
|
||||||
|
|
||||||
|
class TestPRCXIContainerOperations:
|
||||||
|
"""测试 PRCXI 容器操作功能"""
|
||||||
|
|
||||||
|
def test_container_serialization(self, tip_rack_300ul):
|
||||||
|
"""测试容器序列化"""
|
||||||
|
serialized = tip_rack_300ul.serialize_state()
|
||||||
|
assert "Material" in serialized
|
||||||
|
assert serialized["Material"]["Name"] == "300μL Tip头"
|
||||||
|
|
||||||
|
def test_container_deserialization(self, tip_rack_300ul):
|
||||||
|
"""测试容器反序列化"""
|
||||||
|
# 序列化
|
||||||
|
serialized = tip_rack_300ul.serialize_state()
|
||||||
|
|
||||||
|
# 创建新容器并反序列化
|
||||||
|
new_tip_rack = PRCXI9300Container(
|
||||||
|
name="new_tip_rack",
|
||||||
|
size_x=50,
|
||||||
|
size_y=50,
|
||||||
|
size_z=10,
|
||||||
|
category="tip_rack",
|
||||||
|
ordering=collections.OrderedDict()
|
||||||
|
)
|
||||||
|
new_tip_rack.load_state(serialized)
|
||||||
|
|
||||||
|
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
||||||
|
|
||||||
|
def test_trash_container_creation(self, prcxi_materials):
|
||||||
|
"""测试垃圾桶容器创建"""
|
||||||
|
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||||
|
trash.load_state({
|
||||||
|
"Material": {
|
||||||
|
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert trash.name == "trash"
|
||||||
|
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 运行测试
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -93,24 +93,35 @@ class PRCXI9300Deck(Deck):
|
|||||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# T1-T16 默认位置 (4列×4行, Y轴从上往下递减, T1在左上角)
|
# 9320: 4列×4行 = 16 slots(Y轴从上往下递减, T1在左上角)
|
||||||
_DEFAULT_SITE_POSITIONS = [
|
_9320_SITE_POSITIONS = [
|
||||||
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T1-T4 (第1行, 最上)
|
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T1-T4 (第1行, 最上)
|
||||||
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T5-T8 (第2行)
|
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T5-T8 (第2行)
|
||||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T9-T12 (第3行)
|
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T9-T12 (第3行)
|
||||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T13-T16 (第4行, 最下)
|
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T13-T16 (第4行, 最下)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 9300: 3列×2行 = 6 slots,间距与9320相同(X: 138mm, Y: 96mm)
|
||||||
|
_9300_SITE_POSITIONS = [
|
||||||
|
(0, 96, 0), (138, 96, 0), (276, 96, 0), # T1-T3 (第1行, 上)
|
||||||
|
(0, 0, 0), (138, 0, 0), (276, 0, 0), # T4-T6 (第2行, 下)
|
||||||
|
]
|
||||||
|
|
||||||
|
# 向后兼容别名
|
||||||
|
_DEFAULT_SITE_POSITIONS = _9320_SITE_POSITIONS
|
||||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
||||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module"]
|
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module"]
|
||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
sites: Optional[List[Dict[str, Any]]] = None, model: str = "9320", **kwargs):
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
super().__init__(size_x, size_y, size_z, name)
|
||||||
|
self.model = model
|
||||||
if sites is not None:
|
if sites is not None:
|
||||||
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
|
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
|
||||||
else:
|
else:
|
||||||
|
positions = self._9300_SITE_POSITIONS if model == "9300" else self._9320_SITE_POSITIONS
|
||||||
self.sites = []
|
self.sites = []
|
||||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
for i, (x, y, z) in enumerate(positions):
|
||||||
self.sites.append({
|
self.sites.append({
|
||||||
"label": f"T{i + 1}",
|
"label": f"T{i + 1}",
|
||||||
"visible": True,
|
"visible": True,
|
||||||
@@ -174,6 +185,7 @@ class PRCXI9300Deck(Deck):
|
|||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
data = super().serialize()
|
data = super().serialize()
|
||||||
|
data["model"] = self.model
|
||||||
sites_out = []
|
sites_out = []
|
||||||
for i, site in enumerate(self.sites):
|
for i, site in enumerate(self.sites):
|
||||||
occupied = self._get_site_resource(i)
|
occupied = self._get_site_resource(i)
|
||||||
@@ -749,7 +761,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
simulator=False,
|
simulator=False,
|
||||||
step_mode=False,
|
step_mode=False,
|
||||||
matrix_id="",
|
matrix_id="",
|
||||||
is_9320=False,
|
is_9320=None,
|
||||||
):
|
):
|
||||||
tablets_info = []
|
tablets_info = []
|
||||||
for site_id in range(len(deck.sites)):
|
for site_id in range(len(deck.sites)):
|
||||||
@@ -762,6 +774,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if is_9320 is None:
|
||||||
|
is_9320 = getattr(deck, 'model', '9300') == '9320'
|
||||||
if is_9320:
|
if is_9320:
|
||||||
print("当前设备是9320")
|
print("当前设备是9320")
|
||||||
# 始终初始化 step_mode 属性
|
# 始终初始化 step_mode 属性
|
||||||
@@ -1983,8 +1997,20 @@ class DefaultLayout:
|
|||||||
self.rows = 2
|
self.rows = 2
|
||||||
self.columns = 3
|
self.columns = 3
|
||||||
self.layout = [1, 2, 3, 4, 5, 6]
|
self.layout = [1, 2, 3, 4, 5, 6]
|
||||||
self.trash_slot = 3
|
self.trash_slot = 6
|
||||||
self.waste_liquid_slot = 6
|
self.default_layout = {
|
||||||
|
"MatrixId": f"{time.time()}",
|
||||||
|
"MatrixName": f"{time.time()}",
|
||||||
|
"MatrixCount": 6,
|
||||||
|
"WorkTablets": [
|
||||||
|
{"Number": 1, "Code": "T1", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||||
|
{"Number": 2, "Code": "T2", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||||
|
{"Number": 3, "Code": "T3", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||||
|
{"Number": 4, "Code": "T4", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||||
|
{"Number": 5, "Code": "T5", "Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0}},
|
||||||
|
{"Number": 6, "Code": "T6", "Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0}}, # trash
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
elif product_name == "PRCXI9320":
|
elif product_name == "PRCXI9320":
|
||||||
self.rows = 4
|
self.rows = 4
|
||||||
@@ -2081,13 +2107,15 @@ class DefaultLayout:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_layout(self) -> Dict[str, Any]:
|
def get_layout(self) -> Dict[str, Any]:
|
||||||
return {
|
result = {
|
||||||
"rows": self.rows,
|
"rows": self.rows,
|
||||||
"columns": self.columns,
|
"columns": self.columns,
|
||||||
"layout": self.layout,
|
"layout": self.layout,
|
||||||
"trash_slot": self.trash_slot,
|
"trash_slot": self.trash_slot,
|
||||||
"waste_liquid_slot": self.waste_liquid_slot,
|
|
||||||
}
|
}
|
||||||
|
if hasattr(self, 'waste_liquid_slot'):
|
||||||
|
result["waste_liquid_slot"] = self.waste_liquid_slot
|
||||||
|
return result
|
||||||
|
|
||||||
def get_trash_slot(self) -> int:
|
def get_trash_slot(self) -> int:
|
||||||
return self.trash_slot
|
return self.trash_slot
|
||||||
@@ -2105,15 +2133,18 @@ class DefaultLayout:
|
|||||||
if material_name not in self.labresource:
|
if material_name not in self.labresource:
|
||||||
raise ValueError(f"Material {reagent_name} not found in lab resources.")
|
raise ValueError(f"Material {reagent_name} not found in lab resources.")
|
||||||
|
|
||||||
# 预留位置12和16不动
|
# 预留位置动态计算
|
||||||
reserved_positions = {12, 16}
|
reserved_positions = {self.trash_slot}
|
||||||
available_positions = [i for i in range(1, 17) if i not in reserved_positions]
|
if hasattr(self, 'waste_liquid_slot'):
|
||||||
|
reserved_positions.add(self.waste_liquid_slot)
|
||||||
|
total_slots = self.rows * self.columns
|
||||||
|
available_positions = [i for i in range(1, total_slots + 1) if i not in reserved_positions]
|
||||||
|
|
||||||
# 计算总需求
|
# 计算总需求
|
||||||
total_needed = sum(count for _, _, count in needs)
|
total_needed = sum(count for _, _, count in needs)
|
||||||
if total_needed > len(available_positions):
|
if total_needed > len(available_positions):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置(排除位置12和16)"
|
f"需要 {total_needed} 个位置,但只有 {len(available_positions)} 个可用位置(排除预留位置 {reserved_positions})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 依次分配位置
|
# 依次分配位置
|
||||||
|
|||||||
226
unilabos/test/experiments/prcxi_9300_slim.json
Normal file
226
unilabos/test/experiments/prcxi_9300_slim.json
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "PRCXI",
|
||||||
|
"name": "PRCXI",
|
||||||
|
"type": "device",
|
||||||
|
"class": "liquid_handler.prcxi",
|
||||||
|
"parent": "",
|
||||||
|
"pose": {
|
||||||
|
"size": {
|
||||||
|
"width": 424,
|
||||||
|
"height": 202,
|
||||||
|
"depth": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"axis": "Left",
|
||||||
|
"deck": {
|
||||||
|
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||||
|
"_resource_child_name": "PRCXI_Deck"
|
||||||
|
},
|
||||||
|
"host": "10.20.30.167",
|
||||||
|
"port": 9999,
|
||||||
|
"debug": false,
|
||||||
|
"setup": true,
|
||||||
|
"timeout": 10,
|
||||||
|
"simulator": false,
|
||||||
|
"channel_num": 8
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"reset_ok": true
|
||||||
|
},
|
||||||
|
"schema": {},
|
||||||
|
"description": "",
|
||||||
|
"model": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 240,
|
||||||
|
"z": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PRCXI_Deck",
|
||||||
|
"name": "PRCXI_Deck",
|
||||||
|
"children": [],
|
||||||
|
"parent": "PRCXI",
|
||||||
|
"type": "deck",
|
||||||
|
"class": "",
|
||||||
|
"position": {
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "PRCXI9300Deck",
|
||||||
|
"size_x": 404,
|
||||||
|
"size_y": 182,
|
||||||
|
"size_z": 0,
|
||||||
|
"model": "9300",
|
||||||
|
"rotation": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"type": "Rotation"
|
||||||
|
},
|
||||||
|
"category": "deck",
|
||||||
|
"barcode": null,
|
||||||
|
"preferred_pickup_location": null,
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"label": "T1",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T2",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T3",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T4",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T5",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T6",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor",
|
||||||
|
"plateadapter",
|
||||||
|
"module"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user