mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-12 02:35:11 +00:00
Compare commits
26 Commits
31e8d065c4
...
workstatio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965bf36e8d | ||
|
|
aacf3497e0 | ||
|
|
657f952e7a | ||
|
|
0165590290 | ||
|
|
daea1ab54d | ||
|
|
93cb307396 | ||
|
|
1c312772ae | ||
|
|
bad1db5094 | ||
|
|
f26eb69eca | ||
|
|
12c0770c92 | ||
|
|
3d2d428a96 | ||
|
|
78bf57f590 | ||
|
|
e227cddab3 | ||
|
|
f2b993643f | ||
|
|
2e14bf197c | ||
|
|
66c18c080a | ||
|
|
a1c34f138e | ||
|
|
75bb5ec553 | ||
|
|
bb95c89829 | ||
|
|
394c140830 | ||
|
|
e6d8d41183 | ||
|
|
847a300af3 | ||
|
|
a201d7c307 | ||
|
|
3433766bc5 | ||
|
|
7e9e93b29c | ||
|
|
9e1e6da505 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.14
|
||||
version: 0.10.15
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
@@ -24,7 +24,7 @@ extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings
|
||||
"sphinx_rtd_theme",
|
||||
"sphinxcontrib.mermaid"
|
||||
"sphinxcontrib.mermaid",
|
||||
]
|
||||
|
||||
source_suffix = {
|
||||
@@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# sphinx-book-theme 主题选项
|
||||
html_theme_options = {
|
||||
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||
"repository_url": "https://github.com/deepmodeling/Uni-Lab-OS",
|
||||
"use_repository_button": True,
|
||||
"use_issues_button": True,
|
||||
"use_edit_page_button": True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0
|
||||
|
||||
# 用于自动摘要生成
|
||||
sphinx-autobuild>=2024.2.4
|
||||
|
||||
# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX)
|
||||
rinohtype>=0.5.4
|
||||
sphinx-simplepdf>=1.6.0
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.14
|
||||
version: 0.10.15
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.14"
|
||||
version: "0.10.15"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.14',
|
||||
version='0.10.15',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.14"
|
||||
__version__ = "0.10.15"
|
||||
|
||||
@@ -19,6 +19,11 @@ if unilabos_dir not in sys.path:
|
||||
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
|
||||
# Global restart flags (used by ws_client and web/server)
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
@@ -156,6 +161,11 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
@@ -297,6 +307,7 @@ def main():
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
@@ -497,13 +508,19 @@ def main():
|
||||
time.sleep(1)
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
return
|
||||
else:
|
||||
start_backend(**args_dict)
|
||||
start_server(
|
||||
|
||||
# 启动服务器(默认支持WebSocket触发重启)
|
||||
restart_requested = start_server(
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
|
||||
144
unilabos/app/utils.py
Normal file
144
unilabos/app/utils.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
UniLabOS 应用工具函数
|
||||
|
||||
提供清理、重启等工具函数
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from unilabos.utils.banner_print import print_status
|
||||
|
||||
|
||||
def cleanup_for_restart() -> bool:
|
||||
"""
|
||||
Clean up all resources for restart without exiting the process.
|
||||
|
||||
This function prepares the system for re-initialization by:
|
||||
1. Stopping all communication clients
|
||||
2. Destroying ROS nodes
|
||||
3. Resetting singletons
|
||||
4. Waiting for threads to finish
|
||||
|
||||
Returns:
|
||||
bool: True if cleanup was successful, False otherwise
|
||||
"""
|
||||
print_status("[Restart] Starting cleanup for restart...", "info")
|
||||
|
||||
# Step 1: Stop WebSocket communication client
|
||||
print_status("[Restart] Step 1: Stopping WebSocket client...", "info")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
|
||||
comm_client = get_communication_client()
|
||||
if comm_client is not None:
|
||||
comm_client.stop()
|
||||
print_status("[Restart] WebSocket client stopped", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error stopping WebSocket: {e}", "warning")
|
||||
|
||||
# Step 2: Get HostNode and cleanup ROS
|
||||
print_status("[Restart] Step 2: Cleaning up ROS nodes...", "info")
|
||||
try:
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
import rclpy
|
||||
from rclpy.timer import Timer
|
||||
|
||||
host_instance = HostNode.get_instance(timeout=5)
|
||||
if host_instance is not None:
|
||||
print_status(f"[Restart] Found HostNode: {host_instance.device_id}", "info")
|
||||
|
||||
# Gracefully shutdown background threads
|
||||
print_status("[Restart] Shutting down background threads...", "info")
|
||||
HostNode.shutdown_background_threads(timeout=5.0)
|
||||
print_status("[Restart] Background threads shutdown complete", "info")
|
||||
|
||||
# Stop discovery timer
|
||||
if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer):
|
||||
host_instance._discovery_timer.cancel()
|
||||
print_status("[Restart] Discovery timer cancelled", "info")
|
||||
|
||||
# Destroy device nodes
|
||||
device_count = len(host_instance.devices_instances)
|
||||
print_status(f"[Restart] Destroying {device_count} device instances...", "info")
|
||||
for device_id, device_node in list(host_instance.devices_instances.items()):
|
||||
try:
|
||||
if hasattr(device_node, "ros_node_instance") and device_node.ros_node_instance is not None:
|
||||
device_node.ros_node_instance.destroy_node()
|
||||
print_status(f"[Restart] Device {device_id} destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying device {device_id}: {e}", "warning")
|
||||
|
||||
# Clear devices instances
|
||||
host_instance.devices_instances.clear()
|
||||
host_instance.devices_names.clear()
|
||||
|
||||
# Destroy host node
|
||||
try:
|
||||
host_instance.destroy_node()
|
||||
print_status("[Restart] HostNode destroyed", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error destroying HostNode: {e}", "warning")
|
||||
|
||||
# Reset HostNode state
|
||||
HostNode.reset_state()
|
||||
print_status("[Restart] HostNode state reset", "info")
|
||||
|
||||
# Shutdown executor first (to stop executor.spin() gracefully)
|
||||
if hasattr(rclpy, "__executor") and rclpy.__executor is not None:
|
||||
try:
|
||||
rclpy.__executor.shutdown()
|
||||
rclpy.__executor = None # Clear for restart
|
||||
print_status("[Restart] ROS executor shutdown complete", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error shutting down executor: {e}", "warning")
|
||||
|
||||
# Shutdown rclpy
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
print_status("[Restart] rclpy shutdown complete", "info")
|
||||
|
||||
except ImportError as e:
|
||||
print_status(f"[Restart] ROS modules not available: {e}", "warning")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error in ROS cleanup: {e}", "warning")
|
||||
return False
|
||||
|
||||
# Step 3: Reset communication client singleton
|
||||
print_status("[Restart] Step 3: Resetting singletons...", "info")
|
||||
try:
|
||||
from unilabos.app import communication
|
||||
|
||||
if hasattr(communication, "_communication_client"):
|
||||
communication._communication_client = None
|
||||
print_status("[Restart] Communication client singleton reset", "info")
|
||||
except Exception as e:
|
||||
print_status(f"[Restart] Error resetting communication singleton: {e}", "warning")
|
||||
|
||||
# Step 4: Wait for threads to finish
|
||||
print_status("[Restart] Step 4: Waiting for threads to finish...", "info")
|
||||
time.sleep(3) # Give threads time to finish
|
||||
|
||||
# Check remaining threads
|
||||
remaining_threads = []
|
||||
for t in threading.enumerate():
|
||||
if t.name != "MainThread" and t.is_alive():
|
||||
remaining_threads.append(t.name)
|
||||
|
||||
if remaining_threads:
|
||||
print_status(
|
||||
f"[Restart] Warning: {len(remaining_threads)} threads still running: {remaining_threads}", "warning"
|
||||
)
|
||||
else:
|
||||
print_status("[Restart] All threads stopped", "info")
|
||||
|
||||
# Step 5: Force garbage collection
|
||||
print_status("[Restart] Step 5: Running garbage collection...", "info")
|
||||
gc.collect()
|
||||
gc.collect() # Run twice for weak references
|
||||
print_status("[Restart] Garbage collection complete", "info")
|
||||
|
||||
print_status("[Restart] Cleanup complete. Ready for re-initialization.", "info")
|
||||
return True
|
||||
@@ -6,7 +6,6 @@ Web服务器模块
|
||||
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import Response
|
||||
@@ -96,7 +95,7 @@ def setup_server() -> FastAPI:
|
||||
return app
|
||||
|
||||
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> bool:
|
||||
"""
|
||||
启动服务器
|
||||
|
||||
@@ -104,7 +103,14 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
host: 服务器主机
|
||||
port: 服务器端口
|
||||
open_browser: 是否自动打开浏览器
|
||||
|
||||
Returns:
|
||||
bool: True if restart was requested, False otherwise
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from uvicorn import Config, Server
|
||||
|
||||
# 设置服务器
|
||||
setup_server()
|
||||
|
||||
@@ -123,7 +129,37 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
|
||||
# 启动服务器
|
||||
info(f"[Web] 启动FastAPI服务器: {host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_config=log_config)
|
||||
|
||||
# 使用支持重启的模式
|
||||
config = Config(app=app, host=host, port=port, log_config=log_config)
|
||||
server = Server(config)
|
||||
|
||||
# 启动服务器线程
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
while server_thread.is_alive():
|
||||
if hasattr(main_module, "_restart_requested") and main_module._restart_requested:
|
||||
info(
|
||||
f"[Web] Restart requested via WebSocket, reason: {getattr(main_module, '_restart_reason', 'unknown')}"
|
||||
)
|
||||
main_module._restart_requested = False
|
||||
|
||||
# 停止服务器
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
info("[Web] Server stopped, ready for restart")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# 当脚本直接运行时启动服务器
|
||||
|
||||
@@ -359,7 +359,7 @@ class MessageProcessor:
|
||||
self.device_manager = device_manager
|
||||
self.queue_processor = None # 延迟设置
|
||||
self.websocket_client = None # 延迟设置
|
||||
self.session_id = ""
|
||||
self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
||||
|
||||
# WebSocket连接
|
||||
self.websocket = None
|
||||
@@ -488,7 +488,16 @@ class MessageProcessor:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._process_message(data)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
|
||||
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
@@ -554,11 +563,8 @@ class MessageProcessor:
|
||||
finally:
|
||||
logger.debug("[MessageProcessor] Send handler stopped")
|
||||
|
||||
async def _process_message(self, data: Dict[str, Any]):
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
@@ -571,16 +577,19 @@ class MessageProcessor:
|
||||
elif message_type == "cancel_action" or message_type == "cancel_task":
|
||||
await self._handle_cancel_action(message_data)
|
||||
elif message_type == "add_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "add")
|
||||
elif message_type == "update_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "update")
|
||||
elif message_type == "remove_material":
|
||||
# noinspection PyTypeChecker
|
||||
await self._handle_resource_tree_update(message_data, "remove")
|
||||
elif message_type == "session_id":
|
||||
self.session_id = message_data.get("session_id")
|
||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_reload":
|
||||
await self._handle_request_reload(message_data)
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
else:
|
||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||
|
||||
@@ -890,19 +899,48 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_reload(self, data: Dict[str, Any]):
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重载请求
|
||||
处理重启请求
|
||||
|
||||
当LabGo发送request_reload时,重新发送设备注册信息
|
||||
当LabGo发送request_restart时,执行清理并触发重启
|
||||
"""
|
||||
reason = data.get("reason", "unknown")
|
||||
logger.info(f"[MessageProcessor] Received reload request, reason: {reason}")
|
||||
delay = data.get("delay", 2) # 默认延迟2秒
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
# 重新发送host_node_ready信息
|
||||
# 发送确认消息
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
logger.info("[MessageProcessor] Re-sent host_node_ready after reload request")
|
||||
await self.websocket_client.send_message({
|
||||
"action": "restart_acknowledged",
|
||||
"data": {"reason": reason, "delay": delay}
|
||||
})
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
main_module._restart_requested = True
|
||||
main_module._restart_reason = reason
|
||||
|
||||
# 延迟后执行清理
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||
def do_cleanup():
|
||||
import time
|
||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||
try:
|
||||
from unilabos.app.utils import cleanup_for_restart
|
||||
if cleanup_for_restart():
|
||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||
else:
|
||||
logger.error("[MessageProcessor] Cleanup failed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||
|
||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||
cleanup_thread.start()
|
||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||
|
||||
async def _send_action_state_response(
|
||||
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||
|
||||
@@ -16,6 +16,7 @@ class BasicConfig:
|
||||
upload_registry = False
|
||||
machine_name = "undefined"
|
||||
vis_2d_enable = False
|
||||
no_update_feedback = False
|
||||
enable_resource_load = True
|
||||
communication_protocol = "websocket"
|
||||
startup_json_path = None # 填写绝对路径
|
||||
|
||||
@@ -207,14 +207,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
# 处理 use_channels 为 None 的情况(通常用于单通道操作)
|
||||
if use_channels is None:
|
||||
# 对于单通道操作,推断通道为 [0]
|
||||
channels_to_use = [0] * len(resources)
|
||||
else:
|
||||
channels_to_use = use_channels
|
||||
|
||||
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
@@ -927,7 +920,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -991,7 +983,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1174,7 +1165,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1209,7 +1199,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1246,7 +1235,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1283,7 +1271,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1340,7 +1327,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(总体积)
|
||||
@@ -1380,7 +1366,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx:idx+1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
@@ -1416,7 +1401,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[i:i + 8] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||
@@ -1462,7 +1446,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
@@ -1514,19 +1497,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
f"(matching `asp_vols`). Got length {len(dis_vols)}."
|
||||
)
|
||||
|
||||
need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0
|
||||
defer_final_discard = need_mix_after or touch_tip
|
||||
|
||||
if len(use_channels) == 1:
|
||||
# 单通道模式:多次吸液,一次分液
|
||||
|
||||
# 如果需要 before mix,先 pick up tip 并执行 mix
|
||||
# 先混合前(如果需要)
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1534,11 +1508,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
|
||||
# 从每个源容器吸液并分液到目标容器
|
||||
for idx, source in enumerate(sources):
|
||||
tip = []
|
||||
@@ -1556,10 +1527,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
|
||||
# 分液到目标容器
|
||||
if use_proportional_mixing:
|
||||
# 按不同比例混合:使用对应的 dis_vols
|
||||
@@ -1575,7 +1546,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
|
||||
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
|
||||
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||
|
||||
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[dis_vol],
|
||||
@@ -1586,15 +1557,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if not (defer_final_discard and idx == len(sources) - 1):
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if need_mix_after:
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1602,27 +1572,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 8通道模式:需要确保源数量是8的倍数
|
||||
if len(sources) % 8 != 0:
|
||||
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
||||
|
||||
# 如果需要 before mix,先 pick up tips 并执行 mix
|
||||
|
||||
# 每次处理8个源
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1630,11 +1591,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
for i in range(0, len(sources), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
@@ -1692,12 +1650,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
if not (defer_final_discard and i + 8 >= len(sources)):
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if need_mix_after:
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1705,15 +1662,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# except Exception as e:
|
||||
# traceback.print_exc()
|
||||
# raise RuntimeError(f"Liquid addition failed: {e}") from e
|
||||
@@ -1733,12 +1686,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
print(f"Waiting time: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
|
||||
# Use ROS node sleep if available, otherwise use asyncio.sleep
|
||||
if hasattr(self, '_ros_node') and self._ros_node is not None:
|
||||
await self._ros_node.sleep(seconds)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.sleep(seconds)
|
||||
await self._ros_node.sleep(seconds)
|
||||
if msg:
|
||||
print(f"Done: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
@@ -1777,59 +1725,27 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
height_to_bottom: Optional[float] = None,
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
use_channels: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
if mix_time is None or mix_time <= 0: # No mixing required
|
||||
if mix_time is None: # No mixing required
|
||||
return
|
||||
"""Mix the liquid in the target wells."""
|
||||
if mix_vol is None:
|
||||
raise ValueError("`mix_vol` must be provided when `mix_time` is set.")
|
||||
|
||||
targets_list: List[Container] = list(targets)
|
||||
if len(targets_list) == 0:
|
||||
return
|
||||
|
||||
def _expand(value, count: int):
|
||||
if value is None:
|
||||
return [None] * count
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) != count:
|
||||
raise ValueError("Length of per-target parameters must match targets.")
|
||||
return list(value)
|
||||
return [value] * count
|
||||
|
||||
offsets_list = _expand(offsets, len(targets_list))
|
||||
heights_list = _expand(height_to_bottom, len(targets_list))
|
||||
rates_list = _expand(mix_rate, len(targets_list))
|
||||
|
||||
for _ in range(mix_time):
|
||||
for idx, target in enumerate(targets_list):
|
||||
offset_arg = (
|
||||
[offsets_list[idx]] if offsets_list[idx] is not None else None
|
||||
)
|
||||
height_arg = (
|
||||
[heights_list[idx]] if heights_list[idx] is not None else None
|
||||
)
|
||||
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None
|
||||
|
||||
await self.aspirate(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
await self.aspirate(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
|
||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
||||
|
||||
@@ -30,11 +30,10 @@ from pylabrobot.liquid_handling.standard import (
|
||||
ResourceMove,
|
||||
ResourceDrop,
|
||||
)
|
||||
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack, create_homogeneous_resources, create_ordered_items_2d
|
||||
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
|
||||
from unilabos.resources.itemized_carrier import ItemizedCarrier
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class PRCXIError(RuntimeError):
|
||||
@@ -137,31 +136,13 @@ class PRCXI9300Plate(Plate):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location
|
||||
# 如果是反序列化过程,Well 对象可能没有正确的 location,需要让 Plate 重新创建
|
||||
sample_value = next(iter(ordering.values()), None)
|
||||
if sample_value is not None and hasattr(sample_value, 'location'):
|
||||
# 如果是 Well 对象但 location 为 None,说明是反序列化过程
|
||||
# 让 Plate 自己创建 Well 对象
|
||||
if sample_value.location is None:
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# Well 对象有有效的 location,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
elif sample_value is None:
|
||||
# ordering 的值都是 None,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# 其他情况,直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict() # 提供空的 ordering
|
||||
|
||||
ordering_param = None
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
@@ -238,16 +219,9 @@ class PRCXI9300TipRack(TipRack):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,使用 ordering 参数
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
@@ -310,7 +284,7 @@ class PRCXI9300Trash(Trash):
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "plate",
|
||||
category: str = "trash",
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
@@ -383,16 +357,9 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items_to_pass = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,使用 ordering 参数
|
||||
items_to_pass = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
@@ -448,81 +415,6 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
class PRCXI9300PlateAdapterSite(ItemizedCarrier):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
material_info: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
# 处理 sites 参数的不同格式
|
||||
|
||||
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)
|
||||
kwargs.pop('num_items_x', None)
|
||||
kwargs.pop('num_items_y', None)
|
||||
kwargs.pop('num_items_z', None)
|
||||
kwargs.pop('sites', None)
|
||||
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
sites={name: sites},
|
||||
num_items_x=1,
|
||||
num_items_y=1,
|
||||
num_items_z=1,
|
||||
**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):
|
||||
"""重写 assign_child_resource 方法,对于适配器位置,不使用索引分配"""
|
||||
# 直接调用 Resource 的 assign_child_resource,避免 ItemizedCarrier 的索引逻辑
|
||||
from pylabrobot.resources.resource import Resource
|
||||
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
|
||||
|
||||
def unassign_child_resource(self, resource):
|
||||
"""重写 unassign_child_resource 方法,对于适配器位置,不使用 sites 列表"""
|
||||
# 直接调用 Resource 的 unassign_child_resource,避免 ItemizedCarrier 的 sites 逻辑
|
||||
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 = {}
|
||||
|
||||
# 包含 sites 配置信息,但避免序列化 ResourceHolder 对象
|
||||
if hasattr(self, 'sites') and self.sites:
|
||||
# 只保存 sites 的基本信息,不保存 ResourceHolder 对象本身
|
||||
sites_info = []
|
||||
for site in self.sites:
|
||||
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
|
||||
# 对于 pylabrobot 对象,只保存基本信息
|
||||
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
|
||||
|
||||
return data
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""加载状态,包括 sites 配置信息"""
|
||||
super().load_state(state)
|
||||
|
||||
# 从状态中恢复 sites 配置信息
|
||||
if 'sites' in state:
|
||||
self.sites = [state['sites']]
|
||||
|
||||
class PRCXI9300PlateAdapter(PlateAdapter):
|
||||
"""
|
||||
@@ -921,7 +813,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
res = await super().move_plate(
|
||||
return await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
@@ -933,12 +825,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
target_plate_number = to,
|
||||
**backend_kwargs,
|
||||
)
|
||||
plate.unassign()
|
||||
to.assign_child_resource(plate, location=Coordinate(0, 0, 0))
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
return res
|
||||
|
||||
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
||||
|
||||
@@ -4019,8 +4019,7 @@ liquid_handler:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -4176,11 +4175,9 @@ liquid_handler:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -5043,8 +5040,7 @@ liquid_handler.biomek:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -5187,11 +5183,9 @@ liquid_handler.biomek:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -9671,8 +9665,7 @@ liquid_handler.prcxi:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -9828,11 +9821,9 @@ liquid_handler.prcxi:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
|
||||
# from nt import device_encoding
|
||||
import threading
|
||||
import time
|
||||
@@ -55,7 +56,11 @@ def main(
|
||||
) -> None:
|
||||
"""主函数"""
|
||||
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
# Support restart - check if rclpy is already initialized
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
else:
|
||||
logger.info("[ROS] rclpy already initialized, reusing context")
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
# 创建主机节点
|
||||
host_node = HostNode(
|
||||
@@ -88,7 +93,7 @@ def main(
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
# lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
# )
|
||||
# )
|
||||
executor.add_node(resource_mesh_manager)
|
||||
executor.add_node(joint_republisher)
|
||||
# executor.add_node(lh_joint_pub)
|
||||
|
||||
@@ -20,6 +20,8 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from unilabos_msgs.action import SendCmd
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
@@ -790,7 +792,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def _handle_update(
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源更新操作的内部函数
|
||||
|
||||
@@ -802,6 +804,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
Returns:
|
||||
操作结果字典
|
||||
"""
|
||||
original_instances = []
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
if isinstance(plr_resource, ResourceDictInstance):
|
||||
self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新")
|
||||
@@ -861,13 +864,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().info(
|
||||
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个"
|
||||
)
|
||||
original_instances.append(original_instance)
|
||||
|
||||
# 调用driver的update回调
|
||||
func = getattr(self.driver_instance, "resource_tree_update", None)
|
||||
if callable(func):
|
||||
func(plr_resources)
|
||||
func(original_instances)
|
||||
|
||||
return {"success": True, "action": "update"}
|
||||
return {"success": True, "action": "update"}, original_instances
|
||||
|
||||
try:
|
||||
data = json.loads(req.command)
|
||||
@@ -908,14 +912,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
plr_resources.append(tree.root_node)
|
||||
else:
|
||||
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources)
|
||||
result = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||
if not BasicConfig.no_update_feedback:
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances)
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
result = _handle_remove(resources_uuid)
|
||||
|
||||
@@ -70,6 +70,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
_instance: ClassVar[Optional["HostNode"]] = None
|
||||
_ready_event: ClassVar[threading.Event] = threading.Event()
|
||||
_shutting_down: ClassVar[bool] = False # Flag to signal shutdown to background threads
|
||||
_background_threads: ClassVar[List[threading.Thread]] = [] # Track all background threads for cleanup
|
||||
_device_action_status: ClassVar[collections.defaultdict[str, DeviceActionStatus]] = collections.defaultdict(
|
||||
DeviceActionStatus
|
||||
)
|
||||
@@ -81,6 +83,48 @@ class HostNode(BaseROS2DeviceNode):
|
||||
return cls._instance
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def shutdown_background_threads(cls, timeout: float = 5.0) -> None:
|
||||
"""
|
||||
Gracefully shutdown all background threads for clean exit or restart.
|
||||
|
||||
This method:
|
||||
1. Sets shutdown flag to stop background operations
|
||||
2. Waits for background threads to finish with timeout
|
||||
3. Cleans up finished threads from tracking list
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for each thread (seconds)
|
||||
"""
|
||||
cls._shutting_down = True
|
||||
|
||||
# Wait for background threads to finish
|
||||
active_threads = []
|
||||
for t in cls._background_threads:
|
||||
if t.is_alive():
|
||||
t.join(timeout=timeout)
|
||||
if t.is_alive():
|
||||
active_threads.append(t.name)
|
||||
|
||||
if active_threads:
|
||||
logger.warning(f"[Host Node] Some background threads still running: {active_threads}")
|
||||
|
||||
# Clear the thread list
|
||||
cls._background_threads.clear()
|
||||
logger.info(f"[Host Node] Background threads shutdown complete")
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""
|
||||
Reset the HostNode singleton state for restart or clean exit.
|
||||
Call this after destroying the instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._ready_event.clear()
|
||||
cls._shutting_down = False
|
||||
cls._background_threads.clear()
|
||||
logger.info("[Host Node] State reset complete")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
@@ -294,12 +338,37 @@ class HostNode(BaseROS2DeviceNode):
|
||||
bridge.publish_host_ready()
|
||||
self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}")
|
||||
|
||||
def _send_re_register(self, sclient):
|
||||
sclient.wait_for_service()
|
||||
request = SerialCommand.Request()
|
||||
request.command = ""
|
||||
future = sclient.call_async(request)
|
||||
response = future.result()
|
||||
def _send_re_register(self, sclient, device_namespace: str):
|
||||
"""
|
||||
Send re-register command to a device. This is a one-time operation.
|
||||
|
||||
Args:
|
||||
sclient: The service client
|
||||
device_namespace: The device namespace for logging
|
||||
"""
|
||||
try:
|
||||
# Use timeout to prevent indefinite blocking
|
||||
if not sclient.wait_for_service(timeout_sec=10.0):
|
||||
self.lab_logger().debug(f"[Host Node] Re-register timeout for {device_namespace}")
|
||||
return
|
||||
|
||||
# Check shutdown flag after wait
|
||||
if self._shutting_down:
|
||||
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (shutdown)")
|
||||
return
|
||||
|
||||
request = SerialCommand.Request()
|
||||
request.command = ""
|
||||
future = sclient.call_async(request)
|
||||
# Use timeout for result as well
|
||||
future.result(timeout_sec=5.0)
|
||||
self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}")
|
||||
except Exception as e:
|
||||
# Gracefully handle destruction during shutdown
|
||||
if "destruction was requested" in str(e) or self._shutting_down:
|
||||
self.lab_logger().debug(f"[Host Node] Re-register aborted for {device_namespace} (cleanup)")
|
||||
else:
|
||||
self.lab_logger().warning(f"[Host Node] Re-register failed for {device_namespace}: {e}")
|
||||
|
||||
def _discover_devices(self) -> None:
|
||||
"""
|
||||
@@ -331,23 +400,27 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._create_action_clients_for_device(device_id, namespace)
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
t = threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
args=(sclient, namespace),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
)
|
||||
self._background_threads.append(t)
|
||||
t.start()
|
||||
elif device_key not in self._online_devices:
|
||||
# 设备重新上线
|
||||
self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}")
|
||||
self._online_devices.add(device_key)
|
||||
sclient = self.create_client(SerialCommand, f"/srv{namespace}/re_register_device")
|
||||
threading.Thread(
|
||||
t = threading.Thread(
|
||||
target=self._send_re_register,
|
||||
args=(sclient,),
|
||||
args=(sclient, namespace),
|
||||
daemon=True,
|
||||
name=f"ROSDevice{self.device_id}_re_register_device_{namespace}",
|
||||
).start()
|
||||
)
|
||||
self._background_threads.append(t)
|
||||
t.start()
|
||||
|
||||
# 检测离线设备
|
||||
offline_devices = self._online_devices - current_devices
|
||||
@@ -705,13 +778,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k,v in obj.items():
|
||||
for k, v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
@@ -742,9 +816,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted")
|
||||
self._goals[item.job_id] = goal_handle
|
||||
goal_future = goal_handle.get_result_async()
|
||||
goal_future.add_done_callback(
|
||||
lambda f: self.get_result_callback(item, action_id, f)
|
||||
)
|
||||
goal_future.add_done_callback(lambda f: self.get_result_callback(item, action_id, f))
|
||||
goal_future.result()
|
||||
|
||||
def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None:
|
||||
@@ -1167,6 +1239,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
try:
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
data = json.loads(request.command)
|
||||
if "uuid" in data and data["uuid"] is not None:
|
||||
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 542,
|
||||
"height": 374,
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
@@ -21,12 +21,12 @@
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": false,
|
||||
"debug": true,
|
||||
"setup": true,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": false,
|
||||
"simulator": true,
|
||||
"channel_num": 2
|
||||
},
|
||||
"data": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 700,
|
||||
"y": 240,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
@@ -50,8 +50,8 @@
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
@@ -83,7 +83,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -96,7 +96,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
@@ -128,7 +128,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -141,7 +141,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T2",
|
||||
@@ -173,7 +173,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -186,7 +186,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T3",
|
||||
@@ -218,7 +218,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -231,7 +231,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T4",
|
||||
@@ -263,7 +263,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -276,7 +276,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T5",
|
||||
@@ -308,7 +308,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -321,7 +321,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T6",
|
||||
@@ -353,7 +353,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -366,7 +366,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T7",
|
||||
@@ -398,7 +398,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -411,7 +411,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T8",
|
||||
@@ -443,7 +443,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -456,7 +456,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T9",
|
||||
@@ -488,7 +488,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -501,7 +501,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T10",
|
||||
@@ -533,7 +533,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -546,7 +546,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T11",
|
||||
@@ -578,7 +578,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -591,7 +591,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T12",
|
||||
@@ -623,7 +623,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -636,7 +636,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T13",
|
||||
@@ -668,7 +668,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -681,7 +681,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T14",
|
||||
@@ -713,7 +713,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -726,7 +726,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T15",
|
||||
@@ -758,7 +758,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"type": "PRCXI9300Container",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -771,7 +771,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
|
||||
"ordering": {},
|
||||
"sites": [
|
||||
{
|
||||
"label": "T16",
|
||||
@@ -40712,7 +40712,7 @@
|
||||
|
||||
"children": [],
|
||||
"parent": "T16",
|
||||
"type": "trash",
|
||||
"type": "plate",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.14</version>
|
||||
<version>0.10.15</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user