mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 13:25:13 +00:00
Compare commits
26 Commits
470d7283e4
...
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,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]]
|
||||
|
||||
|
||||
@@ -848,7 +848,7 @@ class MessageProcessor:
|
||||
device_action_groups[key_add].append(item["uuid"])
|
||||
|
||||
logger.info(
|
||||
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||
)
|
||||
else:
|
||||
# 正常update
|
||||
@@ -863,11 +863,11 @@ class MessageProcessor:
|
||||
device_action_groups[key] = []
|
||||
device_action_groups[key].append(item["uuid"])
|
||||
|
||||
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||
|
||||
# 为每个(device_id, action)创建独立的更新线程
|
||||
for (device_id, actual_action), items in device_action_groups.items():
|
||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||
|
||||
def _notify_resource_tree(dev_id, act, item_list):
|
||||
try:
|
||||
|
||||
@@ -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,11 @@ 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):
|
||||
"""Lilith 返回 Success=false 时抛出的业务异常"""
|
||||
@@ -71,10 +71,7 @@ class PRCXI9300Deck(Deck):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = []
|
||||
|
||||
for i in range(0, 16):
|
||||
self.slot_locations.append(Coordinate((i%4)*137.5+5, (3-int(i/4))*96+13, 0))
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
@@ -139,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,
|
||||
@@ -240,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
|
||||
@@ -312,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):
|
||||
|
||||
@@ -376,24 +348,18 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
ordering_param = None
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,这种情况下我们让 TubeRack 使用默认行为
|
||||
# 不在初始化时创建 items,而是在 deserialize 后处理
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象
|
||||
items_to_pass = None
|
||||
ordering_param = collections.OrderedDict() # 提供空的 ordering 来满足要求
|
||||
# 保存 ordering 信息以便后续处理
|
||||
self._temp_ordering = ordering
|
||||
# 使用 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:
|
||||
# 如果没有有效对象,创建空的 ordered_items
|
||||
items_to_pass = {}
|
||||
ordering_param = None
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
@@ -404,50 +370,25 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items_to_pass is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items_to_pass,
|
||||
model=model,
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordered_items=items_to_pass,
|
||||
model=model,
|
||||
**kwargs)
|
||||
elif ordering_param is not None:
|
||||
# 直接调用 ItemizedResource 的构造函数来处理 ordering
|
||||
from pylabrobot.resources import ItemizedResource
|
||||
ItemizedResource.__init__(self, name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
category=category,
|
||||
model=model,
|
||||
**kwargs)
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
model=model,
|
||||
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
ordering=ordering_param,
|
||||
model=model,
|
||||
**kwargs)
|
||||
|
||||
else:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
model=model,
|
||||
**kwargs)
|
||||
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
# 如果有临时 ordering 信息,在初始化完成后处理
|
||||
if hasattr(self, '_temp_ordering') and self._temp_ordering:
|
||||
self._process_temp_ordering()
|
||||
|
||||
def _process_temp_ordering(self):
|
||||
"""处理临时的 ordering 信息,创建相应的 Tube 对象"""
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
|
||||
for location, item_type in self._temp_ordering.items():
|
||||
if item_type == 'Tube' or item_type == 'tube':
|
||||
# 为每个位置创建 Tube 对象
|
||||
tube = Tube(name=f"{self.name}_{location}", size_x=10, size_y=10, size_z=50, max_volume=2000.0)
|
||||
# 使用 assign_child_resource 添加到 rack 中
|
||||
self.assign_child_resource(tube, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 清理临时数据
|
||||
del self._temp_ordering
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""从给定的状态加载工作台信息。"""
|
||||
# super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
@@ -474,97 +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)
|
||||
sites_in = kwargs.pop('sites', None)
|
||||
|
||||
# 创建默认的sites字典
|
||||
sites_dict = {name: sites}
|
||||
# 优先从 sites_in 读取 'content_type',否则使用默认值
|
||||
|
||||
content_type = [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
]
|
||||
# 如果提供了sites参数,则用sites_in中的值替换sites_dict中对应的元素
|
||||
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):
|
||||
"""重写 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):
|
||||
"""
|
||||
@@ -660,51 +510,16 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
step_mode=False,
|
||||
matrix_id="",
|
||||
is_9320=False,
|
||||
start_rail=2,
|
||||
rail_nums=4,
|
||||
rail_interval=0,
|
||||
x_increase = -0.003636,
|
||||
y_increase = -0.003636,
|
||||
x_offset = -0.8,
|
||||
y_offset = -37.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
|
||||
|
||||
tablets_info = {}
|
||||
plate_positions = []
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
number = int(child.name.replace("T", ""))
|
||||
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
tablets_info[number] = child.children[0]._unilabos_state["Material"].get("uuid", "730067cf07ae43849ddf4034299030e9")
|
||||
else:
|
||||
tablets_info[number] = "730067cf07ae43849ddf4034299030e9"
|
||||
else:
|
||||
tablets_info[number] = "730067cf07ae43849ddf4034299030e9"
|
||||
pos = self.plr_pos_to_prcxi(child)
|
||||
plate_positions.append(
|
||||
{
|
||||
"Number": number,
|
||||
"XPos": pos.x,
|
||||
"YPos": pos.y,
|
||||
"ZPos": pos.z
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"])
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
@@ -714,38 +529,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
self.step_mode = step_mode
|
||||
else:
|
||||
print("9300设备不支持 单点动作模式")
|
||||
|
||||
self._unilabos_backend = PRCXI9300Backend(
|
||||
tablets_info, plate_positions, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320,
|
||||
x_increase, y_increase, x_offset, y_offset,
|
||||
deck_z, deck_x=self.deck_x, deck_y=self.deck_y, xy_coupling=xy_coupling
|
||||
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)
|
||||
|
||||
def plr_pos_to_prcxi(self, resource: Resource):
|
||||
resource_pos = resource.get_absolute_location(x="c",y="c",z="t")
|
||||
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),self.deck_x)
|
||||
prcxi_y = min(max(0, prcxi_y),self.deck_y)
|
||||
prcxi_z = min(max(0, prcxi_z),self.deck_z)
|
||||
|
||||
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
super().post_init(ros_node)
|
||||
@@ -1026,7 +813,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
res = await super().move_plate(
|
||||
return await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
@@ -1038,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。
|
||||
@@ -1067,7 +848,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
def __init__(
|
||||
self,
|
||||
tablets_info: list[WorkTablets],
|
||||
plate_positions: dict[int, Coordinate],
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 9999,
|
||||
timeout: float = 10.0,
|
||||
@@ -1076,19 +856,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
setup=True,
|
||||
debug=False,
|
||||
matrix_id="",
|
||||
is_9320=False,
|
||||
x_increase = 0,
|
||||
y_increase = 0,
|
||||
x_offset = 0,
|
||||
y_offset = 0,
|
||||
deck_z = 300,
|
||||
deck_x = 0,
|
||||
deck_y = 0,
|
||||
xy_coupling = 0.0,
|
||||
is_9320=False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tablets_info = tablets_info
|
||||
self.plate_positions = plate_positions
|
||||
self.matrix_id = matrix_id
|
||||
self.api_client = PRCXI9300Api(host, port, timeout, axis, debug, is_9320)
|
||||
self.host, self.port, self.timeout = host, port, timeout
|
||||
@@ -1096,15 +867,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._execute_setup = setup
|
||||
self.debug = debug
|
||||
self.axis = "Left"
|
||||
self.x_increase = x_increase
|
||||
self.y_increase = y_increase
|
||||
self.xy_coupling = xy_coupling
|
||||
self.x_offset = x_offset
|
||||
self.y_offset = y_offset
|
||||
self.deck_x = deck_x
|
||||
self.deck_y = deck_y
|
||||
self.deck_z = deck_z
|
||||
self.tip_length = 0
|
||||
|
||||
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
|
||||
step = self.api_client.shaker_action(
|
||||
@@ -1134,11 +896,13 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
|
||||
async def drop_resource(self, drop: ResourceDrop, **backend_kwargs):
|
||||
|
||||
|
||||
plate_number = None
|
||||
target_plate_number = backend_kwargs.get("target_plate_number", None)
|
||||
if target_plate_number is not None:
|
||||
plate_number = int(target_plate_number.name.replace("T", ""))
|
||||
|
||||
|
||||
is_whole_plate = True
|
||||
balance_height = 0
|
||||
if plate_number is None:
|
||||
@@ -1156,42 +920,29 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def create_protocol(self, protocol_name):
|
||||
if protocol_name == "":
|
||||
protocol_name = f"protocol_{time.time()}"
|
||||
self.protocol_name = protocol_name
|
||||
self.steps_todo_list = []
|
||||
|
||||
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):
|
||||
assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first."
|
||||
run_time = time.time()
|
||||
solution_id = self.api_client.add_solution(
|
||||
f"protocol_{run_time}", self.matrix_id, self.steps_todo_list
|
||||
self.matrix_info = MatrixInfo(
|
||||
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(
|
||||
f"protocol_{run_time}", self.matrix_info["MatrixId"], self.steps_todo_list
|
||||
)
|
||||
else:
|
||||
print(f"PRCXI9300Backend using predefined worktable {self.matrix_id}, skipping matrix creation.")
|
||||
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}")
|
||||
self.api_client.load_solution(solution_id)
|
||||
print(json.dumps(self.steps_todo_list, indent=2))
|
||||
@@ -1234,9 +985,6 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
print("PRCXI9300 reset successfully.")
|
||||
|
||||
self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions)
|
||||
|
||||
except ConnectionRefusedError as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. "
|
||||
@@ -1285,7 +1033,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self._num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Load(
|
||||
@@ -1298,7 +1046,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
blending_times=0,
|
||||
balance_height=0,
|
||||
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
|
||||
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels != 8 else "1,2,3,4,5",
|
||||
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5",
|
||||
)
|
||||
self.steps_todo_list.append(step)
|
||||
|
||||
@@ -1359,7 +1107,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
|
||||
if self.num_channels != 8:
|
||||
if self.channel_num == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.UnLoad(
|
||||
@@ -1411,7 +1159,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
assert mix_time > 0
|
||||
@@ -1468,7 +1216,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
PlateNo = plate_indexes[0] + 1
|
||||
hole_col = tip_columns[0] + 1
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Imbibing(
|
||||
@@ -1526,7 +1274,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
hole_col = tip_columns[0] + 1
|
||||
|
||||
hole_row = 1
|
||||
if self.num_channels != 8:
|
||||
if self.num_channels == 1:
|
||||
hole_row = tipspot_index % 8 + 1
|
||||
|
||||
step = self.api_client.Tapping(
|
||||
@@ -1735,13 +1483,6 @@ class PRCXI9300Api:
|
||||
"""GetWorkTabletMatrixById"""
|
||||
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 add_WorkTablet_Matrix(self, matrix: MatrixInfo):
|
||||
return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix])
|
||||
|
||||
@@ -2015,82 +1756,82 @@ class DefaultLayout:
|
||||
{
|
||||
"Number": 1,
|
||||
"Code": "T1",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 2,
|
||||
"Code": "T2",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 3,
|
||||
"Code": "T3",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 4,
|
||||
"Code": "T4",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 5,
|
||||
"Code": "T5",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 6,
|
||||
"Code": "T6",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 7,
|
||||
"Code": "T7",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 8,
|
||||
"Code": "T8",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 9,
|
||||
"Code": "T9",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 10,
|
||||
"Code": "T10",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 11,
|
||||
"Code": "T11",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 12,
|
||||
"Code": "T12",
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9"},
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
|
||||
}, # 这个设置成废液槽,用储液槽表示
|
||||
{
|
||||
"Number": 13,
|
||||
"Code": "T13",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 14,
|
||||
"Code": "T14",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 15,
|
||||
"Code": "T15",
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f"},
|
||||
"Material": {"uuid": "57b1e4711e9e4a32b529f3132fc5931f", "materialEnum": 0},
|
||||
},
|
||||
{
|
||||
"Number": 16,
|
||||
"Code": "T16",
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9"},
|
||||
"Material": {"uuid": "730067cf07ae43849ddf4034299030e9", "materialEnum": 0},
|
||||
}, # 这个设置成垃圾桶,用储液槽表示
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -9284,13 +9278,7 @@ liquid_handler.prcxi:
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
handles:
|
||||
input:
|
||||
- data_key: wells
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: input_wells
|
||||
label: InputWells
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
wells: unilabos_resources
|
||||
result: {}
|
||||
@@ -9677,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:
|
||||
- ''
|
||||
@@ -9834,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
|
||||
|
||||
@@ -124,32 +124,17 @@ class Registry:
|
||||
"output": [
|
||||
{
|
||||
"handler_key": "labware",
|
||||
"data_type": "resource",
|
||||
"label": "Labware",
|
||||
"data_source": "executor",
|
||||
"data_key": "created_resource_tree.@flatten",
|
||||
},
|
||||
{
|
||||
"handler_key": "liquid_slots",
|
||||
"data_type": "resource",
|
||||
"label": "LiquidSlots",
|
||||
"data_source": "executor",
|
||||
"data_key": "liquid_input_resource_tree.@flatten",
|
||||
},
|
||||
{
|
||||
"handler_key": "materials",
|
||||
"data_type": "resource",
|
||||
"label": "AllMaterials",
|
||||
"data_source": "executor",
|
||||
"data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten",
|
||||
},
|
||||
"data_source": "handle",
|
||||
"data_key": "liquid",
|
||||
}
|
||||
]
|
||||
},
|
||||
"placeholder_keys": {
|
||||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||||
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
||||
"class_name": "unilabos_class",
|
||||
},
|
||||
},
|
||||
"test_latency": {
|
||||
@@ -201,17 +186,7 @@ class Registry:
|
||||
"resources": "unilabos_resources",
|
||||
},
|
||||
"goal_default": {},
|
||||
"handles": {
|
||||
"input": [
|
||||
{
|
||||
"handler_key": "input_resources",
|
||||
"data_type": "resource",
|
||||
"label": "InputResources",
|
||||
"data_source": "handle",
|
||||
"data_key": "resources", # 不为空
|
||||
},
|
||||
]
|
||||
},
|
||||
"handles": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ class RegularContainer(Container):
|
||||
def get_regular_container(name="container"):
|
||||
r = RegularContainer(name=name)
|
||||
r.category = "container"
|
||||
return r
|
||||
return RegularContainer(name=name)
|
||||
|
||||
#
|
||||
# class RegularContainer(object):
|
||||
|
||||
@@ -1151,7 +1151,11 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
|
||||
if resource_class_config["type"] == "pylabrobot":
|
||||
resource_plr = RESOURCE(name=resource_config["name"])
|
||||
if resource_type != ResourcePLR:
|
||||
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
|
||||
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr])
|
||||
# r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None))
|
||||
# # r = resource_plr_to_ulab(resource_plr=resource_plr)
|
||||
# if resource_config.get("position") is not None:
|
||||
# r["position"] = resource_config["position"]
|
||||
r = tree_sets.dump()
|
||||
else:
|
||||
r = resource_plr
|
||||
|
||||
@@ -79,7 +79,6 @@ class ItemizedCarrier(ResourcePLR):
|
||||
category: Optional[str] = "carrier",
|
||||
model: Optional[str] = None,
|
||||
invisible_slots: Optional[str] = None,
|
||||
content_type: Optional[List[str]] = ["bottle", "container", "tube", "bottle_carrier", "tip_rack"],
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
@@ -93,7 +92,6 @@ class ItemizedCarrier(ResourcePLR):
|
||||
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
|
||||
self.invisible_slots = [] if invisible_slots is None else invisible_slots
|
||||
self.layout = "z-y" if self.num_items_z > 1 and self.num_items_x == 1 else "x-z" if self.num_items_z > 1 and self.num_items_y == 1 else "x-y"
|
||||
self.content_type = content_type
|
||||
|
||||
if isinstance(sites, dict):
|
||||
sites = sites or {}
|
||||
@@ -421,7 +419,7 @@ class ItemizedCarrier(ResourcePLR):
|
||||
self[identifier] if isinstance(self[identifier], str) else None,
|
||||
"position": {"x": location.x, "y": location.y, "z": location.z},
|
||||
"size": self.child_size[identifier],
|
||||
"content_type": self.content_type
|
||||
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]
|
||||
} for identifier, location in self.child_locations.items()]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import inspect
|
||||
import traceback
|
||||
import uuid
|
||||
from pydantic import BaseModel, field_serializer, field_validator, ValidationError
|
||||
from pydantic import BaseModel, field_serializer, field_validator
|
||||
from pydantic import Field
|
||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||
|
||||
@@ -147,24 +147,20 @@ class ResourceDictInstance(object):
|
||||
if not content.get("extra"): # MagicCode
|
||||
content["extra"] = {}
|
||||
if "position" in content:
|
||||
pose = content.get("pose", {})
|
||||
if "position" not in pose:
|
||||
pose = content.get("pose",{})
|
||||
if "position" not in pose :
|
||||
if "position" in content["position"]:
|
||||
pose["position"] = content["position"]["position"]
|
||||
else:
|
||||
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
||||
if "size" not in pose:
|
||||
pose["size"] = {
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0),
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0)
|
||||
}
|
||||
content["pose"] = pose
|
||||
try:
|
||||
res_dict = ResourceDict.model_validate(content)
|
||||
return ResourceDictInstance(res_dict)
|
||||
except ValidationError as err:
|
||||
raise err
|
||||
return ResourceDictInstance(ResourceDict.model_validate(content))
|
||||
|
||||
def get_plr_nested_dict(self) -> Dict[str, Any]:
|
||||
"""获取资源实例的嵌套字典表示"""
|
||||
@@ -326,7 +322,7 @@ class ResourceTreeSet(object):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
|
||||
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet":
|
||||
"""
|
||||
从plr资源创建ResourceTreeSet
|
||||
"""
|
||||
@@ -343,8 +339,6 @@ class ResourceTreeSet(object):
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
elif source is None:
|
||||
return ""
|
||||
else:
|
||||
print("转换pylabrobot的时候,出现未知类型", source)
|
||||
return source
|
||||
@@ -355,8 +349,7 @@ class ResourceTreeSet(object):
|
||||
if not uid:
|
||||
uid = str(uuid.uuid4())
|
||||
res.unilabos_uuid = uid
|
||||
if not known_newly_created:
|
||||
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
||||
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
||||
|
||||
# 获取unilabos_extra,默认为空字典
|
||||
extra = getattr(res, "unilabos_extra", {})
|
||||
@@ -455,13 +448,7 @@ class ResourceTreeSet(object):
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
|
||||
# 类型映射
|
||||
TYPE_MAP = {
|
||||
"plate": "Plate",
|
||||
"well": "Well",
|
||||
"deck": "Deck",
|
||||
"container": "RegularContainer",
|
||||
"tip_spot": "TipSpot",
|
||||
}
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
|
||||
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
|
||||
@@ -931,33 +918,6 @@ class DeviceNodeResourceTracker(object):
|
||||
|
||||
return self._traverse_and_process(resource, process)
|
||||
|
||||
def loop_find_with_uuid(self, resource, target_uuid: str):
|
||||
"""
|
||||
递归遍历资源树,根据 uuid 查找并返回对应的资源
|
||||
|
||||
Args:
|
||||
resource: 资源对象(可以是list、dict或实例)
|
||||
target_uuid: 要查找的uuid
|
||||
|
||||
Returns:
|
||||
找到的资源对象,未找到则返回None
|
||||
"""
|
||||
found_resource = None
|
||||
|
||||
def process(res):
|
||||
nonlocal found_resource
|
||||
if found_resource is not None:
|
||||
return 0 # 已找到,跳过后续处理
|
||||
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
|
||||
if current_uuid and current_uuid == target_uuid:
|
||||
found_resource = res
|
||||
logger.trace(f"找到资源UUID: {target_uuid}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
self._traverse_and_process(resource, process)
|
||||
return found_resource
|
||||
|
||||
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
|
||||
"""
|
||||
递归遍历资源树,根据 name 设置所有节点的 extra
|
||||
@@ -1143,7 +1103,7 @@ class DeviceNodeResourceTracker(object):
|
||||
for key in keys_to_remove:
|
||||
self.resource2parent_resource.pop(key, None)
|
||||
|
||||
logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
|
||||
logger.debug(f"成功移除资源: {resource}")
|
||||
return True
|
||||
|
||||
def clear_resource(self):
|
||||
|
||||
@@ -159,14 +159,10 @@ _msg_converter: Dict[Type, Any] = {
|
||||
else Pose()
|
||||
),
|
||||
config=json.dumps(x.get("config", {})),
|
||||
data=json.dumps(obtain_data_with_uuid(x)),
|
||||
data=json.dumps(x.get("data", {})),
|
||||
),
|
||||
}
|
||||
|
||||
def obtain_data_with_uuid(x: dict):
|
||||
data = x.get("data", {})
|
||||
data["unilabos_uuid"] = x.get("uuid", None)
|
||||
return data
|
||||
|
||||
def json_or_yaml_loads(data: str) -> Any:
|
||||
try:
|
||||
@@ -374,35 +370,9 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
|
||||
setattr(ros_msg, key, []) # FIXME
|
||||
elif "array.array" in str(type(attr)):
|
||||
if attr.typecode == "f" or attr.typecode == "d":
|
||||
# 如果是单个值,转换为列表
|
||||
if value is None:
|
||||
value = []
|
||||
elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
||||
value = [value]
|
||||
setattr(ros_msg, key, [float(i) for i in value])
|
||||
else:
|
||||
# 对于整数数组,需要确保是序列且每个值在有效范围内
|
||||
if value is None:
|
||||
value = []
|
||||
elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
||||
# 如果是单个值,转换为列表
|
||||
value = [value]
|
||||
# 确保每个整数值在有效范围内(-2147483648 到 2147483647)
|
||||
converted_value = []
|
||||
for i in value:
|
||||
if i is None:
|
||||
continue # 跳过 None 值
|
||||
if isinstance(i, (int, float)):
|
||||
int_val = int(i)
|
||||
# 确保在 int32 范围内
|
||||
if int_val < -2147483648:
|
||||
int_val = -2147483648
|
||||
elif int_val > 2147483647:
|
||||
int_val = 2147483647
|
||||
converted_value.append(int_val)
|
||||
else:
|
||||
converted_value.append(i)
|
||||
setattr(ros_msg, key, converted_value)
|
||||
setattr(ros_msg, key, value)
|
||||
else:
|
||||
nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
|
||||
setattr(ros_msg, key, nested_ros_msg)
|
||||
|
||||
@@ -392,12 +392,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
parent_resource = self.resource_tracker.figure_resource(
|
||||
{"name": bind_parent_id}
|
||||
)
|
||||
for r in rts.root_nodes:
|
||||
# noinspection PyUnresolvedReferences
|
||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||
else:
|
||||
for r in rts.root_nodes:
|
||||
r.res_content.parent_uuid = self.uuid
|
||||
for r in rts.root_nodes:
|
||||
# noinspection PyUnresolvedReferences
|
||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||
|
||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
||||
# noinspection PyTypeChecker
|
||||
@@ -433,14 +430,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
plr_instances = rts.to_plr_resources()
|
||||
for plr_instance in plr_instances:
|
||||
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
|
||||
rts: ResourceTreeSet = ResourceTreeSet.from_plr_resources(plr_instances)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
||||
final_response = {
|
||||
"created_resource_tree": rts.dump(),
|
||||
"liquid_input_resource_tree": [],
|
||||
"created_resources": rts.dump(),
|
||||
"liquid_input_resources": [],
|
||||
}
|
||||
res.response = json.dumps(final_response)
|
||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||
@@ -466,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return res
|
||||
try:
|
||||
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||
plr_instance = plr_instances[0]
|
||||
plr_instance = rts.to_plr_resources()[0]
|
||||
if isinstance(plr_instance, Plate):
|
||||
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
@@ -491,7 +485,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
res.response = json.dumps(final_response)
|
||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
@@ -659,71 +653,61 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def transfer_to_new_resource(
|
||||
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
|
||||
) -> Optional["ResourcePLR"]:
|
||||
):
|
||||
parent_uuid = tree.root_node.res_content.parent_uuid
|
||||
if not parent_uuid:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource} parent未知,挂载到当前节点下,额外参数:{additional_add_params}"
|
||||
)
|
||||
return None
|
||||
if parent_uuid == self.uuid:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载到{self.identifier},额外参数:{additional_add_params}"
|
||||
)
|
||||
return None
|
||||
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
|
||||
if parent_resource is None:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步
|
||||
additional_params = {}
|
||||
extra = getattr(plr_resource, "unilabos_extra", {})
|
||||
if len(extra):
|
||||
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
|
||||
if "update_resource_site" in extra:
|
||||
additional_add_params["site"] = extra["update_resource_site"]
|
||||
site = additional_add_params.get("site", None)
|
||||
spec = inspect.signature(parent_resource.assign_child_resource)
|
||||
if "spot" in spec.parameters:
|
||||
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
|
||||
if ordering_dict:
|
||||
site = list(ordering_dict.keys()).index(site)
|
||||
additional_params["spot"] = site
|
||||
old_parent = plr_resource.parent
|
||||
if old_parent is not None:
|
||||
# plr并不支持同一个deck的加载和卸载
|
||||
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
|
||||
old_parent.unassign_child_resource(plr_resource)
|
||||
if parent_uuid:
|
||||
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
|
||||
if parent_resource is None:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# 特殊兼容所有plr的物料的assign方法,和create_resource append_resource后期同步
|
||||
additional_params = {}
|
||||
extra = getattr(plr_resource, "unilabos_extra", {})
|
||||
if len(extra):
|
||||
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
|
||||
if "update_resource_site" in extra:
|
||||
additional_add_params["site"] = extra["update_resource_site"]
|
||||
site = additional_add_params.get("site", None)
|
||||
spec = inspect.signature(parent_resource.assign_child_resource)
|
||||
if "spot" in spec.parameters:
|
||||
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
|
||||
if ordering_dict:
|
||||
site = list(ordering_dict.keys()).index(site)
|
||||
additional_params["spot"] = site
|
||||
old_parent = plr_resource.parent
|
||||
if old_parent is not None:
|
||||
# plr并不支持同一个deck的加载和卸载
|
||||
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
|
||||
old_parent.unassign_child_resource(plr_resource)
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
|
||||
)
|
||||
|
||||
# ⭐ assign 之前,需要从 resources 列表中移除
|
||||
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
||||
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
||||
resource_id = id(plr_resource)
|
||||
for i, r in enumerate(self.resource_tracker.resources):
|
||||
if id(r) == resource_id:
|
||||
self.resource_tracker.resources.pop(i)
|
||||
self.lab_logger().debug(
|
||||
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
||||
)
|
||||
break
|
||||
# ⭐ assign 之前,需要从 resources 列表中移除
|
||||
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
||||
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
||||
resource_id = id(plr_resource)
|
||||
for i, r in enumerate(self.resource_tracker.resources):
|
||||
if id(r) == resource_id:
|
||||
self.resource_tracker.resources.pop(i)
|
||||
self.lab_logger().debug(
|
||||
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
||||
)
|
||||
break
|
||||
|
||||
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
|
||||
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
|
||||
|
||||
func = getattr(self.driver_instance, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
||||
func(old_parent, plr_resource, parent_resource)
|
||||
return parent_resource
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
|
||||
)
|
||||
func = getattr(self.driver_instance, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
||||
func(old_parent, plr_resource, parent_resource)
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(
|
||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
"""
|
||||
@@ -738,7 +722,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def _handle_add(
|
||||
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
处理资源添加操作的内部函数
|
||||
|
||||
@@ -750,20 +734,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
Returns:
|
||||
操作结果字典
|
||||
"""
|
||||
parents = [] # 放的是被变更的物料 / 被变更的物料父级
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
self.resource_tracker.add_resource(plr_resource)
|
||||
parent = self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
parents.append(parent)
|
||||
else:
|
||||
parents.append(plr_resource)
|
||||
self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
|
||||
|
||||
func = getattr(self.driver_instance, "resource_tree_add", None)
|
||||
if callable(func):
|
||||
func(plr_resources)
|
||||
|
||||
return {"success": True, "action": "add"}, parents
|
||||
return {"success": True, "action": "add"}
|
||||
|
||||
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -798,11 +777,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if plr_resource.parent is not None:
|
||||
plr_resource.parent.unassign_child_resource(plr_resource)
|
||||
self.resource_tracker.remove_resource(plr_resource)
|
||||
self.lab_logger().info(f"[资源同步] 移除物料 {plr_resource} 及其子节点")
|
||||
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点")
|
||||
|
||||
for other_plr_resource in other_plr_resources:
|
||||
self.resource_tracker.remove_resource(other_plr_resource)
|
||||
self.lab_logger().info(f"[资源同步] 移除物料 {other_plr_resource} 及其子节点")
|
||||
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -834,16 +813,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
||||
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
|
||||
)
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
parent_appended = False
|
||||
|
||||
# Update操作中包含改名:需要先remove再add,这里更新父节点即可
|
||||
if not not_same_parent and old_name != new_name:
|
||||
# Update操作中包含改名:需要先remove再add
|
||||
if original_instance.name != plr_resource.name:
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
|
||||
|
||||
# 收集所有相关的uuid(包括子节点)
|
||||
@@ -852,10 +826,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
_handle_add([original_instance], tree_set, additional_add_params)
|
||||
|
||||
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
|
||||
original_instances.append(original_parent_resource)
|
||||
parent_appended = True
|
||||
|
||||
# 常规更新:不涉及改名
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
|
||||
self.lab_logger().info(
|
||||
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
|
||||
f"目标父节点{target_parent_resource_uuid} 更新"
|
||||
@@ -866,12 +842,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
|
||||
|
||||
# 如果父节点变化,需要重新挂载
|
||||
if not_same_parent:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
original_instances.append(parent)
|
||||
parent_appended = True
|
||||
if (
|
||||
original_parent_resource_uuid != target_parent_resource_uuid
|
||||
and original_parent_resource is not None
|
||||
):
|
||||
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
else:
|
||||
# 判断是否变更了resource_site,重新登记
|
||||
# 判断是否变更了resource_site
|
||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||
@@ -879,10 +856,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
site_index = sites.index(original_instance)
|
||||
site_name = site_names[site_index]
|
||||
if site_name != target_site:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
original_instances.append(parent)
|
||||
parent_appended = True
|
||||
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
|
||||
# 加载状态
|
||||
original_instance.load_all_state(states)
|
||||
@@ -890,8 +864,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().info(
|
||||
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个"
|
||||
)
|
||||
if not parent_appended:
|
||||
original_instances.append(original_instance)
|
||||
original_instances.append(original_instance)
|
||||
|
||||
# 调用driver的update回调
|
||||
func = getattr(self.driver_instance, "resource_tree_update", None)
|
||||
@@ -908,8 +881,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||
self.lab_logger().trace(
|
||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||
self.lab_logger().info(
|
||||
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
@@ -921,20 +894,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if tree_set is None:
|
||||
raise ValueError("tree_set不能为None")
|
||||
plr_resources = tree_set.to_plr_resources()
|
||||
result, parents = _handle_add(plr_resources, tree_set, additional_add_params)
|
||||
parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None]
|
||||
# de_dupe_parents = list(set(parents))
|
||||
# Fix unhashable type error for WareHouse
|
||||
de_dupe_parents = []
|
||||
_seen_ids = set()
|
||||
for p in parents:
|
||||
if id(p) not in _seen_ids:
|
||||
_seen_ids.add(id(p))
|
||||
de_dupe_parents.append(p)
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重
|
||||
for tree in new_tree_set.trees:
|
||||
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
result = _handle_add(plr_resources, tree_set, additional_add_params)
|
||||
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources)
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
@@ -953,10 +914,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
|
||||
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) # 去重
|
||||
for tree in new_tree_set.trees:
|
||||
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
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一致
|
||||
@@ -976,15 +934,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 返回处理结果
|
||||
result_json = {"results": results, "total": len(data)}
|
||||
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
|
||||
# self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
|
||||
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Invalid JSON format: {str(e)}"
|
||||
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error: {str(e)}"
|
||||
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||
|
||||
@@ -1305,8 +1263,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
|
||||
|
||||
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
|
||||
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}")
|
||||
self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
||||
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
||||
error_skip = False
|
||||
# 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过
|
||||
if action_name not in ["create_resource_detailed", "create_resource"]:
|
||||
@@ -1322,14 +1279,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 批量查询资源
|
||||
queried_resources = []
|
||||
for resource_data in resource_inputs:
|
||||
unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
|
||||
if unilabos_uuid is None:
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
else:
|
||||
resource_tree = await self.get_resource([unilabos_uuid])
|
||||
plr_resource = resource_tree.to_plr_resources()[0]
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources.append(plr_resource)
|
||||
@@ -1378,8 +1330,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
|
||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -1399,9 +1352,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -1469,7 +1421,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
for r in rs:
|
||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||
else:
|
||||
res = self.resource_tracker.parent_resource(rs)
|
||||
res = self.resource_tracker.parent_resource(r)
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
@@ -1545,7 +1497,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_data = function_args[arg_name]
|
||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||
try:
|
||||
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
||||
converted_resource = self._convert_resource_sync(resource_data)
|
||||
function_args[arg_name] = converted_resource
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
@@ -1559,8 +1512,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_list = function_args[arg_name]
|
||||
if isinstance(resource_list, list):
|
||||
try:
|
||||
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||
converted_resources = []
|
||||
for resource_data in resource_list:
|
||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||
converted_resource = self._convert_resource_sync(resource_data)
|
||||
converted_resources.append(converted_resource)
|
||||
function_args[arg_name] = converted_resources
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
@@ -1573,27 +1530,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]:
|
||||
"""同步转换资源 UUID 为实例
|
||||
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
|
||||
"""同步转换资源数据为实例"""
|
||||
# 创建资源查询请求
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{
|
||||
"id": resource_data.get("id", None),
|
||||
"uuid": resource_data.get("uuid", None),
|
||||
"with_children": True,
|
||||
}
|
||||
)
|
||||
|
||||
Args:
|
||||
*uuids: 一个或多个资源 UUID
|
||||
|
||||
Returns:
|
||||
单个 UUID 时返回单个资源实例,多个 UUID 时返回资源实例列表
|
||||
"""
|
||||
if not uuids:
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
))
|
||||
# 同步调用资源查询服务
|
||||
future = self._resource_clients["resource_get"].call_async(r)
|
||||
|
||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||
timeout = 30.0
|
||||
@@ -1603,40 +1553,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
elapsed += 0.05
|
||||
|
||||
if not future.done():
|
||||
raise Exception(f"资源查询超时: {uuids_list}")
|
||||
raise Exception(f"资源查询超时: {resource_data}")
|
||||
|
||||
response = future.result()
|
||||
if response is None:
|
||||
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
||||
raise Exception(f"资源查询返回空结果: {resource_data}")
|
||||
|
||||
raw_data = json.loads(response.response)
|
||||
|
||||
# 转换为 PLR 资源
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
if not len(tree_set.trees):
|
||||
raise Exception(f"资源查询返回空树: {raw_data}")
|
||||
plr_resources = tree_set.to_plr_resources()
|
||||
plr_resource = tree_set.to_plr_resources()[0]
|
||||
|
||||
# 通过资源跟踪器获取本地实例
|
||||
figured_resources: List[ResourcePLR] = []
|
||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||
if len(res) == 0:
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例")
|
||||
figured_resources.append(plr_resource)
|
||||
elif len(res) == 1:
|
||||
figured_resources.append(res[0])
|
||||
else:
|
||||
raise ValueError(f"资源转换得到多个实例: {res}")
|
||||
|
||||
mapped_plr_resources = []
|
||||
for uuid in uuids_list:
|
||||
for plr_resource in figured_resources:
|
||||
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
||||
mapped_plr_resources.append(r)
|
||||
break
|
||||
|
||||
return mapped_plr_resources
|
||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||
if len(res) == 0:
|
||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
|
||||
return plr_resource
|
||||
elif len(res) == 1:
|
||||
return res[0]
|
||||
else:
|
||||
raise ValueError(f"资源转换得到多个实例: {res}")
|
||||
|
||||
async def _execute_driver_command_async(self, string: str):
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,6 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import initialize_resource
|
||||
from unilabos.resources.registry import add_schema
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
@@ -362,7 +361,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
request.command = ""
|
||||
future = sclient.call_async(request)
|
||||
# Use timeout for result as well
|
||||
future.result()
|
||||
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:
|
||||
@@ -586,10 +586,11 @@ class HostNode(BaseROS2DeviceNode):
|
||||
)
|
||||
|
||||
try:
|
||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||
new_li = []
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
return res
|
||||
new_li.append(res)
|
||||
return {"resources": new_li, "liquid_input_resources": new_li}
|
||||
except Exception as ex:
|
||||
pass
|
||||
_n = "\n"
|
||||
@@ -794,8 +795,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
action_client.wait_for_server()
|
||||
goal_uuid_obj = UUID(uuid=list(u.bytes))
|
||||
|
||||
@@ -1133,11 +1133,11 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
接收序列化的 ResourceTreeSet 数据并进行处理
|
||||
"""
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
|
||||
try:
|
||||
# 解析请求数据
|
||||
data = json.loads(request.command)
|
||||
action = data["action"]
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
|
||||
data = data["data"]
|
||||
if action == "add":
|
||||
await self._resource_tree_action_add_callback(data, response)
|
||||
@@ -1243,7 +1243,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
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"])
|
||||
elif "id" in data:
|
||||
elif "id" in data and data["id"].startswith("/"):
|
||||
http_req = http_client.resource_get(data["id"], data["with_children"])
|
||||
else:
|
||||
raise ValueError("没有使用正确的物料 id 或 uuid")
|
||||
@@ -1453,16 +1453,10 @@ class HostNode(BaseROS2DeviceNode):
|
||||
}
|
||||
|
||||
def test_resource(
|
||||
self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
|
||||
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]
|
||||
) -> TestResourceReturn:
|
||||
if resources is None:
|
||||
resources = []
|
||||
if devices is None:
|
||||
devices = []
|
||||
if resource is None:
|
||||
resource = RegularContainer("test_resource传入None")
|
||||
return {
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
|
||||
"devices": [device, *devices],
|
||||
}
|
||||
|
||||
@@ -1514,7 +1508,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
# 构建服务地址
|
||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
|
||||
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation")
|
||||
|
||||
# 创建服务客户端
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
@@ -1549,7 +1543,9 @@ class HostNode(BaseROS2DeviceNode):
|
||||
time.sleep(0.05)
|
||||
|
||||
response = future.result()
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user