mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 13:25:13 +00:00
Compare commits
104 Commits
workstatio
...
b045ab4e0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b045ab4e0a | ||
|
|
4595f86725 | ||
|
|
44a4c2362d | ||
|
|
1340bae838 | ||
|
|
ae75f07c8e | ||
|
|
18d0ba7a46 | ||
|
|
de7fbe7ac8 | ||
|
|
31e8d065c4 | ||
|
|
219a480c08 | ||
|
|
e9f1a7bb44 | ||
|
|
ead43b2bc1 | ||
|
|
cef86fd98d | ||
|
|
6993e97ae9 | ||
|
|
db396bcab3 | ||
|
|
1fed8de57d | ||
|
|
63eb0c0a4c | ||
|
|
888c6cf542 | ||
|
|
cc248fc32c | ||
|
|
cfe64b023b | ||
|
|
ad1312cf26 | ||
|
|
799813f85b | ||
|
|
19c9d655d0 | ||
|
|
f9a9e35269 | ||
|
|
8cd306cd32 | ||
|
|
816a0d747b | ||
|
|
b0cff1a7a8 | ||
|
|
71d57c5631 | ||
|
|
546fb633ec | ||
|
|
a3c7fa9385 | ||
|
|
c6cf84def0 | ||
|
|
86512a0482 | ||
|
|
3ddbc1c9b7 | ||
|
|
abf1005241 | ||
|
|
c475eabb60 | ||
|
|
3ad20c85a5 | ||
|
|
44fc80c70f | ||
|
|
8ba911bb55 | ||
|
|
896f287d92 | ||
|
|
0d150f7acd | ||
|
|
c27f7e42d6 | ||
|
|
cc56a68bc6 | ||
|
|
d7302c3b35 | ||
|
|
b46a51c40e | ||
|
|
c6780087b8 | ||
|
|
1ef698dde6 | ||
|
|
91aadba4ef | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
15
tests/devices/liquid_handling/README.md
Normal file
15
tests/devices/liquid_handling/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 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 的快速测试。***
|
||||
|
||||
547
tests/devices/liquid_handling/unit_test.py
Normal file
547
tests/devices/liquid_handling/unit_test.py
Normal file
@@ -0,0 +1,547 @@
|
||||
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]]
|
||||
|
||||
|
||||
@@ -207,7 +207,14 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
||||
|
||||
res_samples = []
|
||||
res_volumes = []
|
||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||
# 处理 use_channels 为 None 的情况(通常用于单通道操作)
|
||||
if use_channels is None:
|
||||
# 对于单通道操作,推断通道为 [0]
|
||||
channels_to_use = [0] * len(resources)
|
||||
else:
|
||||
channels_to_use = use_channels
|
||||
|
||||
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
||||
res_volumes.append(volume)
|
||||
self.pending_liquids_dict[channel] = {
|
||||
@@ -920,6 +927,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -983,6 +991,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1165,6 +1174,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1199,6 +1209,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1235,6 +1246,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.aspirate(
|
||||
@@ -1271,6 +1283,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
@@ -1327,6 +1340,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(总体积)
|
||||
@@ -1366,6 +1380,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[idx:idx+1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
@@ -1401,6 +1416,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[i:i + 8] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||
@@ -1446,6 +1462,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
@@ -1497,10 +1514,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
f"(matching `asp_vols`). Got length {len(dis_vols)}."
|
||||
)
|
||||
|
||||
need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0
|
||||
defer_final_discard = need_mix_after or touch_tip
|
||||
|
||||
if len(use_channels) == 1:
|
||||
# 单通道模式:多次吸液,一次分液
|
||||
# 先混合前(如果需要)
|
||||
|
||||
# 如果需要 before mix,先 pick up tip 并执行 mix
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1508,8 +1534,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 从每个源容器吸液并分液到目标容器
|
||||
for idx, source in enumerate(sources):
|
||||
tip = []
|
||||
@@ -1527,10 +1556,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
|
||||
if delays is not None:
|
||||
await self.custom_delay(seconds=delays[0])
|
||||
|
||||
|
||||
# 分液到目标容器
|
||||
if use_proportional_mixing:
|
||||
# 按不同比例混合:使用对应的 dis_vols
|
||||
@@ -1546,7 +1575,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
dis_offset = offsets[0] if offsets and len(offsets) > 0 else None
|
||||
dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None
|
||||
dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||
|
||||
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[dis_vol],
|
||||
@@ -1557,14 +1586,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
|
||||
if not (defer_final_discard and idx == len(sources) - 1):
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
if need_mix_after:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1572,18 +1602,27 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips(use_channels=use_channels)
|
||||
|
||||
elif len(use_channels) == 8:
|
||||
# 8通道模式:需要确保源数量是8的倍数
|
||||
if len(sources) % 8 != 0:
|
||||
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
||||
|
||||
# 每次处理8个源
|
||||
|
||||
# 如果需要 before mix,先 pick up tips 并执行 mix
|
||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
tip.extend(next(self.current_tip))
|
||||
await self.pick_up_tips(tip)
|
||||
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1591,8 +1630,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
for i in range(0, len(sources), 8):
|
||||
tip = []
|
||||
for _ in range(len(use_channels)):
|
||||
@@ -1650,11 +1692,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
|
||||
if delays is not None and len(delays) > 1:
|
||||
await self.custom_delay(seconds=delays[1])
|
||||
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
|
||||
if not (defer_final_discard and i + 8 >= len(sources)):
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# 最后在目标容器中混合(如果需要)
|
||||
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||
if need_mix_after:
|
||||
await self.mix(
|
||||
targets=[target],
|
||||
mix_time=mix_times,
|
||||
@@ -1662,11 +1705,15 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
offsets=offsets[0:1] if offsets else None,
|
||||
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||
mix_rate=mix_rate if mix_rate else None,
|
||||
use_channels=use_channels,
|
||||
)
|
||||
|
||||
if touch_tip:
|
||||
await self.touch_tip([target])
|
||||
|
||||
if defer_final_discard:
|
||||
await self.discard_tips([0,1,2,3,4,5,6,7])
|
||||
|
||||
# except Exception as e:
|
||||
# traceback.print_exc()
|
||||
# raise RuntimeError(f"Liquid addition failed: {e}") from e
|
||||
@@ -1686,7 +1733,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
print(f"Waiting time: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
|
||||
await self._ros_node.sleep(seconds)
|
||||
# Use ROS node sleep if available, otherwise use asyncio.sleep
|
||||
if hasattr(self, '_ros_node') and self._ros_node is not None:
|
||||
await self._ros_node.sleep(seconds)
|
||||
else:
|
||||
import asyncio
|
||||
await asyncio.sleep(seconds)
|
||||
if msg:
|
||||
print(f"Done: {msg}")
|
||||
print(f"Current time: {time.strftime('%H:%M:%S')}")
|
||||
@@ -1725,27 +1777,59 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
||||
height_to_bottom: Optional[float] = None,
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
use_channels: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
):
|
||||
if mix_time is None: # No mixing required
|
||||
if mix_time is None or mix_time <= 0: # No mixing required
|
||||
return
|
||||
"""Mix the liquid in the target wells."""
|
||||
if mix_vol is None:
|
||||
raise ValueError("`mix_vol` must be provided when `mix_time` is set.")
|
||||
|
||||
targets_list: List[Container] = list(targets)
|
||||
if len(targets_list) == 0:
|
||||
return
|
||||
|
||||
def _expand(value, count: int):
|
||||
if value is None:
|
||||
return [None] * count
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) != count:
|
||||
raise ValueError("Length of per-target parameters must match targets.")
|
||||
return list(value)
|
||||
return [value] * count
|
||||
|
||||
offsets_list = _expand(offsets, len(targets_list))
|
||||
heights_list = _expand(height_to_bottom, len(targets_list))
|
||||
rates_list = _expand(mix_rate, len(targets_list))
|
||||
|
||||
for _ in range(mix_time):
|
||||
await self.aspirate(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[targets],
|
||||
vols=[mix_vol],
|
||||
flow_rates=[mix_rate] if mix_rate else None,
|
||||
offsets=[offsets] if offsets else None,
|
||||
liquid_height=[height_to_bottom] if height_to_bottom else None,
|
||||
)
|
||||
for idx, target in enumerate(targets_list):
|
||||
offset_arg = (
|
||||
[offsets_list[idx]] if offsets_list[idx] is not None else None
|
||||
)
|
||||
height_arg = (
|
||||
[heights_list[idx]] if heights_list[idx] is not None else None
|
||||
)
|
||||
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None
|
||||
|
||||
await self.aspirate(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
await self.custom_delay(seconds=1)
|
||||
await self.dispense(
|
||||
resources=[target],
|
||||
vols=[mix_vol],
|
||||
use_channels=use_channels,
|
||||
flow_rates=rate_arg,
|
||||
offsets=offset_arg,
|
||||
liquid_height=height_arg,
|
||||
)
|
||||
|
||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||
"""Yield tips from a list of TipRacks one-by-one until depleted."""
|
||||
|
||||
@@ -30,10 +30,11 @@ from pylabrobot.liquid_handling.standard import (
|
||||
ResourceMove,
|
||||
ResourceDrop,
|
||||
)
|
||||
from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack
|
||||
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 unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.itemized_carrier import ItemizedCarrier
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||
|
||||
|
||||
class PRCXIError(RuntimeError):
|
||||
@@ -136,13 +137,31 @@ class PRCXI9300Plate(Plate):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
# ordering 的值是对象(可能是 Well 对象),检查是否有有效的 location
|
||||
# 如果是反序列化过程,Well 对象可能没有正确的 location,需要让 Plate 重新创建
|
||||
sample_value = next(iter(ordering.values()), None)
|
||||
if sample_value is not None and hasattr(sample_value, 'location'):
|
||||
# 如果是 Well 对象但 location 为 None,说明是反序列化过程
|
||||
# 让 Plate 自己创建 Well 对象
|
||||
if sample_value.location is None:
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# Well 对象有有效的 location,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
elif sample_value is None:
|
||||
# ordering 的值都是 None,让 Plate 自己创建 Well 对象
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# 其他情况,直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
|
||||
ordering_param = collections.OrderedDict() # 提供空的 ordering
|
||||
|
||||
# 根据情况传递不同的参数
|
||||
if items is not None:
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
@@ -219,9 +238,16 @@ class PRCXI9300TipRack(TipRack):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items = ordering
|
||||
ordering_param = None
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,使用 ordering 参数
|
||||
items = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
items = None
|
||||
ordering_param = None
|
||||
@@ -284,7 +310,7 @@ class PRCXI9300Trash(Trash):
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
category: str = "trash",
|
||||
category: str = "plate",
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs):
|
||||
|
||||
@@ -357,9 +383,16 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
items_to_pass = ordering
|
||||
ordering_param = None
|
||||
# ordering 的值已经是对象,需要过滤掉 None 值
|
||||
# 只保留有效的对象,用于 ordered_items 参数
|
||||
valid_items = {k: v for k, v in ordering.items() if v is not None}
|
||||
if valid_items:
|
||||
items_to_pass = valid_items
|
||||
ordering_param = None
|
||||
else:
|
||||
# 如果没有有效对象,使用 ordering 参数
|
||||
items_to_pass = None
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
elif items is not None:
|
||||
# 兼容旧的 items 参数
|
||||
items_to_pass = items
|
||||
@@ -415,6 +448,81 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
|
||||
data.update(safe_state)
|
||||
return data
|
||||
class PRCXI9300PlateAdapterSite(ItemizedCarrier):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
material_info: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
# 处理 sites 参数的不同格式
|
||||
|
||||
sites = create_homogeneous_resources(
|
||||
klass=ResourceHolder,
|
||||
locations=[Coordinate(0, 0, 0)],
|
||||
resource_size_x=size_x,
|
||||
resource_size_y=size_y,
|
||||
resource_size_z=size_z,
|
||||
name_prefix=name,
|
||||
)[0]
|
||||
# 确保不传递重复的参数
|
||||
kwargs.pop('layout', None)
|
||||
kwargs.pop('num_items_x', None)
|
||||
kwargs.pop('num_items_y', None)
|
||||
kwargs.pop('num_items_z', None)
|
||||
kwargs.pop('sites', None)
|
||||
|
||||
super().__init__(name, size_x, size_y, size_z,
|
||||
sites={name: sites},
|
||||
num_items_x=1,
|
||||
num_items_y=1,
|
||||
num_items_z=1,
|
||||
**kwargs)
|
||||
self._unilabos_state = {}
|
||||
if material_info:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
|
||||
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
|
||||
"""重写 assign_child_resource 方法,对于适配器位置,不使用索引分配"""
|
||||
# 直接调用 Resource 的 assign_child_resource,避免 ItemizedCarrier 的索引逻辑
|
||||
from pylabrobot.resources.resource import Resource
|
||||
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
|
||||
|
||||
def unassign_child_resource(self, resource):
|
||||
"""重写 unassign_child_resource 方法,对于适配器位置,不使用 sites 列表"""
|
||||
# 直接调用 Resource 的 unassign_child_resource,避免 ItemizedCarrier 的 sites 逻辑
|
||||
from pylabrobot.resources.resource import Resource
|
||||
Resource.unassign_child_resource(self, resource)
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
data = super().serialize_state()
|
||||
except AttributeError:
|
||||
data = {}
|
||||
|
||||
# 包含 sites 配置信息,但避免序列化 ResourceHolder 对象
|
||||
if hasattr(self, 'sites') and self.sites:
|
||||
# 只保存 sites 的基本信息,不保存 ResourceHolder 对象本身
|
||||
sites_info = []
|
||||
for site in self.sites:
|
||||
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
|
||||
# 对于 pylabrobot 对象,只保存基本信息
|
||||
sites_info.append({
|
||||
"__pylabrobot_object__": True,
|
||||
"class": site.__class__.__name__,
|
||||
"module": site.__class__.__module__,
|
||||
"name": getattr(site, 'name', str(site))
|
||||
})
|
||||
else:
|
||||
sites_info.append(site)
|
||||
data['sites'] = sites_info
|
||||
|
||||
return data
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""加载状态,包括 sites 配置信息"""
|
||||
super().load_state(state)
|
||||
|
||||
# 从状态中恢复 sites 配置信息
|
||||
if 'sites' in state:
|
||||
self.sites = [state['sites']]
|
||||
|
||||
class PRCXI9300PlateAdapter(PlateAdapter):
|
||||
"""
|
||||
@@ -813,7 +921,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
**backend_kwargs,
|
||||
):
|
||||
|
||||
return await super().move_plate(
|
||||
res = await super().move_plate(
|
||||
plate,
|
||||
to,
|
||||
intermediate_locations,
|
||||
@@ -825,6 +933,12 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
target_plate_number = to,
|
||||
**backend_kwargs,
|
||||
)
|
||||
plate.unassign()
|
||||
to.assign_child_resource(plate, location=Coordinate(0, 0, 0))
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
return res
|
||||
|
||||
class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
"""PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。
|
||||
|
||||
@@ -4019,7 +4019,8 @@ liquid_handler:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_times:
|
||||
- 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -4175,9 +4176,11 @@ liquid_handler:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -5040,7 +5043,8 @@ liquid_handler.biomek:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_times:
|
||||
- 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -5183,9 +5187,11 @@ liquid_handler.biomek:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -9665,7 +9671,8 @@ liquid_handler.prcxi:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_times:
|
||||
- 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -9821,9 +9828,11 @@ liquid_handler.prcxi:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"width": 542,
|
||||
"height": 374,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
@@ -21,12 +21,12 @@
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": true,
|
||||
"debug": false,
|
||||
"setup": true,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": true,
|
||||
"simulator": false,
|
||||
"channel_num": 2
|
||||
},
|
||||
"data": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 240,
|
||||
"y": 700,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
@@ -50,8 +50,8 @@
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
@@ -83,7 +83,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -96,7 +96,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
@@ -128,7 +128,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -141,7 +141,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T2",
|
||||
@@ -173,7 +173,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -186,7 +186,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T3",
|
||||
@@ -218,7 +218,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -231,7 +231,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T4",
|
||||
@@ -263,7 +263,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -276,7 +276,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T5",
|
||||
@@ -308,7 +308,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -321,7 +321,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T6",
|
||||
@@ -353,7 +353,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -366,7 +366,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T7",
|
||||
@@ -398,7 +398,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -411,7 +411,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T8",
|
||||
@@ -443,7 +443,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -456,7 +456,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T9",
|
||||
@@ -488,7 +488,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -501,7 +501,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T10",
|
||||
@@ -533,7 +533,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -546,7 +546,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T11",
|
||||
@@ -578,7 +578,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -591,7 +591,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T12",
|
||||
@@ -623,7 +623,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -636,7 +636,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T13",
|
||||
@@ -668,7 +668,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -681,7 +681,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T14",
|
||||
@@ -713,7 +713,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -726,7 +726,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T15",
|
||||
@@ -758,7 +758,7 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Container",
|
||||
"type": "PRCXI9300PlateAdapterSite",
|
||||
"size_x": 127,
|
||||
"size_y": 85.5,
|
||||
"size_z": 10,
|
||||
@@ -771,7 +771,7 @@
|
||||
"category": "plate",
|
||||
"model": null,
|
||||
"barcode": null,
|
||||
"ordering": {},
|
||||
|
||||
"sites": [
|
||||
{
|
||||
"label": "T16",
|
||||
@@ -40712,7 +40712,7 @@
|
||||
|
||||
"children": [],
|
||||
"parent": "T16",
|
||||
"type": "plate",
|
||||
"type": "trash",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 0,
|
||||
|
||||
Reference in New Issue
Block a user