mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 10:45:29 +00:00
Compare commits
78 Commits
feat/lab_r
...
v0.10.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
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 |
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
# edge = {"source": source, "target": target, **attrs}
|
||||
edge = {
|
||||
"source": source, "target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_io": "source",
|
||||
"target_handle_io": "target",
|
||||
**attrs
|
||||
}
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
@@ -359,7 +358,7 @@ def main():
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = platform.node()
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
|
||||
@@ -478,7 +478,7 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
@@ -556,7 +556,7 @@ class MessageProcessor:
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -1090,7 +1090,7 @@ class QueueProcessor:
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1305,6 +1305,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1317,11 +1318,13 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
|
||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
# 检查ROS 2环境变量
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,6 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -91,107 +90,20 @@ class PRCXI9300Deck(Deck):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
# T1-T16 默认位置 (4列×4行)
|
||||
_DEFAULT_SITE_POSITIONS = [
|
||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
|
||||
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"]
|
||||
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||
super().__init__( size_x, size_y, size_z, name=name)
|
||||
if sites is not None:
|
||||
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
|
||||
else:
|
||||
self.sites = []
|
||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||
self.sites.append({
|
||||
"label": f"T{i + 1}",
|
||||
"visible": True,
|
||||
"position": {"x": x, "y": y, "z": z},
|
||||
"size": dict(self._DEFAULT_SITE_SIZE),
|
||||
"content_type": list(self._DEFAULT_CONTENT_TYPE),
|
||||
})
|
||||
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
self.root = self.get_root()
|
||||
|
||||
def _get_site_location(self, idx: int) -> Coordinate:
|
||||
pos = self.sites[idx]["position"]
|
||||
return Coordinate(pos["x"], pos["y"], pos["z"])
|
||||
|
||||
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
||||
site_loc = self._get_site_location(idx)
|
||||
for child in self.children:
|
||||
if child.location == site_loc:
|
||||
return child
|
||||
return None
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None,
|
||||
):
|
||||
idx = spot
|
||||
if spot is not None:
|
||||
idx = spot
|
||||
else:
|
||||
for i, site in enumerate(self.sites):
|
||||
site_loc = self._get_site_location(i)
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if location is not None and site_loc == location:
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx is None:
|
||||
for i in range(len(self.sites)):
|
||||
if self._get_site_resource(i) is None:
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx is None:
|
||||
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:
|
||||
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)
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
raise ValueError(f"Spot {slot} is already occupied")
|
||||
|
||||
def serialize(self) -> dict:
|
||||
data = super().serialize()
|
||||
sites_out = []
|
||||
for i, site in enumerate(self.sites):
|
||||
occupied = self._get_site_resource(i)
|
||||
sites_out.append({
|
||||
"label": site["label"],
|
||||
"visible": site.get("visible", True),
|
||||
"occupied_by": occupied.name if occupied is not None else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
self.slots[slot - 1] = resource
|
||||
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
|
||||
|
||||
|
||||
class PRCXI9300Container(Container):
|
||||
class PRCXI9300Container(Plate):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
@@ -204,10 +116,11 @@ class PRCXI9300Container(Container):
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str,
|
||||
ordering: collections.OrderedDict,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, model=model)
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
|
||||
self._unilabos_state = {}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
@@ -654,14 +567,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
# 如果放其他类型的物料,是不可以的
|
||||
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
@@ -798,7 +711,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
touch_tip: bool = False,
|
||||
liquid_height: 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",
|
||||
is_96_well: bool = False,
|
||||
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
|
||||
@@ -809,9 +721,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
) -> TransferLiquidReturn:
|
||||
if self.step_mode:
|
||||
await self.create_protocol(f"transfer_liquid{time.time()}")
|
||||
res = await super().transfer_liquid(
|
||||
return await super().transfer_liquid(
|
||||
sources,
|
||||
targets,
|
||||
tip_racks,
|
||||
@@ -824,7 +734,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
touch_tip=touch_tip,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
blow_out_air_volume_before=blow_out_air_volume_before,
|
||||
spread=spread,
|
||||
is_96_well=is_96_well,
|
||||
mix_stage=mix_stage,
|
||||
@@ -835,9 +744,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
delays=delays,
|
||||
none_keys=none_keys,
|
||||
)
|
||||
if self.step_mode:
|
||||
await self.run_protocol()
|
||||
return res
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
return await super().custom_delay(seconds, msg)
|
||||
@@ -854,10 +760,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
use_channels: Optional[List[int]] = [0],
|
||||
):
|
||||
return await self._unilabos_backend.mix(
|
||||
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
|
||||
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys
|
||||
)
|
||||
|
||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||
@@ -1286,15 +1191,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
use_channels: Optional[List[int]] = [0],
|
||||
):
|
||||
"""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 = []
|
||||
for op in targets:
|
||||
deck = op.parent.parent.parent
|
||||
|
||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
simulate_rviz=True)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -4976,13 +4976,13 @@ liquid_handler.biomek:
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: sources
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
@@ -7656,43 +7656,6 @@ liquid_handler.prcxi:
|
||||
title: iter_tips参数
|
||||
type: object
|
||||
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:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -7726,31 +7689,6 @@ liquid_handler.prcxi:
|
||||
title: move_to参数
|
||||
type: object
|
||||
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:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -7871,47 +7809,6 @@ liquid_handler.prcxi:
|
||||
title: shaker_action参数
|
||||
type: object
|
||||
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:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -10137,28 +10034,116 @@ liquid_handler.prcxi:
|
||||
type: Transfer
|
||||
transfer_liquid:
|
||||
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:
|
||||
asp_flow_rates: null
|
||||
asp_vols: null
|
||||
blow_out_air_volume: null
|
||||
blow_out_air_volume_before: null
|
||||
delays: null
|
||||
dis_flow_rates: null
|
||||
dis_vols: null
|
||||
asp_flow_rates:
|
||||
- 0.0
|
||||
asp_vols:
|
||||
- 0.0
|
||||
blow_out_air_volume:
|
||||
- 0.0
|
||||
delays:
|
||||
- 0
|
||||
dis_flow_rates:
|
||||
- 0.0
|
||||
dis_vols:
|
||||
- 0.0
|
||||
is_96_well: false
|
||||
liquid_height: null
|
||||
mix_liquid_height: null
|
||||
mix_rate: null
|
||||
mix_stage: none
|
||||
mix_times: null
|
||||
mix_vol: null
|
||||
none_keys: []
|
||||
offsets: null
|
||||
sources: null
|
||||
spread: wide
|
||||
targets: null
|
||||
tip_racks: null
|
||||
liquid_height:
|
||||
- 0.0
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
offsets:
|
||||
- x: 0.0
|
||||
y: 0.0
|
||||
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
|
||||
use_channels:
|
||||
- 0
|
||||
@@ -10174,7 +10159,7 @@ liquid_handler.prcxi:
|
||||
data_type: resource
|
||||
handler_key: targets_identifier
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
- data_key: tip_rack
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack_identifier
|
||||
@@ -10198,7 +10183,11 @@ liquid_handler.prcxi:
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: LiquidHandlerTransfer_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
asp_flow_rates:
|
||||
@@ -10213,10 +10202,6 @@ liquid_handler.prcxi:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
blow_out_air_volume_before:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
delays:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
@@ -10232,7 +10217,6 @@ liquid_handler.prcxi:
|
||||
type: number
|
||||
type: array
|
||||
is_96_well:
|
||||
default: false
|
||||
type: boolean
|
||||
liquid_height:
|
||||
items:
|
||||
@@ -10245,7 +10229,6 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_stage:
|
||||
default: none
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
@@ -10256,7 +10239,6 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
none_keys:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -10352,7 +10334,6 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
spread:
|
||||
default: wide
|
||||
type: string
|
||||
targets:
|
||||
items:
|
||||
@@ -10505,7 +10486,6 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
touch_tip:
|
||||
default: false
|
||||
type: boolean
|
||||
use_channels:
|
||||
items:
|
||||
@@ -10514,221 +10494,45 @@ liquid_handler.prcxi:
|
||||
type: integer
|
||||
type: array
|
||||
required:
|
||||
- asp_vols
|
||||
- dis_vols
|
||||
- sources
|
||||
- targets
|
||||
- tip_racks
|
||||
- asp_vols
|
||||
- dis_vols
|
||||
- use_channels
|
||||
- asp_flow_rates
|
||||
- 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
|
||||
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:
|
||||
sources:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Sources
|
||||
type: array
|
||||
targets:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Targets
|
||||
type: array
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- sources
|
||||
- targets
|
||||
title: TransferLiquidReturn
|
||||
- return_info
|
||||
- success
|
||||
title: LiquidHandlerTransfer_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: transfer_liquid参数
|
||||
title: LiquidHandlerTransfer
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
type: LiquidHandlerTransfer
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler
|
||||
status_types:
|
||||
reset_ok: bool
|
||||
@@ -10751,12 +10555,6 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
deck:
|
||||
type: object
|
||||
deck_y:
|
||||
default: 400
|
||||
type: string
|
||||
deck_z:
|
||||
default: 300
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
is_9320:
|
||||
@@ -10767,44 +10565,17 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
rail_interval:
|
||||
default: 0
|
||||
type: string
|
||||
rail_nums:
|
||||
default: 4
|
||||
type: string
|
||||
rail_width:
|
||||
default: 27.5
|
||||
type: string
|
||||
setup:
|
||||
default: true
|
||||
type: string
|
||||
simulator:
|
||||
default: false
|
||||
type: string
|
||||
start_rail:
|
||||
default: 2
|
||||
type: string
|
||||
step_mode:
|
||||
default: false
|
||||
type: string
|
||||
timeout:
|
||||
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:
|
||||
- deck
|
||||
- host
|
||||
|
||||
@@ -175,8 +175,7 @@ class Registry:
|
||||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||||
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
||||
"class_name": "unilabos_class", # 当前实验室物料的class name
|
||||
"slot_on_deck": "unilabos_resource_slot:parent", # 勾选的parent的config中的sites的name,展示name,参数对应slot(index)
|
||||
"class_name": "unilabos_class",
|
||||
},
|
||||
},
|
||||
"test_latency": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
from pylabrobot.resources import Container
|
||||
from unilabos_msgs.msg import Resource
|
||||
|
||||
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
|
||||
|
||||
|
||||
class RegularContainer(Container):
|
||||
@@ -12,12 +16,12 @@ class RegularContainer(Container):
|
||||
kwargs["size_y"] = 0
|
||||
if "size_z" not in kwargs:
|
||||
kwargs["size_z"] = 0
|
||||
|
||||
self.kwargs = kwargs
|
||||
self.state = {}
|
||||
super().__init__(*args, category="container", **kwargs)
|
||||
|
||||
def load_state(self, state: Dict[str, Any]):
|
||||
super().load_state(state)
|
||||
self.state = state
|
||||
|
||||
|
||||
def get_regular_container(name="container"):
|
||||
@@ -25,6 +29,7 @@ def get_regular_container(name="container"):
|
||||
r.category = "container"
|
||||
return r
|
||||
|
||||
#
|
||||
# class RegularContainer(object):
|
||||
# # 第一个参数必须是id传入
|
||||
# # noinspection PyShadowingBuiltins
|
||||
@@ -84,4 +89,4 @@ def get_regular_container(name="container"):
|
||||
# return to_dict
|
||||
#
|
||||
# def __str__(self):
|
||||
# return f"{self.id}"
|
||||
# return f"{self.id}"
|
||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
outer_host_node_id = None
|
||||
@@ -76,7 +76,7 @@ def canonicalize_nodes_data(
|
||||
if sample_id:
|
||||
logger.error(f"{node}的sample_id参数已弃用,sample_id: {sample_id}")
|
||||
for k in list(node.keys()):
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose", "extra"]:
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]:
|
||||
v = node.pop(k)
|
||||
node["config"][k] = v
|
||||
if outer_host_node_id is not None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -16,7 +16,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
EXTRA_CLASS = "unilabos_resource_class"
|
||||
FRONTEND_POSE_EXTRA = "unilabos_frontend_pose_extra"
|
||||
EXTRA_SAMPLE_UUID = "sample_uuid"
|
||||
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
|
||||
|
||||
@@ -75,14 +74,6 @@ class ResourceDictPositionObject(BaseModel):
|
||||
z: float = Field(description="Z coordinate", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPoseExtraObjectType(BaseModel):
|
||||
z_index: int
|
||||
|
||||
|
||||
class ResourceDictPoseExtraObject(BaseModel):
|
||||
z_index: Optional[int] = Field(alias="zIndex", default=None)
|
||||
|
||||
|
||||
class ResourceDictPositionType(TypedDict):
|
||||
size: ResourceDictPositionSizeType
|
||||
scale: ResourceDictPositionScaleType
|
||||
@@ -109,7 +100,6 @@ class ResourceDictPosition(BaseModel):
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field(
|
||||
description="Cross section type", default="rectangle"
|
||||
)
|
||||
extra: Optional[ResourceDictPoseExtraObject] = Field(description="Extra data", default=None)
|
||||
|
||||
|
||||
class ResourceDictType(TypedDict):
|
||||
@@ -147,8 +137,8 @@ class ResourceDict(BaseModel):
|
||||
klass: str = Field(alias="class", description="Resource class name")
|
||||
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
|
||||
data: Dict[str, Any] = Field(description="Resource data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data")
|
||||
|
||||
@field_serializer("parent_uuid")
|
||||
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
||||
@@ -421,15 +411,6 @@ class ResourceTreeSet(object):
|
||||
"tip_spot": "tip_spot",
|
||||
"tube": "tube",
|
||||
"bottle_carrier": "bottle_carrier",
|
||||
"material_hole": "material_hole",
|
||||
"container": "container",
|
||||
"material_plate": "material_plate",
|
||||
"electrode_sheet": "electrode_sheet",
|
||||
"warehouse": "warehouse",
|
||||
"magazine_holder": "magazine_holder",
|
||||
"resource_group": "resource_group",
|
||||
"trash": "trash",
|
||||
"plate_adapter": "plate_adapter",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
@@ -473,7 +454,6 @@ class ResourceTreeSet(object):
|
||||
"position3d": raw_pos,
|
||||
"rotation": d["rotation"],
|
||||
"cross_section_type": d.get("cross_section_type", "rectangle"),
|
||||
"extra": extra.get(FRONTEND_POSE_EXTRA)
|
||||
}
|
||||
|
||||
# 先构建当前节点的字典(不包含children)
|
||||
@@ -534,17 +514,10 @@ class ResourceTreeSet(object):
|
||||
trees.append(tree_instance)
|
||||
return cls(trees)
|
||||
|
||||
def to_plr_resources(
|
||||
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
|
||||
) -> List["PLRResource"]:
|
||||
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
|
||||
"""
|
||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||
|
||||
Args:
|
||||
skip_devices: 是否跳过 device 类型节点
|
||||
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
|
||||
否则返回各树的根节点列表
|
||||
|
||||
Returns:
|
||||
List[PLRResource]: PLR 资源实例列表
|
||||
"""
|
||||
@@ -566,7 +539,6 @@ class ResourceTreeSet(object):
|
||||
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
||||
all_states[node.res_content.name] = node.res_content.data
|
||||
name_to_extra[node.res_content.name] = node.res_content.extra
|
||||
name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra
|
||||
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
|
||||
for child in node.children:
|
||||
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
|
||||
@@ -600,71 +572,6 @@ class ResourceTreeSet(object):
|
||||
d["model"] = res.config.get("model", None)
|
||||
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 = []
|
||||
tracker = DeviceNodeResourceTracker()
|
||||
|
||||
@@ -684,7 +591,9 @@ class ResourceTreeSet(object):
|
||||
raise ValueError(
|
||||
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
||||
)
|
||||
remove_incompatible_params(plr_dict)
|
||||
spec = inspect.signature(sub_cls)
|
||||
if "category" not in spec.parameters:
|
||||
plr_dict.pop("category", None)
|
||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.serializer import deserialize
|
||||
@@ -704,41 +613,6 @@ class ResourceTreeSet(object):
|
||||
logger.error(f"堆栈: {traceback.format_exc()}")
|
||||
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
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -51,7 +51,6 @@ def main(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
discovery_interval: float = 15.0,
|
||||
) -> None:
|
||||
@@ -78,12 +77,12 @@ def main(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
@@ -91,7 +90,7 @@ def main(
|
||||
device_id="resource_mesh_manager",
|
||||
device_uuid=str(uuid.uuid4()),
|
||||
)
|
||||
joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker)
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
# lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
# )
|
||||
@@ -115,7 +114,6 @@ def slave(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
) -> None:
|
||||
"""从节点函数"""
|
||||
@@ -210,12 +208,12 @@ def slave(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
|
||||
@@ -915,24 +915,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else []
|
||||
)
|
||||
if target_site is not None and sites is not None and site_names is not None:
|
||||
site_index = None
|
||||
try:
|
||||
# sites 可能是 Resource 列表或 dict 列表 (如 PRCXI9300Deck)
|
||||
# 只有itemized_carrier在使用,准备弃用
|
||||
site_index = sites.index(original_instance)
|
||||
except ValueError:
|
||||
# dict 类型的 sites: 通过name匹配
|
||||
for idx, site in enumerate(sites):
|
||||
if original_instance.name == site["occupied_by"]:
|
||||
site_index = idx
|
||||
break
|
||||
elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]):
|
||||
site_index = idx
|
||||
break
|
||||
if site_index is None:
|
||||
site_name = None
|
||||
else:
|
||||
site_name = site_names[site_index]
|
||||
site_index = sites.index(original_instance)
|
||||
site_name = site_names[site_index]
|
||||
if site_name != target_site:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
@@ -940,14 +924,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
parent_appended = True
|
||||
|
||||
# 加载状态
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_x = plr_resource._size_x
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_y = plr_resource._size_y
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_z = plr_resource._size_z
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._local_size_z = plr_resource._local_size_z
|
||||
original_instance.location = plr_resource.location
|
||||
original_instance.rotation = plr_resource.rotation
|
||||
original_instance.barcode = plr_resource.barcode
|
||||
@@ -1008,7 +984,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}")
|
||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "update":
|
||||
if tree_set is None:
|
||||
@@ -1034,7 +1010,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}")
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
result = _handle_remove(resources_uuid)
|
||||
|
||||
@@ -1195,7 +1195,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
|
||||
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
|
||||
response.response = json.dumps(uuid_mapping)
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
|
||||
"""
|
||||
|
||||
@@ -23,32 +23,17 @@ from unilabos_msgs.action import SendCmd
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker
|
||||
from unilabos.resources.graphio import initialize_resources
|
||||
from unilabos.resources.resource_tracker import EXTRA_CLASS
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
def __init__(
|
||||
self,
|
||||
resource_model: Optional[dict] = None,
|
||||
resource_config: Optional[list] = None,
|
||||
resource_tracker=None,
|
||||
device_id: str = "resource_mesh_manager",
|
||||
registry_name: str = "",
|
||||
rate=50,
|
||||
**kwargs,
|
||||
):
|
||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", registry_name: str = "", rate=50, **kwargs):
|
||||
"""初始化资源网格管理器节点
|
||||
|
||||
|
||||
Args:
|
||||
resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建)
|
||||
resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载)
|
||||
resource_tracker: 资源追踪器
|
||||
device_id: 节点名称
|
||||
rate: TF 发布频率
|
||||
resource_model (dict): 资源模型字典,包含资源的3D模型信息
|
||||
resource_config (dict): 资源配置字典,包含资源的配置信息
|
||||
device_id (str): 节点名称
|
||||
"""
|
||||
if resource_tracker is None:
|
||||
resource_tracker = DeviceNodeResourceTracker()
|
||||
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
@@ -57,14 +42,12 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
print_publish=False,
|
||||
resource_tracker=resource_tracker,
|
||||
resource_tracker=resource_tracker,
|
||||
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
|
||||
)
|
||||
)
|
||||
|
||||
self.resource_model = resource_model if resource_model is not None else {}
|
||||
self.resource_config_dict = (
|
||||
{item['uuid']: item for item in resource_config} if resource_config else {}
|
||||
)
|
||||
self.resource_model = resource_model
|
||||
self.resource_config_dict = {item['uuid']: item for item in resource_config}
|
||||
self.move_group_ready = False
|
||||
self.resource_tf_dict = {}
|
||||
self.tf_broadcaster = TransformBroadcaster(self)
|
||||
@@ -80,7 +63,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute()
|
||||
self.msg_type = 'resource_status'
|
||||
self.resource_status_dict = {}
|
||||
|
||||
|
||||
callback_group = ReentrantCallbackGroup()
|
||||
self._get_planning_scene_service = self.create_client(
|
||||
srv_type=GetPlanningScene,
|
||||
@@ -93,7 +76,8 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
),
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
|
||||
# Create a service for applying the planning scene
|
||||
self._apply_planning_scene_service = self.create_client(
|
||||
srv_type=ApplyPlanningScene,
|
||||
srv_name="/apply_planning_scene",
|
||||
@@ -119,36 +103,27 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
AttachedCollisionObject, "/attached_collision_object", 0
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于修改resource_tf_dict
|
||||
self._action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"tf_update",
|
||||
self.tf_update,
|
||||
callback_group=callback_group,
|
||||
callback_group=callback_group
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于添加新的资源模型与resource_tf_dict
|
||||
self._add_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"add_resource_mesh",
|
||||
self.add_resource_mesh_callback,
|
||||
callback_group=callback_group,
|
||||
callback_group=callback_group
|
||||
)
|
||||
|
||||
self._reload_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"reload_resource_mesh",
|
||||
self._reload_resource_mesh_callback,
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
if self.resource_config_dict:
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
else:
|
||||
self.get_logger().info("未提供 resource_config,将通过 ActionServer 或 load_from_resource_tree 加载")
|
||||
self.create_timer(1 / self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1 / self.rate, self.check_resource_pose_changes)
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
self.create_timer(1/self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1/self.rate, self.check_resource_pose_changes)
|
||||
|
||||
def check_move_group_ready(self):
|
||||
"""检查move_group节点是否已初始化完成"""
|
||||
@@ -165,156 +140,56 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.add_resource_collision_meshes(self.resource_tf_dict)
|
||||
|
||||
|
||||
def _build_resource_model_for_config(self, resource_config_dict: dict):
|
||||
"""从 registry 中为给定的资源配置自动构建 resource_model(mesh 信息)"""
|
||||
registry = lab_registry
|
||||
for _uuid, res_cfg in resource_config_dict.items():
|
||||
resource_id = res_cfg.get('id', '')
|
||||
resource_class = res_cfg.get('class', '')
|
||||
if not resource_class:
|
||||
continue
|
||||
if resource_class not in registry.resource_type_registry:
|
||||
continue
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' not in reg_entry:
|
||||
continue
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') != 'resource':
|
||||
continue
|
||||
if resource_id in self.resource_model:
|
||||
continue
|
||||
self.resource_model[resource_id] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_id}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
|
||||
def load_from_resource_tree(self):
|
||||
"""从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF"""
|
||||
new_config_dict: dict = {}
|
||||
|
||||
def _collect_plr_resource(res, parent_uuid: Optional[str] = None):
|
||||
res_uuid = getattr(res, 'unilabos_uuid', None)
|
||||
if not res_uuid:
|
||||
res_uuid = str(uuid.uuid4())
|
||||
extra = getattr(res, 'unilabos_extra', {}) or {}
|
||||
resource_class = extra.get(EXTRA_CLASS, '')
|
||||
|
||||
location = getattr(res, 'location', None)
|
||||
pos_x = float(location.x) if location else 0.0
|
||||
pos_y = float(location.y) if location else 0.0
|
||||
pos_z = float(location.z) if location else 0.0
|
||||
|
||||
rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0})
|
||||
|
||||
new_config_dict[res_uuid] = {
|
||||
'id': res.name,
|
||||
'uuid': res_uuid,
|
||||
'class': resource_class,
|
||||
'parent_uuid': parent_uuid,
|
||||
'pose': {
|
||||
'position': {'x': pos_x, 'y': pos_y, 'z': pos_z},
|
||||
'rotation': rotation,
|
||||
},
|
||||
}
|
||||
|
||||
for child in getattr(res, 'children', []) or []:
|
||||
_collect_plr_resource(child, res_uuid)
|
||||
|
||||
for resource in self.resource_tracker.resources:
|
||||
root_parent_uuid = None
|
||||
plr_parent = getattr(resource, 'parent', None)
|
||||
if plr_parent is not None:
|
||||
root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None)
|
||||
_collect_plr_resource(resource, root_parent_uuid)
|
||||
|
||||
if not new_config_dict:
|
||||
self.get_logger().warning("resource_tracker 中没有找到任何资源")
|
||||
return
|
||||
|
||||
self.resource_config_dict = {**self.resource_config_dict, **new_config_dict}
|
||||
self._build_resource_model_for_config(new_config_dict)
|
||||
|
||||
tf_dict = self.resource_mesh_setup(new_config_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.publish_resource_tf()
|
||||
if self.move_group_ready:
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源")
|
||||
|
||||
def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
"""ActionServer 回调:重新从资源树加载所有 mesh"""
|
||||
try:
|
||||
self.load_from_resource_tree()
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"重新加载资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle):
|
||||
tf_update_msg = goal_handle.request
|
||||
try:
|
||||
parsed = json.loads(tf_update_msg.command.replace("'", '"'))
|
||||
if 'resources' in parsed:
|
||||
for res_config in parsed['resources']:
|
||||
self.add_resource_mesh(json.dumps(res_config))
|
||||
else:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
try:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"添加资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh(self,resource_config_str:str):
|
||||
"""刷新资源配置"""
|
||||
|
||||
def add_resource_mesh(self, resource_config_str: str):
|
||||
"""添加单个资源的 mesh 配置"""
|
||||
registry = lab_registry
|
||||
resource_config = json.loads(resource_config_str.replace("'", '"'))
|
||||
|
||||
resource_config = json.loads(resource_config_str.replace("'",'"'))
|
||||
|
||||
if resource_config['id'] in self.resource_config_dict:
|
||||
self.get_logger().info(f'资源 {resource_config["id"]} 已存在')
|
||||
return
|
||||
resource_class = resource_config.get('class', '')
|
||||
if resource_class and resource_class in registry.resource_type_registry:
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' in reg_entry:
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
if resource_config['class'] in registry.resource_type_registry.keys():
|
||||
model_config = registry.resource_type_registry[resource_config['class']]['model']
|
||||
if model_config['type'] == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf']}
|
||||
if 'children_mesh' in model_config.keys():
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf']
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
resources = initialize_resources([resource_config])
|
||||
resource_dict = {item['id']: item for item in resources}
|
||||
self.resource_config_dict = {**self.resource_config_dict, **resource_dict}
|
||||
self.resource_config_dict = {**self.resource_config_dict,**resource_dict}
|
||||
tf_dict = self.resource_mesh_setup(resource_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict}
|
||||
self.publish_resource_tf()
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
|
||||
def resource_mesh_setup(self, resource_config_dict: dict):
|
||||
"""根据资源配置字典设置 TF 关系"""
|
||||
|
||||
def resource_mesh_setup(self, resource_config_dict:dict):
|
||||
"""move_group初始化完成后的设置"""
|
||||
self.get_logger().info('开始设置资源网格管理器')
|
||||
#遍历resource_config中的资源配置,判断panent是否在resource_model中,
|
||||
resource_tf_dict = {}
|
||||
for resource_uuid, resource_config in resource_config_dict.items():
|
||||
parent = None
|
||||
resource_id = resource_config['id']
|
||||
parent_uuid = resource_config.get('parent_uuid')
|
||||
if parent_uuid is not None and parent_uuid != "":
|
||||
parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid)
|
||||
parent = parent_entry['id'] if parent_entry else None
|
||||
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "":
|
||||
parent = resource_config_dict[resource_config['parent_uuid']]['id']
|
||||
|
||||
parent_link = 'world'
|
||||
if parent in self.resource_model:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@
|
||||
res_id: plate_slot_{slot}
|
||||
device_id: /PRCXI
|
||||
class_name: PRCXI_BioER_96_wellplate
|
||||
parent: /PRCXI/PRCXI_Deck
|
||||
parent: /PRCXI/PRCXI_Deck/T{slot}
|
||||
slot_on_deck: "{slot}"
|
||||
- 输出端口: labware(用于连接 set_liquid_from_plate)
|
||||
- 控制流: create_resource 之间通过 ready 端口串联
|
||||
@@ -51,7 +51,6 @@
|
||||
--------------------------------------------------------------------------------
|
||||
- 遍历 workflow 数组,为每个动作创建步骤节点
|
||||
- 参数重命名: 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 数量,将单值扩展为数组
|
||||
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
|
||||
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
|
||||
@@ -120,14 +119,11 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
|
||||
# 节点类型
|
||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||
|
||||
CLASS_NAMES_MAPPING = {
|
||||
"plate": "PRCXI_BioER_96_wellplate",
|
||||
"tip_rack": "PRCXI_300ul_Tips",
|
||||
}
|
||||
# create_resource 节点默认参数
|
||||
CREATE_RESOURCE_DEFAULTS = {
|
||||
"device_id": "/PRCXI",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
|
||||
"class_name": "PRCXI_BioER_96_wellplate",
|
||||
}
|
||||
|
||||
# 默认液体体积 (uL)
|
||||
@@ -371,10 +367,11 @@ def build_protocol_graph(
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
Args:
|
||||
labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
|
||||
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找
|
||||
protocol_steps: 协议步骤列表
|
||||
workstation_name: 工作站名称
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...]
|
||||
"""
|
||||
G = WorkflowGraph()
|
||||
resource_last_writer = {} # reagent_name -> "node_id:port"
|
||||
@@ -382,21 +379,7 @@ def build_protocol_graph(
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||
|
||||
# ==================== 第一步:按 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", ""),
|
||||
}
|
||||
|
||||
# ==================== 第一步:按 slot 创建 create_resource 节点 ====================
|
||||
# 创建 Group 节点,包含所有 create_resource 节点
|
||||
group_node_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
@@ -412,44 +395,41 @@ def build_protocol_graph(
|
||||
param=None,
|
||||
)
|
||||
|
||||
trash_create_node_id = None # 记录 trash 的 create_resource 节点
|
||||
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录,type 即 class_name
|
||||
res_index = 0
|
||||
for lw in (labware_defs or []):
|
||||
slot = str(lw.get("slot", ""))
|
||||
if not slot or slot in slot_to_create_resource:
|
||||
continue # 跳过空 slot 或已处理的 slot
|
||||
|
||||
# 为每个唯一的 slot 创建 create_resource 节点
|
||||
for slot, info in slots_info.items():
|
||||
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())
|
||||
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(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=f"{res_type_name}_slot{slot}",
|
||||
description=f"Create plate on slot {slot}",
|
||||
name=lw_name,
|
||||
description=f"Create {lw_name}",
|
||||
lab_node_type="Labware",
|
||||
footer="create_resource-host_node",
|
||||
device_name=DEVICE_NAME_HOST,
|
||||
type=NODE_TYPE_DEFAULT,
|
||||
parent_uuid=group_node_id, # 指向 Group 节点
|
||||
minimized=True, # 折叠显示
|
||||
parent_uuid=group_node_id,
|
||||
minimized=True,
|
||||
param={
|
||||
"res_id": res_id,
|
||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||
"class_name": res_type_name,
|
||||
"class_name": lw_type,
|
||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
|
||||
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"slot_on_deck": slot,
|
||||
},
|
||||
)
|
||||
slot_to_create_resource[slot] = node_id
|
||||
if object_type == "tiprack":
|
||||
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
|
||||
if object_type == "trash":
|
||||
trash_create_node_id = node_id
|
||||
# create_resource 之间不需要 ready 连接
|
||||
|
||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||
@@ -484,8 +464,6 @@ def build_protocol_graph(
|
||||
# res_id 不能有空格
|
||||
res_id = str(labware_id).replace(" ", "_")
|
||||
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())
|
||||
set_liquid_index += 1
|
||||
@@ -506,7 +484,7 @@ def build_protocol_graph(
|
||||
"plate": [], # 通过连接传递
|
||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
||||
"liquid_names": [res_id] * well_count,
|
||||
"volumes": [liquid_volume] * well_count,
|
||||
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -520,8 +498,8 @@ def build_protocol_graph(
|
||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||
|
||||
# transfer_liquid 之间通过 ready 串联;若存在 trash 节点,第一个 transfer_liquid 从 trash 的 ready 开始
|
||||
last_control_node_id = trash_create_node_id
|
||||
# transfer_liquid 之间通过 ready 串联,从 None 开始
|
||||
last_control_node_id = None
|
||||
|
||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||
INPUT_PORT_MAPPING = {
|
||||
@@ -533,7 +511,6 @@ def build_protocol_graph(
|
||||
"reagent": "reagent",
|
||||
"solvent": "solvent",
|
||||
"compound": "compound",
|
||||
"tip_racks": "tip_rack_identifier",
|
||||
}
|
||||
|
||||
OUTPUT_PORT_MAPPING = {
|
||||
@@ -548,17 +525,8 @@ def build_protocol_graph(
|
||||
"compound": "compound",
|
||||
}
|
||||
|
||||
# 需要根据 wells 数量扩展的参数列表:
|
||||
# - 复数参数(asp_vols 等)支持单值自动扩展
|
||||
# - liquid_height 按 wells 扩展为数组
|
||||
# - mix_* 参数保持标量,避免被转换为 list
|
||||
EXPAND_BY_WELLS_PARAMS = [
|
||||
"asp_vols",
|
||||
"dis_vols",
|
||||
"asp_flow_rates",
|
||||
"dis_flow_rates",
|
||||
"liquid_height",
|
||||
]
|
||||
# 需要根据 wells 数量扩展的参数列表(复数形式)
|
||||
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"]
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
@@ -572,57 +540,6 @@ def build_protocol_graph(
|
||||
if old_name in params:
|
||||
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():
|
||||
resource_name = params.get(param_key)
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
from .common import WorkflowGraph, RegistryAdapter
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Converter ----------------
|
||||
|
||||
class DeviceMethodConverter:
|
||||
"""
|
||||
- 字段统一:resource_name(原 device_class)、template_name(原 action_key)
|
||||
- params 单层;inputs 使用 'params.' 前缀
|
||||
- SimpleGraph.add_workflow_node 负责变量连线与边
|
||||
"""
|
||||
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
|
||||
self.graph = WorkflowGraph()
|
||||
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
|
||||
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
|
||||
self.node_id_counter: int = 0
|
||||
self.registry = RegistryAdapter(device_registry or {})
|
||||
|
||||
# ---- helpers ----
|
||||
def _new_node_id(self) -> int:
|
||||
nid = self.node_id_counter
|
||||
self.node_id_counter += 1
|
||||
return nid
|
||||
|
||||
def _assign_targets(self, targets) -> List[str]:
|
||||
names: List[str] = []
|
||||
import ast
|
||||
if isinstance(targets, ast.Tuple):
|
||||
for elt in targets.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
names.append(elt.id)
|
||||
elif isinstance(targets, ast.Name):
|
||||
names.append(targets.id)
|
||||
return names
|
||||
|
||||
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
|
||||
import ast
|
||||
if not isinstance(node.value, ast.Call):
|
||||
return None
|
||||
callee = node.value.func
|
||||
if isinstance(callee, ast.Name):
|
||||
class_name = callee.id
|
||||
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
|
||||
class_name = callee.attr
|
||||
else:
|
||||
return None
|
||||
if isinstance(node.targets[0], ast.Name):
|
||||
instance = node.targets[0].id
|
||||
return instance, class_name
|
||||
return None
|
||||
|
||||
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
|
||||
import ast
|
||||
owner_name, method_name, call_kind = "", "", "func"
|
||||
if isinstance(call.func, ast.Attribute):
|
||||
method_name = call.func.attr
|
||||
if isinstance(call.func.value, ast.Name):
|
||||
owner_name = call.func.value.id
|
||||
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
|
||||
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
|
||||
owner_name = call.func.value.attr
|
||||
call_kind = "class_or_module"
|
||||
elif isinstance(call.func, ast.Name):
|
||||
method_name = call.func.id
|
||||
call_kind = "func"
|
||||
|
||||
def pack(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return {"type": "variable", "value": node.id}
|
||||
if isinstance(node, ast.Constant):
|
||||
return {"type": "constant", "value": node.value}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {"type": "dict", "value": self._parse_dict(node)}
|
||||
if isinstance(node, ast.List):
|
||||
return {"type": "list", "value": self._parse_list(node)}
|
||||
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
|
||||
|
||||
args: Dict[str, Any] = {}
|
||||
pos: List[Any] = []
|
||||
for a in call.args:
|
||||
pos.append(pack(a))
|
||||
for kw in call.keywords:
|
||||
args[kw.arg] = pack(kw.value)
|
||||
if pos:
|
||||
args["_positional"] = pos
|
||||
return owner_name, method_name, args, call_kind
|
||||
|
||||
def _parse_dict(self, node) -> Dict[str, Any]:
|
||||
import ast
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in zip(node.keys, node.values):
|
||||
if isinstance(k, ast.Constant):
|
||||
key = str(k.value)
|
||||
if isinstance(v, ast.Name):
|
||||
out[key] = f"var:{v.id}"
|
||||
elif isinstance(v, ast.Constant):
|
||||
out[key] = v.value
|
||||
elif isinstance(v, ast.Dict):
|
||||
out[key] = self._parse_dict(v)
|
||||
elif isinstance(v, ast.List):
|
||||
out[key] = self._parse_list(v)
|
||||
return out
|
||||
|
||||
def _parse_list(self, node) -> List[Any]:
|
||||
import ast
|
||||
out: List[Any] = []
|
||||
for elt in node.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
out.append(f"var:{elt.id}")
|
||||
elif isinstance(elt, ast.Constant):
|
||||
out.append(elt.value)
|
||||
elif isinstance(elt, ast.Dict):
|
||||
out.append(self._parse_dict(elt))
|
||||
elif isinstance(elt, ast.List):
|
||||
out.append(self._parse_list(elt))
|
||||
return out
|
||||
|
||||
def _normalize_var_tokens(self, x: Any) -> Any:
|
||||
if isinstance(x, str) and x.startswith("var:"):
|
||||
return {"__var__": x[4:]}
|
||||
if isinstance(x, list):
|
||||
return [self._normalize_var_tokens(i) for i in x]
|
||||
if isinstance(x, dict):
|
||||
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
|
||||
return x
|
||||
|
||||
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
|
||||
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
|
||||
params: Dict[str, Any] = dict(defaults)
|
||||
|
||||
def unpack(p):
|
||||
t, v = p.get("type"), p.get("value")
|
||||
if t == "variable":
|
||||
return {"__var__": v}
|
||||
if t == "dict":
|
||||
return self._normalize_var_tokens(v)
|
||||
if t == "list":
|
||||
return self._normalize_var_tokens(v)
|
||||
return v
|
||||
|
||||
for k, p in call_args.items():
|
||||
if k == "_positional":
|
||||
continue
|
||||
params[k] = unpack(p)
|
||||
|
||||
pos = call_args.get("_positional", [])
|
||||
if pos:
|
||||
if input_keys:
|
||||
for i, p in enumerate(pos):
|
||||
if i >= len(input_keys):
|
||||
break
|
||||
name = input_keys[i]
|
||||
if name in params:
|
||||
continue
|
||||
params[name] = unpack(p)
|
||||
else:
|
||||
for i, p in enumerate(pos):
|
||||
params[f"arg_{i}"] = unpack(p)
|
||||
return params
|
||||
|
||||
# ---- handlers ----
|
||||
def _on_assign(self, stmt):
|
||||
import ast
|
||||
inst = self._extract_device_instantiation(stmt)
|
||||
if inst:
|
||||
instance, code_class = inst
|
||||
resource_name = self.registry.resolve_resource_by_classname(code_class)
|
||||
self.instance_to_resource[instance] = resource_name
|
||||
return
|
||||
|
||||
if isinstance(stmt.value, ast.Call):
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
out_vars = self._assign_targets(stmt.targets[0])
|
||||
for var in out_vars:
|
||||
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
|
||||
|
||||
def _on_expr(self, stmt):
|
||||
import ast
|
||||
if not isinstance(stmt.value, ast.Call):
|
||||
return
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
def convert(self, python_code: str):
|
||||
tree = ast.parse(python_code)
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
self._on_assign(stmt)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
self._on_expr(stmt)
|
||||
return self
|
||||
@@ -1,131 +0,0 @@
|
||||
from typing import List, Any, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
return {"error": error_msg, "success": False}
|
||||
Reference in New Issue
Block a user