Compare commits

...

22 Commits

Author SHA1 Message Date
q434343
1a267729e4 修改tracker的问题,在volumetracker报错时,禁用并进行下一步 2026-03-17 20:29:15 +08:00
q434343
b11f6eac55 修改pylabrobot更新后的影响 2026-03-16 20:42:13 +08:00
q434343
d85ff540c4 完成mix,liquid_hight,touch_tip,delay等参数的传递 2026-03-12 13:58:06 +08:00
q434343
5f45a0b81b 修改transfer liquid方法 2026-03-09 19:48:57 +08:00
Xuwznln
6bf9a319c7 Merge branch 'dev' into feat/lab_resource 2026-03-03 18:05:43 +08:00
q434343
74f0d5ee65 Merge branch 'feat/lab_resource' of https://github.com/deepmodeling/Uni-Lab-OS into feat/lab_resource 2026-03-03 14:17:36 +08:00
Xuwznln
2596d48a2f update materials 2026-03-03 11:43:41 +08:00
Xuwznln
2ac1a3242a 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:02 +08:00
q434343
5d208c832b 修改工作流上传以及lh的物料初步判定 2026-03-02 18:32:44 +08:00
q434343
786498904d 修改上传方式,添加tip_rack的连线 2026-03-02 18:32:18 +08:00
q434343
a9ea9f425d 添加单枪头的多对多移液判定 2026-03-02 18:31:28 +08:00
Xuwznln
b3bc951cae registry update & workflow update 2026-03-02 18:31:26 +08:00
Xuwznln
01df4f1115 add resource 2026-03-02 18:30:07 +08:00
q434343
e1074f06d2 修改工作流上传以及lh的物料初步判定 2026-02-26 10:52:41 +08:00
q434343
0dc273f366 修改上传方式,添加tip_rack的连线 2026-02-24 19:37:11 +08:00
q434343
2e5fac26b3 添加单枪头的多对多移液判定 2026-02-13 13:46:27 +08:00
Xuwznln
5c2da9b793 fix possible crash 2026-02-11 23:44:53 +08:00
Xuwznln
45efbfcd12 fix deck & host_node 2026-02-11 17:33:26 +08:00
Xuwznln
8da6fdfd0b set liquid with tube 2026-02-11 16:20:07 +08:00
Xuwznln
29ea9909a5 Merge branch 'dev' into feat/lab_resource 2026-02-11 14:04:49 +08:00
Xuwznln
ee6307a568 registry update & workflow update 2026-02-10 22:45:51 +08:00
Xuwznln
8a0116c852 add resource 2026-02-10 22:44:45 +08:00
8 changed files with 11718 additions and 874 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -103,7 +103,7 @@ class PRCXI9300Deck(Deck):
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs): sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name) super().__init__( size_x, size_y, size_z, name=name)
if sites is not None: if sites is not None:
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites] self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
else: else:
@@ -120,6 +120,7 @@ class PRCXI9300Deck(Deck):
self._ordering = collections.OrderedDict( self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites (site["label"], None) for site in self.sites
) )
self.root = self.get_root()
def _get_site_location(self, idx: int) -> Coordinate: def _get_site_location(self, idx: int) -> Coordinate:
pos = self.sites[idx]["position"] pos = self.sites[idx]["position"]
@@ -162,7 +163,10 @@ class PRCXI9300Deck(Deck):
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'") raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
if not reassign and self._get_site_resource(idx) is not None: if not reassign and self._get_site_resource(idx) is not None:
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied") existing = self.root.get_resource(resource.name)
if existing is not resource and existing.parent is not None:
existing.parent.unassign_child_resource(existing)
loc = self._get_site_location(idx) loc = self._get_site_location(idx)
super().assign_child_resource(resource, location=loc, reassign=reassign) super().assign_child_resource(resource, location=loc, reassign=reassign)
@@ -794,6 +798,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip: bool = False, touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None, liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None,
blow_out_air_volume_before: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False, is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none", mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
@@ -804,7 +809,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
return await super().transfer_liquid( if self.step_mode:
await self.create_protocol(f"transfer_liquid{time.time()}")
res = await super().transfer_liquid(
sources, sources,
targets, targets,
tip_racks, tip_racks,
@@ -817,6 +824,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip=touch_tip, touch_tip=touch_tip,
liquid_height=liquid_height, liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume, blow_out_air_volume=blow_out_air_volume,
blow_out_air_volume_before=blow_out_air_volume_before,
spread=spread, spread=spread,
is_96_well=is_96_well, is_96_well=is_96_well,
mix_stage=mix_stage, mix_stage=mix_stage,
@@ -827,6 +835,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays=delays, delays=delays,
none_keys=none_keys, none_keys=none_keys,
) )
if self.step_mode:
await self.run_protocol()
return res
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)
@@ -843,9 +854,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
return await self._unilabos_backend.mix( return await self._unilabos_backend.mix(
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
) )
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
@@ -1274,9 +1286,15 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
): ):
"""Mix liquid in the specified resources.""" """Mix liquid in the specified resources."""
if use_channels == [0]:
axis = "Left"
elif use_channels == [1]:
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(use_channels))
plate_indexes = [] plate_indexes = []
for op in targets: for op in targets:
deck = op.parent.parent.parent deck = op.parent.parent.parent

View File

@@ -4976,13 +4976,13 @@ liquid_handler.biomek:
handler_key: tip_rack handler_key: tip_rack
label: tip_rack label: tip_rack
output: output:
- data_key: liquid - data_key: sources
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: sources_out handler_key: sources_out
label: sources label: sources
- data_key: liquid - data_key: targets
data_source: executor data_source: handle
data_type: resource data_type: resource
handler_key: targets_out handler_key: targets_out
label: targets label: targets
@@ -7656,6 +7656,43 @@ liquid_handler.prcxi:
title: iter_tips参数 title: iter_tips参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
auto-magnetic_action:
feedback: {}
goal: {}
goal_default:
height: null
is_wait: null
module_no: null
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
height:
type: integer
is_wait:
type: boolean
module_no:
type: integer
time:
type: integer
required:
- time
- module_no
- height
- is_wait
type: object
result: {}
required:
- goal
title: magnetic_action参数
type: object
type: UniLabJsonCommandAsync
auto-move_to: auto-move_to:
feedback: {} feedback: {}
goal: {} goal: {}
@@ -7689,6 +7726,31 @@ 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: {}
@@ -7809,6 +7871,47 @@ liquid_handler.prcxi:
title: shaker_action参数 title: shaker_action参数
type: object type: object
type: UniLabJsonCommandAsync type: UniLabJsonCommandAsync
auto-shaking_incubation_action:
feedback: {}
goal: {}
goal_default:
amplitude: null
is_wait: null
module_no: null
temperature: null
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
amplitude:
type: integer
is_wait:
type: boolean
module_no:
type: integer
temperature:
type: integer
time:
type: integer
required:
- time
- module_no
- amplitude
- is_wait
- temperature
type: object
result: {}
required:
- goal
title: shaking_incubation_action参数
type: object
type: UniLabJsonCommandAsync
auto-touch_tip: auto-touch_tip:
feedback: {} feedback: {}
goal: {} goal: {}
@@ -10034,116 +10137,28 @@ liquid_handler.prcxi:
type: Transfer type: Transfer
transfer_liquid: transfer_liquid:
feedback: {} feedback: {}
goal: goal: {}
asp_flow_rates: asp_flow_rates
asp_vols: asp_vols
blow_out_air_volume: blow_out_air_volume
delays: delays
dis_flow_rates: dis_flow_rates
dis_vols: dis_vols
is_96_well: is_96_well
liquid_height: liquid_height
mix_liquid_height: mix_liquid_height
mix_rate: mix_rate
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
none_keys: none_keys
offsets: offsets
sources: sources
spread: spread
targets: targets
tip_racks: tip_racks
touch_tip: touch_tip
use_channels: use_channels
goal_default: goal_default:
asp_flow_rates: asp_flow_rates: null
- 0.0 asp_vols: null
asp_vols: blow_out_air_volume: null
- 0.0 blow_out_air_volume_before: null
blow_out_air_volume: delays: null
- 0.0 dis_flow_rates: null
delays: dis_vols: null
- 0
dis_flow_rates:
- 0.0
dis_vols:
- 0.0
is_96_well: false is_96_well: false
liquid_height: liquid_height: null
- 0.0 mix_liquid_height: null
mix_liquid_height: 0.0 mix_rate: null
mix_rate: 0 mix_stage: none
mix_stage: '' mix_times: null
mix_times: 0 mix_vol: null
mix_vol: 0 none_keys: []
none_keys: offsets: null
- '' sources: null
offsets: spread: wide
- x: 0.0 targets: null
y: 0.0 tip_racks: null
z: 0.0
sources:
- category: ''
children: []
config: ''
data: ''
id: ''
name: ''
parent: ''
pose:
orientation:
w: 1.0
x: 0.0
y: 0.0
z: 0.0
position:
x: 0.0
y: 0.0
z: 0.0
sample_id: ''
type: ''
spread: ''
targets:
- category: ''
children: []
config: ''
data: ''
id: ''
name: ''
parent: ''
pose:
orientation:
w: 1.0
x: 0.0
y: 0.0
z: 0.0
position:
x: 0.0
y: 0.0
z: 0.0
sample_id: ''
type: ''
tip_racks:
- category: ''
children: []
config: ''
data: ''
id: ''
name: ''
parent: ''
pose:
orientation:
w: 1.0
x: 0.0
y: 0.0
z: 0.0
position:
x: 0.0
y: 0.0
z: 0.0
sample_id: ''
type: ''
touch_tip: false touch_tip: false
use_channels: use_channels:
- 0 - 0
@@ -10159,7 +10174,7 @@ liquid_handler.prcxi:
data_type: resource data_type: resource
handler_key: targets_identifier handler_key: targets_identifier
label: 转移目标 label: 转移目标
- data_key: tip_rack - data_key: tip_racks
data_source: handle data_source: handle
data_type: resource data_type: resource
handler_key: tip_rack_identifier handler_key: tip_rack_identifier
@@ -10183,11 +10198,7 @@ liquid_handler.prcxi:
schema: schema:
description: '' description: ''
properties: properties:
feedback: feedback: {}
properties: {}
required: []
title: LiquidHandlerTransfer_Feedback
type: object
goal: goal:
properties: properties:
asp_flow_rates: asp_flow_rates:
@@ -10202,6 +10213,10 @@ liquid_handler.prcxi:
items: items:
type: number type: number
type: array type: array
blow_out_air_volume_before:
items:
type: number
type: array
delays: delays:
items: items:
maximum: 2147483647 maximum: 2147483647
@@ -10217,6 +10232,7 @@ liquid_handler.prcxi:
type: number type: number
type: array type: array
is_96_well: is_96_well:
default: false
type: boolean type: boolean
liquid_height: liquid_height:
items: items:
@@ -10229,6 +10245,7 @@ liquid_handler.prcxi:
minimum: -2147483648 minimum: -2147483648
type: integer type: integer
mix_stage: mix_stage:
default: none
type: string type: string
mix_times: mix_times:
maximum: 2147483647 maximum: 2147483647
@@ -10239,6 +10256,7 @@ liquid_handler.prcxi:
minimum: -2147483648 minimum: -2147483648
type: integer type: integer
none_keys: none_keys:
default: []
items: items:
type: string type: string
type: array type: array
@@ -10334,6 +10352,7 @@ liquid_handler.prcxi:
type: object type: object
type: array type: array
spread: spread:
default: wide
type: string type: string
targets: targets:
items: items:
@@ -10486,6 +10505,7 @@ liquid_handler.prcxi:
type: object type: object
type: array type: array
touch_tip: touch_tip:
default: false
type: boolean type: boolean
use_channels: use_channels:
items: items:
@@ -10494,45 +10514,221 @@ liquid_handler.prcxi:
type: integer type: integer
type: array type: array
required: required:
- asp_vols
- dis_vols
- sources - sources
- targets - targets
- tip_racks - tip_racks
- use_channels - asp_vols
- asp_flow_rates - dis_vols
- dis_flow_rates
- offsets
- touch_tip
- liquid_height
- blow_out_air_volume
- spread
- is_96_well
- mix_stage
- mix_times
- mix_vol
- mix_rate
- mix_liquid_height
- delays
- none_keys
title: LiquidHandlerTransfer_Goal
type: object type: object
result: result:
$defs:
ResourceDict:
properties:
class:
description: Resource class name
title: Class
type: string
config:
additionalProperties: true
description: Resource configuration
title: Config
type: object
data:
additionalProperties: true
description: 'Resource data, eg: container liquid data'
title: Data
type: object
description:
default: ''
description: Resource description
title: Description
type: string
extra:
additionalProperties: true
description: 'Extra data, eg: slot index'
title: Extra
type: object
icon:
default: ''
description: Resource icon
title: Icon
type: string
id:
description: Resource ID
title: Id
type: string
model:
additionalProperties: true
description: Resource model
title: Model
type: object
name:
description: Resource name
title: Name
type: string
parent:
anyOf:
- $ref: '#/$defs/ResourceDict'
- type: 'null'
default: null
description: Parent resource object
parent_uuid:
anyOf:
- type: string
- type: 'null'
default: null
description: Parent resource uuid
title: Parent Uuid
pose:
$ref: '#/$defs/ResourceDictPosition'
description: Resource position
schema:
additionalProperties: true
description: Resource schema
title: Schema
type: object
type:
anyOf:
- const: device
type: string
- type: string
description: Resource type
title: Type
uuid:
description: Resource UUID
title: Uuid
type: string
required:
- id
- uuid
- name
- type
- class
- config
- data
- extra
title: ResourceDict
type: object
ResourceDictPosition:
properties:
cross_section_type:
default: rectangle
description: Cross section type
enum:
- rectangle
- circle
- rounded_rectangle
title: Cross Section Type
type: string
layout:
default: x-y
description: Resource layout
enum:
- 2d
- x-y
- z-y
- x-z
title: Layout
type: string
position:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position
position3d:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position in 3D space
rotation:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource rotation
scale:
$ref: '#/$defs/ResourceDictPositionScale'
description: Resource scale
size:
$ref: '#/$defs/ResourceDictPositionSize'
description: Resource size
title: ResourceDictPosition
type: object
ResourceDictPositionObject:
properties:
x:
default: 0.0
description: X coordinate
title: X
type: number
y:
default: 0.0
description: Y coordinate
title: Y
type: number
z:
default: 0.0
description: Z coordinate
title: Z
type: number
title: ResourceDictPositionObject
type: object
ResourceDictPositionScale:
properties:
x:
default: 0.0
description: x scale
title: X
type: number
y:
default: 0.0
description: y scale
title: Y
type: number
z:
default: 0.0
description: z scale
title: Z
type: number
title: ResourceDictPositionScale
type: object
ResourceDictPositionSize:
properties:
depth:
default: 0.0
description: Depth
title: Depth
type: number
height:
default: 0.0
description: Height
title: Height
type: number
width:
default: 0.0
description: Width
title: Width
type: number
title: ResourceDictPositionSize
type: object
properties: properties:
return_info: sources:
type: string items:
success: items:
type: boolean $ref: '#/$defs/ResourceDict'
type: array
title: Sources
type: array
targets:
items:
items:
$ref: '#/$defs/ResourceDict'
type: array
title: Targets
type: array
required: required:
- return_info - sources
- success - targets
title: LiquidHandlerTransfer_Result title: TransferLiquidReturn
type: object type: object
required: required:
- goal - goal
title: LiquidHandlerTransfer title: transfer_liquid参数
type: object type: object
type: LiquidHandlerTransfer type: UniLabJsonCommandAsync
module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler
status_types: status_types:
reset_ok: bool reset_ok: bool
@@ -10555,6 +10751,12 @@ 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:
@@ -10565,17 +10767,44 @@ 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

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

@@ -534,10 +534,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 资源实例列表
""" """
@@ -593,6 +600,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()
@@ -612,9 +684,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
@@ -628,12 +698,47 @@ class ResourceTreeSet(object):
plr_resources.append(plr_resource) plr_resources.append(plr_resource)
except Exception as e: except Exception as e:
logger.error(f"转换 PLR 资源失败: {e} {str(plr_dict)[:1000]}") logger.error(f"转换 PLR 资源失败: {e}")
import traceback import traceback
logger.error(f"堆栈: {traceback.format_exc()}") logger.error(f"堆栈: {traceback.format_exc()}")
raise raise
if requested_uuids:
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
# 优先使用 tracker.uuid_to_resources若映射缺失再递归遍历 PLR 树兜底搜索。
def _find_plr_by_uuid(roots: List["PLRResource"], uid: str) -> Optional["PLRResource"]:
stack = list(roots)
while stack:
node = stack.pop()
node_uid = getattr(node, "unilabos_uuid", None)
if node_uid == uid:
return node
children = getattr(node, "children", None) or []
stack.extend(children)
return None
result = []
missing_uuids = []
for uid in requested_uuids:
found = tracker.uuid_to_resources.get(uid)
if found is None:
found = _find_plr_by_uuid(plr_resources, uid)
if found is not None:
# 回填缓存,后续相同 uuid 可直接命中
tracker.uuid_to_resources[uid] = found
if found is None:
missing_uuids.append(uid)
else:
result.append(found)
if missing_uuids:
raise ValueError(
f"请求的 UUID 未在资源树中找到: {missing_uuids}"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
f"资源树数量: {len(self.trees)}"
)
return result
return plr_resources return plr_resources
@classmethod @classmethod

View File

@@ -51,6 +51,7 @@
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
- 遍历 workflow 数组,为每个动作创建步骤节点 - 遍历 workflow 数组,为每个动作创建步骤节点
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates - 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
- 参数输入转换: liquid_height按 wells 扩展mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 - 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] - 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
@@ -119,11 +120,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", "parent_template": "/PRCXI/PRCXI_Deck",
"class_name": "PRCXI_BioER_96_wellplate",
} }
# 默认液体体积 (uL) # 默认液体体积 (uL)
@@ -367,11 +371,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 +382,21 @@ 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", ""))
labware = item.get("labware", "")
if slot and slot not in slots_info:
res_id = f"{labware}_slot_{slot}"
slots_info[slot] = {
"labware": labware,
"res_id": res_id,
"labware_id": labware_id,
"object": item.get("object", ""),
}
# 创建 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 +412,40 @@ 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")
object_type = info.get("object", "")
res_type_name = f"lab_{res_type_name}"
if object_type == "trash":
res_type_name = "PRCXI_trash"
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"], "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 object_type == "tiprack":
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 节点
@@ -464,6 +480,8 @@ def build_protocol_graph(
# res_id 不能有空格 # res_id 不能有空格
res_id = str(labware_id).replace(" ", "_") res_id = str(labware_id).replace(" ", "_")
well_count = len(wells) well_count = len(wells)
object_type = item.get("object", "")
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
node_id = str(uuid.uuid4()) node_id = str(uuid.uuid4())
set_liquid_index += 1 set_liquid_index += 1
@@ -484,7 +502,7 @@ def build_protocol_graph(
"plate": [], # 通过连接传递 "plate": [], # 通过连接传递
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
"liquid_names": [res_id] * well_count, "liquid_names": [res_id] * well_count,
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count, "volumes": [liquid_volume] * well_count,
}, },
) )
@@ -511,6 +529,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 = {
@@ -525,8 +544,17 @@ def build_protocol_graph(
"compound": "compound", "compound": "compound",
} }
# 需要根据 wells 数量扩展的参数列表(复数形式) # 需要根据 wells 数量扩展的参数列表
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] # - 复数参数asp_vols 等)支持单值自动扩展
# - liquid_height 按 wells 扩展为数组
# - mix_* 参数保持标量,避免被转换为 list
EXPAND_BY_WELLS_PARAMS = [
"asp_vols",
"dis_vols",
"asp_flow_rates",
"dis_flow_rates",
"liquid_height",
]
# 处理协议步骤 # 处理协议步骤
for step in protocol_steps: for step in protocol_steps:
@@ -540,6 +568,57 @@ def build_protocol_graph(
if old_name in params: if old_name in params:
params[new_name] = params.pop(old_name) params[new_name] = params.pop(old_name)
# touch_tip 输入归一化:
# - 支持 bool / 0/1 / "true"/"false" / 单元素 list
# - 最终统一为 bool 标量,避免被下游误当作序列处理
if "touch_tip" in params:
touch_tip_value = params.get("touch_tip")
if isinstance(touch_tip_value, list):
if len(touch_tip_value) == 1:
touch_tip_value = touch_tip_value[0]
elif len(touch_tip_value) == 0:
touch_tip_value = False
else:
warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值")
touch_tip_value = touch_tip_value[0]
if isinstance(touch_tip_value, str):
norm = touch_tip_value.strip().lower()
if norm in {"true", "1", "yes", "y", "on"}:
touch_tip_value = True
elif norm in {"false", "0", "no", "n", "off", ""}:
touch_tip_value = False
else:
warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理")
touch_tip_value = True
elif isinstance(touch_tip_value, (int, float)):
touch_tip_value = bool(touch_tip_value)
elif touch_tip_value is None:
touch_tip_value = False
else:
touch_tip_value = bool(touch_tip_value)
params["touch_tip"] = touch_tip_value
# delays 输入归一化:
# - 支持标量int/float/字符串数字)与 list
# - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用
if "delays" in params:
delays_value = params.get("delays")
if delays_value is None or delays_value == "":
params["delays"] = []
else:
raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
normalized_delays = []
for delay_item in raw_list:
if isinstance(delay_item, str):
delay_item = delay_item.strip()
if delay_item == "":
continue
try:
normalized_delays.append(float(delay_item))
except (TypeError, ValueError):
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
params["delays"] = normalized_delays
# 处理输入连接 # 处理输入连接
for param_key, target_port in INPUT_PORT_MAPPING.items(): for param_key, target_port in INPUT_PORT_MAPPING.items():
resource_name = params.get(param_key) resource_name = params.get(param_key)