merge lab_resource,并修改transfer liquid

This commit is contained in:
q434343
2026-02-28 16:05:29 +08:00
parent f38f3dfc89
commit 41be9e4e19
13 changed files with 11222 additions and 338 deletions

View File

@@ -1177,6 +1177,11 @@ class QueueProcessor:
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
for job_info in queued_jobs: for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
# 此时不应再发送 busy/need_more否则会覆盖已发出的 free=True 通知
if job_info.status != JobStatus.QUEUE:
continue
message = { message = {
"action": "report_action_state", "action": "report_action_state",
"data": { "data": {

View File

@@ -21,7 +21,7 @@ from pylabrobot.resources import (
ResourceHolder, ResourceHolder,
Lid, Lid,
Trash, Trash,
Tip, Tip, TubeRack,
) )
from typing_extensions import TypedDict from typing_extensions import TypedDict
@@ -208,7 +208,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
): ):
if spread == "":
spread = "wide"
if self._simulator: if self._simulator:
return await self._simulate_handler.aspirate( return await self._simulate_handler.aspirate(
resources, resources,
@@ -221,6 +222,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread, spread,
**backend_kwargs, **backend_kwargs,
) )
try:
await super().aspirate( await super().aspirate(
resources, resources,
vols, vols,
@@ -232,6 +234,21 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread, spread,
**backend_kwargs, **backend_kwargs,
) )
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
await super().aspirate(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread="custom",
**backend_kwargs,
)
else:
raise
res_samples = [] res_samples = []
res_volumes = [] res_volumes = []
@@ -243,7 +260,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
channels_to_use = use_channels channels_to_use = use_channels
for resource, volume, channel in zip(resources, vols, channels_to_use): 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)}) sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
res_volumes.append(volume) res_volumes.append(volume)
self.pending_liquids_dict[channel] = { self.pending_liquids_dict[channel] = {
EXTRA_SAMPLE_UUID: sample_uuid_value, EXTRA_SAMPLE_UUID: sample_uuid_value,
@@ -263,6 +281,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
) -> SimpleReturn: ) -> SimpleReturn:
if spread == "":
spread = "wide"
if self._simulator: if self._simulator:
return await self._simulate_handler.dispense( return await self._simulate_handler.dispense(
resources, resources,
@@ -275,6 +295,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread, spread,
**backend_kwargs, **backend_kwargs,
) )
try:
await super().dispense( await super().dispense(
resources, resources,
vols, vols,
@@ -283,8 +304,24 @@ class LiquidHandlerMiddleware(LiquidHandler):
offsets, offsets,
liquid_height, liquid_height,
blow_out_air_volume, blow_out_air_volume,
spread,
**backend_kwargs, **backend_kwargs,
) )
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
await super().dispense(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
"custom",
**backend_kwargs,
)
else:
raise
res_samples = [] res_samples = []
res_volumes = [] res_volumes = []
for resource, volume, channel in zip(resources, vols, use_channels): for resource, volume, channel in zip(resources, vols, use_channels):
@@ -702,10 +739,13 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。 如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
""" """
assert issubclass(plate.__class__, Plate), "plate must be a Plate" assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack) , f"plate must be a Plate, now: {type(plate)}"
plate: Plate = cast(Plate, cast(Resource, plate)) plate: Union[Plate, TubeRack]
# 根据 well_names 获取对应的 Well 对象 # 根据 well_names 获取对应的 Well 对象
if issubclass(plate.__class__, Plate):
wells = [plate.get_well(name) for name in well_names] wells = [plate.get_well(name) for name in well_names]
elif issubclass(plate.__class__, TubeRack):
wells = [plate.get_tube(name) for name in well_names]
res_volumes = [] res_volumes = []
# 如果 liquid_names 和 volumes 都为空,直接返回 # 如果 liquid_names 和 volumes 都为空,直接返回
@@ -851,7 +891,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for _ in range(len(sources)): for _ in range(len(sources)):
tip = [] tip = []
for __ in range(len(use_channels)): for __ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.aspirate( await self.aspirate(
resources=[sources[_]], resources=[sources[_]],
@@ -891,7 +931,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
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)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
current_targets = waste_liquid[i : i + 8] current_targets = waste_liquid[i : i + 8]
current_reagent_sources = sources[i : i + 8] current_reagent_sources = sources[i : i + 8]
@@ -985,7 +1025,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for _ in range(len(targets)): for _ in range(len(targets)):
tip = [] tip = []
for x in range(len(use_channels)): for x in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.aspirate( await self.aspirate(
@@ -1037,7 +1077,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8): for i in range(0, len(targets), 8):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
current_targets = targets[i : i + 8] current_targets = targets[i : i + 8]
current_reagent_sources = reagent_sources[i : i + 8] current_reagent_sources = reagent_sources[i : i + 8]
@@ -1162,11 +1202,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
Number of mix cycles. If *None* (default) no mixing occurs regardless of Number of mix cycles. If *None* (default) no mixing occurs regardless of
mix_stage. mix_stage.
""" """
num_sources = len(sources)
num_targets = len(targets)
len_asp_vols = len(asp_vols)
len_dis_vols = len(dis_vols)
# 确保 use_channels 有默认值 # 确保 use_channels 有默认值
if use_channels is None: if use_channels is None:
# 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7 # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7
use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0] use_channels = list(range(self.channel_num)) if self.channel_num == 8 else [0]
elif len(use_channels) == 8:
if self.channel_num != 8:
raise ValueError(f"if channel_num is 8, use_channels length must be 8, but got {len(use_channels)}")
if num_sources%8 != 0 or num_targets%8 != 0 or len_asp_vols%8 != 0 or len_dis_vols%8 != 0:
raise ValueError(f"if channel_num is 8, sources, targets, asp_vols, and dis_vols length must be divisible by 8, but got {num_sources}, {num_targets}, {len_asp_vols}, and {len_dis_vols}")
if is_96_well: if is_96_well:
pass # This mode is not verified. pass # This mode is not verified.
@@ -1200,86 +1248,227 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# 识别传输模式mix_times 为 None 也应该能正常移液,只是不做 mix # 识别传输模式mix_times 为 None 也应该能正常移液,只是不做 mix
num_sources = len(sources) num_sources = len(sources)
num_targets = len(targets) num_targets = len(targets)
len_asp_vols = len(asp_vols)
len_dis_vols = len(dis_vols)
if num_sources == 1 and num_targets > 1: # if num_targets != 1 and num_sources != 1:
# 模式1: 一对多 (1 source -> N targets) # if len_asp_vols != num_sources and len_asp_vols != num_targets:
await self._transfer_one_to_many( # raise ValueError(f"asp_vols length must be equal to sources or targets length, but got {len_asp_vols} and {num_sources} and {num_targets}")
sources[0], # if len_dis_vols != num_sources and len_dis_vols != num_targets:
targets, # raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
tip_racks,
use_channels, if len(use_channels) != 8:
asp_vols, max_len = max(num_sources, num_targets)
dis_vols, for i in range(max_len):
asp_flow_rates,
dis_flow_rates, # 辅助函数安全地从列表中获取元素如果列表为空则返回None
offsets, def safe_get(lst, idx, default=None):
touch_tip, return [lst[idx]] if lst else default
liquid_height,
blow_out_air_volume, # 动态构建参数字典,只传递实际提供的参数
spread, kwargs = {
mix_stage, 'sources': [sources[i%num_sources]],
mix_times, 'targets': [targets[i%num_targets]],
mix_vol, 'tip_racks': tip_racks,
mix_rate, 'use_channels': use_channels,
mix_liquid_height, 'asp_vols': [asp_vols[i%len_asp_vols]],
delays, 'dis_vols': [dis_vols[i%len_dis_vols]],
) }
elif num_sources > 1 and num_targets == 1:
# 模式2: 多对一 (N sources -> 1 target) # 条件性添加可选参数
await self._transfer_many_to_one( if asp_flow_rates is not None:
sources, kwargs['asp_flow_rates'] = [asp_flow_rates[i%len_asp_vols]]
targets[0], if dis_flow_rates is not None:
tip_racks, kwargs['dis_flow_rates'] = [dis_flow_rates[i%len_dis_vols]]
use_channels, if offsets is not None:
asp_vols, kwargs['offsets'] = safe_get(offsets, i)
dis_vols, if touch_tip is not None:
asp_flow_rates, kwargs['touch_tip'] = touch_tip if touch_tip else False
dis_flow_rates, if liquid_height is not None:
offsets, kwargs['liquid_height'] = safe_get(liquid_height, i)
touch_tip, if blow_out_air_volume is not None:
liquid_height, kwargs['blow_out_air_volume'] = safe_get(blow_out_air_volume, i)
blow_out_air_volume, if spread is not None:
spread, kwargs['spread'] = spread
mix_stage, if mix_stage is not None:
mix_times, kwargs['mix_stage'] = safe_get(mix_stage, i)
mix_vol, if mix_times is not None:
mix_rate, kwargs['mix_times'] = safe_get(mix_times, i)
mix_liquid_height, if mix_vol is not None:
delays, kwargs['mix_vol'] = safe_get(mix_vol, i)
) if mix_rate is not None:
elif num_sources == num_targets: kwargs['mix_rate'] = safe_get(mix_rate, i)
# 模式3: 一对一 (N sources -> N targets) if mix_liquid_height is not None:
await self._transfer_one_to_one( kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i)
sources, if delays is not None:
targets, kwargs['delays'] = safe_get(delays, i)
tip_racks,
use_channels, await self._transfer_base_method(**kwargs)
asp_vols,
dis_vols,
asp_flow_rates,
dis_flow_rates, # if num_sources == 1 and num_targets > 1:
offsets, # # 模式1: 一对多 (1 source -> N targets)
touch_tip, # await self._transfer_one_to_many(
liquid_height, # sources,
blow_out_air_volume, # targets,
spread, # tip_racks,
mix_stage, # use_channels,
mix_times, # asp_vols,
mix_vol, # dis_vols,
mix_rate, # asp_flow_rates,
mix_liquid_height, # dis_flow_rates,
delays, # offsets,
) # touch_tip,
else: # liquid_height,
raise ValueError( # blow_out_air_volume,
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. " # spread,
"Supported modes: 1->N, N->1, or N->N." # mix_stage,
) # mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# elif num_sources > 1 and num_targets == 1:
# # 模式2: 多对一 (N sources -> 1 target)
# await self._transfer_many_to_one(
# sources,
# targets[0],
# tip_racks,
# use_channels,
# asp_vols,
# dis_vols,
# asp_flow_rates,
# dis_flow_rates,
# offsets,
# touch_tip,
# liquid_height,
# blow_out_air_volume,
# spread,
# mix_stage,
# mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# elif num_sources == num_targets:
# # 模式3: 一对一 (N sources -> N targets)
# await self._transfer_one_to_one(
# sources,
# targets,
# tip_racks,
# use_channels,
# asp_vols,
# dis_vols,
# asp_flow_rates,
# dis_flow_rates,
# offsets,
# touch_tip,
# liquid_height,
# blow_out_air_volume,
# spread,
# mix_stage,
# mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# else:
# raise ValueError(
# f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
# "Supported modes: 1->N, N->1, or N->N."
# )
return TransferLiquidReturn( return TransferLiquidReturn(
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
) )
async def _transfer_base_method(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
**kwargs
):
# 从kwargs中提取参数提供默认值
asp_flow_rates = kwargs.get('asp_flow_rates')
dis_flow_rates = kwargs.get('dis_flow_rates')
offsets = kwargs.get('offsets')
touch_tip = kwargs.get('touch_tip', False)
liquid_height = kwargs.get('liquid_height')
blow_out_air_volume = kwargs.get('blow_out_air_volume')
spread = kwargs.get('spread', 'wide')
mix_stage = kwargs.get('mix_stage')
mix_times = kwargs.get('mix_times')
mix_vol = kwargs.get('mix_vol')
mix_rate = kwargs.get('mix_rate')
mix_liquid_height = kwargs.get('mix_liquid_height')
delays = kwargs.get('delays')
tip = []
tip.extend(self._get_next_tip())
await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[0]],
mix_time=mix_times,
mix_vol=mix_vol,
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(
resources=[sources[0]],
vols=[asp_vols[0]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
blow_out_air_volume=(
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
),
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[0]],
vols=[dis_vols[0]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
blow_out_air_volume=(
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
),
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[0]],
mix_time=mix_times,
mix_vol=mix_vol,
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[0])
await self.touch_tip(targets[0])
await self.discard_tips(use_channels=use_channels)
async def _transfer_one_to_one( async def _transfer_one_to_one(
self, self,
@@ -1322,7 +1511,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for _ in range(len(targets)): for _ in range(len(targets)):
tip = [] tip = []
for ___ in range(len(use_channels)): for ___ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
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:
@@ -1386,7 +1575,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8): for i in range(0, len(targets), 8):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
current_targets = targets[i : i + 8] current_targets = targets[i : i + 8]
current_reagent_sources = sources[i : i + 8] current_reagent_sources = sources[i : i + 8]
@@ -1491,7 +1680,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# 单通道模式:一次吸液,多次分液 # 单通道模式:一次吸液,多次分液
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
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:
@@ -1563,7 +1752,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8): for i in range(0, len(targets), 8):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
current_targets = targets[i : i + 8] current_targets = targets[i : i + 8]
@@ -1702,7 +1891,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
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 = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.mix( await self.mix(
@@ -1721,7 +1910,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for idx, source in enumerate(sources): for idx, source in enumerate(sources):
tip = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.aspirate( await self.aspirate(
@@ -1804,7 +1993,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
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 = [] tip = []
for _ in range(len(use_channels)): for _ in range(len(use_channels)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.mix( await self.mix(
@@ -1822,7 +2011,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
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)):
tip.extend(next(self.current_tip)) tip.extend(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
current_sources = sources[i : i + 8] current_sources = sources[i : i + 8]
@@ -2022,7 +2211,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for rack in tip_racks: for rack in tip_racks:
for tip in rack: for tip in rack:
yield tip yield tip
raise RuntimeError("Out of tips!") # raise RuntimeError("Out of tips!")
def _get_next_tip(self):
"""从 current_tip 迭代器获取下一个 tip耗尽时抛出明确错误而非 StopIteration"""
try:
return next(self.current_tip)
except StopIteration as e:
raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e
def set_tiprack(self, tip_racks: Sequence[TipRack]): def set_tiprack(self, tip_racks: Sequence[TipRack]):
"""Set the tip racks for the liquid handler.""" """Set the tip racks for the liquid handler."""

View File

@@ -928,7 +928,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
if self.step_mode: if self.step_mode:
self._unilabos_backend.create_protocol(f"step_mode_protocol_{time.time()}") await self.create_protocol(f"step_mode_protocol_{time.time()}")
res =await super().transfer_liquid( res =await super().transfer_liquid(
sources, sources,
@@ -953,32 +953,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays=delays, delays=delays,
none_keys=none_keys, none_keys=none_keys,
) )
self._unilabos_backend.run_protocol() if self.step_mode:
await self.run_protocol()
return res return res
else:
return await super().transfer_liquid(
sources,
targets,
tip_racks,
use_channels=use_channels,
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
mix_times=mix_times,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
delays=delays,
none_keys=none_keys,
)
async def custom_delay(self, seconds=0, msg=None): async def custom_delay(self, seconds=0, msg=None):
return await super().custom_delay(seconds, msg) return await super().custom_delay(seconds, msg)
@@ -1028,7 +1006,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
): ):
try:
return await super().aspirate( return await super().aspirate(
resources, resources,
vols, vols,
@@ -1040,6 +1018,20 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
spread, spread,
**backend_kwargs, **backend_kwargs,
) )
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
return await super().aspirate(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread="custom",
**backend_kwargs,
)
raise
async def drop_tips( async def drop_tips(
self, self,
@@ -1063,6 +1055,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
): ):
try:
return await super().dispense( return await super().dispense(
resources, resources,
vols, vols,
@@ -1074,6 +1067,21 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
spread, spread,
**backend_kwargs, **backend_kwargs,
) )
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
# 目标资源过小无法分布多通道时,退化为 custom所有通道对准中心
return await super().dispense(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
"custom",
**backend_kwargs,
)
raise
async def discard_tips( async def discard_tips(
self, self,

View File

@@ -4134,19 +4134,8 @@ liquid_handler:
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources handler_key: sources
label: sources label: 待移动液体
- data_key: targets - data_key: targets
data_source: handle
data_type: resource
handler_key: targets
label: targets
- data_key: tip_racks
data_source: handle
data_type: resource
handler_key: tip_racks
label: tip_racks
output:
- data_key: sources
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: targets handler_key: targets
@@ -4161,9 +4150,9 @@ liquid_handler:
data_source: executor data_source: executor
data_type: resource data_type: resource
handler_key: sources_out handler_key: sources_out
label: sources label: 移液后源孔
- data_key: targets - data_key: targets.@flatten
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: targets_out handler_key: targets_out
label: 移液后目标孔 label: 移液后目标孔
@@ -4812,13 +4801,13 @@ liquid_handler.biomek:
targets: '' targets: ''
handles: handles:
input: input:
- data_key: sources - data_key: liquid
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources handler_key: sources
label: sources label: sources
output: output:
- data_key: targets - data_key: liquid
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: targets handler_key: targets
@@ -4971,21 +4960,21 @@ liquid_handler.biomek:
volume: 0.0 volume: 0.0
handles: handles:
input: input:
- data_key: sources - data_key: liquid
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources handler_key: sources
label: sources label: sources
- data_key: targets - data_key: liquid
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: targets handler_key: targets
label: targets label: targets
- data_key: tip_racks - data_key: liquid
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: tip_racks handler_key: tip_rack
label: tip_racks label: tip_rack
output: output:
- data_key: sources - data_key: sources
data_source: handle data_source: handle
@@ -5166,28 +5155,30 @@ liquid_handler.biomek:
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources handler_key: sources
label: sources io_type: target
label: 待移动液体
- data_key: targets - data_key: targets
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: targets handler_key: targets
label: targets label: 转移目标
- data_key: tip_racks - data_key: tip_racks
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: tip_racks handler_key: tip_rack
label: tip_racks label: 枪头盒
output: output:
- data_key: sources - data_key: sources.@flatten
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: sources_out handler_key: sources_out
label: sources io_type: source
- data_key: targets label: 移液后源孔
data_source: handle - data_key: targets.@flatten
data_source: executor
data_type: resource data_type: resource
handler_key: targets_out handler_key: targets_out
label: targets label: 移液后目标孔
placeholder_keys: placeholder_keys:
sources: unilabos_resources sources: unilabos_resources
targets: unilabos_resources targets: unilabos_resources
@@ -7735,31 +7726,6 @@ liquid_handler.prcxi:
title: move_to参数 title: move_to参数
type: object type: object
type: UniLabJsonCommandAsync type: UniLabJsonCommandAsync
auto-plr_pos_to_prcxi:
feedback: {}
goal: {}
goal_default:
resource: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
resource:
type: object
required:
- resource
type: object
result: {}
required:
- goal
title: plr_pos_to_prcxi参数
type: object
type: UniLabJsonCommand
auto-post_init: auto-post_init:
feedback: {} feedback: {}
goal: {} goal: {}
@@ -8655,19 +8621,7 @@ liquid_handler.prcxi:
z: 0.0 z: 0.0
sample_id: '' sample_id: ''
type: '' type: ''
handles: handles: {}
input:
- data_key: plate
data_source: handle
data_type: resource
handler_key: plate
label: plate
output:
- data_key: plate
data_source: handle
data_type: resource
handler_key: plate
label: plate
placeholder_keys: placeholder_keys:
plate: unilabos_resources plate: unilabos_resources
to: unilabos_resources to: unilabos_resources
@@ -10276,26 +10230,26 @@ liquid_handler.prcxi:
- data_key: sources - data_key: sources
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources handler_key: sources_identifier
label: sources label: 待移动液体
- data_key: targets - data_key: targets
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: targets handler_key: targets_identifier
label: targets label: 转移目标
- data_key: tip_racks - data_key: tip_racks
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: tip_racks handler_key: tip_rack_identifier
label: tip_racks label: 枪头盒
output: output:
- data_key: sources - data_key: sources.@flatten
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: sources_out handler_key: sources_out
label: sources label: 移液后源孔
- data_key: targets - data_key: targets.@flatten
data_source: handle data_source: executor
data_type: resource data_type: resource
handler_key: targets_out handler_key: targets_out
label: 移液后目标孔 label: 移液后目标孔
@@ -10679,12 +10633,6 @@ liquid_handler.prcxi:
type: string type: string
deck: deck:
type: object type: object
deck_y:
default: 400
type: string
deck_z:
default: 300
type: string
host: host:
type: string type: string
is_9320: is_9320:
@@ -10695,44 +10643,17 @@ liquid_handler.prcxi:
type: string type: string
port: port:
type: integer type: integer
rail_interval:
default: 0
type: string
rail_nums:
default: 4
type: string
rail_width:
default: 27.5
type: string
setup: setup:
default: true default: true
type: string type: string
simulator: simulator:
default: false default: false
type: string type: string
start_rail:
default: 2
type: string
step_mode: step_mode:
default: false default: false
type: string type: string
timeout: timeout:
type: number type: number
x_increase:
default: -0.003636
type: string
x_offset:
default: -0.8
type: string
xy_coupling:
default: -0.0045
type: string
y_increase:
default: -0.003636
type: string
y_offset:
default: -37.98
type: string
required: required:
- deck - deck
- host - host

View File

@@ -92,7 +92,7 @@ class Registry:
test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {}) test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {})
test_resource_schema = self._generate_unilab_json_command_schema( test_resource_schema = self._generate_unilab_json_command_schema(
test_resource_method_info.get("args", []), test_resource_method_info.get("args", []),
"auto-test_resource", "test_resource",
test_resource_method_info.get("return_annotation"), test_resource_method_info.get("return_annotation"),
) )
test_resource_schema["description"] = "用于测试物料、设备和样本。" test_resource_schema["description"] = "用于测试物料、设备和样本。"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -38,24 +38,52 @@ class LabSample(TypedDict):
extra: Dict[str, Any] extra: Dict[str, Any]
class ResourceDictPositionSizeType(TypedDict):
depth: float
width: float
height: float
class ResourceDictPositionSize(BaseModel): class ResourceDictPositionSize(BaseModel):
depth: float = Field(description="Depth", default=0.0) # z depth: float = Field(description="Depth", default=0.0) # z
width: float = Field(description="Width", default=0.0) # x width: float = Field(description="Width", default=0.0) # x
height: float = Field(description="Height", default=0.0) # y height: float = Field(description="Height", default=0.0) # y
class ResourceDictPositionScaleType(TypedDict):
x: float
y: float
z: float
class ResourceDictPositionScale(BaseModel): class ResourceDictPositionScale(BaseModel):
x: float = Field(description="x scale", default=0.0) x: float = Field(description="x scale", default=0.0)
y: float = Field(description="y scale", default=0.0) y: float = Field(description="y scale", default=0.0)
z: float = Field(description="z scale", default=0.0) z: float = Field(description="z scale", default=0.0)
class ResourceDictPositionObjectType(TypedDict):
x: float
y: float
z: float
class ResourceDictPositionObject(BaseModel): class ResourceDictPositionObject(BaseModel):
x: float = Field(description="X coordinate", default=0.0) x: float = Field(description="X coordinate", default=0.0)
y: float = Field(description="Y coordinate", default=0.0) y: float = Field(description="Y coordinate", default=0.0)
z: float = Field(description="Z coordinate", default=0.0) z: float = Field(description="Z coordinate", default=0.0)
class ResourceDictPositionType(TypedDict):
size: ResourceDictPositionSizeType
scale: ResourceDictPositionScaleType
layout: Literal["2d", "x-y", "z-y", "x-z"]
position: ResourceDictPositionObjectType
position3d: ResourceDictPositionObjectType
rotation: ResourceDictPositionObjectType
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
class ResourceDictPosition(BaseModel): class ResourceDictPosition(BaseModel):
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize) size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale) scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
@@ -74,6 +102,24 @@ class ResourceDictPosition(BaseModel):
) )
class ResourceDictType(TypedDict):
id: str
uuid: str
name: str
description: str
resource_schema: Dict[str, Any]
model: Dict[str, Any]
icon: str
parent_uuid: Optional[str]
parent: Optional["ResourceDictType"]
type: Union[Literal["device"], str]
klass: str
pose: ResourceDictPositionType
config: Dict[str, Any]
data: Dict[str, Any]
extra: Dict[str, Any]
# 统一的资源字典模型parent 自动序列化为 parent_uuidchildren 不序列化 # 统一的资源字典模型parent 自动序列化为 parent_uuidchildren 不序列化
class ResourceDict(BaseModel): class ResourceDict(BaseModel):
id: str = Field(description="Resource ID") id: str = Field(description="Resource ID")
@@ -468,10 +514,17 @@ class ResourceTreeSet(object):
trees.append(tree_instance) trees.append(tree_instance)
return cls(trees) return cls(trees)
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]: def to_plr_resources(
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
) -> List["PLRResource"]:
""" """
将 ResourceTreeSet 转换为 PLR 资源列表 将 ResourceTreeSet 转换为 PLR 资源列表
Args:
skip_devices: 是否跳过 device 类型节点
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
否则返回各树的根节点列表
Returns: Returns:
List[PLRResource]: PLR 资源实例列表 List[PLRResource]: PLR 资源实例列表
""" """
@@ -526,6 +579,71 @@ class ResourceTreeSet(object):
d["model"] = res.config.get("model", None) d["model"] = res.config.get("model", None)
return d return d
# deserialize 会单独处理的元数据 key不传给构造函数
_META_KEYS = {"type", "parent_name", "location", "children", "rotation", "barcode"}
# deserialize 自定义逻辑使用的 key如 TipSpot 用 prototype_tip 构建 make_tip需保留
_DESERIALIZE_PRESERVED_KEYS = {"prototype_tip"}
def remove_incompatible_params(plr_d: dict) -> None:
"""递归移除 PLR 类不接受的参数,避免 deserialize 报错。
- 移除构造函数不接受的参数(如 compute_height_from_volume、ordering、category
- 对 TubeRack将 ordering 转为 ordered_items
- 保留 deserialize 自定义逻辑需要的 key如 prototype_tip
"""
if "type" in plr_d:
sub_cls = find_subclass(plr_d["type"], PLRResource)
if sub_cls is not None:
spec = inspect.signature(sub_cls)
valid_params = set(spec.parameters.keys())
# TubeRack 特殊处理:先转换 ordering再参与后续过滤
if "ordering" not in valid_params and "ordering" in plr_d:
ordering = plr_d.pop("ordering", None)
if sub_cls.__name__ == "TubeRack":
plr_d["ordered_items"] = (
_ordering_to_ordered_items(plr_d, ordering)
if ordering
else {}
)
# 移除构造函数不接受的参数(保留 META 和 deserialize 自定义逻辑需要的 key
for key in list(plr_d.keys()):
if (
key not in _META_KEYS
and key not in _DESERIALIZE_PRESERVED_KEYS
and key not in valid_params
):
plr_d.pop(key, None)
for child in plr_d.get("children", []):
remove_incompatible_params(child)
def _ordering_to_ordered_items(plr_d: dict, ordering: dict) -> dict:
"""将 ordering 转为 ordered_items从 children 构建 Tube 对象"""
from pylabrobot.resources import Tube, Coordinate
from pylabrobot.serializer import deserialize as plr_deserialize
children = plr_d.get("children", [])
ordered_items = {}
for idx, (ident, child_name) in enumerate(ordering.items()):
child_data = children[idx] if idx < len(children) else None
if child_data is None:
continue
loc_data = child_data.get("location")
loc = (
plr_deserialize(loc_data)
if loc_data
else Coordinate(0, 0, 0)
)
tube = Tube(
name=child_data.get("name", child_name or ident),
size_x=child_data.get("size_x", 10),
size_y=child_data.get("size_y", 10),
size_z=child_data.get("size_z", 50),
max_volume=child_data.get("max_volume", 1000),
)
tube.location = loc
ordered_items[ident] = tube
plr_d["children"] = [] # 已并入 ordered_items避免重复反序列化
return ordered_items
plr_resources = [] plr_resources = []
tracker = DeviceNodeResourceTracker() tracker = DeviceNodeResourceTracker()
@@ -545,9 +663,7 @@ class ResourceTreeSet(object):
raise ValueError( raise ValueError(
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}" f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
) )
spec = inspect.signature(sub_cls) remove_incompatible_params(plr_dict)
if "category" not in spec.parameters:
plr_dict.pop("category", None)
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True) plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.serializer import deserialize from pylabrobot.serializer import deserialize
@@ -567,6 +683,18 @@ class ResourceTreeSet(object):
logger.error(f"堆栈: {traceback.format_exc()}") logger.error(f"堆栈: {traceback.format_exc()}")
raise raise
if requested_uuids:
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
result = []
for uid in requested_uuids:
if uid in tracker.uuid_to_resources:
result.append(tracker.uuid_to_resources[uid])
else:
raise ValueError(
f"请求的 UUID {uid} 在资源树中未找到。"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
)
return result
return plr_resources return plr_resources
@classmethod @classmethod

View File

@@ -460,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
# 如果driver自己就有assign的方法那就使用driver自己的assign方法 # 如果driver自己就有assign的方法那就使用driver自己的assign方法
if hasattr(self.driver_instance, "create_resource"): if hasattr(self.driver_instance, "create_resource") and self.node_name != "host_node":
create_resource_func = getattr(self.driver_instance, "create_resource") create_resource_func = getattr(self.driver_instance, "create_resource")
try: try:
ret = create_resource_func( ret = create_resource_func(
@@ -1389,9 +1389,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uuid_indices: if uuid_indices:
uuids = [item[1] for item in uuid_indices] uuids = [item[1] for item in uuid_indices]
resource_tree = await self.get_resource(uuids) resource_tree = await self.get_resource(uuids)
plr_resources = resource_tree.to_plr_resources() plr_resources = resource_tree.to_plr_resources(requested_uuids=uuids)
for i, (idx, _, resource_data) in enumerate(uuid_indices): for i, (idx, _, resource_data) in enumerate(uuid_indices):
try:
plr_resource = plr_resources[i] plr_resource = plr_resources[i]
except Exception as e:
self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在")
raise e
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"] plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
queried_resources[idx] = plr_resource queried_resources[idx] = plr_resource

View File

@@ -35,7 +35,7 @@ from unilabos.resources.resource_tracker import (
ResourceTreeInstance, ResourceTreeInstance,
RETURN_UNILABOS_SAMPLES, RETURN_UNILABOS_SAMPLES,
JSON_UNILABOS_PARAM, JSON_UNILABOS_PARAM,
PARAM_SAMPLE_UUIDS, PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample,
) )
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -65,6 +65,7 @@ class DeviceActionStatus:
class TestResourceReturn(TypedDict): class TestResourceReturn(TypedDict):
resources: List[List[ResourceDict]] resources: List[List[ResourceDict]]
devices: List[Dict[str, Any]] devices: List[Dict[str, Any]]
unilabos_samples: List[LabSample]
class TestLatencyReturn(TypedDict): class TestLatencyReturn(TypedDict):
@@ -1582,6 +1583,7 @@ class HostNode(BaseROS2DeviceNode):
def test_resource( def test_resource(
self, self,
sample_uuids: SampleUUIDsType,
resource: ResourceSlot = None, resource: ResourceSlot = None,
resources: List[ResourceSlot] = None, resources: List[ResourceSlot] = None,
device: DeviceSlot = None, device: DeviceSlot = None,
@@ -1596,6 +1598,7 @@ class HostNode(BaseROS2DeviceNode):
return { return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(), "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
"devices": [device, *devices], "devices": [device, *devices],
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
} }
def handle_pong_response(self, pong_data: dict): def handle_pong_response(self, pong_data: dict):

View File

@@ -21,12 +21,12 @@
}, },
"host": "10.20.30.184", "host": "10.20.30.184",
"port": 9999, "port": 9999,
"debug": true, "debug": false,
"setup": true, "setup": false,
"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": true, "simulator": false,
"channel_num": 2 "channel_num": 2
}, },
"data": { "data": {
@@ -789,6 +789,47 @@
] ]
}, },
"data": {} "data": {}
},
{
"id": "trash",
"name": "trash",
"children": [],
"parent": "T16",
"type": "trash",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "PRCXI9300Trash",
"size_x": 127.5,
"size_y": 86,
"size_z": 10,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "trash",
"model": null,
"barcode": null,
"max_volume": "Infinity",
"material_z_thickness": 0,
"compute_volume_from_height": null,
"compute_height_from_volume": null
},
"data": {
"liquids": [],
"pending_liquids": [],
"liquid_history": [],
"Material": {
"uuid": "730067cf07ae43849ddf4034299030e9"
}
}
} }
], ],
"edges": [] "edges": []

View File

@@ -119,11 +119,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
# 节点类型 # 节点类型
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型 NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
CLASS_NAMES_MAPPING = {
"plate": "PRCXI_BioER_96_wellplate",
"tip_rack": "PRCXI_300ul_Tips",
}
# create_resource 节点默认参数 # create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = { CREATE_RESOURCE_DEFAULTS = {
"device_id": "/PRCXI", "device_id": "/PRCXI",
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值 "parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
"class_name": "PRCXI_BioER_96_wellplate",
} }
# 默认液体体积 (uL) # 默认液体体积 (uL)
@@ -367,11 +370,10 @@ def build_protocol_graph(
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑 """统一的协议图构建函数,根据设备类型自动选择构建逻辑
Args: Args:
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找 labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
protocol_steps: 协议步骤列表 protocol_steps: 协议步骤列表
workstation_name: 工作站名称 workstation_name: 工作站名称
action_resource_mapping: action 到 resource_name 的映射字典,可选 action_resource_mapping: action 到 resource_name 的映射字典,可选
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...]
""" """
G = WorkflowGraph() G = WorkflowGraph()
resource_last_writer = {} # reagent_name -> "node_id:port" resource_last_writer = {} # reagent_name -> "node_id:port"
@@ -379,7 +381,19 @@ def build_protocol_graph(
protocol_steps = refactor_data(protocol_steps, action_resource_mapping) protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
# ==================== 第一步:按 slot 创建 create_resource 节点 ==================== # ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
# 收集所有唯一的 slot
slots_info = {} # slot -> {labware, res_id}
for labware_id, item in labware_info.items():
slot = str(item.get("slot", ""))
if slot and slot not in slots_info:
res_id = f"plate_slot_{slot}"
slots_info[slot] = {
"labware": item.get("labware", ""),
"res_id": res_id,
"labware_id": labware_id,
}
# 创建 Group 节点,包含所有 create_resource 节点 # 创建 Group 节点,包含所有 create_resource 节点
group_node_id = str(uuid.uuid4()) group_node_id = str(uuid.uuid4())
G.add_node( G.add_node(
@@ -395,41 +409,38 @@ def build_protocol_graph(
param=None, param=None,
) )
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录type 即 class_name # 为每个唯一的 slot 创建 create_resource 节点
res_index = 0 for slot, info in slots_info.items():
for lw in (labware_defs or []):
slot = str(lw.get("slot", ""))
if not slot or slot in slot_to_create_resource:
continue # 跳过空 slot 或已处理的 slot
lw_name = lw.get("name", f"slot {slot}")
lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"])
res_id = f"plate_slot_{slot}"
res_index += 1
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
res_id = info["res_id"]
res_type_name = info["labware"].lower().replace(".", "point")
res_type_name = f"lab_{res_type_name}"
G.add_node( G.add_node(
node_id, node_id,
template_name="create_resource", template_name="create_resource",
resource_name="host_node", resource_name="host_node",
name=lw_name, name=f"{res_type_name}_slot{slot}",
description=f"Create {lw_name}", description=f"Create plate on slot {slot}",
lab_node_type="Labware", lab_node_type="Labware",
footer="create_resource-host_node", footer="create_resource-host_node",
device_name=DEVICE_NAME_HOST, device_name=DEVICE_NAME_HOST,
type=NODE_TYPE_DEFAULT, type=NODE_TYPE_DEFAULT,
parent_uuid=group_node_id, parent_uuid=group_node_id, # 指向 Group 节点
minimized=True, minimized=True, # 折叠显示
param={ param={
"res_id": res_id, "res_id": res_id,
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"], "device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
"class_name": lw_type, "class_name": res_type_name,
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
"slot_on_deck": slot, "slot_on_deck": slot,
}, },
) )
slot_to_create_resource[slot] = node_id slot_to_create_resource[slot] = node_id
if "tip" in res_type_name and "rack" in res_type_name:
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
# create_resource 之间不需要 ready 连接
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点 # 创建 Group 节点,包含所有 set_liquid_from_plate 节点
@@ -511,6 +522,7 @@ def build_protocol_graph(
"reagent": "reagent", "reagent": "reagent",
"solvent": "solvent", "solvent": "solvent",
"compound": "compound", "compound": "compound",
"tip_racks": "tip_rack_identifier",
} }
OUTPUT_PORT_MAPPING = { OUTPUT_PORT_MAPPING = {