Compare commits

..

48 Commits

Author SHA1 Message Date
q434343
9a6f744afd Merge branch 'sjs_middle_school' into feat/lab_resource 2026-04-01 11:51:54 +08:00
q434343
8164d990cc 适配前吸空气部分 2026-04-01 11:50:42 +08:00
q434343
5c9c8a4ee9 Merge branch 'prcix9320' into sjs_middle_school 2026-03-31 18:48:20 +08:00
q434343
a48985720c 添加run_protocol参数 2026-03-31 16:11:11 +08:00
q434343
ad66fc1841 其他修改, 2026-03-31 14:57:51 +08:00
q434343
6b3f9756a0 修改真机运动方式, 2026-03-31 14:33:50 +08:00
q434343
afddc6e40c 修改上传工作流部分代码 2026-03-31 14:32:48 +08:00
q434343
edd67e4880 Merge branch 'dev' into feat/lab_resource 2026-03-27 19:50:51 +08:00
q434343
1ab1ed69d4 Merge branch 'dev' into feat/lab_resource 2026-03-25 19:48:39 +08:00
q434343
ad2e5a1c04 更新prcxi的版面更新与工作流上传方法 2026-03-25 19:42:25 +08:00
q434343
04c0564366 Update .gitignore 2026-03-25 06:04:36 +08:00
q434343
9d65718f37 Merge branch 'prcix9320' into feat/lab_resource 2026-03-23 00:38:40 +08:00
q434343
35bcf6765d 修改rviz显示逻辑与joint_publisher,添加moveit2相关节点描述 2026-03-23 00:00:57 +08:00
q434343
cdbca70222 修改workflow上传逻辑,在trash初始化后再开始移液,修改枪头pick和drop的判断 2026-03-19 02:35:25 +08:00
q434343
1a267729e4 修改tracker的问题,在volumetracker报错时,禁用并进行下一步 2026-03-17 20:29:15 +08:00
q434343
b11f6eac55 修改pylabrobot更新后的影响 2026-03-16 20:42:13 +08:00
q434343
d85ff540c4 完成mix,liquid_hight,touch_tip,delay等参数的传递 2026-03-12 13:58:06 +08:00
q434343
5f45a0b81b 修改transfer liquid方法 2026-03-09 19:48:57 +08:00
Xuwznln
6bf9a319c7 Merge branch 'dev' into feat/lab_resource 2026-03-03 18:05:43 +08:00
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
q434343
74f0d5ee65 Merge branch 'feat/lab_resource' of https://github.com/deepmodeling/Uni-Lab-OS into feat/lab_resource 2026-03-03 14:17:36 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
2596d48a2f update materials 2026-03-03 11:43:41 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
2ac1a3242a 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:02 +08:00
q434343
5d208c832b 修改工作流上传以及lh的物料初步判定 2026-03-02 18:32:44 +08:00
q434343
786498904d 修改上传方式,添加tip_rack的连线 2026-03-02 18:32:18 +08:00
q434343
a9ea9f425d 添加单枪头的多对多移液判定 2026-03-02 18:31:28 +08:00
Xuwznln
b3bc951cae registry update & workflow update 2026-03-02 18:31:26 +08:00
Xuwznln
01df4f1115 add resource 2026-03-02 18:30:07 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
q434343
e1074f06d2 修改工作流上传以及lh的物料初步判定 2026-02-26 10:52:41 +08:00
q434343
0dc273f366 修改上传方式,添加tip_rack的连线 2026-02-24 19:37:11 +08:00
q434343
2e5fac26b3 添加单枪头的多对多移液判定 2026-02-13 13:46:27 +08:00
Xuwznln
07cf690897 fix possible crash 2026-02-12 01:46:26 +08:00
Xuwznln
cfea27460a fix deck & host_node 2026-02-12 01:46:24 +08:00
Xuwznln
b7d3e980a9 set liquid with tube 2026-02-12 01:46:23 +08:00
Xuwznln
5c2da9b793 fix possible crash 2026-02-11 23:44:53 +08:00
Xuwznln
45efbfcd12 fix deck & host_node 2026-02-11 17:33:26 +08:00
Xuwznln
8da6fdfd0b set liquid with tube 2026-02-11 16:20:07 +08:00
Xuwznln
29ea9909a5 Merge branch 'dev' into feat/lab_resource 2026-02-11 14:04:49 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
Xuwznln
ee6307a568 registry update & workflow update 2026-02-10 22:45:51 +08:00
Xuwznln
8a0116c852 add resource 2026-02-10 22:44:45 +08:00
25 changed files with 6677 additions and 2865 deletions

View File

@@ -2,7 +2,7 @@
package: package:
name: unilabos-env name: unilabos-env
version: 0.10.17 version: 0.10.19
build: build:
noarch: generic noarch: generic

4
.gitignore vendored
View File

@@ -253,8 +253,4 @@ test_config.py
/.claude /.claude
/.conda
/.cursor /.cursor
/.github
/.conda/base
.conda/base/recipe.yaml

View File

@@ -1,4 +1,95 @@
# CLAUDE.md
Please follow the rules defined in: This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md ## Build & Development
```bash
# Install (requires mamba env with python 3.11)
mamba create -n unilab python=3.11.14
mamba activate unilab
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
pip install -e .
uv pip install -r unilabos/utils/requirements.txt
# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
unilab --test_mode # simulate hardware, no real execution
unilab --check_mode # CI validation of registry imports (AST-based)
unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
unilab --restart_mode # auto-restart on config changes (supervisor/child process)
# Workflow upload subcommand
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
# Tests
pytest tests/ # all tests
pytest tests/resources/test_resourcetreeset.py # single test file
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
# CI check (matches .github/workflows/ci-check.yml)
python -m unilabos --check_mode --skip_env_check
```
## Architecture
### Startup Flow
`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
### Core Layers
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: YAML definitions in `registry/devices/*.yaml` and Python decorators (`@device`, `@action`, `@resource` in `registry/decorators.py`). AST scanning discovers decorated classes without importing them. Class paths resolved to Python classes via `utils/import_manager.py`.
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict``ResourceDictInstance``ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`.
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers.
**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/``host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`).
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger).
**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`.
**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections.
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002.
### Configuration System
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`)
- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
- Device topology defined in graph files (JSON node-link format or GraphML)
### Key Data Flow
1. Graph file → `graphio.read_node_link_json()``(nx.Graph, ResourceTreeSet, resource_links)`
2. `ResourceTreeSet` + `Registry``initialize_device.initialize_device_from_dict()``ROS2DeviceNode` instances
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
### Test Data
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
## Code Conventions
- Code comments and log messages in **simplified Chinese**
- Python 3.11+, type hints expected
- Pydantic models for data validation (`resource_tracker.py`)
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
- CLI argument dashes auto-converted to underscores for consistency
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`)
## Licensing
- Framework code: GPL-3.0
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute

File diff suppressed because it is too large Load Diff

539
test/devices/test_prcxi.py Normal file
View 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"])

View File

@@ -201,17 +201,42 @@ class ResourceVisualization:
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name] self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
@staticmethod
def _ensure_ros2_env() -> dict:
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
import sys
env = dict(os.environ)
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
env["AMENT_PREFIX_PATH"] = candidate
os.environ["AMENT_PREFIX_PATH"] = candidate
extra_bin_dirs = [
os.path.join(conda_prefix, "Library", "bin"),
os.path.join(conda_prefix, "Library", "lib"),
os.path.join(conda_prefix, "Scripts"),
conda_prefix,
]
current_path = env.get("PATH", "")
for d in extra_bin_dirs:
if d not in current_path:
current_path = d + os.pathsep + current_path
env["PATH"] = current_path
os.environ["PATH"] = current_path
return env
def create_launch_description(self) -> LaunchDescription: def create_launch_description(self) -> LaunchDescription:
""" """
创建launch描述包含robot_state_publisher和move_group节点 创建launch描述包含robot_state_publisher和move_group节点
Args:
urdf_str: URDF文本
Returns: Returns:
LaunchDescription: launch描述对象 LaunchDescription: launch描述对象
""" """
# 检查ROS 2环境变量 launch_env = self._ensure_ros2_env()
if "AMENT_PREFIX_PATH" not in os.environ: if "AMENT_PREFIX_PATH" not in os.environ:
raise OSError( raise OSError(
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n" "ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
@@ -290,7 +315,7 @@ class ResourceVisualization:
{"robot_description": robot_description}, {"robot_description": robot_description},
ros2_controllers, ros2_controllers,
], ],
env=dict(os.environ) env=launch_env,
) )
) )
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']: for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -300,7 +325,7 @@ class ResourceVisualization:
executable="spawner", executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"], arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen", output="screen",
env=dict(os.environ) env=launch_env,
) )
) )
controllers.append( controllers.append(
@@ -309,7 +334,7 @@ class ResourceVisualization:
executable="spawner", executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"], arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen", output="screen",
env=dict(os.environ) env=launch_env,
) )
) )
for i in controllers: for i in controllers:
@@ -317,7 +342,6 @@ class ResourceVisualization:
else: else:
ros2_controllers = None ros2_controllers = None
# 创建robot_state_publisher节点
robot_state_publisher = nd( robot_state_publisher = nd(
package='robot_state_publisher', package='robot_state_publisher',
executable='robot_state_publisher', executable='robot_state_publisher',
@@ -327,9 +351,8 @@ class ResourceVisualization:
'robot_description': robot_description, 'robot_description': robot_description,
'use_sim_time': False 'use_sim_time': False
}, },
# kinematics_dict
], ],
env=dict(os.environ) env=launch_env,
) )
@@ -361,7 +384,7 @@ class ResourceVisualization:
executable='move_group', executable='move_group',
output='screen', output='screen',
parameters=moveit_params, parameters=moveit_params,
env=dict(os.environ) env=launch_env,
) )
@@ -379,13 +402,11 @@ class ResourceVisualization:
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"], arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
output='screen', output='screen',
parameters=[ parameters=[
{'robot_description_kinematics': kinematics_dict, {'robot_description_kinematics': kinematics_dict},
},
robot_description_planning, robot_description_planning,
planning_pipelines, planning_pipelines,
], ],
env=dict(os.environ) env=launch_env,
) )
self.launch_description.add_action(rviz_node) self.launch_description.add_action(rviz_node)

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@ from pylabrobot.resources import (
Trash, Trash,
PlateAdapter, PlateAdapter,
TubeRack, TubeRack,
create_homogeneous_resources,
) )
from unilabos.devices.liquid_handling.liquid_handler_abstract import ( from unilabos.devices.liquid_handling.liquid_handler_abstract import (
@@ -55,6 +56,7 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
TransferLiquidReturn, TransferLiquidReturn,
) )
from unilabos.registry.placeholder_type import ResourceSlot from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.resources.itemized_carrier import ItemizedCarrier
from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -85,25 +87,46 @@ class MatrixInfo(TypedDict):
WorkTablets: list[WorkTablets] WorkTablets: list[WorkTablets]
def _get_slot_number(resource) -> Optional[int]:
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
extra = getattr(resource, "unilabos_extra", {}) or {}
site = extra.get("update_resource_site", "")
if site:
digits = "".join(c for c in str(site) if c.isdigit())
return int(digits) if digits else None
loc = getattr(resource, "location", None)
if loc is not None and loc.x is not None and loc.y is not None:
col = round((loc.x - 5) / 137.5)
row = round(3 - (loc.y - 13) / 96)
idx = row * 4 + col
if 0 <= idx < 16:
return idx + 1
return None
class PRCXI9300Deck(Deck): class PRCXI9300Deck(Deck):
"""PRCXI 9300 的专用 Deck 类,继承自 Deck。 """PRCXI 9300 的专用 Deck 类,继承自 Deck。
该类定义了 PRCXI 9300 的工作台布局和槽位信息。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。
""" """
# T1-T16 默认位置 (4列×4行) _9320_SITE_POSITIONS = [((i%4)*137.5+5, (3-int(i/4))*96+13, 0) for i in range(0, 16)]
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8 # 9300: 3列×2行 = 6 slots间距与9320相同X: 138mm, Y: 96mm
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12 _9300_SITE_POSITIONS = [
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16 (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"] _DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module", "trash"]
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, **kwargs):
super().__init__(size_x, size_y, size_z, name) super().__init__( size_x, size_y, size_z, name=name)
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:
@@ -120,6 +143,7 @@ class PRCXI9300Deck(Deck):
self._ordering = collections.OrderedDict( self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites (site["label"], None) for site in self.sites
) )
self.root = self.get_root()
def _get_site_location(self, idx: int) -> Coordinate: def _get_site_location(self, idx: int) -> Coordinate:
pos = self.sites[idx]["position"] pos = self.sites[idx]["position"]
@@ -162,7 +186,10 @@ class PRCXI9300Deck(Deck):
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'") raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
if not reassign and self._get_site_resource(idx) is not None: if not reassign and self._get_site_resource(idx) is not None:
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied") existing = self.root.get_resource(resource.name)
if existing is not resource and existing.parent is not None:
existing.parent.unassign_child_resource(existing)
loc = self._get_site_location(idx) loc = self._get_site_location(idx)
super().assign_child_resource(resource, location=loc, reassign=reassign) super().assign_child_resource(resource, location=loc, reassign=reassign)
@@ -172,6 +199,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)
@@ -544,6 +572,108 @@ class PRCXI9300TubeRack(TubeRack):
return data return data
class PRCXI9300ModuleSite(ItemizedCarrier):
"""
PRCXI 功能模块的基础站点类(加热/冷却/震荡/磁吸等)。
- 继承 ItemizedCarrier可被拖放到 Deck 槽位上
- 顶面有一个 ResourceHolder 站点,可吸附板类资源(叠放)
- content_type 包含 "plateadapter" 以支持适配器叠放
- 支持 material_info 注入
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
material_info: Optional[Dict[str, Any]] = None, **kwargs):
sites = create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(0, 0, 0)],
resource_size_x=size_x,
resource_size_y=size_y,
resource_size_z=size_z,
name_prefix=name,
)[0]
kwargs.pop('layout', None)
sites_in = kwargs.pop('sites', None)
sites_dict = {name: sites}
content_type = [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"plateadapter",
]
if sites_in is not None and isinstance(sites_in, dict):
for site_key, site_value in sites_in.items():
if site_key in sites_dict:
sites_dict[site_key] = site_value
super().__init__(
name, size_x, size_y, size_z,
sites=sites_dict,
num_items_x=kwargs.pop('num_items_x', 1),
num_items_y=kwargs.pop('num_items_y', 1),
num_items_z=kwargs.pop('num_items_z', 1),
content_type=content_type,
**kwargs,
)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
from pylabrobot.resources.resource import Resource
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
def unassign_child_resource(self, resource):
from pylabrobot.resources.resource import Resource
Resource.unassign_child_resource(self, resource)
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
try:
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, 'sites') and self.sites:
sites_info = []
for site in self.sites:
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
sites_info.append({
"__pylabrobot_object__": True,
"class": site.__class__.__name__,
"module": site.__class__.__module__,
"name": getattr(site, 'name', str(site))
})
else:
sites_info.append(site)
data['sites'] = sites_info
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state: Dict[str, Any] = {}
for k, v in self._unilabos_state.items():
if k == "Material" and isinstance(v, dict):
safe_material: Dict[str, Any] = {}
for mk, mv in v.items():
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
safe_material[mk] = mv
safe_state[k] = safe_material
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
def load_state(self, state: Dict[str, Any]) -> None:
super().load_state(state)
if 'sites' in state:
self.sites = [state['sites']]
class PRCXI9300PlateAdapter(PlateAdapter): class PRCXI9300PlateAdapter(PlateAdapter):
""" """
专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。 专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。
@@ -646,20 +776,48 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
step_mode=False, step_mode=False,
matrix_id="", matrix_id="",
is_9320=False, is_9320=False,
start_rail=2,
rail_nums=4,
rail_interval=0,
x_increase = -0.003636,
y_increase = -0.003636,
x_offset = 9.2,
y_offset = -27.98,
deck_z = 300,
deck_y = 400,
rail_width=27.5,
xy_coupling = -0.0045,
): ):
self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
self.deck_y = deck_y
self.deck_z = deck_z
self.x_increase = x_increase
self.y_increase = y_increase
self.x_offset = x_offset
self.y_offset = y_offset
self.xy_coupling = xy_coupling
self.left_2_claw = Coordinate(-130.2, 34, -134)
self.right_2_left = Coordinate(22,-1, 8)
plate_positions = []
tablets_info = [] tablets_info = []
for site_id in range(len(deck.sites)):
child = deck._get_site_resource(site_id) if is_9320 is None:
# 如果放其他类型的物料,是不可以的 is_9320 = getattr(deck, 'model', '9300') == '9320'
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = site_id + 1
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
)
if is_9320: if is_9320:
print("当前设备是9320") print("当前设备是9320")
else:
for site_id in range(len(deck.sites)):
child = deck._get_site_resource(site_id)
# 如果放其他类型的物料,是不可以的
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = site_id + 1
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
)
# 始终初始化 step_mode 属性 # 始终初始化 step_mode 属性
self.step_mode = False self.step_mode = False
if step_mode: if step_mode:
@@ -671,6 +829,190 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320 tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320
) )
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
self._first_transfer_done = False
@staticmethod
def _get_slot_number(resource) -> Optional[int]:
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
return _get_slot_number(resource)
def _match_and_create_matrix(self):
"""首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。"""
backend = self._unilabos_backend
api = backend.api_client
if backend.matrix_id:
return
material_list = api.get_all_materials()
if not material_list:
return
# 按 materialEnum 分组: {enum_value: [material, ...]}
material_dict = {}
material_uuid_map = {}
for m in material_list:
enum_key = m.get("materialEnum")
material_dict.setdefault(enum_key, []).append(m)
if "uuid" in m:
material_uuid_map[m["uuid"]] = m
work_tablets = []
slot_none = [i for i in range(1, 17)]
for child in self.deck.children:
resource = child
number = self._get_slot_number(resource)
if number is None:
continue
# 如果 resource 已有 Material UUID直接使用
if hasattr(resource, "_unilabos_state") and "Material" in getattr(resource, "_unilabos_state", {}):
mat_uuid = resource._unilabos_state["Material"].get("uuid")
if mat_uuid and mat_uuid in material_uuid_map:
work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]})
continue
# 根据 resource 类型推断 materialEnum
# MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6
expected_enum = None
if isinstance(resource, PRCXI9300TipRack) or isinstance(resource, TipRack):
expected_enum = 1 # Tips
elif isinstance(resource, PRCXI9300Trash) or isinstance(resource, Trash):
expected_enum = 6 # WasteBox
elif isinstance(resource, (PRCXI9300Plate, Plate)):
expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate不限定
# 根据 expected_enum 筛选候选耗材列表
if expected_enum is not None:
candidates = material_dict.get(expected_enum, [])
else:
# expected_enum 未确定时,搜索所有耗材
candidates = material_list
# 根据 children 个数和容量匹配最相似的耗材
num_children = len(resource.children)
child_max_volume = None
if resource.children:
first_child = resource.children[0]
if hasattr(first_child, "max_volume") and first_child.max_volume is not None:
child_max_volume = first_child.max_volume
best_material = None
best_score = float("inf")
for material in candidates:
hole_count = (material.get("HoleRow", 0) or 0) * (material.get("HoleColum", 0) or 0)
material_volume = material.get("Volume", 0) or 0
# 孔数差异(高权重优先匹配孔数)
hole_diff = abs(num_children - hole_count)
# 容量差异(归一化)
if child_max_volume is not None and material_volume > 0:
vol_diff = abs(child_max_volume - material_volume) / material_volume
else:
vol_diff = 0
score = hole_diff * 1000 + vol_diff
if score < best_score:
best_score = score
best_material = material
if best_material:
work_tablets.append({"Number": number, "Material": best_material})
slot_none.remove(number)
if not work_tablets:
return
matrix_id = str(uuid.uuid4())
matrix_info = {
"MatrixId": matrix_id,
"MatrixName": matrix_id,
"WorkTablets": work_tablets +
[{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none],
}
res = api.add_WorkTablet_Matrix(matrix_info)
if res.get("Success"):
backend.matrix_id = matrix_id
backend.matrix_info = matrix_info
# 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源)
pipetting_positions = []
plate_positions = []
for child in self.deck.children:
number = self._get_slot_number(child)
if number is None:
continue
pos = self.plr_pos_to_prcxi(child)
plate_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z})
if child.children:
pip_pos = self.plr_pos_to_prcxi(child.children[0], self.left_2_claw)
else:
pip_pos = self.plr_pos_to_prcxi(child, Coordinate(-100, self.left_2_claw.y, self.left_2_claw.z))
half_x = child.get_size_x() / 2 * abs(1 + self.x_increase)
z_wall = child.get_size_z()
pipetting_positions.append({
"Number": number,
"XPos": pip_pos.x,
"YPos": pip_pos.y,
"ZPos": pip_pos.z,
"X_Left": half_x,
"X_Right": half_x,
"ZAgainstTheWall": pip_pos.z - z_wall,
"X2Pos": pip_pos.x + self.right_2_left.x,
"Y2Pos": pip_pos.y + self.right_2_left.y,
"Z2Pos": pip_pos.z + self.right_2_left.z,
"X2_Left": half_x,
"X2_Right": half_x,
"ZAgainstTheWall2": pip_pos.z - z_wall,
})
if pipetting_positions:
api.update_pipetting_position(matrix_id, pipetting_positions)
# 更新 backend 中的 plate_positions
backend.plate_positions = plate_positions
if plate_positions:
api.update_clamp_jaw_position(matrix_id, plate_positions)
print(f"Auto-matched materials and created matrix: {matrix_id}")
else:
raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}")
def plr_pos_to_prcxi(self, resource: Resource, offset: Coordinate = Coordinate(0, 0, 0)):
z_pos = 'c'
if isinstance(resource, Tip):
z_pos = 'b'
resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos)
x = resource_pos.x
y = resource_pos.y
z = resource_pos.z
# 如果z等于0则递归resource.parent的高度并向z加使用get_size_z方法
parent = resource.parent
res_z = resource.location.z
while not isinstance(parent, LiquidHandlerAbstract) and (res_z == 0) and parent is not None:
z += parent.get_size_z()
res_z = parent.location.z
parent = getattr(parent, "parent", None)
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y)
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset
prcxi_z = self.deck_z - z
prcxi_x = min(max(0, prcxi_x+offset.x),self.deck_x)
prcxi_y = min(max(0, prcxi_y+offset.y),self.deck_y)
prcxi_z = min(max(0, prcxi_z+offset.z),self.deck_z)
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
def post_init(self, ros_node: BaseROS2DeviceNode): def post_init(self, ros_node: BaseROS2DeviceNode):
super().post_init(ros_node) super().post_init(ros_node)
@@ -702,8 +1044,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
): ):
self._unilabos_backend.create_protocol(protocol_name) self._unilabos_backend.create_protocol(protocol_name)
async def run_protocol(self): async def run_protocol(self, protocol_id: str = None):
return self._unilabos_backend.run_protocol() return self._unilabos_backend.run_protocol(protocol_id)
async def remove_liquid( async def remove_liquid(
self, self,
@@ -794,6 +1136,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip: bool = False, touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None, liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None,
blow_out_air_volume_before: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False, is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none", mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
@@ -804,7 +1147,12 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
return await super().transfer_liquid( if not self._first_transfer_done:
self._match_and_create_matrix()
self._first_transfer_done = True
if self.step_mode:
await self.create_protocol(f"transfer_liquid{time.time()}")
res = await super().transfer_liquid(
sources, sources,
targets, targets,
tip_racks, tip_racks,
@@ -817,6 +1165,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip=touch_tip, touch_tip=touch_tip,
liquid_height=liquid_height, liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume, blow_out_air_volume=blow_out_air_volume,
blow_out_air_volume_before=blow_out_air_volume_before,
spread=spread, spread=spread,
is_96_well=is_96_well, is_96_well=is_96_well,
mix_stage=mix_stage, mix_stage=mix_stage,
@@ -827,6 +1176,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays=delays, delays=delays,
none_keys=none_keys, none_keys=none_keys,
) )
if self.step_mode:
await self.run_protocol()
return res
async def custom_delay(self, seconds=0, msg=None): async def custom_delay(self, seconds=0, msg=None):
return await super().custom_delay(seconds, msg) return await super().custom_delay(seconds, msg)
@@ -843,9 +1195,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
return await self._unilabos_backend.mix( return await self._unilabos_backend.mix(
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
) )
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
@@ -858,10 +1211,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None, offsets: Optional[List[Coordinate]] = None,
**backend_kwargs, **backend_kwargs,
): ):
if self.step_mode:
await self.create_protocol(f"单点动作{time.time()}")
await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
await self.run_protocol()
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
async def aspirate( async def aspirate(
@@ -1013,6 +1362,24 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.debug = debug self.debug = debug
self.axis = "Left" self.axis = "Left"
@staticmethod
def _deck_plate_slot_no(plate, deck) -> int:
"""台面板位槽号116与 PRCXI9300Handler._get_slot_number 一致;无法解析时退回 deck 子项顺序 +1。"""
sn = PRCXI9300Handler._get_slot_number(plate)
if sn is not None:
return sn
return deck.children.index(plate) + 1
@staticmethod
def _resource_num_items_y(resource) -> int:
"""板/TipRack 等在 Y 向孔位数;无 ``num_items_y`` 或非正数时返回 1。"""
ny = getattr(resource, "num_items_y", None)
try:
n = int(ny) if ny is not None else 1
except (TypeError, ValueError):
n = 1
return n if n >= 1 else 1
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
step = self.api_client.shaker_action( step = self.api_client.shaker_action(
time=time, time=time,
@@ -1064,26 +1431,40 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.protocol_name = protocol_name self.protocol_name = protocol_name
self.steps_todo_list = [] self.steps_todo_list = []
def run_protocol(self): if not len(self.matrix_id):
self.matrix_id = str(uuid.uuid4())
material_list = self.api_client.get_all_materials()
material_dict = {material["uuid"]: material for material in material_list}
work_tablets = []
for num, material_id in self.tablets_info.items():
work_tablets.append({
"Number": num,
"Material": material_dict[material_id]
})
self.matrix_info = {
"MatrixId": self.matrix_id,
"MatrixName": self.matrix_id,
"WorkTablets": work_tablets,
}
# print(json.dumps(self.matrix_info, indent=2))
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
if not res["Success"]:
self.matrix_id = ""
raise AssertionError(f"Failed to create matrix: {res.get('Message', 'Unknown error')}")
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
def run_protocol(self, protocol_id: str = None):
assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first." assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first."
run_time = time.time() run_time = time.time()
self.matrix_info = MatrixInfo( if protocol_id == "" or protocol_id is None:
MatrixId=f"{int(run_time)}",
MatrixName=f"protocol_{run_time}",
MatrixCount=len(self.tablets_info),
WorkTablets=self.tablets_info,
)
# print(json.dumps(self.matrix_info, indent=2))
if not len(self.matrix_id):
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
assert res["Success"], f"Failed to create matrix: {res.get('Message', 'Unknown error')}"
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
solution_id = self.api_client.add_solution( solution_id = self.api_client.add_solution(
f"protocol_{run_time}", self.matrix_info["MatrixId"], self.steps_todo_list f"protocol_{run_time}", self.matrix_id, self.steps_todo_list
) )
else: else:
print(f"PRCXI9300Backend using predefined worktable {self.matrix_id}, skipping matrix creation.") solution_id = protocol_id
solution_id = self.api_client.add_solution(f"protocol_{run_time}", self.matrix_id, self.steps_todo_list)
print(f"PRCXI9300Backend created solution with ID: {solution_id}") print(f"PRCXI9300Backend created solution with ID: {solution_id}")
self.api_client.load_solution(solution_id) self.api_client.load_solution(solution_id)
print(json.dumps(self.steps_todo_list, indent=2)) print(json.dumps(self.steps_todo_list, indent=2))
@@ -1126,6 +1507,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
else: else:
await asyncio.sleep(1) await asyncio.sleep(1)
print("PRCXI9300 reset successfully.") print("PRCXI9300 reset successfully.")
# self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions)
except ConnectionRefusedError as e: except ConnectionRefusedError as e:
raise RuntimeError( raise RuntimeError(
f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. " f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. "
@@ -1149,33 +1533,33 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right" axis = "Right"
else: else:
raise ValueError("Invalid use channels: " + str(_use_channels)) raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = [] plate_slots = []
for op in ops: for op in ops:
plate = op.resource.parent plate = op.resource.parent
deck = plate.parent.parent deck = plate.parent
plate_index = deck.children.index(plate.parent) plate_slots.append(self._deck_plate_slot_no(plate, deck))
# print(f"Plate index: {plate_index}, Plate name: {plate.name}")
# print(f"Number of children in deck: {len(deck.children)}")
plate_indexes.append(plate_index) if len(set(plate_slots)) != 1:
raise ValueError("All pickups must be from the same plate (slot). Found different slots: " + str(plate_slots))
if len(set(plate_indexes)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes))
_rack = ops[0].resource.parent
ny = self._resource_num_items_y(_rack)
tip_columns = [] tip_columns = []
for op in ops: for op in ops:
tipspot = op.resource tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All pickups must use tip racks with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot) tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8) tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1: if len(set(tip_columns)) != 1:
raise ValueError( raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns) "All pickups must be from the same tip column. Found different columns: " + str(tip_columns)
) )
PlateNo = plate_indexes[0] + 1 PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1 hole_row = 1
if self._num_channels == 1: if self.num_channels != 8:
hole_row = tipspot_index % 8 + 1 hole_row = tipspot_index % ny + 1
step = self.api_client.Load( step = self.api_client.Load(
axis=axis, axis=axis,
@@ -1186,8 +1570,8 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5", hole_numbers=f"{(hole_col - 1) * ny + hole_row}" if self._num_channels != 8 else "1,2,3,4,5",
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -1205,8 +1589,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
raise ValueError("Invalid use channels: " + str(_use_channels)) raise ValueError("Invalid use channels: " + str(_use_channels))
# 检查trash # # 检查trash #
if ops[0].resource.name == "trash": if ops[0].resource.name == "trash":
_plate = ops[0].resource
PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1 _deck = _plate.parent
PlateNo = self._deck_plate_slot_no(_plate, _deck)
step = self.api_client.UnLoad( step = self.api_client.UnLoad(
axis=axis, axis=axis,
@@ -1224,32 +1609,35 @@ class PRCXI9300Backend(LiquidHandlerBackend):
return return
# print(ops[0].resource.parent.children.index(ops[0].resource)) # print(ops[0].resource.parent.children.index(ops[0].resource))
plate_indexes = [] plate_slots = []
for op in ops: for op in ops:
plate = op.resource.parent plate = op.resource.parent
deck = plate.parent.parent deck = plate.parent
plate_index = deck.children.index(plate.parent) plate_slots.append(self._deck_plate_slot_no(plate, deck))
plate_indexes.append(plate_index) if len(set(plate_slots)) != 1:
if len(set(plate_indexes)) != 1:
raise ValueError( raise ValueError(
"All drop_tips must be from the same plate. Found different plates: " + str(plate_indexes) "All drop_tips must be from the same plate (slot). Found different slots: " + str(plate_slots)
) )
_rack = ops[0].resource.parent
ny = self._resource_num_items_y(_rack)
tip_columns = [] tip_columns = []
for op in ops: for op in ops:
tipspot = op.resource tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All drop_tips must use tip racks with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot) tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8) tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1: if len(set(tip_columns)) != 1:
raise ValueError( raise ValueError(
"All drop_tips must be from the same tip column. Found different columns: " + str(tip_columns) "All drop_tips must be from the same tip column. Found different columns: " + str(tip_columns)
) )
PlateNo = plate_indexes[0] + 1 PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1
if self.channel_num == 1: if self.num_channels != 8:
hole_row = tipspot_index % 8 + 1 hole_row = tipspot_index % ny + 1
step = self.api_client.UnLoad( step = self.api_client.UnLoad(
axis=axis, axis=axis,
@@ -1260,7 +1648,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -1274,34 +1662,43 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
"""Mix liquid in the specified resources.""" """Mix liquid in the specified resources."""
if use_channels == [0]:
plate_indexes = [] axis = "Left"
elif use_channels == [1]:
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(use_channels))
plate_slots = []
for op in targets: for op in targets:
deck = op.parent.parent.parent deck = op.parent.parent.parent
plate = op.parent plate = op.parent
plate_index = deck.children.index(plate.parent) plate_slots.append(self._deck_plate_slot_no(plate, deck))
plate_indexes.append(plate_index)
if len(set(plate_indexes)) != 1: if len(set(plate_slots)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes)) raise ValueError("All mix targets must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = targets[0].parent
ny = self._resource_num_items_y(_plate0)
tip_columns = [] tip_columns = []
for op in targets: for op in targets:
if self._resource_num_items_y(op.parent) != ny:
raise ValueError("All mix targets must be on plates with the same num_items_y")
tipspot_index = op.parent.children.index(op) tipspot_index = op.parent.children.index(op)
tip_columns.append(tipspot_index // 8) tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1: if len(set(tip_columns)) != 1:
raise ValueError( raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns) "All mix targets must be in the same column group. Found different columns: " + str(tip_columns)
) )
PlateNo = plate_indexes[0] + 1 PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1 hole_row = 1
if self.num_channels == 1: if self.num_channels != 8:
hole_row = tipspot_index % 8 + 1 hole_row = tipspot_index % ny + 1
assert mix_time > 0 assert mix_time > 0
step = self.api_client.Blending( step = self.api_client.Blending(
@@ -1312,13 +1709,15 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col, hole_col=hole_col,
blending_times=mix_time, blending_times=mix_time,
balance_height=0, balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None): async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None):
"""Aspirate liquid from the specified resources.""" """Aspirate liquid from the specified resources."""
if ops[0].blow_out_air_volume and ops[0].volume == 0:
return
if hasattr(use_channels, "tolist"): if hasattr(use_channels, "tolist"):
_use_channels = use_channels.tolist() _use_channels = use_channels.tolist()
else: else:
@@ -1329,36 +1728,39 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right" axis = "Right"
else: else:
raise ValueError("Invalid use channels: " + str(_use_channels)) raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = [] plate_slots = []
for op in ops: for op in ops:
plate = op.resource.parent plate = op.resource.parent
deck = plate.parent.parent deck = plate.parent
plate_index = deck.children.index(plate.parent) plate_slots.append(self._deck_plate_slot_no(plate, deck))
plate_indexes.append(plate_index)
if len(set(plate_indexes)) != 1: if len(set(plate_slots)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes)) raise ValueError("All aspirate must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = ops[0].resource.parent
ny = self._resource_num_items_y(_plate0)
tip_columns = [] tip_columns = []
for op in ops: for op in ops:
tipspot = op.resource tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All aspirate wells must be on plates with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot) tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8) tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1: if len(set(tip_columns)) != 1:
raise ValueError( raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns) "All aspirate must be from the same tip column. Found different columns: " + str(tip_columns)
) )
volumes = [op.volume for op in ops] volumes = [op.volume for op in ops]
if len(set(volumes)) != 1: if len(set(volumes)) != 1:
raise ValueError("All aspirate volumes must be the same. Found different volumes: " + str(volumes)) raise ValueError("All aspirate volumes must be the same. Found different volumes: " + str(volumes))
PlateNo = plate_indexes[0] + 1 PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1 hole_row = 1
if self.num_channels == 1: if self.num_channels != 8:
hole_row = tipspot_index % 8 + 1 hole_row = tipspot_index % ny + 1
step = self.api_client.Imbibing( step = self.api_client.Imbibing(
axis=axis, axis=axis,
@@ -1369,7 +1771,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -1386,21 +1788,24 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right" axis = "Right"
else: else:
raise ValueError("Invalid use channels: " + str(_use_channels)) raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = [] plate_slots = []
for op in ops: for op in ops:
plate = op.resource.parent plate = op.resource.parent
deck = plate.parent.parent deck = plate.parent
plate_index = deck.children.index(plate.parent) plate_slots.append(self._deck_plate_slot_no(plate, deck))
plate_indexes.append(plate_index)
if len(set(plate_indexes)) != 1: if len(set(plate_slots)) != 1:
raise ValueError("All dispense must be from the same plate. Found different plates: " + str(plate_indexes)) raise ValueError("All dispense must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = ops[0].resource.parent
ny = self._resource_num_items_y(_plate0)
tip_columns = [] tip_columns = []
for op in ops: for op in ops:
tipspot = op.resource tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All dispense wells must be on plates with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot) tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8) tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1: if len(set(tip_columns)) != 1:
raise ValueError( raise ValueError(
@@ -1411,12 +1816,12 @@ class PRCXI9300Backend(LiquidHandlerBackend):
if len(set(volumes)) != 1: if len(set(volumes)) != 1:
raise ValueError("All dispense volumes must be the same. Found different volumes: " + str(volumes)) raise ValueError("All dispense volumes must be the same. Found different volumes: " + str(volumes))
PlateNo = plate_indexes[0] + 1 PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1 hole_row = 1
if self.num_channels == 1: if self.num_channels != 8:
hole_row = tipspot_index % 8 + 1 hole_row = tipspot_index % ny + 1
step = self.api_client.Tapping( step = self.api_client.Tapping(
axis=axis, axis=axis,
@@ -1427,7 +1832,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -1623,6 +2028,21 @@ class PRCXI9300Api:
"""GetWorkTabletMatrixById""" """GetWorkTabletMatrixById"""
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id]) return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]):
position_params = {
"MatrixId": target_matrix_id,
"WorkTablets": plate_positions
}
return self.call("IMatrix", "UpdateClampJawPosition", [position_params])
def update_pipetting_position(self, target_matrix_id: str, pipetting_positions: List[Dict[str, Any]]):
"""UpdatePipettingPosition - 更新移液位置"""
position_params = {
"MatrixId": target_matrix_id,
"WorkTablets": pipetting_positions
}
return self.call("IMatrix", "UpdatePipettingPosition", [position_params])
def add_WorkTablet_Matrix(self, matrix: MatrixInfo): def add_WorkTablet_Matrix(self, matrix: MatrixInfo):
return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix]) return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix])
@@ -1879,8 +2299,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
@@ -1977,13 +2409,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
@@ -2001,15 +2435,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}"
) )
# 依次分配位置 # 依次分配位置

View File

@@ -1,4 +1,4 @@
from typing import Optional from typing import Any, Callable, Dict, List, Optional, Tuple
from pylabrobot.resources import Tube, Coordinate from pylabrobot.resources import Tube, Coordinate
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
from pylabrobot.resources.tip import Tip, TipCreator from pylabrobot.resources.tip import Tip, TipCreator
@@ -838,4 +838,102 @@ def PRCXI_30mm_Adapter(name: str) -> PRCXI9300PlateAdapter:
"Name": "30mm适配器", "Name": "30mm适配器",
"SupplyType": 2 "SupplyType": 2
} }
) )
# ---------------------------------------------------------------------------
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
# ---------------------------------------------------------------------------
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
probe = "__unilab_template_probe__"
if factory.__name__ == "PRCXI_trash":
return factory()
return factory(probe)
def _first_child_capacity_for_match(resource: Any) -> float:
"""Well max_volume 或 Tip 的 maximal_volume用于与设备端 Volume 类似的打分。"""
ch = getattr(resource, "children", None) or []
if not ch:
return 0.0
c0 = ch[0]
mv = getattr(c0, "max_volume", None)
if mv is not None:
return float(mv)
tip = getattr(c0, "tip", None)
if tip is not None:
mv2 = getattr(tip, "maximal_volume", None)
if mv2 is not None:
return float(mv2)
return 0.0
# (factory, kind) — 不含各类 Adapter避免与真实板子误匹配
PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [
(PRCXI_BioER_96_wellplate, "plate"),
(PRCXI_nest_1_troughplate, "plate"),
(PRCXI_BioRad_384_wellplate, "plate"),
(PRCXI_AGenBio_4_troughplate, "plate"),
(PRCXI_nest_12_troughplate, "plate"),
(PRCXI_CellTreat_96_wellplate, "plate"),
(PRCXI_10ul_eTips, "tip_rack"),
(PRCXI_300ul_Tips, "tip_rack"),
(PRCXI_PCR_Plate_200uL_nonskirted, "plate"),
(PRCXI_PCR_Plate_200uL_semiskirted, "plate"),
(PRCXI_PCR_Plate_200uL_skirted, "plate"),
(PRCXI_trash, "trash"),
(PRCXI_96_DeepWell, "plate"),
(PRCXI_EP_Adapter, "tube_rack"),
(PRCXI_1250uL_Tips, "tip_rack"),
(PRCXI_10uL_Tips, "tip_rack"),
(PRCXI_1000uL_Tips, "tip_rack"),
(PRCXI_200uL_Tips, "tip_rack"),
(PRCXI_48_DeepWell, "plate"),
]
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
global _PRCXI_TEMPLATE_SPECS_CACHE
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
return _PRCXI_TEMPLATE_SPECS_CACHE
out: List[Dict[str, Any]] = []
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
try:
r = _probe_prcxi_resource(factory)
except Exception:
continue
nx = int(getattr(r, "num_items_x", None) or 0)
ny = int(getattr(r, "num_items_y", None) or 0)
nchild = len(getattr(r, "children", []) or [])
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
hole_row = ny if nx > 0 and ny > 0 else 0
hole_col = nx if nx > 0 and ny > 0 else 0
mi = getattr(r, "material_info", None) or {}
vol = _first_child_capacity_for_match(r)
menum = mi.get("materialEnum")
if menum is None and kind == "tip_rack":
menum = 1
elif menum is None and kind == "trash":
menum = 6
out.append(
{
"class_name": factory.__name__,
"kind": kind,
"materialEnum": menum,
"HoleRow": hole_row,
"HoleColum": hole_col,
"Volume": vol,
"hole_count": hole_count,
"material_uuid": mi.get("uuid"),
"material_code": mi.get("Code"),
}
)
_PRCXI_TEMPLATE_SPECS_CACHE = out
return out

View File

@@ -0,0 +1,150 @@
from typing import Any, Dict, Optional
from .prcxi import PRCXI9300ModuleSite
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
"""
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
设计目标:
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier
- 顶面存在一个站点site可吸附标准板类资源plate / tip_rack / tube_rack 等)。
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
module_type: Optional[str] = None,
category: str = "module",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
material_info=material_info,
model=model,
category=category,
**kwargs,
)
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
self.module_type = module_type or "generic"
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
self._unilabos_state = {}
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
if material_info is not None and "Material" not in self._unilabos_state:
self._unilabos_state["Material"] = material_info
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
self._unilabos_state.setdefault("category", category)
self._unilabos_state["module_type"] = module_type
# ============================================================================
# 具体功能模块定义
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
# ============================================================================
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="heating",
model="PRCXI_Heating_Module",
material_info={
"uuid": "TODO-HEATING-MODULE-UUID",
"Code": "HEAT-MOD",
"Name": "PRCXI 加热模块",
"SupplyType": 3,
},
)
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
"""金属冷却模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="metal_cooling",
model="PRCXI_MetalCooling_Module",
material_info={
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
"Code": "METAL-COOL-MOD",
"Name": "PRCXI 金属冷却模块",
"SupplyType": 3,
},
)
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=50.0,
module_type="shaking",
model="PRCXI_Shaking_Module",
material_info={
"uuid": "TODO-SHAKING-MODULE-UUID",
"Code": "SHAKE-MOD",
"Name": "PRCXI 震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=55.0,
module_type="heating_shaking",
model="PRCXI_Heating_Shaking_Module",
material_info={
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
"Code": "HEAT-SHAKE-MOD",
"Name": "PRCXI 加热震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
"""磁吸模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
module_type="magnetic",
model="PRCXI_Magnetic_Module",
material_info={
"uuid": "TODO-MAGNETIC-MODULE-UUID",
"Code": "MAG-MOD",
"Name": "PRCXI 磁吸模块",
"SupplyType": 3,
},
)

View File

@@ -59,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.total_height = total_height self.total_height = total_height
self.joint_config = kwargs.get("joint_config", None) self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher") self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
self.simulate_rviz = kwargs.get("simulate_rviz", False)
if not rclpy.ok(): if not rclpy.ok():
rclpy.init() rclpy.init()
self.joint_state_publisher = None self.joint_state_publisher = None
@@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.joint_state_publisher = LiquidHandlerJointPublisher( self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config, joint_config=self.joint_config,
lh_device_id=self.lh_device_id, lh_device_id=self.lh_device_id,
simulate_rviz=True) simulate_rviz=self.simulate_rviz)
# 启动ROS executor # 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor() self.executor = rclpy.executors.MultiThreadedExecutor()

View File

@@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node):
while self.resource_action is None: while self.resource_action is None:
self.resource_action = self.check_tf_update_actions() self.resource_action = self.check_tf_update_actions()
time.sleep(1) time.sleep(1)
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action) self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0): while not self.resource_action_client.wait_for_server(timeout_sec=1.0):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
PRCXI_Heating_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Module
type: pylabrobot
description: '加热模块 (Code: HEAT-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_MetalCooling_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_MetalCooling_Module
type: pylabrobot
description: '金属冷却模块 (Code: METAL-COOL-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Shaking_Module
type: pylabrobot
description: '震荡模块 (Code: SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Heating_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Shaking_Module
type: pylabrobot
description: '加热震荡模块 (Code: HEAT-SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Magnetic_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Magnetic_Module
type: pylabrobot
description: '磁吸模块 (Code: MAG-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -9,6 +9,9 @@ def register():
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300ModuleSite
# noinspection PyUnresolvedReferences
from unilabos.devices.liquid_handling.prcxi.prcxi_modules import PRCXI9300FunctionalModule
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from unilabos.devices.workstation.workstation_base import WorkStationContainer from unilabos.devices.workstation.workstation_base import WorkStationContainer

View File

@@ -459,6 +459,8 @@ class ResourceTreeSet(object):
"reagent_bottle": "reagent_bottle", "reagent_bottle": "reagent_bottle",
"flask": "flask", "flask": "flask",
"beaker": "beaker", "beaker": "beaker",
"module": "module",
"carrier": "carrier",
} }
if source in replace_info: if source in replace_info:
return replace_info[source] return replace_info[source]
@@ -596,6 +598,8 @@ class ResourceTreeSet(object):
"deck": "Deck", "deck": "Deck",
"container": "RegularContainer", "container": "RegularContainer",
"tip_spot": "TipSpot", "tip_spot": "TipSpot",
"module": "PRCXI9300ModuleSite",
"carrier": "ItemizedCarrier",
} }
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
@@ -958,6 +962,17 @@ class ResourceTreeSet(object):
f"从远端同步了 {added_count} 个物料子树" f"从远端同步了 {added_count} 个物料子树"
) )
else: else:
# 二级是物料
if remote_child_name not in local_children_map:
# 本地不存在该物料,直接引入
remote_child.res_content.parent = local_device.res_content
local_device.children.append(remote_child)
local_children_map[remote_child_name] = remote_child
logger.info(
f"物料 '{remote_root_id}/{remote_child_name}': "
f"从远端同步了整个子树"
)
continue
# 二级物料已存在,比较三级子节点是否缺失 # 二级物料已存在,比较三级子节点是否缺失
local_material = local_children_map[remote_child_name] local_material = local_children_map[remote_child_name]
local_material_children_map = {child.res_content.name: child for child in local_material_children_map = {child.res_content.name: child for child in

View File

@@ -51,6 +51,7 @@ def main(
bridges: List[Any] = [], bridges: List[Any] = [],
visual: str = "disable", visual: str = "disable",
resources_mesh_config: dict = {}, resources_mesh_config: dict = {},
resources_mesh_resource_list: list = [],
rclpy_init_args: List[str] = ["--log-level", "debug"], rclpy_init_args: List[str] = ["--log-level", "debug"],
discovery_interval: float = 15.0, discovery_interval: float = 15.0,
) -> None: ) -> None:
@@ -77,12 +78,12 @@ def main(
if visual != "disable": if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件 # 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
resources_list = ( if resources_mesh_resource_list:
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resources_list = resources_mesh_resource_list
if resources_config else:
else [] # fallback: 从 ResourceTreeSet 获取
) resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
resource_mesh_manager = ResourceMeshManager( resource_mesh_manager = ResourceMeshManager(
resources_mesh_config, resources_mesh_config,
resources_list, resources_list,
@@ -90,7 +91,7 @@ def main(
device_id="resource_mesh_manager", device_id="resource_mesh_manager",
device_uuid=str(uuid.uuid4()), device_uuid=str(uuid.uuid4()),
) )
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker)
# lh_joint_pub = LiquidHandlerJointPublisher( # lh_joint_pub = LiquidHandlerJointPublisher(
# resources_config=resources_list, resource_tracker=host_node.resource_tracker # resources_config=resources_list, resource_tracker=host_node.resource_tracker
# ) # )
@@ -114,6 +115,7 @@ def slave(
bridges: List[Any] = [], bridges: List[Any] = [],
visual: str = "disable", visual: str = "disable",
resources_mesh_config: dict = {}, resources_mesh_config: dict = {},
resources_mesh_resource_list: list = [],
rclpy_init_args: List[str] = ["--log-level", "debug"], rclpy_init_args: List[str] = ["--log-level", "debug"],
) -> None: ) -> None:
"""从节点函数""" """从节点函数"""
@@ -208,12 +210,12 @@ def slave(
if visual != "disable": if visual != "disable":
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
# 将 ResourceTreeSet 转换为 list 用于 visual 组件 # 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
resources_list = ( if resources_mesh_resource_list:
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resources_list = resources_mesh_resource_list
if resources_config else:
else [] # fallback: 从 ResourceTreeSet 获取
) resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
resource_mesh_manager = ResourceMeshManager( resource_mesh_manager = ResourceMeshManager(
resources_mesh_config, resources_mesh_config,
resources_list, resources_list,

View File

@@ -22,6 +22,7 @@ from unilabos_msgs.srv import (
SerialCommand, SerialCommand,
) # type: ignore ) # type: ignore
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos_msgs.action import SendCmd
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device, action, NodeType from unilabos.registry.decorators import device, action, NodeType
@@ -313,9 +314,15 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group, callback_group=self.callback_group,
), ),
} # 用来存储多个ActionClient实例 } # 用来存储多个ActionClient实例
self._add_resource_mesh_client = ActionClient(
self,
SendCmd,
"/devices/resource_mesh_manager/add_resource_mesh",
callback_group=self.callback_group,
)
self._action_value_mappings: Dict[str, Dict] = { self._action_value_mappings: Dict[str, Dict] = {
device_id: self._action_value_mappings device_id: self._action_value_mappings
} # device_id -> action_value_mappings(本地+远程设备统一存储) } # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings) self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -1131,6 +1138,27 @@ class HostNode(BaseROS2DeviceNode):
), ),
} }
def _notify_resource_mesh_add(self, resource_tree_set: ResourceTreeSet):
"""通知 ResourceMeshManager 添加资源的 mesh 可视化"""
if not self._add_resource_mesh_client.server_is_ready():
self.lab_logger().debug("[Host Node] ResourceMeshManager 未就绪,跳过 mesh 添加通知")
return
resource_configs = []
for node in resource_tree_set.all_nodes:
res_dict = node.res_content.model_dump(by_alias=True)
if res_dict.get("type") == "device":
continue
resource_configs.append(res_dict)
if not resource_configs:
return
goal_msg = SendCmd.Goal()
goal_msg.command = json.dumps({"resources": resource_configs})
self._add_resource_mesh_client.send_goal_async(goal_msg)
self.lab_logger().info(f"[Host Node] 已发送 {len(resource_configs)} 个资源 mesh 添加请求")
async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
resource_tree_set = ResourceTreeSet.load(data["data"]) resource_tree_set = ResourceTreeSet.load(data["data"])
mount_uuid = data["mount_uuid"] mount_uuid = data["mount_uuid"]
@@ -1171,6 +1199,12 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping) if success else "FAILED" response.response = json.dumps(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
if success:
try:
self._notify_resource_mesh_add(resource_tree_set)
except Exception as e:
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 添加 mesh 失败: {e}")
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
uuid_list: List[str] = data["data"] uuid_list: List[str] = data["data"]
with_children: bool = data["with_children"] with_children: bool = data["with_children"]
@@ -1222,6 +1256,12 @@ class HostNode(BaseROS2DeviceNode):
response.response = json.dumps(uuid_mapping) response.response = json.dumps(uuid_mapping)
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
if success:
try:
self._notify_resource_mesh_add(new_tree_set)
except Exception as e:
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 更新 mesh 失败: {e}")
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response): async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
""" """
子节点通知Host物料树更新 子节点通知Host物料树更新

View File

@@ -23,17 +23,32 @@ from unilabos_msgs.action import SendCmd
from rclpy.action.server import ServerGoalHandle from rclpy.action.server import ServerGoalHandle
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker
from unilabos.resources.graphio import initialize_resources from unilabos.resources.graphio import initialize_resources
from unilabos.resources.resource_tracker import EXTRA_CLASS
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
class ResourceMeshManager(BaseROS2DeviceNode): class ResourceMeshManager(BaseROS2DeviceNode):
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", registry_name: str = "", rate=50, **kwargs): def __init__(
self,
resource_model: Optional[dict] = None,
resource_config: Optional[list] = None,
resource_tracker=None,
device_id: str = "resource_mesh_manager",
registry_name: str = "",
rate=50,
**kwargs,
):
"""初始化资源网格管理器节点 """初始化资源网格管理器节点
Args: Args:
resource_model (dict): 资源模型字典,包含资源的3D模型信息 resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建)
resource_config (dict): 资源配置字典,包含资源的配置信息 resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载)
device_id (str): 节点名称 resource_tracker: 资源追踪器
device_id: 节点名称
rate: TF 发布频率
""" """
if resource_tracker is None:
resource_tracker = DeviceNodeResourceTracker()
super().__init__( super().__init__(
driver_instance=self, driver_instance=self,
device_id=device_id, device_id=device_id,
@@ -42,12 +57,14 @@ class ResourceMeshManager(BaseROS2DeviceNode):
action_value_mappings={}, action_value_mappings={},
hardware_interface={}, hardware_interface={},
print_publish=False, print_publish=False,
resource_tracker=resource_tracker, resource_tracker=resource_tracker,
device_uuid=kwargs.get("uuid", str(uuid.uuid4())), device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
) )
self.resource_model = resource_model self.resource_model = resource_model if resource_model is not None else {}
self.resource_config_dict = {item['uuid']: item for item in resource_config} self.resource_config_dict = (
{item['uuid']: item for item in resource_config} if resource_config else {}
)
self.move_group_ready = False self.move_group_ready = False
self.resource_tf_dict = {} self.resource_tf_dict = {}
self.tf_broadcaster = TransformBroadcaster(self) self.tf_broadcaster = TransformBroadcaster(self)
@@ -63,7 +80,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute() self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute()
self.msg_type = 'resource_status' self.msg_type = 'resource_status'
self.resource_status_dict = {} self.resource_status_dict = {}
callback_group = ReentrantCallbackGroup() callback_group = ReentrantCallbackGroup()
self._get_planning_scene_service = self.create_client( self._get_planning_scene_service = self.create_client(
srv_type=GetPlanningScene, srv_type=GetPlanningScene,
@@ -76,8 +93,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
), ),
callback_group=callback_group, callback_group=callback_group,
) )
# Create a service for applying the planning scene
self._apply_planning_scene_service = self.create_client( self._apply_planning_scene_service = self.create_client(
srv_type=ApplyPlanningScene, srv_type=ApplyPlanningScene,
srv_name="/apply_planning_scene", srv_name="/apply_planning_scene",
@@ -103,27 +119,36 @@ class ResourceMeshManager(BaseROS2DeviceNode):
AttachedCollisionObject, "/attached_collision_object", 0 AttachedCollisionObject, "/attached_collision_object", 0
) )
# 创建一个Action Server用于修改resource_tf_dict
self._action_server = ActionServer( self._action_server = ActionServer(
self, self,
SendCmd, SendCmd,
f"tf_update", f"tf_update",
self.tf_update, self.tf_update,
callback_group=callback_group callback_group=callback_group,
) )
# 创建一个Action Server用于添加新的资源模型与resource_tf_dict
self._add_resource_mesh_action_server = ActionServer( self._add_resource_mesh_action_server = ActionServer(
self, self,
SendCmd, SendCmd,
f"add_resource_mesh", f"add_resource_mesh",
self.add_resource_mesh_callback, self.add_resource_mesh_callback,
callback_group=callback_group callback_group=callback_group,
) )
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict) self._reload_resource_mesh_action_server = ActionServer(
self.create_timer(1/self.rate, self.publish_resource_tf) self,
self.create_timer(1/self.rate, self.check_resource_pose_changes) SendCmd,
f"reload_resource_mesh",
self._reload_resource_mesh_callback,
callback_group=callback_group,
)
if self.resource_config_dict:
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
else:
self.get_logger().info("未提供 resource_config将通过 ActionServer 或 load_from_resource_tree 加载")
self.create_timer(1 / self.rate, self.publish_resource_tf)
self.create_timer(1 / self.rate, self.check_resource_pose_changes)
def check_move_group_ready(self): def check_move_group_ready(self):
"""检查move_group节点是否已初始化完成""" """检查move_group节点是否已初始化完成"""
@@ -140,56 +165,156 @@ class ResourceMeshManager(BaseROS2DeviceNode):
self.add_resource_collision_meshes(self.resource_tf_dict) self.add_resource_collision_meshes(self.resource_tf_dict)
def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle): def _build_resource_model_for_config(self, resource_config_dict: dict):
"""从 registry 中为给定的资源配置自动构建 resource_modelmesh 信息)"""
registry = lab_registry
for _uuid, res_cfg in resource_config_dict.items():
resource_id = res_cfg.get('id', '')
resource_class = res_cfg.get('class', '')
if not resource_class:
continue
if resource_class not in registry.resource_type_registry:
continue
reg_entry = registry.resource_type_registry[resource_class]
if 'model' not in reg_entry:
continue
model_config = reg_entry['model']
if model_config.get('type') != 'resource':
continue
if resource_id in self.resource_model:
continue
self.resource_model[resource_id] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
'mesh_tf': model_config['mesh_tf'],
}
if model_config.get('children_mesh') is not None:
self.resource_model[f"{resource_id}_"] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
'mesh_tf': model_config['children_mesh_tf'],
}
def load_from_resource_tree(self):
"""从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF"""
new_config_dict: dict = {}
def _collect_plr_resource(res, parent_uuid: Optional[str] = None):
res_uuid = getattr(res, 'unilabos_uuid', None)
if not res_uuid:
res_uuid = str(uuid.uuid4())
extra = getattr(res, 'unilabos_extra', {}) or {}
resource_class = extra.get(EXTRA_CLASS, '')
location = getattr(res, 'location', None)
pos_x = float(location.x) if location else 0.0
pos_y = float(location.y) if location else 0.0
pos_z = float(location.z) if location else 0.0
rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0})
new_config_dict[res_uuid] = {
'id': res.name,
'uuid': res_uuid,
'class': resource_class,
'parent_uuid': parent_uuid,
'pose': {
'position': {'x': pos_x, 'y': pos_y, 'z': pos_z},
'rotation': rotation,
},
}
for child in getattr(res, 'children', []) or []:
_collect_plr_resource(child, res_uuid)
for resource in self.resource_tracker.resources:
root_parent_uuid = None
plr_parent = getattr(resource, 'parent', None)
if plr_parent is not None:
root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None)
_collect_plr_resource(resource, root_parent_uuid)
if not new_config_dict:
self.get_logger().warning("resource_tracker 中没有找到任何资源")
return
self.resource_config_dict = {**self.resource_config_dict, **new_config_dict}
self._build_resource_model_for_config(new_config_dict)
tf_dict = self.resource_mesh_setup(new_config_dict)
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
self.publish_resource_tf()
if self.move_group_ready:
self.add_resource_collision_meshes(tf_dict)
self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源")
def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
"""ActionServer 回调:重新从资源树加载所有 mesh"""
try:
self.load_from_resource_tree()
except Exception as e:
self.get_logger().error(f"重新加载资源失败: {e}")
goal_handle.abort()
return SendCmd.Result(success=False)
goal_handle.succeed()
return SendCmd.Result(success=True)
def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
tf_update_msg = goal_handle.request tf_update_msg = goal_handle.request
try: try:
self.add_resource_mesh(tf_update_msg.command) parsed = json.loads(tf_update_msg.command.replace("'", '"'))
if 'resources' in parsed:
for res_config in parsed['resources']:
self.add_resource_mesh(json.dumps(res_config))
else:
self.add_resource_mesh(tf_update_msg.command)
except Exception as e: except Exception as e:
self.get_logger().error(f"添加资源失败: {e}") self.get_logger().error(f"添加资源失败: {e}")
goal_handle.abort() goal_handle.abort()
return SendCmd.Result(success=False) return SendCmd.Result(success=False)
goal_handle.succeed() goal_handle.succeed()
return SendCmd.Result(success=True) return SendCmd.Result(success=True)
def add_resource_mesh(self,resource_config_str:str):
"""刷新资源配置"""
def add_resource_mesh(self, resource_config_str: str):
"""添加单个资源的 mesh 配置"""
registry = lab_registry registry = lab_registry
resource_config = json.loads(resource_config_str.replace("'",'"')) resource_config = json.loads(resource_config_str.replace("'", '"'))
if resource_config['id'] in self.resource_config_dict: if resource_config['id'] in self.resource_config_dict:
self.get_logger().info(f'资源 {resource_config["id"]} 已存在') self.get_logger().info(f'资源 {resource_config["id"]} 已存在')
return return
if resource_config['class'] in registry.resource_type_registry.keys(): resource_class = resource_config.get('class', '')
model_config = registry.resource_type_registry[resource_config['class']]['model'] if resource_class and resource_class in registry.resource_type_registry:
if model_config['type'] == 'resource': reg_entry = registry.resource_type_registry[resource_class]
self.resource_model[resource_config['id']] = { if 'model' in reg_entry:
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}", model_config = reg_entry['model']
'mesh_tf': model_config['mesh_tf']} if model_config.get('type') == 'resource':
if 'children_mesh' in model_config.keys(): self.resource_model[resource_config['id']] = {
self.resource_model[f"{resource_config['id']}_"] = { 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}", 'mesh_tf': model_config['mesh_tf'],
'mesh_tf': model_config['children_mesh_tf']
} }
if model_config.get('children_mesh') is not None:
self.resource_model[f"{resource_config['id']}_"] = {
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
'mesh_tf': model_config['children_mesh_tf'],
}
resources = initialize_resources([resource_config]) resources = initialize_resources([resource_config])
resource_dict = {item['id']: item for item in resources} resource_dict = {item['id']: item for item in resources}
self.resource_config_dict = {**self.resource_config_dict,**resource_dict} self.resource_config_dict = {**self.resource_config_dict, **resource_dict}
tf_dict = self.resource_mesh_setup(resource_dict) tf_dict = self.resource_mesh_setup(resource_dict)
self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict} self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
self.publish_resource_tf() self.publish_resource_tf()
self.add_resource_collision_meshes(tf_dict) self.add_resource_collision_meshes(tf_dict)
def resource_mesh_setup(self, resource_config_dict: dict):
def resource_mesh_setup(self, resource_config_dict:dict): """根据资源配置字典设置 TF 关系"""
"""move_group初始化完成后的设置"""
self.get_logger().info('开始设置资源网格管理器') self.get_logger().info('开始设置资源网格管理器')
#遍历resource_config中的资源配置判断panent是否在resource_model中
resource_tf_dict = {} resource_tf_dict = {}
for resource_uuid, resource_config in resource_config_dict.items(): for resource_uuid, resource_config in resource_config_dict.items():
parent = None parent = None
resource_id = resource_config['id'] resource_id = resource_config['id']
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "": parent_uuid = resource_config.get('parent_uuid')
parent = resource_config_dict[resource_config['parent_uuid']]['id'] if parent_uuid is not None and parent_uuid != "":
parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid)
parent = parent_entry['id'] if parent_entry else None
parent_link = 'world' parent_link = 'world'
if parent in self.resource_model: if parent in self.resource_model:

View File

@@ -0,0 +1,232 @@
{
"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.31.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",
"trash"
]
},
{
"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",
"trash"
]
},
{
"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",
"trash"
]
},
{
"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",
"trash"
]
},
{
"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",
"trash"
]
},
{
"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",
"trash"
]
}
]
},
"data": {}
}
],
"edges": []
}

View File

@@ -8,8 +8,8 @@
"parent": "", "parent": "",
"pose": { "pose": {
"size": { "size": {
"width": 562, "width": 550,
"height": 394, "height": 400,
"depth": 0 "depth": 0
} }
}, },
@@ -55,9 +55,9 @@
}, },
"config": { "config": {
"type": "PRCXI9300Deck", "type": "PRCXI9300Deck",
"size_x": 542, "size_x": 550,
"size_y": 374, "size_y": 400,
"size_z": 0, "size_z": 17,
"rotation": { "rotation": {
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -74,7 +74,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 0, "x": 0,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -89,7 +89,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
}, },
{ {
@@ -98,7 +101,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 138, "x": 138,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -112,7 +115,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
}, },
{ {
@@ -121,7 +127,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 276, "x": 276,
"y": 0, "y": 288,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -135,7 +141,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
}, },
{ {
@@ -144,6 +153,240 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 414, "x": 414,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T9",
"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",
"trash"
]
},
{
"label": "T10",
"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",
"trash"
]
},
{
"label": "T11",
"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",
"trash"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"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",
"trash"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 0, "y": 0,
"z": 0 "z": 0
}, },
@@ -158,214 +401,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
] "plateadapter",
}, "module",
{ "trash"
"label": "T5",
"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"
]
},
{
"label": "T6",
"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"
]
},
{
"label": "T7",
"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"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
] ]
}, },
{ {
@@ -374,7 +413,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 138, "x": 138,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -388,7 +427,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
}, },
{ {
@@ -397,7 +439,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 276, "x": 276,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -411,7 +453,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
}, },
{ {
@@ -420,7 +465,7 @@
"occupied_by": null, "occupied_by": null,
"position": { "position": {
"x": 414, "x": 414,
"y": 288, "y": 0,
"z": 0 "z": 0
}, },
"size": { "size": {
@@ -434,7 +479,10 @@
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack", "tube_rack",
"adaptor" "adaptor",
"plateadapter",
"module",
"trash"
] ]
} }
] ]

View File

@@ -108,7 +108,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -153,7 +154,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -198,7 +200,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -243,7 +246,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -288,7 +292,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -333,7 +338,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -378,7 +384,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -423,7 +430,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -468,7 +476,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -513,7 +522,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -558,7 +568,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -603,7 +614,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -648,7 +660,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -693,7 +706,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -738,7 +752,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]
@@ -783,7 +798,8 @@
"tip_rack", "tip_rack",
"plates", "plates",
"tip_racks", "tip_racks",
"tube_rack" "tube_rack",
"plateadapter"
] ]
} }
] ]

View File

@@ -51,6 +51,7 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
- 遍历 workflow 数组,为每个动作创建步骤节点 - 遍历 workflow 数组,为每个动作创建步骤节点
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates - 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
- 参数输入转换: liquid_height按 wells 扩展mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 - 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] - 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
@@ -119,11 +120,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
# 节点类型 # 节点类型
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型 NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
CLASS_NAMES_MAPPING = {
"plate": "PRCXI_BioER_96_wellplate",
"tip_rack": "PRCXI_300ul_Tips",
}
# create_resource 节点默认参数 # create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = { CREATE_RESOURCE_DEFAULTS = {
"device_id": "/PRCXI", "device_id": "/PRCXI",
"parent_template": "/PRCXI/PRCXI_Deck", "parent_template": "/PRCXI/PRCXI_Deck",
"class_name": "PRCXI_BioER_96_wellplate",
} }
# 默认液体体积 (uL) # 默认液体体积 (uL)
@@ -138,6 +142,263 @@ PARAM_RENAME_MAPPING = {
} }
def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
"""协议槽位 -> 实际 deck4→138→1412+trash→16其余不变。"""
s = "" if raw_slot is None else str(raw_slot).strip()
if not s:
return ""
if s == "12" and (object_type or "").strip().lower() == "trash":
return "16"
return {"4": "13", "8": "14"}.get(s, s)
def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
m: Dict[str, Dict[str, Any]] = {}
for d in labware_defs or []:
for k in ("id", "name", "reagent_id", "reagent"):
key = d.get(k)
if key is not None and str(key):
m[str(key)] = d
return m
def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str:
"""合并 id 与协议里的 labware 描述OpenTrons 全名常在 labware 字段)。"""
parts = [str(labware_id), str(item.get("labware", "") or "")]
return " ".join(parts).lower()
def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
ot = (item.get("object") or "").strip().lower()
if ot == "trash":
return "trash"
if ot == "tiprack":
return "tip_rack"
lid = _labware_hint_text(labware_id, item)
if "trash" in lid:
return "trash"
# tiprack / tip + rack顺序在 tuberack 之前)
if "tiprack" in lid or ("tip" in lid and "rack" in lid):
return "tip_rack"
# 离心管架 / OpenTrons tuberack勿与 96 tiprack 混淆)
if "tuberack" in lid or "tube_rack" in lid:
return "tube_rack"
if "eppendorf" in lid and "rack" in lid:
return "tube_rack"
if "safelock" in lid and "rack" in lid:
return "tube_rack"
if "rack" in lid and "tip" not in lid:
return "tube_rack"
return "plate"
def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int:
"""从 ``24_tuberack`` 等命名中解析孔位数;解析不到则默认 24与 PRCXI_EP_Adapter 4×6 一致)。"""
hint = _labware_hint_text(labware_id, item)
m = re.search(r"(\d+)_tuberack", hint)
if m:
return int(m.group(1))
m = re.search(r"tuberack[_\s]*(\d+)", hint)
if m:
return int(m.group(1))
m = re.search(r"(\d+)\s*[-_]?\s*pos(?:ition)?s?", hint)
if m:
return int(m.group(1))
return 96
def _tip_volume_hint(item: Dict[str, Any], labware_id: str) -> Optional[float]:
s = _labware_hint_text(labware_id, item)
for v in (1250, 1000, 300, 200, 10):
if f"{v}ul" in s or f"{v}μl" in s or f"{v}u" in s:
return float(v)
if f" {v} " in f" {s} ":
return float(v)
return None
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
"""有明确需求体积时,模板标称 Volume 必须 >= 需求;无 Volume 的模板不参与trash 除外)。"""
if kind == "trash":
return True
if req is None or req <= 0:
return True
mv = float(template.get("Volume") or 0)
if mv <= 0:
return False
return mv >= req
def _direct_labware_class_name(item: Dict[str, Any]) -> str:
"""仅用于 tip_rack 且 ``preserve_tip_rack_incoming_class=True````class_name``/``class`` 原样;否则 ``labware`` → ``lab_*``。"""
explicit = item.get("class_name") or item.get("class")
if explicit is not None and str(explicit).strip() != "":
return str(explicit).strip()
lw = str(item.get("labware", "") or "").strip()
if lw:
return f"lab_{lw.lower().replace('.', 'point').replace(' ', '_')}"
return ""
def _match_score_prcxi_template(
template: Dict[str, Any],
num_children: int,
child_max_volume: Optional[float],
) -> float:
"""孔数差主导;有需求体积且模板已满足 >= 时,余量比例 (模板-需求)/需求 越小越好(优先选刚好够的)。"""
hole_count = int(template.get("hole_count") or 0)
hole_diff = abs(num_children - hole_count)
material_volume = float(template.get("Volume") or 0)
req = child_max_volume
if req is not None and req > 0 and material_volume > 0:
vol_diff = (material_volume - req) / max(req, 1e-9)
elif material_volume > 0 and req is not None:
vol_diff = abs(float(req) - material_volume) / material_volume
else:
vol_diff = 0.0
return hole_diff * 1000 + vol_diff
def _apply_prcxi_labware_auto_match(
labware_info: Dict[str, Dict[str, Any]],
labware_defs: Optional[List[Dict[str, Any]]] = None,
*,
preserve_tip_rack_incoming_class: bool = True,
) -> None:
"""上传构建图前:按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名,写入 ``prcxi_class_name``。
若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。
``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或
``labware````lab_*``)直接给出;**plate / tube_rack / trash 等**仍按注册模板匹配。
``False`` 时 **全部**(含 tip_rack走模板匹配。"""
if not labware_info:
return
default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
try:
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
except Exception:
return
templates = get_prcxi_labware_template_specs()
if not templates:
return
def_map = _labware_def_index(labware_defs)
for labware_id, item in labware_info.items():
if item.get("prcxi_class_name"):
continue
kind = _infer_reagent_kind(labware_id, item)
if preserve_tip_rack_incoming_class and kind == "tip_rack":
inc_s = _direct_labware_class_name(item)
if inc_s == default_prcxi_tip_class:
inc_s = ""
if inc_s:
item["prcxi_class_name"] = inc_s
continue
explicit = item.get("class_name") or item.get("class")
if explicit and str(explicit).startswith("PRCXI_"):
item["prcxi_class_name"] = str(explicit)
continue
extra = def_map.get(str(labware_id), {})
wells = item.get("well") or []
well_n = len(wells) if isinstance(wells, list) else 0
num_from_def = int(extra.get("num_wells") or extra.get("well_count") or item.get("num_wells") or 0)
if kind == "trash":
num_children = 0
elif kind == "tip_rack":
num_children = num_from_def if num_from_def > 0 else 96
elif kind == "tube_rack":
if num_from_def > 0:
num_children = num_from_def
elif well_n > 0:
num_children = well_n
else:
num_children = _infer_tube_rack_num_positions(labware_id, item)
else:
num_children = num_from_def if num_from_def > 0 else 96
child_max_volume = item.get("max_volume")
if child_max_volume is None:
child_max_volume = extra.get("max_volume")
try:
child_max_volume_f = float(child_max_volume) if child_max_volume is not None else None
except (TypeError, ValueError):
child_max_volume_f = None
if kind == "tip_rack" and child_max_volume_f is None:
child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0
candidates = [t for t in templates if t["kind"] == kind]
if not candidates:
continue
best = None
best_score = float("inf")
for t in candidates:
if kind != "trash" and int(t.get("hole_count") or 0) <= 0:
continue
if not _volume_template_covers_requirement(t, child_max_volume_f, kind):
continue
sc = _match_score_prcxi_template(t, num_children, child_max_volume_f)
if sc < best_score:
best_score = sc
best = t
if best:
item["prcxi_class_name"] = best["class_name"]
def _reconcile_slot_carrier_prcxi_class(
labware_info: Dict[str, Dict[str, Any]],
*,
preserve_tip_rack_incoming_class: bool = False,
) -> None:
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``prcxi_class_name``,避免先遍历到 96 板后槽位被错误绑定。
``preserve_tip_rack_incoming_class=True`` 时tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。"""
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
for lid, item in labware_info.items():
ot = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), ot)
if not slot:
continue
by_slot.setdefault(str(slot), []).append((lid, item))
priority = {"trash": 0, "tube_rack": 1, "tip_rack": 2, "plate": 3}
for _slot, pairs in by_slot.items():
if len(pairs) < 2:
continue
def _rank(p: Tuple[str, Dict[str, Any]]) -> int:
return priority.get(_infer_reagent_kind(p[0], p[1]), 9)
pairs_sorted = sorted(pairs, key=_rank)
best_cls = None
for lid, it in pairs_sorted:
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
continue
c = it.get("prcxi_class_name")
if c:
best_cls = c
break
if not best_cls:
continue
for lid, it in pairs:
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
continue
it["prcxi_class_name"] = best_cls
# ---------------- Graph ---------------- # ---------------- Graph ----------------
@@ -363,23 +624,77 @@ def build_protocol_graph(
workstation_name: str, workstation_name: str,
action_resource_mapping: Optional[Dict[str, str]] = None, action_resource_mapping: Optional[Dict[str, str]] = None,
labware_defs: Optional[List[Dict[str, Any]]] = None, labware_defs: Optional[List[Dict[str, Any]]] = None,
preserve_tip_rack_incoming_class: bool = True,
) -> WorkflowGraph: ) -> WorkflowGraph:
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑 """统一的协议图构建函数,根据设备类型自动选择构建逻辑
Args: Args:
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找 labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
protocol_steps: 协议步骤列表 protocol_steps: 协议步骤列表
workstation_name: 工作站名称 workstation_name: 工作站名称
action_resource_mapping: action 到 resource_name 的映射字典,可选 action_resource_mapping: action 到 resource_name 的映射字典,可选
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...] labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack都走模板匹配。
""" """
G = WorkflowGraph() G = WorkflowGraph()
resource_last_writer = {} # reagent_name -> "node_id:port" resource_last_writer = {} # reagent_name -> "node_id:port"
slot_to_create_resource = {} # slot -> create_resource node_id slot_to_create_resource = {} # slot -> create_resource node_id
_apply_prcxi_labware_auto_match(
labware_info,
labware_defs,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
)
_reconcile_slot_carrier_prcxi_class(
labware_info,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
)
protocol_steps = refactor_data(protocol_steps, action_resource_mapping) protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
# ==================== 第一步:按 slot 创建 create_resource 节点 ==================== # ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
for labware_id, item in labware_info.items():
object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
if not slot:
continue
by_slot.setdefault(slot, []).append((labware_id, item))
slots_info: Dict[str, Dict[str, Any]] = {}
for slot, pairs in by_slot.items():
def _ot_tip(it: Dict[str, Any]) -> bool:
return str(it.get("object", "") or "").strip().lower() == "tiprack"
tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
chosen_lid = ""
chosen_item: Dict[str, Any] = {}
prcxi_val: Optional[str] = None
scan = tip_pairs if tip_pairs else pairs
for lid, it in scan:
c = it.get("prcxi_class_name")
if c:
chosen_lid, chosen_item, prcxi_val = lid, it, str(c)
break
if not chosen_lid and scan:
chosen_lid, chosen_item = scan[0]
pv = chosen_item.get("prcxi_class_name")
prcxi_val = str(pv) if pv else None
labware = str(chosen_item.get("labware", "") or "")
res_id = f"{labware}_slot_{slot}" if labware.strip() else f"{chosen_lid}_slot_{slot}"
res_id = res_id.replace(" ", "_")
slots_info[slot] = {
"labware": labware,
"res_id": res_id,
"labware_id": chosen_lid,
"object": chosen_item.get("object", "") or "",
"prcxi_class_name": prcxi_val,
}
# 创建 Group 节点,包含所有 create_resource 节点 # 创建 Group 节点,包含所有 create_resource 节点
group_node_id = str(uuid.uuid4()) group_node_id = str(uuid.uuid4())
G.add_node( G.add_node(
@@ -395,41 +710,54 @@ def build_protocol_graph(
param=None, param=None,
) )
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录type 即 class_name trash_create_node_id = None # 记录 trash 的 create_resource 节点
res_index = 0
for lw in (labware_defs or []):
slot = str(lw.get("slot", ""))
if not slot or slot in slot_to_create_resource:
continue # 跳过空 slot 或已处理的 slot
lw_name = lw.get("name", f"slot {slot}") # 为每个唯一的 slot 创建 create_resource 节点
lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"]) for slot, info in slots_info.items():
res_id = f"plate_slot_{slot}"
res_index += 1
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
res_id = info["res_id"]
object_type = info.get("object", "") or ""
ot_lo = str(object_type).strip().lower()
matched = info.get("prcxi_class_name")
if ot_lo == "trash":
res_type_name = "PRCXI_trash"
elif matched:
res_type_name = matched
elif ot_lo == "tiprack":
if preserve_tip_rack_incoming_class:
lid = str(info.get("labware_id") or "").strip() or "tip_rack"
res_type_name = f"lab_{lid.lower().replace('.', 'point').replace(' ', '_')}"
else:
res_type_name = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
else:
res_type_name = f"lab_{info['labware'].lower().replace('.', 'point')}"
G.add_node( G.add_node(
node_id, node_id,
template_name="create_resource", template_name="create_resource",
resource_name="host_node", resource_name="host_node",
name=lw_name, name=f"{res_type_name}_slot{slot}",
description=f"Create {lw_name}", description=f"Create plate on slot {slot}",
lab_node_type="Labware", lab_node_type="Labware",
footer="create_resource-host_node", footer="create_resource-host_node",
device_name=DEVICE_NAME_HOST, device_name=DEVICE_NAME_HOST,
type=NODE_TYPE_DEFAULT, type=NODE_TYPE_DEFAULT,
parent_uuid=group_node_id, parent_uuid=group_node_id, # 指向 Group 节点
minimized=True, minimized=True, # 折叠显示
param={ param={
"res_id": res_id, "res_id": res_id,
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"], "device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
"class_name": lw_type, "class_name": res_type_name,
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"], "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
"slot_on_deck": slot, "slot_on_deck": slot,
}, },
) )
slot_to_create_resource[slot] = node_id slot_to_create_resource[slot] = node_id
if ot_lo == "tiprack":
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
if ot_lo == "trash":
trash_create_node_id = node_id
# create_resource 之间不需要 ready 连接
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点 # 创建 Group 节点,包含所有 set_liquid_from_plate 节点
@@ -456,7 +784,8 @@ def build_protocol_graph(
if item.get("type") == "hardware": if item.get("type") == "hardware":
continue continue
slot = str(item.get("slot", "")) object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
wells = item.get("well", []) wells = item.get("well", [])
if not wells or not slot: if not wells or not slot:
continue continue
@@ -464,6 +793,7 @@ def build_protocol_graph(
# res_id 不能有空格 # res_id 不能有空格
res_id = str(labware_id).replace(" ", "_") res_id = str(labware_id).replace(" ", "_")
well_count = len(wells) well_count = len(wells)
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
set_liquid_index += 1 set_liquid_index += 1
@@ -484,7 +814,7 @@ def build_protocol_graph(
"plate": [], # 通过连接传递 "plate": [], # 通过连接传递
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
"liquid_names": [res_id] * well_count, "liquid_names": [res_id] * well_count,
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count, "volumes": [liquid_volume] * well_count,
}, },
) )
@@ -498,8 +828,12 @@ def build_protocol_graph(
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
resource_last_writer[labware_id] = f"{node_id}:output_wells" resource_last_writer[labware_id] = f"{node_id}:output_wells"
# transfer_liquid 之间通过 ready 串联,从 None 开始 # 收集所有 create_resource 节点 ID用于让第一个 transfer_liquid 等待所有资源创建完成
last_control_node_id = None all_create_resource_node_ids = list(slot_to_create_resource.values())
# transfer_liquid 之间通过 ready 串联;第一个 transfer_liquid 需要等待所有 create_resource 完成
last_control_node_id = trash_create_node_id
is_first_action_node = True
# 端口名称映射JSON 字段名 -> 实际 handle key # 端口名称映射JSON 字段名 -> 实际 handle key
INPUT_PORT_MAPPING = { INPUT_PORT_MAPPING = {
@@ -511,6 +845,7 @@ def build_protocol_graph(
"reagent": "reagent", "reagent": "reagent",
"solvent": "solvent", "solvent": "solvent",
"compound": "compound", "compound": "compound",
"tip_racks": "tip_rack_identifier",
} }
OUTPUT_PORT_MAPPING = { OUTPUT_PORT_MAPPING = {
@@ -525,8 +860,17 @@ def build_protocol_graph(
"compound": "compound", "compound": "compound",
} }
# 需要根据 wells 数量扩展的参数列表(复数形式) # 需要根据 wells 数量扩展的参数列表
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] # - 复数参数asp_vols 等)支持单值自动扩展
# - liquid_height 按 wells 扩展为数组
# - mix_* 参数保持标量,避免被转换为 list
EXPAND_BY_WELLS_PARAMS = [
"asp_vols",
"dis_vols",
"asp_flow_rates",
"dis_flow_rates",
"liquid_height",
]
# 处理协议步骤 # 处理协议步骤
for step in protocol_steps: for step in protocol_steps:
@@ -540,6 +884,57 @@ def build_protocol_graph(
if old_name in params: if old_name in params:
params[new_name] = params.pop(old_name) params[new_name] = params.pop(old_name)
# touch_tip 输入归一化:
# - 支持 bool / 0/1 / "true"/"false" / 单元素 list
# - 最终统一为 bool 标量,避免被下游误当作序列处理
if "touch_tip" in params:
touch_tip_value = params.get("touch_tip")
if isinstance(touch_tip_value, list):
if len(touch_tip_value) == 1:
touch_tip_value = touch_tip_value[0]
elif len(touch_tip_value) == 0:
touch_tip_value = False
else:
warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值")
touch_tip_value = touch_tip_value[0]
if isinstance(touch_tip_value, str):
norm = touch_tip_value.strip().lower()
if norm in {"true", "1", "yes", "y", "on"}:
touch_tip_value = True
elif norm in {"false", "0", "no", "n", "off", ""}:
touch_tip_value = False
else:
warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理")
touch_tip_value = True
elif isinstance(touch_tip_value, (int, float)):
touch_tip_value = bool(touch_tip_value)
elif touch_tip_value is None:
touch_tip_value = False
else:
touch_tip_value = bool(touch_tip_value)
params["touch_tip"] = touch_tip_value
# delays 输入归一化:
# - 支持标量int/float/字符串数字)与 list
# - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用
if "delays" in params:
delays_value = params.get("delays")
if delays_value is None or delays_value == "":
params["delays"] = []
else:
raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
normalized_delays = []
for delay_item in raw_list:
if isinstance(delay_item, str):
delay_item = delay_item.strip()
if delay_item == "":
continue
try:
normalized_delays.append(float(delay_item))
except (TypeError, ValueError):
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
params["delays"] = normalized_delays
# 处理输入连接 # 处理输入连接
for param_key, target_port in INPUT_PORT_MAPPING.items(): for param_key, target_port in INPUT_PORT_MAPPING.items():
resource_name = params.get(param_key) resource_name = params.get(param_key)
@@ -606,7 +1001,12 @@ def build_protocol_graph(
G.add_node(node_id, **step_copy) G.add_node(node_id, **step_copy)
# 控制流 # 控制流
if last_control_node_id is not None: if is_first_action_node:
# 第一个 transfer_liquid 需要等待所有 create_resource 完成
for cr_node_id in all_create_resource_node_ids:
G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready")
is_first_action_node = False
elif last_control_node_id is not None:
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
last_control_node_id = node_id last_control_node_id = node_id

View File

@@ -210,6 +210,7 @@ def convert_from_json(
data: Union[str, PathLike, Dict[str, Any]], data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION, workstation_name: str = DEFAULT_WORKSTATION,
validate: bool = True, validate: bool = True,
preserve_tip_rack_incoming_class: bool = True,
) -> WorkflowGraph: ) -> WorkflowGraph:
""" """
从 JSON 数据或文件转换为 WorkflowGraph 从 JSON 数据或文件转换为 WorkflowGraph
@@ -221,6 +222,8 @@ def convert_from_json(
data: JSON 文件路径、字典数据、或 JSON 字符串 data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi" workstation_name: 工作站名称,默认 "PRCXi"
validate: 是否校验句柄配置,默认 True validate: 是否校验句柄配置,默认 True
preserve_tip_rack_incoming_class: True默认时仅 tip_rack 不跑模板、按传入类名/labware其它载体仍自动匹配。
False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。
Returns: Returns:
WorkflowGraph: 构建好的工作流图 WorkflowGraph: 构建好的工作流图
@@ -263,6 +266,10 @@ def convert_from_json(
# reagent 已经是字典格式,用于 set_liquid 和 well 数量查找 # reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
labware_info = reagent labware_info = reagent
preserve = preserve_tip_rack_incoming_class
if "preserve_tip_rack_incoming_class" in json_data:
preserve = bool(json_data["preserve_tip_rack_incoming_class"])
# 构建工作流图 # 构建工作流图
graph = build_protocol_graph( graph = build_protocol_graph(
labware_info=labware_info, labware_info=labware_info,
@@ -270,6 +277,7 @@ def convert_from_json(
workstation_name=workstation_name, workstation_name=workstation_name,
action_resource_mapping=ACTION_RESOURCE_MAPPING, action_resource_mapping=ACTION_RESOURCE_MAPPING,
labware_defs=labware_defs, labware_defs=labware_defs,
preserve_tip_rack_incoming_class=preserve,
) )
# 校验句柄配置 # 校验句柄配置
@@ -287,6 +295,7 @@ def convert_from_json(
def convert_json_to_node_link( def convert_json_to_node_link(
data: Union[str, PathLike, Dict[str, Any]], data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION, workstation_name: str = DEFAULT_WORKSTATION,
preserve_tip_rack_incoming_class: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
将 JSON 数据转换为 node-link 格式的字典 将 JSON 数据转换为 node-link 格式的字典
@@ -298,13 +307,18 @@ def convert_json_to_node_link(
Returns: Returns:
Dict: node-link 格式的工作流数据 Dict: node-link 格式的工作流数据
""" """
graph = convert_from_json(data, workstation_name) graph = convert_from_json(
data,
workstation_name,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
)
return graph.to_node_link_dict() return graph.to_node_link_dict()
def convert_json_to_workflow_list( def convert_json_to_workflow_list(
data: Union[str, PathLike, Dict[str, Any]], data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION, workstation_name: str = DEFAULT_WORKSTATION,
preserve_tip_rack_incoming_class: bool = True,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
将 JSON 数据转换为工作流列表格式 将 JSON 数据转换为工作流列表格式
@@ -316,5 +330,9 @@ def convert_json_to_workflow_list(
Returns: Returns:
List: 工作流节点列表 List: 工作流节点列表
""" """
graph = convert_from_json(data, workstation_name) graph = convert_from_json(
data,
workstation_name,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
)
return graph.to_dict() return graph.to_dict()

View File

@@ -234,6 +234,7 @@ def convert_from_json(
data: Union[str, PathLike, Dict[str, Any]], data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = "PRCXi", workstation_name: str = "PRCXi",
validate: bool = True, validate: bool = True,
preserve_tip_rack_incoming_class: bool = True,
) -> WorkflowGraph: ) -> WorkflowGraph:
""" """
从 JSON 数据或文件转换为 WorkflowGraph 从 JSON 数据或文件转换为 WorkflowGraph
@@ -246,6 +247,7 @@ def convert_from_json(
data: JSON 文件路径、字典数据、或 JSON 字符串 data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi" workstation_name: 工作站名称,默认 "PRCXi"
validate: 是否校验句柄配置,默认 True validate: 是否校验句柄配置,默认 True
preserve_tip_rack_incoming_class: True 时仅 tip 不跑模板False 时全部匹配JSON 根字段同名可覆盖
Returns: Returns:
WorkflowGraph: 构建好的工作流图 WorkflowGraph: 构建好的工作流图
@@ -295,12 +297,17 @@ def convert_from_json(
"3. {'steps': [...], 'labware': [...]}" "3. {'steps': [...], 'labware': [...]}"
) )
preserve = preserve_tip_rack_incoming_class
if "preserve_tip_rack_incoming_class" in json_data:
preserve = bool(json_data["preserve_tip_rack_incoming_class"])
# 构建工作流图 # 构建工作流图
graph = build_protocol_graph( graph = build_protocol_graph(
labware_info=labware_info, labware_info=labware_info,
protocol_steps=protocol_steps, protocol_steps=protocol_steps,
workstation_name=workstation_name, workstation_name=workstation_name,
action_resource_mapping=ACTION_RESOURCE_MAPPING, action_resource_mapping=ACTION_RESOURCE_MAPPING,
preserve_tip_rack_incoming_class=preserve,
) )
# 校验句柄配置 # 校验句柄配置