mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-05 14:05:12 +00:00
Compare commits
3 Commits
546fb633ec
...
v0.10.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.12
|
||||
version: 0.10.13
|
||||
|
||||
source:
|
||||
path: ../unilabos
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.12
|
||||
version: 0.10.13
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.12"
|
||||
version: "0.10.13"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -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(
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.12',
|
||||
version='0.10.13',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
@@ -1 +1 @@
|
||||
__version__ = "0.10.12"
|
||||
__version__ = "0.10.13"
|
||||
|
||||
@@ -20,7 +20,6 @@ if unilabos_dir not in sys.path:
|
||||
from unilabos.utils.banner_print import print_status, print_unilab_banner
|
||||
from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||
@@ -42,7 +41,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
for i, arg in enumerate(sys.argv):
|
||||
for option_string in option_strings:
|
||||
if arg.startswith(option_string):
|
||||
new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :]
|
||||
new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):]
|
||||
sys.argv[i] = new_arg
|
||||
break
|
||||
|
||||
@@ -50,8 +49,6 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
||||
def parse_args():
|
||||
"""解析命令行参数"""
|
||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
|
||||
|
||||
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
||||
parser.add_argument(
|
||||
@@ -156,39 +153,6 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
aliases=["wf"],
|
||||
help="Upload workflow from xdl/json/python files",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-f",
|
||||
"--workflow_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the workflow file (JSON format)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"-n",
|
||||
"--workflow_name",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Workflow name, if not provided will use the name from file or filename",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--tags",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Tags for the workflow (space-separated)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--published",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -204,6 +168,7 @@ def main():
|
||||
if not args_dict.get("skip_env_check", False):
|
||||
from unilabos.utils.environment_check import check_environment
|
||||
|
||||
print_status("正在进行环境依赖检查...", "info")
|
||||
if not check_environment(auto_install=True):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
os._exit(1)
|
||||
@@ -276,12 +241,9 @@ def main():
|
||||
if args_dict.get("sk", ""):
|
||||
BasicConfig.sk = args_dict.get("sk", "")
|
||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||
BasicConfig.working_dir = working_dir
|
||||
|
||||
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
|
||||
|
||||
# 使用远程资源启动
|
||||
if not workflow_upload and args_dict["use_remote_resource"]:
|
||||
if args_dict["use_remote_resource"]:
|
||||
print_status("使用远程资源启动", "info")
|
||||
from unilabos.app.web import http_client
|
||||
|
||||
@@ -294,6 +256,7 @@ def main():
|
||||
|
||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||
BasicConfig.working_dir = working_dir
|
||||
BasicConfig.is_host_mode = not args_dict.get("is_slave", False)
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
@@ -322,31 +285,9 @@ def main():
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||
)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
# 处理 workflow_upload 子命令
|
||||
if workflow_upload:
|
||||
from unilabos.workflow.wf_utils import handle_workflow_upload_command
|
||||
|
||||
handle_workflow_upload_command(args_dict)
|
||||
print_status("工作流上传完成,程序退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
@@ -427,6 +368,20 @@ def main():
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
args_dict["graph"] = graph_res.physical_setup_graph
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||
else:
|
||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||
|
||||
if args_dict["controllers"] is not None:
|
||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||
else:
|
||||
@@ -441,7 +396,6 @@ def main():
|
||||
comm_client = get_communication_client()
|
||||
if "websocket" in args_dict["app_bridges"]:
|
||||
args_dict["bridges"].append(comm_client)
|
||||
|
||||
def _exit(signum, frame):
|
||||
comm_client.stop()
|
||||
sys.exit(0)
|
||||
@@ -483,13 +437,16 @@ def main():
|
||||
resource_visualization.start()
|
||||
except OSError as e:
|
||||
if "AMENT_PREFIX_PATH" in str(e):
|
||||
print_status(f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}", "warning")
|
||||
print_status(
|
||||
f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}",
|
||||
"warning"
|
||||
)
|
||||
print_status(
|
||||
"建议解决方案:\n"
|
||||
"1. 激活Conda环境: conda activate unilab\n"
|
||||
"2. 或使用 --backend simple 参数\n"
|
||||
"3. 或使用 --visual disable 参数禁用可视化",
|
||||
"info",
|
||||
"info"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -76,8 +76,7 @@ class HTTPClient:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
@@ -336,67 +335,6 @@ class HTTPClient:
|
||||
logger.error(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def workflow_import(
|
||||
self,
|
||||
name: str,
|
||||
workflow_uuid: str,
|
||||
workflow_name: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
||||
payload = {
|
||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
# 保存响应到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -438,7 +438,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())
|
||||
@@ -503,7 +503,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:
|
||||
@@ -965,7 +965,7 @@ class QueueProcessor:
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1175,6 +1175,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:
|
||||
@@ -1187,11 +1188,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客户端"""
|
||||
|
||||
@@ -21,8 +21,7 @@ class BasicConfig:
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
|
||||
@classmethod
|
||||
def auth_secret(cls):
|
||||
@@ -66,14 +65,13 @@ def _update_config_from_module(module):
|
||||
if not attr.startswith("_"):
|
||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||
|
||||
|
||||
def _update_config_from_env():
|
||||
prefix = "UNILABOS_"
|
||||
for env_key, env_value in os.environ.items():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
try:
|
||||
key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix
|
||||
key_path = env_key[len(prefix):] # Remove UNILAB_ prefix
|
||||
class_field = key_path.upper().split("_", 1)
|
||||
if len(class_field) != 2:
|
||||
logger.warning(f"[ENV] 环境变量格式不正确:{env_key}")
|
||||
|
||||
@@ -174,6 +174,35 @@ bioyond_dispensing_station:
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
|
||||
@@ -9,6 +9,7 @@ cameracontroller_device:
|
||||
goal_default:
|
||||
config: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
@@ -31,6 +32,7 @@ cameracontroller_device:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
|
||||
@@ -4,6 +4,73 @@ separator.chinwe:
|
||||
- chinwe
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-connect:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: connect参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-disconnect:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: disconnect参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-execute_command_from_outer:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
command_dict: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
command_dict:
|
||||
type: object
|
||||
required:
|
||||
- command_dict
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_command_from_outer参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_rotate_quarter:
|
||||
goal:
|
||||
direction: 顺时针
|
||||
@@ -303,42 +370,44 @@ separator.chinwe:
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
goal:
|
||||
baudrate:
|
||||
default: 9600
|
||||
description: 串口波特率
|
||||
type: integer
|
||||
motor_ids:
|
||||
default:
|
||||
- 4
|
||||
- 5
|
||||
description: 步进电机ID列表
|
||||
items:
|
||||
config:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 9600
|
||||
type: integer
|
||||
type: array
|
||||
port:
|
||||
default: 192.168.1.200:8899
|
||||
description: 串口号或 IP:Port
|
||||
type: string
|
||||
pump_ids:
|
||||
default:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
description: 注射泵ID列表
|
||||
items:
|
||||
motor_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
port:
|
||||
default: 192.168.1.200:8899
|
||||
type: string
|
||||
pump_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
sensor_id:
|
||||
default: 6
|
||||
type: integer
|
||||
type: array
|
||||
sensor_id:
|
||||
default: 6
|
||||
description: XKC传感器ID
|
||||
type: integer
|
||||
sensor_threshold:
|
||||
default: 300
|
||||
description: 传感器液位判定阈值
|
||||
type: integer
|
||||
timeout:
|
||||
default: 10
|
||||
description: 通信超时时间 (秒)
|
||||
type: integer
|
||||
sensor_threshold:
|
||||
default: 300
|
||||
type: integer
|
||||
timeout:
|
||||
default: 10.0
|
||||
type: number
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
is_connected:
|
||||
type: boolean
|
||||
sensor_level:
|
||||
type: boolean
|
||||
sensor_rssi:
|
||||
type: integer
|
||||
required:
|
||||
- sensor_level
|
||||
- sensor_rssi
|
||||
- is_connected
|
||||
type: object
|
||||
version: 2.1.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,11 @@ xyz_stepper_controller:
|
||||
- laiyu_liquid_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-define_current_as_zero:
|
||||
auto-degrees_to_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
save_path: work_origin.json
|
||||
degrees: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -17,23 +17,22 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
save_path:
|
||||
default: work_origin.json
|
||||
type: string
|
||||
required: []
|
||||
degrees:
|
||||
type: number
|
||||
required:
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: define_current_as_zero参数
|
||||
title: degrees_to_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-enable:
|
||||
auto-emergency_stop:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
axis: null
|
||||
state: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -44,27 +43,415 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
state:
|
||||
type: boolean
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
- state
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable参数
|
||||
title: emergency_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to:
|
||||
auto-enable_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
enable: true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
enable:
|
||||
default: true
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_all_axes参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-enable_motor:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acc: 500
|
||||
axis: null
|
||||
precision: 50
|
||||
speed: 2000
|
||||
enable: true
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
enable:
|
||||
default: true
|
||||
type: boolean
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_motor参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-home_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_all_axes参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-home_axis:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
axis: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_axis参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
position: null
|
||||
precision: 100
|
||||
speed: 5000
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
position:
|
||||
type: integer
|
||||
precision:
|
||||
default: 100
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- position
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position_degrees:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
degrees: null
|
||||
precision: 100
|
||||
speed: 5000
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
degrees:
|
||||
type: number
|
||||
precision:
|
||||
default: 100
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- degrees
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_to_position_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
precision: 100
|
||||
revolutions: null
|
||||
speed: 5000
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
precision:
|
||||
default: 100
|
||||
type: integer
|
||||
revolutions:
|
||||
type: number
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x: null
|
||||
y: null
|
||||
z: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x:
|
||||
type: string
|
||||
y:
|
||||
type: string
|
||||
z:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz_degrees:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x_deg: null
|
||||
y_deg: null
|
||||
z_deg: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_deg:
|
||||
type: string
|
||||
y_deg:
|
||||
type: string
|
||||
z_deg:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
speed: 5000
|
||||
x_rev: null
|
||||
y_rev: null
|
||||
z_rev: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
speed:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_rev:
|
||||
type: string
|
||||
y_rev:
|
||||
type: string
|
||||
z_rev:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-revolutions_to_steps:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
revolutions: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
revolutions:
|
||||
type: number
|
||||
required:
|
||||
- revolutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: revolutions_to_steps参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_speed_mode:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acceleration: 1000
|
||||
axis: null
|
||||
speed: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acceleration:
|
||||
default: 1000
|
||||
type: integer
|
||||
axis:
|
||||
type: object
|
||||
speed:
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_speed_mode参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-steps_to_degrees:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
steps: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
@@ -75,38 +462,22 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acc:
|
||||
default: 500
|
||||
type: integer
|
||||
axis:
|
||||
type: string
|
||||
precision:
|
||||
default: 50
|
||||
type: integer
|
||||
speed:
|
||||
default: 2000
|
||||
type: integer
|
||||
steps:
|
||||
type: integer
|
||||
required:
|
||||
- axis
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to参数
|
||||
title: steps_to_degrees参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-move_xyz_work:
|
||||
auto-steps_to_revolutions:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acc: 1500
|
||||
speed: 100
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
steps: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -116,35 +487,21 @@ xyz_stepper_controller:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acc:
|
||||
default: 1500
|
||||
steps:
|
||||
type: integer
|
||||
speed:
|
||||
default: 100
|
||||
type: integer
|
||||
x:
|
||||
default: 0.0
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
type: number
|
||||
required: []
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_work参数
|
||||
title: steps_to_revolutions参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-return_to_work_origin:
|
||||
auto-stop_all_axes:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
acc: 800
|
||||
speed: 200
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -153,22 +510,16 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
acc:
|
||||
default: 800
|
||||
type: integer
|
||||
speed:
|
||||
default: 200
|
||||
type: integer
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: return_to_work_origin参数
|
||||
title: stop_all_axes参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_complete:
|
||||
auto-wait_for_completion:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
@@ -184,22 +535,23 @@ xyz_stepper_controller:
|
||||
goal:
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
type: object
|
||||
timeout:
|
||||
default: 30.0
|
||||
type: string
|
||||
type: number
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_complete参数
|
||||
title: wait_for_completion参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.laiyu_liquid_test.xyz_stepper_driver:XYZStepperController
|
||||
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
|
||||
status_types:
|
||||
status: list
|
||||
all_positions: dict
|
||||
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
|
||||
type: python
|
||||
config_info: []
|
||||
description: 新XYZ控制器
|
||||
@@ -210,23 +562,24 @@ xyz_stepper_controller:
|
||||
properties:
|
||||
baudrate:
|
||||
default: 115200
|
||||
type: string
|
||||
client:
|
||||
type: string
|
||||
origin_path:
|
||||
default: unilabos/devices/laiyu_liquid_test/work_origin.json
|
||||
type: string
|
||||
type: integer
|
||||
port:
|
||||
default: /dev/ttyUSB0
|
||||
type: string
|
||||
required: []
|
||||
timeout:
|
||||
default: 1.0
|
||||
type: number
|
||||
required:
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
status:
|
||||
type: array
|
||||
all_positions:
|
||||
type: object
|
||||
motor_status:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- motor_status
|
||||
- all_positions
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -4019,8 +4019,7 @@ liquid_handler:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -4176,11 +4175,9 @@ liquid_handler:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -5043,8 +5040,7 @@ liquid_handler.biomek:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -5187,11 +5183,9 @@ liquid_handler.biomek:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
@@ -9671,8 +9665,7 @@ liquid_handler.prcxi:
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times:
|
||||
- 0
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
none_keys:
|
||||
- ''
|
||||
@@ -9744,34 +9737,7 @@ liquid_handler.prcxi:
|
||||
touch_tip: false
|
||||
use_channels:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: targets
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
@@ -9828,11 +9794,9 @@ liquid_handler.prcxi:
|
||||
mix_stage:
|
||||
type: string
|
||||
mix_times:
|
||||
items:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
type: array
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_vol:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
|
||||
@@ -5,6 +5,73 @@ neware_battery_test_system:
|
||||
- battery_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_status_summary:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_status_summary参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-test_connection:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: test_connection参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
debug_resource_names:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -407,6 +474,8 @@ neware_battery_test_system:
|
||||
status_types:
|
||||
channel_status: dict
|
||||
connection_info: dict
|
||||
device_summary: dict
|
||||
plate_status: dict
|
||||
status: str
|
||||
total_channels: int
|
||||
type: python
|
||||
@@ -418,36 +487,30 @@ neware_battery_test_system:
|
||||
config:
|
||||
properties:
|
||||
devtype:
|
||||
default: '27'
|
||||
type: string
|
||||
ip:
|
||||
default: 127.0.0.1
|
||||
type: string
|
||||
machine_id:
|
||||
default: 1
|
||||
type: integer
|
||||
oss_prefix:
|
||||
default: neware_backup
|
||||
description: OSS对象路径前缀
|
||||
type: string
|
||||
oss_upload_enabled:
|
||||
default: false
|
||||
description: 是否启用OSS上传功能
|
||||
type: boolean
|
||||
port:
|
||||
default: 502
|
||||
type: integer
|
||||
size_x:
|
||||
default: 500.0
|
||||
default: 50
|
||||
type: number
|
||||
size_y:
|
||||
default: 500.0
|
||||
default: 50
|
||||
type: number
|
||||
size_z:
|
||||
default: 2000.0
|
||||
default: 20
|
||||
type: number
|
||||
timeout:
|
||||
default: 20
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
@@ -459,6 +522,8 @@ neware_battery_test_system:
|
||||
type: object
|
||||
device_summary:
|
||||
type: object
|
||||
plate_status:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
total_channels:
|
||||
@@ -468,6 +533,7 @@ neware_battery_test_system:
|
||||
- channel_status
|
||||
- connection_info
|
||||
- total_channels
|
||||
- plate_status
|
||||
- device_summary
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -49,7 +49,32 @@ opcua_example:
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-refresh_node_values:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
@@ -67,7 +92,32 @@ opcua_example:
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: refresh_node_values参数
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-read_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
node_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
node_name:
|
||||
type: string
|
||||
required:
|
||||
- node_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: read_node参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
@@ -99,50 +149,9 @@ opcua_example:
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: start_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-stop_node_refresh:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_node_refresh参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types:
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
@@ -152,15 +161,23 @@ opcua_example:
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
refresh_interval:
|
||||
default: 1.0
|
||||
type: number
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
@@ -168,9 +185,12 @@ opcua_example:
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -3,6 +3,106 @@ post_process_station:
|
||||
- post_process_station
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-load_config:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
config_path: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
config_path:
|
||||
type: string
|
||||
required:
|
||||
- config_path
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: print_cache_stats参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_node_value:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
name: null
|
||||
value: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- value
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_node_value参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
disconnect:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -602,29 +702,46 @@ post_process_station:
|
||||
type: SendCmd
|
||||
module: unilabos.devices.workstation.post_process.post_process:OpcUaClient
|
||||
status_types:
|
||||
acetone_tank_empty_alarm: Bool
|
||||
atomization_fast_speed: Float64
|
||||
atomization_pressure_kpa: Int32
|
||||
cleaning_complete: Bool
|
||||
device_ready: Bool
|
||||
door_open_alarm: Bool
|
||||
grab_complete: Bool
|
||||
grab_trigger: Bool
|
||||
injection_pump_push_speed: Int32
|
||||
injection_pump_suction_speed: Int32
|
||||
nmp_tank_empty_alarm: Bool
|
||||
post_process_complete: Bool
|
||||
post_process_trigger: Bool
|
||||
raw_tank_number: Int32
|
||||
reaction_tank_number: Int32
|
||||
remote_mode: Bool
|
||||
wash_slow_speed: Float64
|
||||
waste_tank_full_alarm: Bool
|
||||
water_tank_empty_alarm: Bool
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: 后处理站
|
||||
handles: []
|
||||
icon: post_process_station.webp
|
||||
init_param_schema: {}
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
cache_timeout:
|
||||
default: 5.0
|
||||
type: number
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
subscription_interval:
|
||||
default: 500
|
||||
type: integer
|
||||
url:
|
||||
type: string
|
||||
use_subscription:
|
||||
default: true
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -222,7 +222,7 @@ class Registry:
|
||||
abs_path = Path(path).absolute()
|
||||
resource_path = abs_path / "resources"
|
||||
files = list(resource_path.glob("*/*.yaml"))
|
||||
logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}")
|
||||
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
|
||||
current_resource_number = len(self.resource_type_registry) + 1
|
||||
for i, file in enumerate(files):
|
||||
with open(file, encoding="utf-8", mode="r") as f:
|
||||
|
||||
@@ -10,7 +10,6 @@ POST_PROCESS_Raw_1BottleCarrier:
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
POST_PROCESS_Reaction_1BottleCarrier:
|
||||
category:
|
||||
- bottle_carriers
|
||||
|
||||
@@ -8,4 +8,3 @@ POST_PROCESS_PolymerStation_Reagent_Bottle:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
post_process_deck:
|
||||
category:
|
||||
- post_process_deck
|
||||
- deck
|
||||
class:
|
||||
module: unilabos.devices.workstation.post_process.decks:post_process_deck
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PRCXI_30mm_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter
|
||||
type: pylabrobot
|
||||
@@ -11,8 +12,9 @@ PRCXI_30mm_Adapter:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter
|
||||
type: pylabrobot
|
||||
@@ -23,8 +25,9 @@ PRCXI_Adapter:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_Deep10_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter
|
||||
type: pylabrobot
|
||||
@@ -35,8 +38,9 @@ PRCXI_Deep10_Adapter:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_Deep300_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter
|
||||
type: pylabrobot
|
||||
@@ -47,8 +51,9 @@ PRCXI_Deep300_Adapter:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_PCR_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter
|
||||
type: pylabrobot
|
||||
@@ -59,8 +64,9 @@ PRCXI_PCR_Adapter:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_Reservoir_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter
|
||||
type: pylabrobot
|
||||
@@ -73,6 +79,7 @@ PRCXI_Reservoir_Adapter:
|
||||
PRCXI_Tip10_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter
|
||||
type: pylabrobot
|
||||
@@ -85,6 +92,7 @@ PRCXI_Tip10_Adapter:
|
||||
PRCXI_Tip1250_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter
|
||||
type: pylabrobot
|
||||
@@ -97,6 +105,7 @@ PRCXI_Tip1250_Adapter:
|
||||
PRCXI_Tip300_Adapter:
|
||||
category:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PRCXI_48_DeepWell:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell
|
||||
type: pylabrobot
|
||||
@@ -11,8 +12,9 @@ PRCXI_48_DeepWell:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_96_DeepWell:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell
|
||||
type: pylabrobot
|
||||
@@ -23,8 +25,9 @@ PRCXI_96_DeepWell:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_AGenBio_4_troughplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate
|
||||
type: pylabrobot
|
||||
@@ -35,8 +38,9 @@ PRCXI_AGenBio_4_troughplate:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_BioER_96_wellplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate
|
||||
type: pylabrobot
|
||||
@@ -47,8 +51,9 @@ PRCXI_BioER_96_wellplate:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_BioRad_384_wellplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate
|
||||
type: pylabrobot
|
||||
@@ -59,8 +64,9 @@ PRCXI_BioRad_384_wellplate:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_CellTreat_96_wellplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate
|
||||
type: pylabrobot
|
||||
@@ -71,8 +77,9 @@ PRCXI_CellTreat_96_wellplate:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_PCR_Plate_200uL_nonskirted:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted
|
||||
type: pylabrobot
|
||||
@@ -83,8 +90,9 @@ PRCXI_PCR_Plate_200uL_nonskirted:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_PCR_Plate_200uL_semiskirted:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted
|
||||
type: pylabrobot
|
||||
@@ -95,8 +103,9 @@ PRCXI_PCR_Plate_200uL_semiskirted:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_PCR_Plate_200uL_skirted:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted
|
||||
type: pylabrobot
|
||||
@@ -107,8 +116,9 @@ PRCXI_PCR_Plate_200uL_skirted:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_nest_12_troughplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate
|
||||
type: pylabrobot
|
||||
@@ -119,8 +129,9 @@ PRCXI_nest_12_troughplate:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_nest_1_troughplate:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PRCXI_1000uL_Tips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -11,8 +12,9 @@ PRCXI_1000uL_Tips:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_10uL_Tips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -23,8 +25,9 @@ PRCXI_10uL_Tips:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_10ul_eTips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips
|
||||
type: pylabrobot
|
||||
@@ -35,8 +38,9 @@ PRCXI_10ul_eTips:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_1250uL_Tips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -47,8 +51,9 @@ PRCXI_1250uL_Tips:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_200uL_Tips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips
|
||||
type: pylabrobot
|
||||
@@ -59,8 +64,9 @@ PRCXI_200uL_Tips:
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
PRCXI_300ul_Tips:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PRCXI_trash:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- trash
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash
|
||||
type: pylabrobot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PRCXI_EP_Adapter:
|
||||
category:
|
||||
category:
|
||||
- prcxi
|
||||
- tube_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter
|
||||
type: pylabrobot
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.service import Service
|
||||
from unilabos_msgs.action import SendCmd
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import (
|
||||
@@ -48,7 +49,8 @@ from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos.ros.nodes.resource_tracker import (
|
||||
DeviceNodeResourceTracker,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance, ResourceDictInstance,
|
||||
ResourceTreeInstance,
|
||||
ResourceDictInstance,
|
||||
)
|
||||
from unilabos.ros.x.rclpyx import get_event_loop
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
@@ -168,6 +170,7 @@ class PropertyPublisher:
|
||||
msg_type,
|
||||
initial_period: float = 5.0,
|
||||
print_publish=True,
|
||||
qos: int = 10,
|
||||
):
|
||||
self.node = node
|
||||
self.name = name
|
||||
@@ -175,10 +178,11 @@ class PropertyPublisher:
|
||||
self.get_method = get_method
|
||||
self.timer_period = initial_period
|
||||
self.print_publish = print_publish
|
||||
self.qos = qos
|
||||
|
||||
self._value = None
|
||||
try:
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", 10)
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", qos)
|
||||
except AttributeError as ex:
|
||||
self.node.lab_logger().error(
|
||||
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
||||
@@ -186,7 +190,7 @@ class PropertyPublisher:
|
||||
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
||||
self.__loop = get_event_loop()
|
||||
str_msg_type = str(msg_type)[8:-2]
|
||||
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒")
|
||||
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}")
|
||||
|
||||
def get_property(self):
|
||||
if asyncio.iscoroutinefunction(self.get_method):
|
||||
@@ -326,6 +330,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
continue
|
||||
self.create_ros_action_server(action_name, action_value_mapping)
|
||||
|
||||
# 创建订阅者(通过 @subscribe 装饰器)
|
||||
self._topic_subscribers: Dict[str, Any] = {}
|
||||
self._setup_decorated_subscribers()
|
||||
|
||||
# 创建线程池执行器
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
|
||||
@@ -1043,6 +1051,29 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
|
||||
"""创建ROS发布者"""
|
||||
# 检测装饰器配置(支持 get_{attr_name} 方法和 @property)
|
||||
topic_config = {}
|
||||
|
||||
# 优先检测 get_{attr_name} 方法
|
||||
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
||||
getter_method = getattr(self.driver_instance, f"get_{attr_name}")
|
||||
topic_config = get_topic_config(getter_method)
|
||||
|
||||
# 如果没有配置,检测 @property 装饰的属性
|
||||
if not topic_config:
|
||||
driver_class = type(self.driver_instance)
|
||||
if hasattr(driver_class, attr_name):
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if isinstance(class_attr, property) and class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
|
||||
# 使用装饰器配置或默认值
|
||||
cfg_period = topic_config.get("period")
|
||||
cfg_print = topic_config.get("print_publish")
|
||||
cfg_qos = topic_config.get("qos")
|
||||
period: float = cfg_period if cfg_period is not None else initial_period
|
||||
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
|
||||
qos: int = cfg_qos if cfg_qos is not None else 10
|
||||
|
||||
# 获取属性值的方法
|
||||
def get_device_attr():
|
||||
@@ -1063,7 +1094,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
|
||||
self._property_publishers[attr_name] = PropertyPublisher(
|
||||
self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish
|
||||
self, attr_name, get_device_attr, msg_type, period, print_publish, qos
|
||||
)
|
||||
|
||||
def create_ros_action_server(self, action_name, action_value_mapping):
|
||||
@@ -1081,6 +1112,76 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}")
|
||||
|
||||
def _setup_decorated_subscribers(self):
|
||||
"""扫描 driver_instance 中带有 @subscribe 装饰器的方法并创建订阅者"""
|
||||
subscriptions = get_all_subscriptions(self.driver_instance)
|
||||
|
||||
for method_name, method, config in subscriptions:
|
||||
topic_template = config.get("topic")
|
||||
msg_type = config.get("msg_type")
|
||||
qos = config.get("qos", 10)
|
||||
|
||||
if not topic_template:
|
||||
self.lab_logger().warning(f"订阅方法 {method_name} 缺少 topic 配置,跳过")
|
||||
continue
|
||||
|
||||
# 如果没有指定 msg_type,尝试从类型注解推断
|
||||
if msg_type is None:
|
||||
try:
|
||||
hints = get_type_hints(method)
|
||||
# 第一个参数是 self,第二个是 msg
|
||||
param_names = list(hints.keys())
|
||||
if param_names:
|
||||
msg_type = hints[param_names[0]]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if msg_type is None:
|
||||
self.lab_logger().warning(f"订阅方法 {method_name} 缺少 msg_type 配置且无法从类型注解推断,跳过")
|
||||
continue
|
||||
|
||||
# 替换 topic 模板中的占位符
|
||||
topic = self._resolve_topic_template(topic_template)
|
||||
|
||||
self.create_ros_subscriber(topic, msg_type, method, qos)
|
||||
|
||||
def _resolve_topic_template(self, topic_template: str) -> str:
|
||||
"""
|
||||
解析 topic 模板,替换占位符
|
||||
|
||||
支持的占位符:
|
||||
- {device_id}: 设备ID
|
||||
- {namespace}: 完整命名空间
|
||||
"""
|
||||
return topic_template.format(
|
||||
device_id=self.device_id,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def create_ros_subscriber(self, topic: str, msg_type, callback, qos: int = 10):
|
||||
"""
|
||||
创建ROS订阅者
|
||||
|
||||
Args:
|
||||
topic: Topic 名称
|
||||
msg_type: ROS 消息类型
|
||||
callback: 回调方法(会自动绑定到 driver_instance)
|
||||
qos: QoS 深度配置
|
||||
"""
|
||||
try:
|
||||
subscription = self.create_subscription(
|
||||
msg_type,
|
||||
topic,
|
||||
callback,
|
||||
qos,
|
||||
callback_group=self.callback_group,
|
||||
)
|
||||
self._topic_subscribers[topic] = subscription
|
||||
str_msg_type = str(msg_type)[8:-2] if str(msg_type).startswith("<class") else str(msg_type)
|
||||
self.lab_logger().trace(f"订阅Topic: {topic}, 类型: {str_msg_type}, QoS: {qos}")
|
||||
except Exception as ex:
|
||||
self.lab_logger().error(f"创建订阅者 {topic} 失败,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}")
|
||||
|
||||
def get_real_function(self, instance, attr_name):
|
||||
if hasattr(instance.__class__, attr_name):
|
||||
obj = getattr(instance.__class__, attr_name)
|
||||
@@ -1180,6 +1281,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if asyncio.iscoroutinefunction(ACTION):
|
||||
try:
|
||||
self.lab_logger().trace(f"异步执行动作 {ACTION}")
|
||||
|
||||
def _handle_future_exception(fut: Future):
|
||||
nonlocal execution_error, execution_success, action_return_value
|
||||
try:
|
||||
@@ -1552,6 +1654,7 @@ class ROS2DeviceNode:
|
||||
这个类封装了设备类实例和ROS2节点的功能,提供ROS2接口。
|
||||
它不继承设备类,而是通过代理模式访问设备类的属性和方法。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def safe_task_wrapper(trace_callback, func, **kwargs):
|
||||
try:
|
||||
@@ -1574,7 +1677,9 @@ class ROS2DeviceNode:
|
||||
error(f"异步任务 {func.__name__} 获取结果失败")
|
||||
error(traceback.format_exc())
|
||||
|
||||
future = rclpy.get_global_executor().create_task(ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs))
|
||||
future = rclpy.get_global_executor().create_task(
|
||||
ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs)
|
||||
)
|
||||
if trace_error:
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
return future
|
||||
|
||||
@@ -1167,12 +1167,11 @@ class HostNode(BaseROS2DeviceNode):
|
||||
响应对象,包含查询到的资源
|
||||
"""
|
||||
try:
|
||||
from unilabos.app.web import http_client
|
||||
data = json.loads(request.command)
|
||||
if "uuid" in data and data["uuid"] is not None:
|
||||
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
|
||||
http_req = self.bridges[-1].resource_tree_get([data["uuid"]], data["with_children"])
|
||||
elif "id" in data and data["id"].startswith("/"):
|
||||
http_req = http_client.resource_get(data["id"], data["with_children"])
|
||||
http_req = self.bridges[-1].resource_get(data["id"], data["with_children"])
|
||||
else:
|
||||
raise ValueError("没有使用正确的物料 id 或 uuid")
|
||||
response.response = json.dumps(http_req["data"])
|
||||
|
||||
@@ -66,8 +66,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"]):
|
||||
@@ -448,7 +448,7 @@ class ResourceTreeSet(object):
|
||||
from pylabrobot.utils.object_parsing import find_subclass
|
||||
|
||||
# 类型映射
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer"}
|
||||
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
|
||||
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
|
||||
|
||||
40429
unilabos/test/experiments/deprecated/prcxi_9320_with_res.json
Normal file
40429
unilabos/test/experiments/deprecated/prcxi_9320_with_res.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,49 +7,18 @@ from typing import Dict, Any, List
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
class SmartPumpController:
|
||||
"""
|
||||
智能泵控制器
|
||||
class AnyDevice:
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "Idle"
|
||||
|
||||
支持多种泵送模式,具有高精度流量控制和自动校准功能。
|
||||
适用于实验室自动化系统中的液体处理任务。
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"):
|
||||
"""
|
||||
初始化智能泵控制器
|
||||
|
||||
Args:
|
||||
device_id: 设备唯一标识符
|
||||
port: 通信端口
|
||||
"""
|
||||
self.device_id = device_id
|
||||
self.port = port
|
||||
self.is_connected = False
|
||||
self.current_flow_rate = 0.0
|
||||
self.total_volume_pumped = 0.0
|
||||
self.calibration_factor = 1.0
|
||||
self.pump_mode = "continuous" # continuous, volume, rate
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
def connect_device(self, timeout: int = 10) -> bool:
|
||||
"""
|
||||
连接到泵设备
|
||||
|
||||
Args:
|
||||
timeout: 连接超时时间(秒)
|
||||
|
||||
Returns:
|
||||
bool: 连接是否成功
|
||||
"""
|
||||
# 模拟连接过程
|
||||
self.is_connected = True
|
||||
async def action(self, addr: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def disconnect_device(self) -> bool:
|
||||
"""
|
||||
断开设备连接
|
||||
|
||||
0
unilabos/test/ros/__init__.py
Normal file
0
unilabos/test/ros/__init__.py
Normal file
0
unilabos/test/workflow/__init__.py
Normal file
0
unilabos/test/workflow/__init__.py
Normal file
94
unilabos/test/workflow/merge_workflow.py
Normal file
94
unilabos/test/workflow/merge_workflow.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
|
||||
def _normalize_steps(data):
|
||||
normalized = []
|
||||
for step in data:
|
||||
action = step.get("action") or step.get("operation")
|
||||
if not action:
|
||||
continue
|
||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
||||
params = dict(raw_params)
|
||||
|
||||
if "source" in raw_params and "sources" not in raw_params:
|
||||
params["sources"] = raw_params["source"]
|
||||
if "target" in raw_params and "targets" not in raw_params:
|
||||
params["targets"] = raw_params["target"]
|
||||
|
||||
description = step.get("description") or step.get("purpose")
|
||||
step_dict = {"action": action, "parameters": params}
|
||||
if description:
|
||||
step_dict["description"] = description
|
||||
normalized.append(step_dict)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_labware(data):
|
||||
labware = {}
|
||||
for item in data:
|
||||
reagent_name = item.get("reagent_name")
|
||||
key = reagent_name or item.get("material_name") or item.get("name")
|
||||
if not key:
|
||||
continue
|
||||
key = str(key)
|
||||
idx = 1
|
||||
original_key = key
|
||||
while key in labware:
|
||||
idx += 1
|
||||
key = f"{original_key}_{idx}"
|
||||
|
||||
labware[key] = {
|
||||
"slot": item.get("positions") or item.get("slot"),
|
||||
"labware": item.get("material_name") or item.get("labware"),
|
||||
"well": item.get("well", []),
|
||||
"type": item.get("type", "reagent"),
|
||||
"role": item.get("role", ""),
|
||||
"name": key,
|
||||
}
|
||||
return labware
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol_name", [
|
||||
"example_bio",
|
||||
# "bioyond_materials_liquidhandling_1",
|
||||
"example_prcxi",
|
||||
])
|
||||
def test_build_protocol_graph(protocol_name):
|
||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
||||
with data_path.open("r", encoding="utf-8") as fp:
|
||||
d = json.load(fp)
|
||||
|
||||
if "workflow" in d and "reagent" in d:
|
||||
protocol_steps = d["workflow"]
|
||||
labware_info = d["reagent"]
|
||||
elif "steps_info" in d and "labware_info" in d:
|
||||
protocol_steps = _normalize_steps(d["steps_info"])
|
||||
labware_info = _normalize_labware(d["labware_info"])
|
||||
else:
|
||||
raise ValueError("Unsupported protocol format")
|
||||
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name="PRCXi",
|
||||
)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
||||
print(graph)
|
||||
@@ -1,3 +1,9 @@
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def singleton(cls):
|
||||
"""
|
||||
单例装饰器
|
||||
@@ -12,3 +18,167 @@ def singleton(cls):
|
||||
|
||||
return get_instance
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
|
||||
Example:
|
||||
class MyDriver:
|
||||
# 方式1: 装饰 get_{attr_name} 方法
|
||||
@topic_config(period=1.0, print_publish=False, qos=5)
|
||||
def get_temperature(self):
|
||||
return self._temperature
|
||||
|
||||
# 方式2: 与 @property 连用(topic_config 放在下面)
|
||||
@property
|
||||
@topic_config(period=0.1)
|
||||
def position(self):
|
||||
return self._position
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加配置属性 (type: ignore 用于动态属性)
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""
|
||||
获取函数上的topic配置
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
|
||||
Returns:
|
||||
包含 period, print_publish, qos 的配置字典
|
||||
"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def subscribe(
|
||||
topic: str,
|
||||
msg_type: Optional[type] = None,
|
||||
qos: int = 10,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic订阅装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其成为 ROS topic 的订阅回调。
|
||||
当 ROS2DeviceNode 初始化时,会自动扫描并创建对应的订阅者。
|
||||
|
||||
Args:
|
||||
topic: Topic 名称模板,支持以下占位符:
|
||||
- {device_id}: 设备ID (如 "pump_1")
|
||||
- {namespace}: 完整命名空间 (如 "/devices/pump_1")
|
||||
msg_type: ROS 消息类型。如果为 None,需要在回调函数的类型注解中指定
|
||||
qos: QoS 深度配置,默认为 10
|
||||
|
||||
Example:
|
||||
from std_msgs.msg import String, Float64
|
||||
|
||||
class MyDriver:
|
||||
@subscribe(topic="/devices/{device_id}/set_speed", msg_type=Float64)
|
||||
def on_speed_update(self, msg: Float64):
|
||||
self._speed = msg.data
|
||||
print(f"Speed updated to: {self._speed}")
|
||||
|
||||
@subscribe(topic="{namespace}/command")
|
||||
def on_command(self, msg: String):
|
||||
# msg_type 可从类型注解推断
|
||||
self.execute_command(msg.data)
|
||||
|
||||
Note:
|
||||
- 回调方法的第一个参数是 self,第二个参数是收到的 ROS 消息
|
||||
- topic 中的占位符会在创建订阅时被实际值替换
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加订阅配置
|
||||
wrapper._subscribe_topic = topic # type: ignore[attr-defined]
|
||||
wrapper._subscribe_msg_type = msg_type # type: ignore[attr-defined]
|
||||
wrapper._subscribe_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._has_subscribe = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_subscribe_config(func) -> dict:
|
||||
"""
|
||||
获取函数上的订阅配置
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
|
||||
Returns:
|
||||
包含 topic, msg_type, qos 的配置字典
|
||||
"""
|
||||
if hasattr(func, "_has_subscribe") and getattr(func, "_has_subscribe", False):
|
||||
return {
|
||||
"topic": getattr(func, "_subscribe_topic", None),
|
||||
"msg_type": getattr(func, "_subscribe_msg_type", None),
|
||||
"qos": getattr(func, "_subscribe_qos", 10),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def get_all_subscriptions(instance) -> list:
|
||||
"""
|
||||
扫描实例的所有方法,获取带有 @subscribe 装饰器的方法及其配置
|
||||
|
||||
Args:
|
||||
instance: 要扫描的实例
|
||||
|
||||
Returns:
|
||||
包含 (method_name, method, config) 元组的列表
|
||||
"""
|
||||
subscriptions = []
|
||||
for attr_name in dir(instance):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
attr = getattr(instance, attr_name)
|
||||
if callable(attr):
|
||||
config = get_subscribe_config(attr)
|
||||
if config:
|
||||
subscriptions.append((attr_name, attr, config))
|
||||
except Exception:
|
||||
pass
|
||||
return subscriptions
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import matplotlib.pyplot as plt
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Graph ----------------
|
||||
|
||||
|
||||
class WorkflowGraph:
|
||||
"""简单的有向图实现:使用 params 单层参数;inputs 内含连线;支持 node-link 导出"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: Dict[str, Dict[str, Any]] = {}
|
||||
self.edges: List[Dict[str, Any]] = []
|
||||
|
||||
def add_node(self, node_id: str, **attrs):
|
||||
self.nodes[node_id] = attrs
|
||||
|
||||
def add_edge(self, source: str, target: str, **attrs):
|
||||
# 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key
|
||||
source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "")
|
||||
target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "")
|
||||
|
||||
edge = {
|
||||
"source": source,
|
||||
"target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_key": source_handle_key,
|
||||
"source_handle_io": attrs.pop("source_handle_io", "source"),
|
||||
"target_handle_key": target_handle_key,
|
||||
"target_handle_io": attrs.pop("target_handle_io", "target"),
|
||||
**attrs,
|
||||
}
|
||||
self.edges.append(edge)
|
||||
|
||||
def _materialize_wiring_into_inputs(
|
||||
self,
|
||||
obj: Any,
|
||||
inputs: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
target_node_id: str,
|
||||
base_path: List[str],
|
||||
):
|
||||
has_var = False
|
||||
|
||||
def walk(node: Any, path: List[str]):
|
||||
nonlocal has_var
|
||||
if isinstance(node, dict):
|
||||
if "__var__" in node:
|
||||
has_var = True
|
||||
varname = node["__var__"]
|
||||
placeholder = f"${{{varname}}}"
|
||||
src = variable_sources.get(varname)
|
||||
if src:
|
||||
key = ".".join(path) # e.g. "params.foo.bar.0"
|
||||
inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")}
|
||||
self.add_edge(
|
||||
str(src["node_id"]),
|
||||
target_node_id,
|
||||
source_handle_io=src.get("output_name", "result"),
|
||||
target_handle_io=key,
|
||||
)
|
||||
return placeholder
|
||||
return {k: walk(v, path + [k]) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [walk(v, path + [str(i)]) for i, v in enumerate(node)]
|
||||
return node
|
||||
|
||||
replaced = walk(obj, base_path[:])
|
||||
return replaced, has_var
|
||||
|
||||
def add_workflow_node(
|
||||
self,
|
||||
node_id: int,
|
||||
*,
|
||||
device_key: Optional[str] = None, # 实例名,如 "ser"
|
||||
resource_name: Optional[str] = None, # registry key(原 device_class)
|
||||
module: Optional[str] = None,
|
||||
template_name: Optional[str] = None, # 动作/模板名(原 action_key)
|
||||
params: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
add_ready_if_no_vars: bool = True,
|
||||
prev_node_id: Optional[int] = None,
|
||||
**extra_attrs,
|
||||
) -> None:
|
||||
"""添加工作流节点:params 单层;自动变量连线与 ready 串联;支持附加属性"""
|
||||
node_id_str = str(node_id)
|
||||
inputs: Dict[str, Any] = {}
|
||||
|
||||
params, has_var = self._materialize_wiring_into_inputs(
|
||||
params, inputs, variable_sources, node_id_str, base_path=["params"]
|
||||
)
|
||||
|
||||
if add_ready_if_no_vars and not has_var:
|
||||
last_id = str(prev_node_id) if prev_node_id is not None else "-1"
|
||||
inputs["ready"] = {"node": int(last_id), "output": "ready"}
|
||||
self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready")
|
||||
|
||||
node_obj = {
|
||||
"device_key": device_key,
|
||||
"resource_name": resource_name, # ✅ 新名字
|
||||
"module": module,
|
||||
"template_name": template_name, # ✅ 新名字
|
||||
"params": params,
|
||||
"inputs": inputs,
|
||||
}
|
||||
node_obj.update(extra_attrs or {})
|
||||
self.add_node(node_id_str, parameters=node_obj)
|
||||
|
||||
# 顺序工作流导出(连线在 inputs,不返回 edges)
|
||||
def to_dict(self) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node = {"uuid": node_id}
|
||||
params = dict(attrs.get("parameters", {}) or {})
|
||||
flat = {k: v for k, v in attrs.items() if k != "parameters"}
|
||||
flat.update(params)
|
||||
node.update(flat)
|
||||
result.append(node)
|
||||
return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"])
|
||||
|
||||
# node-link 导出(含 edges)
|
||||
def to_node_link_dict(self) -> Dict[str, Any]:
|
||||
nodes_list = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node_attrs = attrs.copy()
|
||||
params = node_attrs.pop("parameters", {}) or {}
|
||||
node_attrs.update(params)
|
||||
nodes_list.append({"uuid": node_id, **node_attrs})
|
||||
return {
|
||||
"directed": True,
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
|
||||
def refactor_data(
|
||||
data: List[Dict[str, Any]],
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "transfer_liquid",
|
||||
"transfer": "transfer",
|
||||
"incubation": "incubation",
|
||||
"move_labware": "move_labware",
|
||||
"oscillation": "oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "HeatChillProtocol",
|
||||
"StopHeatChill": "HeatChillStopProtocol",
|
||||
"StartHeatChill": "HeatChillStartProtocol",
|
||||
"HeatChill": "HeatChillProtocol",
|
||||
"Dissolve": "DissolveProtocol",
|
||||
"Transfer": "TransferProtocol",
|
||||
"Evaporate": "EvaporateProtocol",
|
||||
"Recrystallize": "RecrystallizeProtocol",
|
||||
"Filter": "FilterProtocol",
|
||||
"Dry": "DryProtocol",
|
||||
"Add": "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, action_resource_mapping)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template_name = OPERATION_MAPPING.get(operation)
|
||||
if not template_name:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template_name = f"biomek-{operation}"
|
||||
else:
|
||||
template_name = f"{operation}Protocol"
|
||||
|
||||
# 获取 resource_name
|
||||
resource_name = f"device.{operation.lower()}"
|
||||
if action_resource_mapping:
|
||||
resource_name = action_resource_mapping.get(operation, resource_name)
|
||||
|
||||
# 获取步骤编号,生成 name 字段
|
||||
step_number = step.get("step_number")
|
||||
name = f"Step {step_number}" if step_number is not None else None
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template_name": template_name,
|
||||
"resource_name": resource_name,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"param": step.get("parameters", step.get("action_args", {})),
|
||||
"footer": f"{template_name}-{resource_name}",
|
||||
}
|
||||
if name:
|
||||
step_data["name"] = name
|
||||
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,
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> WorkflowGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
Args:
|
||||
labware_info: labware 信息字典
|
||||
protocol_steps: 协议步骤列表
|
||||
workstation_name: 工作站名称
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
"""
|
||||
G = WorkflowGraph()
|
||||
resource_last_writer = {}
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||
# 有机化学&移液站协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
res_index = 0
|
||||
for labware_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 "Rack" in str(labware_id) or "Tip" in str(labware_id):
|
||||
lab_node_type = "Labware"
|
||||
description = f"Prepare Labware: {labware_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
elif item.get("type") == "hardware" or "reactor" in str(labware_id).lower():
|
||||
if "reactor" not in str(labware_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {labware_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {labware_id}"
|
||||
liquid_type = [labware_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
res_index += 1
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=f"Res {res_index}",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
footer="create_resource-host_node",
|
||||
param={
|
||||
"res_id": labware_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": "",
|
||||
},
|
||||
)
|
||||
resource_last_writer[labware_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("param", {})
|
||||
input_resources_possible_names = [
|
||||
"vessel",
|
||||
"to_vessel",
|
||||
"from_vessel",
|
||||
"reagent",
|
||||
"solvent",
|
||||
"compound",
|
||||
"sources",
|
||||
"targets",
|
||||
]
|
||||
|
||||
for target_port in input_resources_possible_names:
|
||||
resource_name = params.get(target_port)
|
||||
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 = {
|
||||
"vessel_out": params.get("vessel"),
|
||||
"from_vessel_out": params.get("from_vessel"),
|
||||
"to_vessel_out": params.get("to_vessel"),
|
||||
"filtrate_out": 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: WorkflowGraph, 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_name", 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}'")
|
||||
|
||||
|
||||
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_name", 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_handle_key") or edge.get("source_port")
|
||||
tp = edge.get("target_handle_key") or edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_handle_key=sp, target_handle_key=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}'")
|
||||
|
||||
|
||||
# ---------------- Registry Adapter ----------------
|
||||
|
||||
|
||||
class RegistryAdapter:
|
||||
"""根据 module 的类名(冒号右侧)反查 registry 的 resource_name(原 device_class),并抽取参数顺序"""
|
||||
|
||||
def __init__(self, device_registry: Dict[str, Any]):
|
||||
self.device_registry = device_registry or {}
|
||||
self.module_class_to_resource = self._build_module_class_index()
|
||||
|
||||
def _build_module_class_index(self) -> Dict[str, str]:
|
||||
idx = {}
|
||||
for resource_name, info in self.device_registry.items():
|
||||
module = info.get("module")
|
||||
if isinstance(module, str) and ":" in module:
|
||||
cls = module.split(":")[-1]
|
||||
idx[cls] = resource_name
|
||||
idx[cls.lower()] = resource_name
|
||||
return idx
|
||||
|
||||
def resolve_resource_by_classname(self, class_name: str) -> Optional[str]:
|
||||
if not class_name:
|
||||
return None
|
||||
return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower())
|
||||
|
||||
def get_device_module(self, resource_name: Optional[str]) -> Optional[str]:
|
||||
if not resource_name:
|
||||
return None
|
||||
return self.device_registry.get(resource_name, {}).get("module")
|
||||
|
||||
def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]:
|
||||
if not resource_name:
|
||||
return {}
|
||||
return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {}
|
||||
|
||||
def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("schema")
|
||||
|
||||
def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {}
|
||||
|
||||
def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]:
|
||||
schema = self.get_action_schema(resource_name, template_name) or {}
|
||||
goal = (schema.get("properties") or {}).get("goal") or {}
|
||||
props = goal.get("properties") or {}
|
||||
required = goal.get("required") or []
|
||||
return list(dict.fromkeys(required + list(props.keys())))
|
||||
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
JSON 工作流转换模块
|
||||
|
||||
提供从多种 JSON 格式转换为统一工作流格式的功能。
|
||||
支持的格式:
|
||||
1. workflow/reagent 格式
|
||||
2. steps_info/labware_info 格式
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
|
||||
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
从 registry 获取指定设备和动作的 handles 配置
|
||||
|
||||
Args:
|
||||
resource_name: 设备资源名称,如 "liquid_handler.prcxi"
|
||||
template_name: 动作模板名称,如 "transfer_liquid"
|
||||
|
||||
Returns:
|
||||
包含 source 和 target handler_keys 的字典:
|
||||
{"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]}
|
||||
"""
|
||||
result = {"source": [], "target": []}
|
||||
|
||||
device_info = lab_registry.device_type_registry.get(resource_name, {})
|
||||
if not device_info:
|
||||
return result
|
||||
|
||||
action_mappings = device_info.get("class", {}).get("action_value_mappings", {})
|
||||
action_config = action_mappings.get(template_name, {})
|
||||
handles = action_config.get("handles", {})
|
||||
|
||||
if isinstance(handles, dict):
|
||||
# 处理 input handles (作为 target)
|
||||
for handle in handles.get("input", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["source"].append(handler_key)
|
||||
# 处理 output handles (作为 source)
|
||||
for handle in handles.get("output", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["target"].append(handler_key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
校验工作流图中所有边的句柄配置是否正确
|
||||
|
||||
Args:
|
||||
graph: 工作流图对象
|
||||
|
||||
Returns:
|
||||
(is_valid, errors): 是否有效,错误信息列表
|
||||
"""
|
||||
errors = []
|
||||
nodes = graph.nodes
|
||||
|
||||
for edge in graph.edges:
|
||||
left_uuid = edge.get("source")
|
||||
right_uuid = edge.get("target")
|
||||
# target_handle_key是target, right的输入节点(入节点)
|
||||
# source_handle_key是source, left的输出节点(出节点)
|
||||
right_source_conn_key = edge.get("target_handle_key", "")
|
||||
left_target_conn_key = edge.get("source_handle_key", "")
|
||||
|
||||
# 获取源节点和目标节点信息
|
||||
left_node = nodes.get(left_uuid, {})
|
||||
right_node = nodes.get(right_uuid, {})
|
||||
|
||||
left_res_name = left_node.get("resource_name", "")
|
||||
left_template_name = left_node.get("template_name", "")
|
||||
right_res_name = right_node.get("resource_name", "")
|
||||
right_template_name = right_node.get("template_name", "")
|
||||
|
||||
# 获取源节点的 output handles
|
||||
left_node_handles = get_action_handles(left_res_name, left_template_name)
|
||||
target_valid_keys = left_node_handles.get("target", [])
|
||||
target_valid_keys.append("ready")
|
||||
|
||||
# 获取目标节点的 input handles
|
||||
right_node_handles = get_action_handles(right_res_name, right_template_name)
|
||||
source_valid_keys = right_node_handles.get("source", [])
|
||||
source_valid_keys.append("ready")
|
||||
|
||||
# 如果节点配置了 output handles,则 source_port 必须有效
|
||||
if not right_source_conn_key:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}")
|
||||
elif right_source_conn_key not in source_valid_keys:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(
|
||||
f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}"
|
||||
)
|
||||
|
||||
# 如果节点配置了 input handles,则 target_port 必须有效
|
||||
if not left_target_conn_key:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}")
|
||||
elif left_target_conn_key not in target_valid_keys:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(
|
||||
f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在,"
|
||||
f"支持的端点: {target_valid_keys}"
|
||||
)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
# action 到 resource_name 的映射
|
||||
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "liquid_handler.prcxi",
|
||||
"transfer": "liquid_handler.prcxi",
|
||||
"incubation": "incubator.prcxi",
|
||||
"move_labware": "labware_mover.prcxi",
|
||||
"oscillation": "shaker.prcxi",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "heatchill.chemputer",
|
||||
"StopHeatChill": "heatchill.chemputer",
|
||||
"StartHeatChill": "heatchill.chemputer",
|
||||
"HeatChill": "heatchill.chemputer",
|
||||
"Dissolve": "stirrer.chemputer",
|
||||
"Transfer": "liquid_handler.chemputer",
|
||||
"Evaporate": "rotavap.chemputer",
|
||||
"Recrystallize": "reactor.chemputer",
|
||||
"Filter": "filter.chemputer",
|
||||
"Dry": "dryer.chemputer",
|
||||
"Add": "liquid_handler.chemputer",
|
||||
}
|
||||
|
||||
|
||||
def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的步骤数据规范化为统一格式
|
||||
|
||||
支持的输入格式:
|
||||
- action + parameters
|
||||
- action + action_args
|
||||
- operation + parameters
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...]
|
||||
"""
|
||||
normalized = []
|
||||
for idx, step in enumerate(data):
|
||||
# 获取动作名称(支持 action 或 operation 字段)
|
||||
action = step.get("action") or step.get("operation")
|
||||
if not action:
|
||||
continue
|
||||
|
||||
# 获取参数(支持 parameters 或 action_args 字段)
|
||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
||||
params = dict(raw_params)
|
||||
|
||||
# 规范化 source/target -> sources/targets
|
||||
if "source" in raw_params and "sources" not in raw_params:
|
||||
params["sources"] = raw_params["source"]
|
||||
if "target" in raw_params and "targets" not in raw_params:
|
||||
params["targets"] = raw_params["target"]
|
||||
|
||||
# 获取描述(支持 description 或 purpose 字段)
|
||||
description = step.get("description") or step.get("purpose")
|
||||
|
||||
# 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1)
|
||||
step_number = step.get("step_number", idx + 1)
|
||||
|
||||
step_dict = {"action": action, "parameters": params, "step_number": step_number}
|
||||
if description:
|
||||
step_dict["description"] = description
|
||||
|
||||
normalized.append(step_dict)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的 labware 数据规范化为统一的字典格式
|
||||
|
||||
支持的输入格式:
|
||||
- reagent_name + material_name + positions
|
||||
- name + labware + slot
|
||||
|
||||
Args:
|
||||
data: 原始 labware 数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...}
|
||||
"""
|
||||
labware = {}
|
||||
for item in data:
|
||||
# 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name)
|
||||
reagent_name = item.get("reagent_name")
|
||||
key = reagent_name or item.get("material_name") or item.get("name")
|
||||
if not key:
|
||||
continue
|
||||
|
||||
key = str(key)
|
||||
|
||||
# 处理重复 key,自动添加后缀
|
||||
idx = 1
|
||||
original_key = key
|
||||
while key in labware:
|
||||
idx += 1
|
||||
key = f"{original_key}_{idx}"
|
||||
|
||||
labware[key] = {
|
||||
"slot": item.get("positions") or item.get("slot"),
|
||||
"labware": item.get("material_name") or item.get("labware"),
|
||||
"well": item.get("well", []),
|
||||
"type": item.get("type", "reagent"),
|
||||
"role": item.get("role", ""),
|
||||
"name": key,
|
||||
}
|
||||
|
||||
return labware
|
||||
|
||||
|
||||
def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
validate: bool = True,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
|
||||
支持的 JSON 格式:
|
||||
1. {"workflow": [...], "reagent": {...}} - 直接格式
|
||||
2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的 JSON 格式 或 句柄校验失败
|
||||
FileNotFoundError: 文件不存在
|
||||
json.JSONDecodeError: JSON 解析失败
|
||||
"""
|
||||
# 处理输入数据
|
||||
if isinstance(data, (str, PathLike)):
|
||||
path = Path(data)
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
json_data = json.load(fp)
|
||||
elif isinstance(data, str):
|
||||
# 尝试作为 JSON 字符串解析
|
||||
json_data = json.loads(data)
|
||||
else:
|
||||
raise FileNotFoundError(f"文件不存在: {data}")
|
||||
elif isinstance(data, dict):
|
||||
json_data = data
|
||||
else:
|
||||
raise TypeError(f"不支持的数据类型: {type(data)}")
|
||||
|
||||
# 根据格式解析数据
|
||||
if "workflow" in json_data and "reagent" in json_data:
|
||||
# 格式1: workflow/reagent(已经是规范格式)
|
||||
protocol_steps = json_data["workflow"]
|
||||
labware_info = json_data["reagent"]
|
||||
elif "steps_info" in json_data and "labware_info" in json_data:
|
||||
# 格式2: steps_info/labware_info(需要规范化)
|
||||
protocol_steps = normalize_steps(json_data["steps_info"])
|
||||
labware_info = normalize_labware(json_data["labware_info"])
|
||||
elif "steps" in json_data and "labware" in json_data:
|
||||
# 格式3: steps/labware(另一种常见格式)
|
||||
protocol_steps = normalize_steps(json_data["steps"])
|
||||
if isinstance(json_data["labware"], list):
|
||||
labware_info = normalize_labware(json_data["labware"])
|
||||
else:
|
||||
labware_info = json_data["labware"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"不支持的 JSON 格式。支持的格式:\n"
|
||||
"1. {'workflow': [...], 'reagent': {...}}\n"
|
||||
"2. {'steps_info': [...], 'labware_info': [...]}\n"
|
||||
"3. {'steps': [...], 'labware': [...]}"
|
||||
)
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
if validate:
|
||||
is_valid, errors = validate_workflow_handles(graph)
|
||||
if not is_valid:
|
||||
import warnings
|
||||
|
||||
for error in errors:
|
||||
warnings.warn(f"句柄校验警告: {error}")
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def convert_json_to_node_link(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 JSON 数据转换为 node-link 格式的字典
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
Dict: node-link 格式的工作流数据
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_node_link_dict()
|
||||
|
||||
|
||||
def convert_json_to_workflow_list(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 JSON 数据转换为工作流列表格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
List: 工作流节点列表
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_dict()
|
||||
|
||||
|
||||
# 为了向后兼容,保留下划线前缀的别名
|
||||
_normalize_steps = normalize_steps
|
||||
_normalize_labware = normalize_labware
|
||||
@@ -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}
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.12</version>
|
||||
<version>0.10.13</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user