mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 13:25:13 +00:00
Compare commits
33 Commits
b045ab4e0a
...
feat/add_c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882c33bd22 | ||
|
|
ad21644db0 | ||
|
|
9dfd58e9af | ||
|
|
31c9f9a172 | ||
|
|
02cd8de4c5 | ||
|
|
a66603ec1c | ||
|
|
ec015e16cd | ||
|
|
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"])
|
device_action_groups[key_add].append(item["uuid"])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 正常update
|
# 正常update
|
||||||
@@ -863,11 +863,11 @@ class MessageProcessor:
|
|||||||
device_action_groups[key] = []
|
device_action_groups[key] = []
|
||||||
device_action_groups[key].append(item["uuid"])
|
device_action_groups[key].append(item["uuid"])
|
||||||
|
|
||||||
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||||
|
|
||||||
# 为每个(device_id, action)创建独立的更新线程
|
# 为每个(device_id, action)创建独立的更新线程
|
||||||
for (device_id, actual_action), items in device_action_groups.items():
|
for (device_id, actual_action), items in device_action_groups.items():
|
||||||
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
||||||
|
|
||||||
def _notify_resource_tree(dev_id, act, item_list):
|
def _notify_resource_tree(dev_id, act, item_list):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -207,14 +207,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
|
|
||||||
res_samples = []
|
res_samples = []
|
||||||
res_volumes = []
|
res_volumes = []
|
||||||
# 处理 use_channels 为 None 的情况(通常用于单通道操作)
|
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||||
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):
|
|
||||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
||||||
res_volumes.append(volume)
|
res_volumes.append(volume)
|
||||||
self.pending_liquids_dict[channel] = {
|
self.pending_liquids_dict[channel] = {
|
||||||
@@ -927,7 +920,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=delays[1])
|
await self.custom_delay(seconds=delays[1])
|
||||||
@@ -991,7 +983,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=delays[1])
|
await self.custom_delay(seconds=delays[1])
|
||||||
@@ -1174,7 +1165,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
@@ -1209,7 +1199,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=delays[1])
|
await self.custom_delay(seconds=delays[1])
|
||||||
@@ -1246,7 +1235,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
@@ -1283,7 +1271,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=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,
|
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,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate 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,
|
offsets=offsets[idx:idx+1] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
if touch_tip:
|
if touch_tip:
|
||||||
await self.touch_tip([target])
|
await self.touch_tip([target])
|
||||||
@@ -1416,7 +1401,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets[i:i + 8] if offsets else None,
|
offsets=offsets[i:i + 8] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||||
@@ -1462,7 +1446,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if touch_tip:
|
if touch_tip:
|
||||||
@@ -1514,19 +1497,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
f"(matching `asp_vols`). Got length {len(dis_vols)}."
|
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:
|
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:
|
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(
|
await self.mix(
|
||||||
targets=[target],
|
targets=[target],
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
@@ -1534,11 +1508,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets[0:1] if offsets else None,
|
offsets=offsets[0:1] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate 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):
|
for idx, source in enumerate(sources):
|
||||||
tip = []
|
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,
|
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,
|
spread=spread,
|
||||||
)
|
)
|
||||||
|
|
||||||
if delays is not None:
|
if delays is not None:
|
||||||
await self.custom_delay(seconds=delays[0])
|
await self.custom_delay(seconds=delays[0])
|
||||||
|
|
||||||
# 分液到目标容器
|
# 分液到目标容器
|
||||||
if use_proportional_mixing:
|
if use_proportional_mixing:
|
||||||
# 按不同比例混合:使用对应的 dis_vols
|
# 按不同比例混合:使用对应的 dis_vols
|
||||||
@@ -1575,7 +1546,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
|
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_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
|
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(
|
await self.dispense(
|
||||||
resources=[target],
|
resources=[target],
|
||||||
vols=[dis_vol],
|
vols=[dis_vol],
|
||||||
@@ -1586,15 +1557,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
|
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
|
||||||
spread=spread,
|
spread=spread,
|
||||||
)
|
)
|
||||||
|
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=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(
|
await self.mix(
|
||||||
targets=[target],
|
targets=[target],
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
@@ -1602,27 +1572,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets[0:1] if offsets else None,
|
offsets=offsets[0:1] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if touch_tip:
|
if touch_tip:
|
||||||
await self.touch_tip([target])
|
await self.touch_tip([target])
|
||||||
|
|
||||||
if defer_final_discard:
|
|
||||||
await self.discard_tips(use_channels=use_channels)
|
|
||||||
|
|
||||||
elif len(use_channels) == 8:
|
elif len(use_channels) == 8:
|
||||||
# 8通道模式:需要确保源数量是8的倍数
|
# 8通道模式:需要确保源数量是8的倍数
|
||||||
if len(sources) % 8 != 0:
|
if len(sources) % 8 != 0:
|
||||||
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
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:
|
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(
|
await self.mix(
|
||||||
targets=[target],
|
targets=[target],
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
@@ -1630,11 +1591,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets[0:1] if offsets else None,
|
offsets=offsets[0:1] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate 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):
|
for i in range(0, len(sources), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
@@ -1692,12 +1650,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
|
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=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(
|
await self.mix(
|
||||||
targets=[target],
|
targets=[target],
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
@@ -1705,15 +1662,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
offsets=offsets[0:1] if offsets else None,
|
offsets=offsets[0:1] if offsets else None,
|
||||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
mix_rate=mix_rate if mix_rate else None,
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
use_channels=use_channels,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if touch_tip:
|
if touch_tip:
|
||||||
await self.touch_tip([target])
|
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:
|
# except Exception as e:
|
||||||
# traceback.print_exc()
|
# traceback.print_exc()
|
||||||
# raise RuntimeError(f"Liquid addition failed: {e}") from e
|
# raise RuntimeError(f"Liquid addition failed: {e}") from e
|
||||||
@@ -1733,12 +1686,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
print(f"Waiting time: {msg}")
|
print(f"Waiting time: {msg}")
|
||||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||||
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
|
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
|
await self._ros_node.sleep(seconds)
|
||||||
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)
|
|
||||||
if msg:
|
if msg:
|
||||||
print(f"Done: {msg}")
|
print(f"Done: {msg}")
|
||||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||||
@@ -1777,59 +1725,27 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
height_to_bottom: Optional[float] = None,
|
height_to_bottom: Optional[float] = None,
|
||||||
offsets: Optional[Coordinate] = None,
|
offsets: Optional[Coordinate] = None,
|
||||||
mix_rate: Optional[float] = None,
|
mix_rate: Optional[float] = None,
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
none_keys: List[str] = [],
|
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
|
return
|
||||||
"""Mix the liquid in the target wells."""
|
"""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 _ in range(mix_time):
|
||||||
for idx, target in enumerate(targets_list):
|
await self.aspirate(
|
||||||
offset_arg = (
|
resources=[targets],
|
||||||
[offsets_list[idx]] if offsets_list[idx] is not None else None
|
vols=[mix_vol],
|
||||||
)
|
flow_rates=[mix_rate] if mix_rate else None,
|
||||||
height_arg = (
|
offsets=[offsets] if offsets else None,
|
||||||
[heights_list[idx]] if heights_list[idx] is not None else None
|
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||||
)
|
)
|
||||||
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None
|
await self.custom_delay(seconds=1)
|
||||||
|
await self.dispense(
|
||||||
await self.aspirate(
|
resources=[targets],
|
||||||
resources=[target],
|
vols=[mix_vol],
|
||||||
vols=[mix_vol],
|
flow_rates=[mix_rate] if mix_rate else None,
|
||||||
use_channels=use_channels,
|
offsets=[offsets] if offsets else None,
|
||||||
flow_rates=rate_arg,
|
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||||
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ from pylabrobot.liquid_handling.standard import (
|
|||||||
ResourceMove,
|
ResourceMove,
|
||||||
ResourceDrop,
|
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.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
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
|
||||||
|
|
||||||
|
|
||||||
class PRCXIError(RuntimeError):
|
class PRCXIError(RuntimeError):
|
||||||
@@ -137,31 +136,13 @@ class PRCXI9300Plate(Plate):
|
|||||||
# 使用 ordering 参数,只包含位置信息(键)
|
# 使用 ordering 参数,只包含位置信息(键)
|
||||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||||
else:
|
else:
|
||||||
# ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location
|
# ordering 的值已经是对象,可以直接使用
|
||||||
# 如果是反序列化过程,Well 对象可能没有正确的 location,需要让 Plate 重新创建
|
items = ordering
|
||||||
sample_value = next(iter(ordering.values()), None)
|
ordering_param = 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
|
|
||||||
else:
|
else:
|
||||||
items = None
|
items = None
|
||||||
ordering_param = collections.OrderedDict() # 提供空的 ordering
|
ordering_param = None
|
||||||
|
|
||||||
# 根据情况传递不同的参数
|
# 根据情况传递不同的参数
|
||||||
if items is not None:
|
if items is not None:
|
||||||
super().__init__(name, size_x, size_y, size_z,
|
super().__init__(name, size_x, size_y, size_z,
|
||||||
@@ -238,16 +219,9 @@ class PRCXI9300TipRack(TipRack):
|
|||||||
# 使用 ordering 参数,只包含位置信息(键)
|
# 使用 ordering 参数,只包含位置信息(键)
|
||||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||||
else:
|
else:
|
||||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
# ordering 的值已经是对象,可以直接使用
|
||||||
# 只保留有效的对象,用于 ordered_items 参数
|
items = ordering
|
||||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
ordering_param = 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())
|
|
||||||
else:
|
else:
|
||||||
items = None
|
items = None
|
||||||
ordering_param = None
|
ordering_param = None
|
||||||
@@ -310,7 +284,7 @@ class PRCXI9300Trash(Trash):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
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,
|
material_info: Optional[Dict[str, Any]] = None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
|
|
||||||
@@ -383,16 +357,9 @@ class PRCXI9300TubeRack(TubeRack):
|
|||||||
# 使用 ordering 参数,只包含位置信息(键)
|
# 使用 ordering 参数,只包含位置信息(键)
|
||||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||||
else:
|
else:
|
||||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
# ordering 的值已经是对象,可以直接使用
|
||||||
# 只保留有效的对象,用于 ordered_items 参数
|
items_to_pass = ordering
|
||||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
ordering_param = None
|
||||||
if valid_items:
|
|
||||||
items_to_pass = valid_items
|
|
||||||
ordering_param = None
|
|
||||||
else:
|
|
||||||
# 如果没有有效对象,使用 ordering 参数
|
|
||||||
items_to_pass = None
|
|
||||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
|
||||||
elif items is not None:
|
elif items is not None:
|
||||||
# 兼容旧的 items 参数
|
# 兼容旧的 items 参数
|
||||||
items_to_pass = items
|
items_to_pass = items
|
||||||
@@ -448,81 +415,6 @@ class PRCXI9300TubeRack(TubeRack):
|
|||||||
|
|
||||||
data.update(safe_state)
|
data.update(safe_state)
|
||||||
return data
|
return data
|
||||||
class PRCXI9300PlateAdapterSite(ItemizedCarrier):
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
|
||||||
material_info: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
# 处理 sites 参数的不同格式
|
|
||||||
|
|
||||||
sites = create_homogeneous_resources(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
locations=[Coordinate(0, 0, 0)],
|
|
||||||
resource_size_x=size_x,
|
|
||||||
resource_size_y=size_y,
|
|
||||||
resource_size_z=size_z,
|
|
||||||
name_prefix=name,
|
|
||||||
)[0]
|
|
||||||
# 确保不传递重复的参数
|
|
||||||
kwargs.pop('layout', None)
|
|
||||||
kwargs.pop('num_items_x', None)
|
|
||||||
kwargs.pop('num_items_y', None)
|
|
||||||
kwargs.pop('num_items_z', None)
|
|
||||||
kwargs.pop('sites', None)
|
|
||||||
|
|
||||||
super().__init__(name, size_x, size_y, size_z,
|
|
||||||
sites={name: sites},
|
|
||||||
num_items_x=1,
|
|
||||||
num_items_y=1,
|
|
||||||
num_items_z=1,
|
|
||||||
**kwargs)
|
|
||||||
self._unilabos_state = {}
|
|
||||||
if material_info:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
|
|
||||||
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
|
|
||||||
"""重写 assign_child_resource 方法,对于适配器位置,不使用索引分配"""
|
|
||||||
# 直接调用 Resource 的 assign_child_resource,避免 ItemizedCarrier 的索引逻辑
|
|
||||||
from pylabrobot.resources.resource import Resource
|
|
||||||
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
|
|
||||||
|
|
||||||
def unassign_child_resource(self, resource):
|
|
||||||
"""重写 unassign_child_resource 方法,对于适配器位置,不使用 sites 列表"""
|
|
||||||
# 直接调用 Resource 的 unassign_child_resource,避免 ItemizedCarrier 的 sites 逻辑
|
|
||||||
from pylabrobot.resources.resource import Resource
|
|
||||||
Resource.unassign_child_resource(self, resource)
|
|
||||||
|
|
||||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
data = super().serialize_state()
|
|
||||||
except AttributeError:
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
# 包含 sites 配置信息,但避免序列化 ResourceHolder 对象
|
|
||||||
if hasattr(self, 'sites') and self.sites:
|
|
||||||
# 只保存 sites 的基本信息,不保存 ResourceHolder 对象本身
|
|
||||||
sites_info = []
|
|
||||||
for site in self.sites:
|
|
||||||
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
|
|
||||||
# 对于 pylabrobot 对象,只保存基本信息
|
|
||||||
sites_info.append({
|
|
||||||
"__pylabrobot_object__": True,
|
|
||||||
"class": site.__class__.__name__,
|
|
||||||
"module": site.__class__.__module__,
|
|
||||||
"name": getattr(site, 'name', str(site))
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
sites_info.append(site)
|
|
||||||
data['sites'] = sites_info
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def load_state(self, state: Dict[str, Any]) -> None:
|
|
||||||
"""加载状态,包括 sites 配置信息"""
|
|
||||||
super().load_state(state)
|
|
||||||
|
|
||||||
# 从状态中恢复 sites 配置信息
|
|
||||||
if 'sites' in state:
|
|
||||||
self.sites = [state['sites']]
|
|
||||||
|
|
||||||
class PRCXI9300PlateAdapter(PlateAdapter):
|
class PRCXI9300PlateAdapter(PlateAdapter):
|
||||||
"""
|
"""
|
||||||
@@ -921,7 +813,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
|
|
||||||
res = await super().move_plate(
|
return await super().move_plate(
|
||||||
plate,
|
plate,
|
||||||
to,
|
to,
|
||||||
intermediate_locations,
|
intermediate_locations,
|
||||||
@@ -933,12 +825,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
target_plate_number = to,
|
target_plate_number = to,
|
||||||
**backend_kwargs,
|
**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):
|
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||||
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
||||||
|
|||||||
@@ -4019,8 +4019,7 @@ liquid_handler:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -4176,11 +4175,9 @@ liquid_handler:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
@@ -5043,8 +5040,7 @@ liquid_handler.biomek:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -5187,11 +5183,9 @@ liquid_handler.biomek:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
@@ -9284,7 +9278,13 @@ liquid_handler.prcxi:
|
|||||||
z: 0.0
|
z: 0.0
|
||||||
sample_id: ''
|
sample_id: ''
|
||||||
type: ''
|
type: ''
|
||||||
handles: {}
|
handles:
|
||||||
|
input:
|
||||||
|
- data_key: wells
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: input_wells
|
||||||
|
label: InputWells
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
wells: unilabos_resources
|
wells: unilabos_resources
|
||||||
result: {}
|
result: {}
|
||||||
@@ -9671,8 +9671,7 @@ liquid_handler.prcxi:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -9828,11 +9827,9 @@ liquid_handler.prcxi:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
|
|||||||
@@ -124,17 +124,32 @@ class Registry:
|
|||||||
"output": [
|
"output": [
|
||||||
{
|
{
|
||||||
"handler_key": "labware",
|
"handler_key": "labware",
|
||||||
"label": "Labware",
|
|
||||||
"data_type": "resource",
|
"data_type": "resource",
|
||||||
"data_source": "handle",
|
"label": "Labware",
|
||||||
"data_key": "liquid",
|
"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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"placeholder_keys": {
|
"placeholder_keys": {
|
||||||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||||||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||||||
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
||||||
|
"class_name": "unilabos_class",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"test_latency": {
|
"test_latency": {
|
||||||
@@ -186,7 +201,17 @@ class Registry:
|
|||||||
"resources": "unilabos_resources",
|
"resources": "unilabos_resources",
|
||||||
},
|
},
|
||||||
"goal_default": {},
|
"goal_default": {},
|
||||||
"handles": {},
|
"handles": {
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"handler_key": "input_resources",
|
||||||
|
"data_type": "resource",
|
||||||
|
"label": "InputResources",
|
||||||
|
"data_source": "handle",
|
||||||
|
"data_key": "resources", # 不为空
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class RegularContainer(Container):
|
|||||||
def get_regular_container(name="container"):
|
def get_regular_container(name="container"):
|
||||||
r = RegularContainer(name=name)
|
r = RegularContainer(name=name)
|
||||||
r.category = "container"
|
r.category = "container"
|
||||||
return RegularContainer(name=name)
|
return r
|
||||||
|
|
||||||
#
|
#
|
||||||
# class RegularContainer(object):
|
# class RegularContainer(object):
|
||||||
|
|||||||
@@ -1151,11 +1151,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
|
|||||||
if resource_class_config["type"] == "pylabrobot":
|
if resource_class_config["type"] == "pylabrobot":
|
||||||
resource_plr = RESOURCE(name=resource_config["name"])
|
resource_plr = RESOURCE(name=resource_config["name"])
|
||||||
if resource_type != ResourcePLR:
|
if resource_type != ResourcePLR:
|
||||||
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr])
|
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
|
||||||
# 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()
|
r = tree_sets.dump()
|
||||||
else:
|
else:
|
||||||
r = resource_plr
|
r = resource_plr
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel, field_serializer, field_validator
|
from pydantic import BaseModel, field_serializer, field_validator, ValidationError
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||||
|
|
||||||
@@ -147,20 +147,24 @@ class ResourceDictInstance(object):
|
|||||||
if not content.get("extra"): # MagicCode
|
if not content.get("extra"): # MagicCode
|
||||||
content["extra"] = {}
|
content["extra"] = {}
|
||||||
if "position" in content:
|
if "position" in content:
|
||||||
pose = content.get("pose",{})
|
pose = content.get("pose", {})
|
||||||
if "position" not in pose :
|
if "position" not in pose:
|
||||||
if "position" in content["position"]:
|
if "position" in content["position"]:
|
||||||
pose["position"] = content["position"]["position"]
|
pose["position"] = content["position"]["position"]
|
||||||
else:
|
else:
|
||||||
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
||||||
if "size" not in pose:
|
if "size" not in pose:
|
||||||
pose["size"] = {
|
pose["size"] = {
|
||||||
"width": content["config"].get("size_x", 0),
|
"width": content["config"].get("size_x", 0),
|
||||||
"height": content["config"].get("size_y", 0),
|
"height": content["config"].get("size_y", 0),
|
||||||
"depth": content["config"].get("size_z", 0)
|
"depth": content["config"].get("size_z", 0),
|
||||||
}
|
}
|
||||||
content["pose"] = pose
|
content["pose"] = pose
|
||||||
return ResourceDictInstance(ResourceDict.model_validate(content))
|
try:
|
||||||
|
res_dict = ResourceDict.model_validate(content)
|
||||||
|
return ResourceDictInstance(res_dict)
|
||||||
|
except ValidationError as err:
|
||||||
|
raise err
|
||||||
|
|
||||||
def get_plr_nested_dict(self) -> Dict[str, Any]:
|
def get_plr_nested_dict(self) -> Dict[str, Any]:
|
||||||
"""获取资源实例的嵌套字典表示"""
|
"""获取资源实例的嵌套字典表示"""
|
||||||
@@ -322,7 +326,7 @@ class ResourceTreeSet(object):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet":
|
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
|
||||||
"""
|
"""
|
||||||
从plr资源创建ResourceTreeSet
|
从plr资源创建ResourceTreeSet
|
||||||
"""
|
"""
|
||||||
@@ -339,6 +343,8 @@ class ResourceTreeSet(object):
|
|||||||
}
|
}
|
||||||
if source in replace_info:
|
if source in replace_info:
|
||||||
return replace_info[source]
|
return replace_info[source]
|
||||||
|
elif source is None:
|
||||||
|
return ""
|
||||||
else:
|
else:
|
||||||
print("转换pylabrobot的时候,出现未知类型", source)
|
print("转换pylabrobot的时候,出现未知类型", source)
|
||||||
return source
|
return source
|
||||||
@@ -349,7 +355,8 @@ class ResourceTreeSet(object):
|
|||||||
if not uid:
|
if not uid:
|
||||||
uid = str(uuid.uuid4())
|
uid = str(uuid.uuid4())
|
||||||
res.unilabos_uuid = uid
|
res.unilabos_uuid = uid
|
||||||
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
if not known_newly_created:
|
||||||
|
logger.warning(f"{res}没有uuid,请设置后再传入,默认填充{uid}!\n{traceback.format_exc()}")
|
||||||
|
|
||||||
# 获取unilabos_extra,默认为空字典
|
# 获取unilabos_extra,默认为空字典
|
||||||
extra = getattr(res, "unilabos_extra", {})
|
extra = getattr(res, "unilabos_extra", {})
|
||||||
@@ -448,7 +455,13 @@ class ResourceTreeSet(object):
|
|||||||
from pylabrobot.utils.object_parsing import find_subclass
|
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):
|
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"""
|
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
|
||||||
@@ -918,6 +931,33 @@ class DeviceNodeResourceTracker(object):
|
|||||||
|
|
||||||
return self._traverse_and_process(resource, process)
|
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:
|
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
|
||||||
"""
|
"""
|
||||||
递归遍历资源树,根据 name 设置所有节点的 extra
|
递归遍历资源树,根据 name 设置所有节点的 extra
|
||||||
@@ -1103,7 +1143,7 @@ class DeviceNodeResourceTracker(object):
|
|||||||
for key in keys_to_remove:
|
for key in keys_to_remove:
|
||||||
self.resource2parent_resource.pop(key, None)
|
self.resource2parent_resource.pop(key, None)
|
||||||
|
|
||||||
logger.debug(f"成功移除资源: {resource}")
|
logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def clear_resource(self):
|
def clear_resource(self):
|
||||||
|
|||||||
@@ -159,10 +159,14 @@ _msg_converter: Dict[Type, Any] = {
|
|||||||
else Pose()
|
else Pose()
|
||||||
),
|
),
|
||||||
config=json.dumps(x.get("config", {})),
|
config=json.dumps(x.get("config", {})),
|
||||||
data=json.dumps(x.get("data", {})),
|
data=json.dumps(obtain_data_with_uuid(x)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:
|
def json_or_yaml_loads(data: str) -> Any:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -392,9 +392,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
parent_resource = self.resource_tracker.figure_resource(
|
parent_resource = self.resource_tracker.figure_resource(
|
||||||
{"name": bind_parent_id}
|
{"name": bind_parent_id}
|
||||||
)
|
)
|
||||||
for r in rts.root_nodes:
|
for r in rts.root_nodes:
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||||
|
else:
|
||||||
|
for r in rts.root_nodes:
|
||||||
|
r.res_content.parent_uuid = self.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):
|
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
|
# noinspection PyTypeChecker
|
||||||
@@ -430,11 +433,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
})
|
})
|
||||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||||
uuid_maps = json.loads(tree_response.response)
|
uuid_maps = json.loads(tree_response.response)
|
||||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
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.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
||||||
final_response = {
|
final_response = {
|
||||||
"created_resources": rts.dump(),
|
"created_resource_tree": rts.dump(),
|
||||||
"liquid_input_resources": [],
|
"liquid_input_resource_tree": [],
|
||||||
}
|
}
|
||||||
res.response = json.dumps(final_response)
|
res.response = json.dumps(final_response)
|
||||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||||
@@ -460,7 +466,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
return res
|
return res
|
||||||
try:
|
try:
|
||||||
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||||
plr_instance = rts.to_plr_resources()[0]
|
plr_instance = plr_instances[0]
|
||||||
if isinstance(plr_instance, Plate):
|
if isinstance(plr_instance, Plate):
|
||||||
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
|
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:
|
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||||
@@ -485,7 +491,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
input_wells = []
|
input_wells = []
|
||||||
for r in LIQUID_INPUT_SLOT:
|
for r in LIQUID_INPUT_SLOT:
|
||||||
input_wells.append(plr_instance.children[r])
|
input_wells.append(plr_instance.children[r])
|
||||||
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||||
res.response = json.dumps(final_response)
|
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:
|
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"])
|
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||||
@@ -653,61 +659,71 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
def transfer_to_new_resource(
|
def transfer_to_new_resource(
|
||||||
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
|
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
|
||||||
):
|
) -> Optional["ResourcePLR"]:
|
||||||
parent_uuid = tree.root_node.res_content.parent_uuid
|
parent_uuid = tree.root_node.res_content.parent_uuid
|
||||||
if parent_uuid:
|
if not parent_uuid:
|
||||||
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
|
self.lab_logger().warning(
|
||||||
if parent_resource is None:
|
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)
|
||||||
self.lab_logger().warning(
|
self.lab_logger().warning(
|
||||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
|
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
|
||||||
)
|
)
|
||||||
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 列表中移除
|
# ⭐ assign 之前,需要从 resources 列表中移除
|
||||||
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
|
||||||
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
# 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children
|
||||||
resource_id = id(plr_resource)
|
resource_id = id(plr_resource)
|
||||||
for i, r in enumerate(self.resource_tracker.resources):
|
for i, r in enumerate(self.resource_tracker.resources):
|
||||||
if id(r) == resource_id:
|
if id(r) == resource_id:
|
||||||
self.resource_tracker.resources.pop(i)
|
self.resource_tracker.resources.pop(i)
|
||||||
self.lab_logger().debug(
|
self.lab_logger().debug(
|
||||||
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
|
||||||
)
|
)
|
||||||
break
|
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)
|
func = getattr(self.driver_instance, "resource_tree_transfer", None)
|
||||||
if callable(func):
|
if callable(func):
|
||||||
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
# 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了)
|
||||||
func(old_parent, plr_resource, parent_resource)
|
func(old_parent, plr_resource, parent_resource)
|
||||||
except Exception as e:
|
return parent_resource
|
||||||
self.lab_logger().warning(
|
except Exception as e:
|
||||||
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
|
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):
|
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
|
||||||
"""
|
"""
|
||||||
@@ -722,7 +738,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
def _handle_add(
|
def _handle_add(
|
||||||
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||||
"""
|
"""
|
||||||
处理资源添加操作的内部函数
|
处理资源添加操作的内部函数
|
||||||
|
|
||||||
@@ -734,15 +750,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
Returns:
|
Returns:
|
||||||
操作结果字典
|
操作结果字典
|
||||||
"""
|
"""
|
||||||
|
parents = [] # 放的是被变更的物料 / 被变更的物料父级
|
||||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||||
self.resource_tracker.add_resource(plr_resource)
|
self.resource_tracker.add_resource(plr_resource)
|
||||||
self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
|
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)
|
||||||
|
|
||||||
func = getattr(self.driver_instance, "resource_tree_add", None)
|
func = getattr(self.driver_instance, "resource_tree_add", None)
|
||||||
if callable(func):
|
if callable(func):
|
||||||
func(plr_resources)
|
func(plr_resources)
|
||||||
|
|
||||||
return {"success": True, "action": "add"}
|
return {"success": True, "action": "add"}, parents
|
||||||
|
|
||||||
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
|
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -777,11 +798,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if plr_resource.parent is not None:
|
if plr_resource.parent is not None:
|
||||||
plr_resource.parent.unassign_child_resource(plr_resource)
|
plr_resource.parent.unassign_child_resource(plr_resource)
|
||||||
self.resource_tracker.remove_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:
|
for other_plr_resource in other_plr_resources:
|
||||||
self.resource_tracker.remove_resource(other_plr_resource)
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -813,11 +834,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
||||||
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
|
{"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
|
# Update操作中包含改名:需要先remove再add,这里更新父节点即可
|
||||||
if original_instance.name != plr_resource.name:
|
if not not_same_parent and old_name != new_name:
|
||||||
old_name = original_instance.name
|
|
||||||
new_name = plr_resource.name
|
|
||||||
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
|
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
|
||||||
|
|
||||||
# 收集所有相关的uuid(包括子节点)
|
# 收集所有相关的uuid(包括子节点)
|
||||||
@@ -826,12 +852,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
_handle_add([original_instance], tree_set, additional_add_params)
|
_handle_add([original_instance], tree_set, additional_add_params)
|
||||||
|
|
||||||
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
|
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(
|
self.lab_logger().info(
|
||||||
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
|
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
|
||||||
f"目标父节点{target_parent_resource_uuid} 更新"
|
f"目标父节点{target_parent_resource_uuid} 更新"
|
||||||
@@ -842,13 +866,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
|
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
|
||||||
|
|
||||||
# 如果父节点变化,需要重新挂载
|
# 如果父节点变化,需要重新挂载
|
||||||
if (
|
if not_same_parent:
|
||||||
original_parent_resource_uuid != target_parent_resource_uuid
|
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||||
and original_parent_resource is not None
|
original_instances.append(parent)
|
||||||
):
|
parent_appended = True
|
||||||
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
|
||||||
else:
|
else:
|
||||||
# 判断是否变更了resource_site
|
# 判断是否变更了resource_site,重新登记
|
||||||
target_site = original_instance.unilabos_extra.get("update_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
|
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 []
|
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||||
@@ -856,7 +879,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
site_index = sites.index(original_instance)
|
site_index = sites.index(original_instance)
|
||||||
site_name = site_names[site_index]
|
site_name = site_names[site_index]
|
||||||
if site_name != target_site:
|
if site_name != target_site:
|
||||||
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||||
|
if parent is not None:
|
||||||
|
original_instances.append(parent)
|
||||||
|
parent_appended = True
|
||||||
|
|
||||||
# 加载状态
|
# 加载状态
|
||||||
original_instance.load_all_state(states)
|
original_instance.load_all_state(states)
|
||||||
@@ -864,7 +890,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
self.lab_logger().info(
|
self.lab_logger().info(
|
||||||
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个"
|
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个"
|
||||||
)
|
)
|
||||||
original_instances.append(original_instance)
|
if not parent_appended:
|
||||||
|
original_instances.append(original_instance)
|
||||||
|
|
||||||
# 调用driver的update回调
|
# 调用driver的update回调
|
||||||
func = getattr(self.driver_instance, "resource_tree_update", None)
|
func = getattr(self.driver_instance, "resource_tree_update", None)
|
||||||
@@ -881,8 +908,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
action = i.get("action") # remove, add, update
|
action = i.get("action") # remove, add, update
|
||||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||||
self.lab_logger().info(
|
self.lab_logger().trace(
|
||||||
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}"
|
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||||
)
|
)
|
||||||
tree_set = None
|
tree_set = None
|
||||||
if action in ["add", "update"]:
|
if action in ["add", "update"]:
|
||||||
@@ -894,8 +921,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if tree_set is None:
|
if tree_set is None:
|
||||||
raise ValueError("tree_set不能为None")
|
raise ValueError("tree_set不能为None")
|
||||||
plr_resources = tree_set.to_plr_resources()
|
plr_resources = tree_set.to_plr_resources()
|
||||||
result = _handle_add(plr_resources, tree_set, additional_add_params)
|
result, parents = _handle_add(plr_resources, tree_set, additional_add_params)
|
||||||
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources)
|
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
|
||||||
r = SerialCommand.Request()
|
r = SerialCommand.Request()
|
||||||
r.command = json.dumps(
|
r.command = json.dumps(
|
||||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||||
@@ -914,7 +953,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
|
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
|
||||||
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
|
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||||
if not BasicConfig.no_update_feedback:
|
if not BasicConfig.no_update_feedback:
|
||||||
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances)
|
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
|
||||||
r = SerialCommand.Request()
|
r = SerialCommand.Request()
|
||||||
r.command = json.dumps(
|
r.command = json.dumps(
|
||||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||||
@@ -934,15 +976,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
# 返回处理结果
|
# 返回处理结果
|
||||||
result_json = {"results": results, "total": len(data)}
|
result_json = {"results": results, "total": len(data)}
|
||||||
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
|
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:
|
except json.JSONDecodeError as e:
|
||||||
error_msg = f"Invalid JSON format: {str(e)}"
|
error_msg = f"Invalid JSON format: {str(e)}"
|
||||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Unexpected error: {str(e)}"
|
error_msg = f"Unexpected error: {str(e)}"
|
||||||
self.lab_logger().error(f"[Resource Tree Update] {error_msg}")
|
self.lab_logger().error(f"[资源同步] {error_msg}")
|
||||||
self.lab_logger().error(traceback.format_exc())
|
self.lab_logger().error(traceback.format_exc())
|
||||||
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -1263,7 +1305,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
|
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"])
|
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
|
||||||
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}")
|
||||||
|
self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
|
||||||
error_skip = False
|
error_skip = False
|
||||||
# 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过
|
# 向Host查询物料当前状态,如果是host本身的增加物料的请求,则直接跳过
|
||||||
if action_name not in ["create_resource_detailed", "create_resource"]:
|
if action_name not in ["create_resource_detailed", "create_resource"]:
|
||||||
@@ -1279,9 +1322,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
# 批量查询资源
|
# 批量查询资源
|
||||||
queried_resources = []
|
queried_resources = []
|
||||||
for resource_data in resource_inputs:
|
for resource_data in resource_inputs:
|
||||||
plr_resource = await self.get_resource_with_dir(
|
unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
|
||||||
resource_id=resource_data["id"], with_children=True
|
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]
|
||||||
if "sample_id" in resource_data:
|
if "sample_id" in resource_data:
|
||||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||||
queried_resources.append(plr_resource)
|
queried_resources.append(plr_resource)
|
||||||
@@ -1330,9 +1378,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
execution_success = True
|
execution_success = True
|
||||||
except Exception as _:
|
except Exception as _:
|
||||||
execution_error = traceback.format_exc()
|
execution_error = traceback.format_exc()
|
||||||
error(
|
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||||
)
|
|
||||||
|
|
||||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||||
future.add_done_callback(_handle_future_exception)
|
future.add_done_callback(_handle_future_exception)
|
||||||
@@ -1352,8 +1399,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
except Exception as _:
|
except Exception as _:
|
||||||
execution_error = traceback.format_exc()
|
execution_error = traceback.format_exc()
|
||||||
error(
|
error(
|
||||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||||
)
|
trace(
|
||||||
|
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||||
|
|
||||||
future.add_done_callback(_handle_future_exception)
|
future.add_done_callback(_handle_future_exception)
|
||||||
|
|
||||||
@@ -1421,7 +1469,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
for r in rs:
|
for r in rs:
|
||||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||||
else:
|
else:
|
||||||
res = self.resource_tracker.parent_resource(r)
|
res = self.resource_tracker.parent_resource(rs)
|
||||||
if id(res) not in seen:
|
if id(res) not in seen:
|
||||||
seen.add(id(res))
|
seen.add(id(res))
|
||||||
unique_resources.append(res)
|
unique_resources.append(res)
|
||||||
@@ -1497,8 +1545,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
resource_data = function_args[arg_name]
|
resource_data = function_args[arg_name]
|
||||||
if isinstance(resource_data, dict) and "id" in resource_data:
|
if isinstance(resource_data, dict) and "id" in resource_data:
|
||||||
try:
|
try:
|
||||||
converted_resource = self._convert_resource_sync(resource_data)
|
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
||||||
function_args[arg_name] = converted_resource
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.lab_logger().error(
|
self.lab_logger().error(
|
||||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||||
@@ -1512,12 +1559,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
resource_list = function_args[arg_name]
|
resource_list = function_args[arg_name]
|
||||||
if isinstance(resource_list, list):
|
if isinstance(resource_list, list):
|
||||||
try:
|
try:
|
||||||
converted_resources = []
|
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||||
for resource_data in resource_list:
|
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||||
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:
|
except Exception as e:
|
||||||
self.lab_logger().error(
|
self.lab_logger().error(
|
||||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||||
@@ -1530,20 +1573,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _convert_resource_sync(self, resource_data: Dict[str, Any]):
|
def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]:
|
||||||
"""同步转换资源数据为实例"""
|
"""同步转换资源 UUID 为实例
|
||||||
# 创建资源查询请求
|
|
||||||
r = SerialCommand.Request()
|
|
||||||
r.command = json.dumps(
|
|
||||||
{
|
|
||||||
"id": resource_data.get("id", None),
|
|
||||||
"uuid": resource_data.get("uuid", None),
|
|
||||||
"with_children": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 同步调用资源查询服务
|
Args:
|
||||||
future = self._resource_clients["resource_get"].call_async(r)
|
*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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||||
timeout = 30.0
|
timeout = 30.0
|
||||||
@@ -1553,27 +1603,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
elapsed += 0.05
|
elapsed += 0.05
|
||||||
|
|
||||||
if not future.done():
|
if not future.done():
|
||||||
raise Exception(f"资源查询超时: {resource_data}")
|
raise Exception(f"资源查询超时: {uuids_list}")
|
||||||
|
|
||||||
response = future.result()
|
response = future.result()
|
||||||
if response is None:
|
if response is None:
|
||||||
raise Exception(f"资源查询返回空结果: {resource_data}")
|
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
||||||
|
|
||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
|
|
||||||
# 转换为 PLR 资源
|
# 转换为 PLR 资源
|
||||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||||
plr_resource = tree_set.to_plr_resources()[0]
|
if not len(tree_set.trees):
|
||||||
|
raise Exception(f"资源查询返回空树: {raw_data}")
|
||||||
|
plr_resources = tree_set.to_plr_resources()
|
||||||
|
|
||||||
# 通过资源跟踪器获取本地实例
|
# 通过资源跟踪器获取本地实例
|
||||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
figured_resources: List[ResourcePLR] = []
|
||||||
if len(res) == 0:
|
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
||||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
|
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||||
return plr_resource
|
if len(res) == 0:
|
||||||
elif len(res) == 1:
|
self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例")
|
||||||
return res[0]
|
figured_resources.append(plr_resource)
|
||||||
else:
|
elif len(res) == 1:
|
||||||
raise ValueError(f"资源转换得到多个实例: {res}")
|
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
|
||||||
|
|
||||||
async def _execute_driver_command_async(self, string: str):
|
async def _execute_driver_command_async(self, string: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
|
|||||||
from unique_identifier_msgs.msg import UUID
|
from unique_identifier_msgs.msg import UUID
|
||||||
|
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
|
from unilabos.resources.container import RegularContainer
|
||||||
from unilabos.resources.graphio import initialize_resource
|
from unilabos.resources.graphio import initialize_resource
|
||||||
from unilabos.resources.registry import add_schema
|
from unilabos.resources.registry import add_schema
|
||||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||||
@@ -361,8 +362,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
request.command = ""
|
request.command = ""
|
||||||
future = sclient.call_async(request)
|
future = sclient.call_async(request)
|
||||||
# Use timeout for result as well
|
# Use timeout for result as well
|
||||||
future.result(timeout_sec=5.0)
|
future.result()
|
||||||
self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Gracefully handle destruction during shutdown
|
# Gracefully handle destruction during shutdown
|
||||||
if "destruction was requested" in str(e) or self._shutting_down:
|
if "destruction was requested" in str(e) or self._shutting_down:
|
||||||
@@ -586,11 +586,10 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
new_li = []
|
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||||
for i in response:
|
for i in response:
|
||||||
res = json.loads(i)
|
res = json.loads(i)
|
||||||
new_li.append(res)
|
return res
|
||||||
return {"resources": new_li, "liquid_input_resources": new_li}
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
pass
|
pass
|
||||||
_n = "\n"
|
_n = "\n"
|
||||||
@@ -795,7 +794,8 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
assign_sample_id(action_kwargs)
|
assign_sample_id(action_kwargs)
|
||||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), 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}: {goal_msg}")
|
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}")
|
||||||
action_client.wait_for_server()
|
action_client.wait_for_server()
|
||||||
goal_uuid_obj = UUID(uuid=list(u.bytes))
|
goal_uuid_obj = UUID(uuid=list(u.bytes))
|
||||||
|
|
||||||
@@ -1133,11 +1133,11 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
接收序列化的 ResourceTreeSet 数据并进行处理
|
接收序列化的 ResourceTreeSet 数据并进行处理
|
||||||
"""
|
"""
|
||||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
|
|
||||||
try:
|
try:
|
||||||
# 解析请求数据
|
# 解析请求数据
|
||||||
data = json.loads(request.command)
|
data = json.loads(request.command)
|
||||||
action = data["action"]
|
action = data["action"]
|
||||||
|
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
|
||||||
data = data["data"]
|
data = data["data"]
|
||||||
if action == "add":
|
if action == "add":
|
||||||
await self._resource_tree_action_add_callback(data, response)
|
await self._resource_tree_action_add_callback(data, response)
|
||||||
@@ -1243,7 +1243,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
data = json.loads(request.command)
|
data = json.loads(request.command)
|
||||||
if "uuid" in data and data["uuid"] is not None:
|
if "uuid" in data and data["uuid"] is not None:
|
||||||
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
||||||
elif "id" in data and data["id"].startswith("/"):
|
elif "id" in data:
|
||||||
http_req = http_client.resource_get(data["id"], data["with_children"])
|
http_req = http_client.resource_get(data["id"], data["with_children"])
|
||||||
else:
|
else:
|
||||||
raise ValueError("没有使用正确的物料 id 或 uuid")
|
raise ValueError("没有使用正确的物料 id 或 uuid")
|
||||||
@@ -1453,10 +1453,16 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def test_resource(
|
def test_resource(
|
||||||
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot]
|
self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
|
||||||
) -> TestResourceReturn:
|
) -> TestResourceReturn:
|
||||||
|
if resources is None:
|
||||||
|
resources = []
|
||||||
|
if devices is None:
|
||||||
|
devices = []
|
||||||
|
if resource is None:
|
||||||
|
resource = RegularContainer("test_resource传入None")
|
||||||
return {
|
return {
|
||||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(),
|
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||||
"devices": [device, *devices],
|
"devices": [device, *devices],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1508,7 +1514,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
# 构建服务地址
|
# 构建服务地址
|
||||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||||
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation")
|
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
|
||||||
|
|
||||||
# 创建服务客户端
|
# 创建服务客户端
|
||||||
sclient = self.create_client(SerialCommand, srv_address)
|
sclient = self.create_client(SerialCommand, srv_address)
|
||||||
@@ -1543,9 +1549,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
response = future.result()
|
response = future.result()
|
||||||
self.lab_logger().info(
|
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
|
||||||
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
"parent": "",
|
"parent": "",
|
||||||
"pose": {
|
"pose": {
|
||||||
"size": {
|
"size": {
|
||||||
"width": 542,
|
"width": 562,
|
||||||
"height": 374,
|
"height": 394,
|
||||||
"depth": 0
|
"depth": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
},
|
},
|
||||||
"host": "10.20.30.184",
|
"host": "10.20.30.184",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": false,
|
"debug": true,
|
||||||
"setup": true,
|
"setup": true,
|
||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
"simulator": false,
|
"simulator": true,
|
||||||
"channel_num": 2
|
"channel_num": 2
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"model": null,
|
"model": null,
|
||||||
"position": {
|
"position": {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 700,
|
"y": 240,
|
||||||
"z": 0
|
"z": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"type": "deck",
|
"type": "deck",
|
||||||
"class": "",
|
"class": "",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 0,
|
"x": 10,
|
||||||
"y": 0,
|
"y": 10,
|
||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T1",
|
"label": "T1",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T2",
|
"label": "T2",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T3",
|
"label": "T3",
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T4",
|
"label": "T4",
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T5",
|
"label": "T5",
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T6",
|
"label": "T6",
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T7",
|
"label": "T7",
|
||||||
@@ -398,7 +398,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T8",
|
"label": "T8",
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T9",
|
"label": "T9",
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T10",
|
"label": "T10",
|
||||||
@@ -533,7 +533,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -546,7 +546,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T11",
|
"label": "T11",
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -591,7 +591,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T12",
|
"label": "T12",
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -636,7 +636,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T13",
|
"label": "T13",
|
||||||
@@ -668,7 +668,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -681,7 +681,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T14",
|
"label": "T14",
|
||||||
@@ -713,7 +713,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -726,7 +726,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T15",
|
"label": "T15",
|
||||||
@@ -758,7 +758,7 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"type": "PRCXI9300PlateAdapterSite",
|
"type": "PRCXI9300Container",
|
||||||
"size_x": 127,
|
"size_x": 127,
|
||||||
"size_y": 85.5,
|
"size_y": 85.5,
|
||||||
"size_z": 10,
|
"size_z": 10,
|
||||||
@@ -771,7 +771,7 @@
|
|||||||
"category": "plate",
|
"category": "plate",
|
||||||
"model": null,
|
"model": null,
|
||||||
"barcode": null,
|
"barcode": null,
|
||||||
|
"ordering": {},
|
||||||
"sites": [
|
"sites": [
|
||||||
{
|
{
|
||||||
"label": "T16",
|
"label": "T16",
|
||||||
@@ -40712,7 +40712,7 @@
|
|||||||
|
|
||||||
"children": [],
|
"children": [],
|
||||||
"parent": "T16",
|
"parent": "T16",
|
||||||
"type": "trash",
|
"type": "plate",
|
||||||
"class": "",
|
"class": "",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user