mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-27 22:00:00 +00:00
Compare commits
87 Commits
5dca3d8c3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71ea2a258 | ||
|
|
f6b2bfaf8e | ||
|
|
71107e9552 | ||
|
|
1ad4766221 | ||
|
|
67a74172dc | ||
|
|
ccbf5378dd | ||
|
|
c001f6a151 | ||
|
|
145fcaae65 | ||
|
|
a79c0a88bf | ||
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -54,7 +54,7 @@ requirements:
|
|||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.19
|
- uni-lab::unilabos-env ==0.11.1
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-env
|
name: unilabos-env
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
@@ -11,7 +11,7 @@ build:
|
|||||||
requirements:
|
requirements:
|
||||||
run:
|
run:
|
||||||
# Base unilabos package (includes unilabos-env)
|
# Base unilabos package (includes unilabos-env)
|
||||||
- uni-lab::unilabos ==0.10.19
|
- uni-lab::unilabos ==0.11.1
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ from unilabos.registry.decorators import action
|
|||||||
- `_` 开头的方法 → 不扫描
|
- `_` 开头的方法 → 不扫描
|
||||||
- `@not_action` 标记的方法 → 排除
|
- `@not_action` 标记的方法 → 排除
|
||||||
|
|
||||||
|
### 参数文档 → JSON Schema 元数据
|
||||||
|
|
||||||
|
在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
param[显示名称]: 参数说明,会写入 JSON Schema 的 description。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。
|
||||||
|
- `:` 后面的说明会写入 goal property 的 `description`。
|
||||||
|
- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。
|
||||||
|
- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。
|
||||||
|
|
||||||
### @topic_config — 状态属性配置
|
### @topic_config — 状态属性配置
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -105,13 +121,27 @@ import logging
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
from unilabos.registry.decorators import action, device, not_action, topic_config
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
@device(
|
||||||
|
id="my_device",
|
||||||
|
category=["my_category"],
|
||||||
|
description="设备描述",
|
||||||
|
display_name="设备显示名",
|
||||||
|
)
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
|
"""设备类说明。"""
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
"""
|
||||||
|
初始化设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id[设备ID]: 设备实例 ID,默认使用 my_device。
|
||||||
|
config[设备配置]: 设备启动配置。
|
||||||
|
"""
|
||||||
self.device_id = device_id or "my_device"
|
self.device_id = device_id or "my_device"
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
@@ -133,7 +163,13 @@ class MyDevice:
|
|||||||
|
|
||||||
@action(description="执行操作")
|
@action(description="执行操作")
|
||||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
"""
|
||||||
|
带 @action 装饰器 → 注册为 'my_action' 动作。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param[操作数值]: 操作使用的数值参数。
|
||||||
|
name[操作名称]: 操作名称或备注。
|
||||||
|
"""
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
def get_info(self) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.11.1"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
|||||||
|
|
||||||
def add_edge(self, source, target, **attrs):
|
def add_edge(self, source, target, **attrs):
|
||||||
"""添加边"""
|
"""添加边"""
|
||||||
# edge = {"source": source, "target": 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
|
|
||||||
}
|
|
||||||
self.edges.append(edge)
|
self.edges.append(edge)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
|||||||
"multigraph": False,
|
"multigraph": False,
|
||||||
"graph": {},
|
"graph": {},
|
||||||
"nodes": nodes_list,
|
"nodes": nodes_list,
|
||||||
"edges": self.edges,
|
|
||||||
"links": self.edges,
|
"links": self.edges,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
|||||||
return text
|
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(
|
def create_workflow(
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.11.1',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ from typing import Dict, Any, List
|
|||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||||
|
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
from typing import Union, Dict, Optional
|
from typing import Union, Dict, Optional
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
|
||||||
class VirtualMultiwayValve:
|
class VirtualMultiwayValve:
|
||||||
"""
|
"""
|
||||||
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
|
|||||||
def target_position(self) -> int:
|
def target_position(self) -> int:
|
||||||
return self._target_position
|
return self._target_position
|
||||||
|
|
||||||
def get_current_position(self) -> int:
|
@property
|
||||||
"""获取当前阀门位置 📍"""
|
@topic_config()
|
||||||
return self._current_position
|
def current_port(self) -> str:
|
||||||
|
"""当前连接的端口名称 🔌"""
|
||||||
def get_current_port(self) -> str:
|
return self.port
|
||||||
"""获取当前连接的端口名称 🔌"""
|
|
||||||
return self._current_position
|
|
||||||
|
|
||||||
def set_position(self, command: Union[int, str]):
|
def set_position(self, command: Union[int, str]):
|
||||||
"""
|
"""
|
||||||
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._valve_state = "Closed"
|
self._valve_state = "Closed"
|
||||||
|
|
||||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
||||||
self.logger.info(close_msg)
|
self.logger.info(close_msg)
|
||||||
return close_msg
|
return close_msg
|
||||||
|
|
||||||
def get_valve_position(self) -> int:
|
@property
|
||||||
"""获取阀门位置 - 兼容性方法 📍"""
|
@topic_config()
|
||||||
|
def valve_position(self) -> int:
|
||||||
|
"""阀门位置 📍"""
|
||||||
return self._current_position
|
return self._current_position
|
||||||
|
|
||||||
def set_valve_position(self, command: Union[int, str]):
|
def set_valve_position(self, command: Union[int, str]):
|
||||||
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
|
|||||||
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
||||||
return self.set_to_pump_position()
|
return self.set_to_pump_position()
|
||||||
|
|
||||||
def get_flow_path(self) -> str:
|
@property
|
||||||
"""获取当前流路路径描述 🌊"""
|
@topic_config()
|
||||||
current_port = self.get_current_port()
|
def flow_path(self) -> str:
|
||||||
|
"""当前流路路径描述 🌊"""
|
||||||
if self._current_position == 0:
|
if self._current_position == 0:
|
||||||
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||||
else:
|
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
||||||
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
|
|
||||||
|
|
||||||
# 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}")
|
|
||||||
return flow_path
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
current_port = self.get_current_port()
|
current_port = self.current_port
|
||||||
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
||||||
|
|
||||||
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
||||||
@@ -253,7 +252,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||||
print(f"🏠 初始状态: {valve}")
|
print(f"🏠 初始状态: {valve}")
|
||||||
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
print(f"🌊 当前流路: {valve.flow_path}")
|
||||||
|
|
||||||
# 切换到试剂瓶1(1号位)
|
# 切换到试剂瓶1(1号位)
|
||||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
class VirtualStirrer:
|
class VirtualStirrer:
|
||||||
@@ -314,9 +315,11 @@ class VirtualStirrer:
|
|||||||
def min_speed(self) -> float:
|
def min_speed(self) -> float:
|
||||||
return self._min_speed
|
return self._min_speed
|
||||||
|
|
||||||
def get_device_info(self) -> Dict[str, Any]:
|
@property
|
||||||
"""获取设备状态信息 📊"""
|
@topic_config()
|
||||||
info = {
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""设备状态快照信息 📊"""
|
||||||
|
return {
|
||||||
"device_id": self.device_id,
|
"device_id": self.device_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"operation_mode": self.operation_mode,
|
"operation_mode": self.operation_mode,
|
||||||
@@ -325,12 +328,9 @@ class VirtualStirrer:
|
|||||||
"is_stirring": self.is_stirring,
|
"is_stirring": self.is_stirring,
|
||||||
"remaining_time": self.remaining_time,
|
"remaining_time": self.remaining_time,
|
||||||
"max_speed": self._max_speed,
|
"max_speed": self._max_speed,
|
||||||
"min_speed": self._min_speed
|
"min_speed": self._min_speed,
|
||||||
}
|
}
|
||||||
|
|
||||||
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
|
||||||
return info
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||||
@@ -4,6 +4,7 @@ from enum import Enum
|
|||||||
from typing import Union, Optional
|
from typing import Union, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
@@ -385,8 +386,10 @@ class VirtualTransferPump:
|
|||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
@property
|
||||||
"""获取剩余容量"""
|
@topic_config()
|
||||||
|
def remaining_capacity(self) -> float:
|
||||||
|
"""剩余容量 (ml)"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
|
|||||||
@@ -14,20 +14,30 @@ Virtual Workbench Device - 模拟工作台设备
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Lock, RLock
|
from threading import Lock, RLock
|
||||||
|
from typing import Any, Dict, List, Optional, cast
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.registry.decorators import (
|
from unilabos.registry.decorators import (
|
||||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
|
ActionInputHandle,
|
||||||
|
ActionOutputHandle,
|
||||||
|
DataSource,
|
||||||
|
NodeType,
|
||||||
|
action,
|
||||||
|
device,
|
||||||
|
not_action,
|
||||||
|
topic_config,
|
||||||
)
|
)
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
|
from unilabos.resources.resource_tracker import (
|
||||||
|
SampleUUIDsType,
|
||||||
|
LabSample,
|
||||||
|
ResourceTreeSet,
|
||||||
|
)
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
|
|
||||||
@@ -112,6 +122,7 @@ class HeatingStation:
|
|||||||
|
|
||||||
@device(
|
@device(
|
||||||
id="virtual_workbench",
|
id="virtual_workbench",
|
||||||
|
display_name="虚拟工作台",
|
||||||
category=["virtual_device"],
|
category=["virtual_device"],
|
||||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||||
)
|
)
|
||||||
@@ -137,7 +148,19 @@ class VirtualWorkbench:
|
|||||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化虚拟工作台。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。
|
||||||
|
config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。
|
||||||
|
"""
|
||||||
# 处理可能的不同调用方式
|
# 处理可能的不同调用方式
|
||||||
if device_id is None and "id" in kwargs:
|
if device_id is None and "id" in kwargs:
|
||||||
device_id = kwargs.pop("id")
|
device_id = kwargs.pop("id")
|
||||||
@@ -151,9 +174,13 @@ class VirtualWorkbench:
|
|||||||
self.data: Dict[str, Any] = {}
|
self.data: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 从config中获取可配置参数
|
# 从config中获取可配置参数
|
||||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
self.ARM_OPERATION_TIME = float(
|
||||||
|
self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)
|
||||||
|
)
|
||||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
self.NUM_HEATING_STATIONS = int(
|
||||||
|
self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)
|
||||||
|
)
|
||||||
|
|
||||||
# 机械臂状态和锁
|
# 机械臂状态和锁
|
||||||
self._arm_lock = Lock()
|
self._arm_lock = Lock()
|
||||||
@@ -162,7 +189,8 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
# 加热台状态
|
# 加热台状态
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
self._heating_stations: Dict[int, HeatingStation] = {
|
||||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
i: HeatingStation(station_id=i)
|
||||||
|
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||||
}
|
}
|
||||||
self._stations_lock = RLock()
|
self._stations_lock = RLock()
|
||||||
|
|
||||||
@@ -292,45 +320,113 @@ class VirtualWorkbench:
|
|||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
|
always_free=True,
|
||||||
"assignee_user_ids": "unilabos_manual_confirm"
|
node_type=NodeType.MANUAL_CONFIRM,
|
||||||
}, goal_default={
|
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
||||||
"timeout_seconds": 3600,
|
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
|
||||||
"assignee_user_ids": []
|
feedback_interval=300,
|
||||||
}, feedback_interval=300,
|
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="target_device", data_type="device_id",
|
ActionInputHandle(
|
||||||
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
key="target_device",
|
||||||
ActionInputHandle(key="resource", data_type="resource",
|
data_type="device_id",
|
||||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
label="目标设备",
|
||||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
data_key="target_device",
|
||||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
ActionInputHandle(
|
||||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
key="resource",
|
||||||
ActionInputHandle(key="active_material", data_type="active_material",
|
data_type="resource",
|
||||||
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
label="待转移资源",
|
||||||
ActionInputHandle(key="capacity", data_type="capacity",
|
data_key="resource",
|
||||||
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
data_source=DataSource.HANDLE,
|
||||||
ActionInputHandle(key="battery_system", data_type="battery_system",
|
),
|
||||||
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
# transfer使用
|
# transfer使用
|
||||||
ActionOutputHandle(key="target_device", data_type="device_id",
|
ActionOutputHandle(
|
||||||
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
|
key="target_device",
|
||||||
ActionOutputHandle(key="resource", data_type="resource",
|
data_type="device_id",
|
||||||
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
|
label="目标设备",
|
||||||
ActionOutputHandle(key="mount_resource", data_type="resource",
|
data_key="target_device",
|
||||||
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
# test使用
|
# test使用
|
||||||
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
|
ActionOutputHandle(
|
||||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
|
key="collector_mass",
|
||||||
ActionOutputHandle(key="active_material", data_type="active_material",
|
data_type="collector_mass",
|
||||||
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
|
label="极流体质量",
|
||||||
ActionOutputHandle(key="capacity", data_type="capacity",
|
data_key="collector_mass",
|
||||||
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.EXECUTOR,
|
||||||
ActionOutputHandle(key="battery_system", data_type="battery_system",
|
),
|
||||||
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(
|
||||||
]
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def manual_confirm(
|
def manual_confirm(
|
||||||
self,
|
self,
|
||||||
@@ -343,67 +439,156 @@ class VirtualWorkbench:
|
|||||||
battery_system: List[str],
|
battery_system: List[str],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
assignee_user_ids: list[str],
|
assignee_user_ids: list[str],
|
||||||
**kwargs
|
**kwargs,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
timeout_seconds: 超时时间(秒),默认3600秒
|
人工确认资源转移和扣电测试参数。
|
||||||
collector_mass: 极流体质量
|
|
||||||
active_material: 活性物质含量
|
Args:
|
||||||
capacity: 克容量(mAh/g)
|
resource[待转移资源]: 需要人工确认的资源列表。
|
||||||
battery_system: 电池体系
|
target_device[目标设备]: 资源要转移到的目标设备 ID。
|
||||||
修改的结果无效,是只读的
|
mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
timeout_seconds[超时时间]: 人工确认超时时间,单位秒。
|
||||||
|
assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。
|
||||||
|
|
||||||
|
Note:
|
||||||
|
修改的结果无效,是只读的。
|
||||||
"""
|
"""
|
||||||
resource = ResourceTreeSet.from_plr_resources(resource).dump()
|
resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump()
|
||||||
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
|
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
|
||||||
kwargs.update(locals())
|
kwargs.update(locals())
|
||||||
kwargs.pop("kwargs")
|
kwargs.pop("kwargs")
|
||||||
kwargs.pop("self")
|
kwargs.pop("self")
|
||||||
|
kwargs["resource"] = resource_tree
|
||||||
|
kwargs["mount_resource"] = mount_resource_tree
|
||||||
|
kwargs.pop("resource_tree")
|
||||||
|
kwargs.pop("mount_resource_tree")
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
description="转移物料",
|
description="转移物料",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="target_device", data_type="device_id",
|
ActionInputHandle(
|
||||||
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
key="target_device",
|
||||||
ActionInputHandle(key="resource", data_type="resource",
|
data_type="device_id",
|
||||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
label="目标设备",
|
||||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
data_key="target_device",
|
||||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
data_source=DataSource.HANDLE,
|
||||||
]
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
|
async def transfer(
|
||||||
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
转移资源到目标设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待转移资源]: 待转移的资源列表。
|
||||||
|
target_device[目标设备]: 接收资源的目标设备 ID。
|
||||||
|
mount_resource[目标孔位]: 目标设备上的挂载孔位列表。
|
||||||
|
"""
|
||||||
|
future = ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.transfer_resource_to_another,
|
||||||
|
True,
|
||||||
**{
|
**{
|
||||||
"plr_resources": resource,
|
"plr_resources": resource,
|
||||||
"target_device_id": target_device,
|
"target_device_id": target_device,
|
||||||
"target_resources": mount_resource,
|
"target_resources": mount_resource,
|
||||||
"sites": [None] * len(mount_resource),
|
"sites": [None] * len(mount_resource),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
result = await future
|
result = await future
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
description="扣电测试启动",
|
description="扣电测试启动",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="resource", data_type="resource",
|
ActionInputHandle(
|
||||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
key="resource",
|
||||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
data_type="resource",
|
||||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
data_source=DataSource.HANDLE,
|
||||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
),
|
||||||
ActionInputHandle(key="active_material", data_type="active_material",
|
ActionInputHandle(
|
||||||
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
key="mount_resource",
|
||||||
ActionInputHandle(key="capacity", data_type="capacity",
|
data_type="resource",
|
||||||
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
label="目标孔位",
|
||||||
ActionInputHandle(key="battery_system", data_type="battery_system",
|
data_key="mount_resource",
|
||||||
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
data_source=DataSource.HANDLE,
|
||||||
]
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def test(
|
async def test(
|
||||||
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: list[str],
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
启动扣电测试。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待测试资源]: 需要进行扣电测试的资源列表。
|
||||||
|
mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
"""
|
||||||
print(resource)
|
print(resource)
|
||||||
print(mount_resource)
|
print(mount_resource)
|
||||||
print(collector_mass)
|
print(collector_mass)
|
||||||
@@ -415,16 +600,11 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||||
handles=[
|
handles=[
|
||||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
|
||||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
|
||||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def prepare_materials(
|
def prepare_materials(
|
||||||
@@ -437,6 +617,9 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count[物料数量]: 要生成的物料数量,默认生成 5 个。
|
||||||
"""
|
"""
|
||||||
materials = [i for i in range(1, count + 1)]
|
materials = [i for i in range(1, count + 1)]
|
||||||
|
|
||||||
@@ -457,7 +640,11 @@ class VirtualWorkbench:
|
|||||||
LabSample(
|
LabSample(
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid=sample_uuid,
|
||||||
oss_path="",
|
oss_path="",
|
||||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
extra=(
|
||||||
|
{"material_uuid": content}
|
||||||
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
],
|
],
|
||||||
@@ -467,12 +654,27 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
ActionInputHandle(
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
key="material_input",
|
||||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
data_type="workbench_material",
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
label="物料编号",
|
||||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
data_key="material_number",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_station_output",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="material_number_output",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_heating_station(
|
def move_to_heating_station(
|
||||||
@@ -484,6 +686,9 @@ class VirtualWorkbench:
|
|||||||
将物料从An位置移动到加热台
|
将物料从An位置移动到加热台
|
||||||
|
|
||||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
|
||||||
"""
|
"""
|
||||||
material_id = f"A{material_number}"
|
material_id = f"A{material_number}"
|
||||||
task_desc = f"移动{material_id}到加热台"
|
task_desc = f"移动{material_id}到加热台"
|
||||||
@@ -546,7 +751,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -569,7 +775,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -581,14 +788,34 @@ class VirtualWorkbench:
|
|||||||
always_free=True,
|
always_free=True,
|
||||||
description="启动指定加热台的加热程序",
|
description="启动指定加热台的加热程序",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="station_id_input",
|
||||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
data_key="station_id",
|
||||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
),
|
||||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
ActionInputHandle(
|
||||||
|
key="material_number_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_station",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热完成-加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_material",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="加热完成-物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def start_heating(
|
def start_heating(
|
||||||
@@ -599,6 +826,10 @@ class VirtualWorkbench:
|
|||||||
) -> StartHeatingResult:
|
) -> StartHeatingResult:
|
||||||
"""
|
"""
|
||||||
启动指定加热台的加热程序
|
启动指定加热台的加热程序
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 要启动加热的加热台编号。
|
||||||
|
material_number[物料编号]: 当前加热台上的物料编号。
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
@@ -615,7 +846,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -638,7 +870,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -658,7 +891,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -698,7 +932,9 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||||
|
|
||||||
if time.time() - last_countdown_log >= 5.0:
|
if time.time() - last_countdown_log >= 5.0:
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s"
|
||||||
|
)
|
||||||
last_countdown_log = time.time()
|
last_countdown_log = time.time()
|
||||||
|
|
||||||
if elapsed >= self.HEATING_TIME:
|
if elapsed >= self.HEATING_TIME:
|
||||||
@@ -715,7 +951,9 @@ class VirtualWorkbench:
|
|||||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热完成")
|
self._update_data_status(f"加热台{station_id}加热完成")
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -729,7 +967,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -740,10 +979,20 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从加热台移动到输出位置Cn",
|
description="将物料从加热台移动到输出位置Cn",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="output_station_input",
|
||||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="output_material_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_output(
|
def move_to_output(
|
||||||
@@ -754,6 +1003,10 @@ class VirtualWorkbench:
|
|||||||
) -> MoveToOutputResult:
|
) -> MoveToOutputResult:
|
||||||
"""
|
"""
|
||||||
将物料从加热台移动到输出位置Cn
|
将物料从加热台移动到输出位置Cn
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 已完成加热的加热台编号。
|
||||||
|
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
|
||||||
"""
|
"""
|
||||||
output_number = material_number
|
output_number = material_number
|
||||||
|
|
||||||
@@ -770,7 +1023,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -794,7 +1048,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -814,7 +1069,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -896,7 +1152,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
MAX_SCAN_DEPTH = 10 # 最大目录递归深度
|
MAX_SCAN_DEPTH = 10 # 最大目录递归深度
|
||||||
MAX_SCAN_FILES = 1000 # 最大扫描文件数量
|
MAX_SCAN_FILES = 1000 # 最大扫描文件数量
|
||||||
_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增
|
_CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增
|
||||||
|
|
||||||
# 合法的装饰器来源模块
|
# 合法的装饰器来源模块
|
||||||
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
|
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
|
||||||
@@ -258,8 +258,6 @@ def scan_directory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# File-level parsing
|
# File-level parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -361,6 +359,7 @@ def _parse_file(
|
|||||||
"actions": class_body.get("actions", {}),
|
"actions": class_body.get("actions", {}),
|
||||||
"status_properties": class_body.get("status_properties", {}),
|
"status_properties": class_body.get("status_properties", {}),
|
||||||
"init_params": class_body.get("init_params", []),
|
"init_params": class_body.get("init_params", []),
|
||||||
|
"init_docstring": class_body.get("init_docstring"),
|
||||||
"auto_methods": class_body.get("auto_methods", {}),
|
"auto_methods": class_body.get("auto_methods", {}),
|
||||||
"import_map": import_map,
|
"import_map": import_map,
|
||||||
}
|
}
|
||||||
@@ -497,7 +496,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]:
|
|||||||
return import_map
|
return import_map
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Decorator finding & argument extraction
|
# Decorator finding & argument extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -768,6 +766,7 @@ def _extract_class_body(
|
|||||||
"actions": {}, # method_name -> action_info
|
"actions": {}, # method_name -> action_info
|
||||||
"status_properties": {}, # prop_name -> status_info
|
"status_properties": {}, # prop_name -> status_info
|
||||||
"init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...]
|
"init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...]
|
||||||
|
"init_docstring": None,
|
||||||
"auto_methods": {}, # method_name -> method_info (no @action decorator)
|
"auto_methods": {}, # method_name -> method_info (no @action decorator)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +779,7 @@ def _extract_class_body(
|
|||||||
# --- __init__ ---
|
# --- __init__ ---
|
||||||
if method_name == "__init__":
|
if method_name == "__init__":
|
||||||
result["init_params"] = _extract_method_params(item, import_map)
|
result["init_params"] = _extract_method_params(item, import_map)
|
||||||
|
result["init_docstring"] = ast.get_docstring(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- Skip private/dunder ---
|
# --- Skip private/dunder ---
|
||||||
|
|||||||
@@ -3960,6 +3960,14 @@ virtual_separator:
|
|||||||
io_type: source
|
io_type: source
|
||||||
label: bottom_phase_out
|
label: bottom_phase_out
|
||||||
side: SOUTH
|
side: SOUTH
|
||||||
|
- data_key: top_outlet
|
||||||
|
data_source: executor
|
||||||
|
data_type: fluid
|
||||||
|
description: 上相(轻相)液体输出口
|
||||||
|
handler_key: topphaseout
|
||||||
|
io_type: source
|
||||||
|
label: top_phase_out
|
||||||
|
side: NORTH
|
||||||
- data_key: mechanical_port
|
- data_key: mechanical_port
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: mechanical
|
data_type: mechanical
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ class Registry:
|
|||||||
registry_cache.pkl 一个文件中,删除即可完全重置。
|
registry_cache.pkl 一个文件中,删除即可完全重置。
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION
|
||||||
from unilabos.registry.ast_registry_scanner import scan_directory
|
from unilabos.registry.ast_registry_scanner import scan_directory
|
||||||
|
|
||||||
scan_t0 = _time.perf_counter()
|
scan_t0 = _time.perf_counter()
|
||||||
@@ -286,6 +287,10 @@ class Registry:
|
|||||||
# ---- 统一缓存:一个 pkl 包含所有数据 ----
|
# ---- 统一缓存:一个 pkl 包含所有数据 ----
|
||||||
unified_cache = self._load_config_cache()
|
unified_cache = self._load_config_cache()
|
||||||
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
|
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
|
||||||
|
if ast_cache.get("version") != AST_SCAN_CACHE_VERSION:
|
||||||
|
ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}}
|
||||||
|
unified_cache["_ast_scan"] = ast_cache
|
||||||
|
unified_cache.pop("_build_results", None)
|
||||||
|
|
||||||
# 默认:扫描 unilabos 包所在的父目录
|
# 默认:扫描 unilabos 包所在的父目录
|
||||||
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
|
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
|
||||||
@@ -561,13 +566,38 @@ class Registry:
|
|||||||
|
|
||||||
return prop_schema
|
return prop_schema
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_docstring_param_metadata(
|
||||||
|
schema: Dict[str, Any],
|
||||||
|
doc_info: Dict[str, Any],
|
||||||
|
field_to_param: Optional[Dict[str, str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Apply parsed docstring display names and descriptions to schema properties."""
|
||||||
|
if not schema or not doc_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
param_descs = doc_info.get("params", {}) or {}
|
||||||
|
param_display_names = doc_info.get("param_display_names", {}) or {}
|
||||||
|
for field_name, prop_schema in props.items():
|
||||||
|
if not isinstance(prop_schema, dict):
|
||||||
|
continue
|
||||||
|
param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name
|
||||||
|
if not isinstance(param_name, str):
|
||||||
|
continue
|
||||||
|
param_name = param_name.removesuffix("[]")
|
||||||
|
prop_schema["title"] = param_display_names.get(param_name, prop_schema.get("title") or field_name)
|
||||||
|
prop_schema["description"] = param_descs.get(param_name, prop_schema.get("description") or "")
|
||||||
|
|
||||||
def _generate_unilab_json_command_schema(
|
def _generate_unilab_json_command_schema(
|
||||||
self, method_args: list, docstring: Optional[str] = None,
|
self, method_args: list, docstring: Optional[str] = None,
|
||||||
import_map: Optional[Dict[str, str]] = None,
|
import_map: Optional[Dict[str, str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
|
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
|
||||||
doc_info = parse_docstring(docstring)
|
doc_info = parse_docstring(docstring)
|
||||||
param_descs = doc_info.get("params", {})
|
|
||||||
|
|
||||||
schema = {
|
schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -598,12 +628,10 @@ class Registry:
|
|||||||
param_name, param_type, param_default, import_map=import_map
|
param_name, param_type, param_default, import_map=import_map
|
||||||
)
|
)
|
||||||
|
|
||||||
if param_name in param_descs:
|
|
||||||
schema["properties"][param_name]["description"] = param_descs[param_name]
|
|
||||||
|
|
||||||
if param_required:
|
if param_required:
|
||||||
schema["required"].append(param_name)
|
schema["required"].append(param_name)
|
||||||
|
|
||||||
|
self._apply_docstring_param_metadata(schema, doc_info)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
|
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -799,6 +827,7 @@ class Registry:
|
|||||||
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
|
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
|
||||||
params = method_info.get("params", [])
|
params = method_info.get("params", [])
|
||||||
method_doc = method_info.get("docstring")
|
method_doc = method_info.get("docstring")
|
||||||
|
method_doc_info = parse_docstring(method_doc)
|
||||||
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
|
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
|
||||||
|
|
||||||
if action_args is not None:
|
if action_args is not None:
|
||||||
@@ -828,7 +857,11 @@ class Registry:
|
|||||||
|
|
||||||
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式
|
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式
|
||||||
raw_handles = (action_args or {}).get("handles")
|
raw_handles = (action_args or {}).get("handles")
|
||||||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
handles = (
|
||||||
|
normalize_ast_action_handles(raw_handles)
|
||||||
|
if isinstance(raw_handles, list)
|
||||||
|
else (raw_handles or {})
|
||||||
|
)
|
||||||
|
|
||||||
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
|
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
|
||||||
pk = detect_placeholder_keys(params)
|
pk = detect_placeholder_keys(params)
|
||||||
@@ -847,7 +880,12 @@ class Registry:
|
|||||||
"goal": goal,
|
"goal": goal,
|
||||||
"feedback": (action_args or {}).get("feedback") or {},
|
"feedback": (action_args or {}).get("feedback") or {},
|
||||||
"result": (action_args or {}).get("result") or {},
|
"result": (action_args or {}).get("result") or {},
|
||||||
"schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema),
|
"schema": wrap_action_schema(
|
||||||
|
goal_schema,
|
||||||
|
action_name,
|
||||||
|
description=(action_args or {}).get("description") or method_doc_info.get("description", ""),
|
||||||
|
result_schema=result_schema,
|
||||||
|
),
|
||||||
"goal_default": goal_default,
|
"goal_default": goal_default,
|
||||||
"handles": handles,
|
"handles": handles,
|
||||||
"placeholder_keys": pk,
|
"placeholder_keys": pk,
|
||||||
@@ -886,7 +924,11 @@ class Registry:
|
|||||||
action_name = f"auto-{action_name}"
|
action_name = f"auto-{action_name}"
|
||||||
|
|
||||||
raw_handles = action_args.get("handles")
|
raw_handles = action_args.get("handles")
|
||||||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
handles = (
|
||||||
|
normalize_ast_action_handles(raw_handles)
|
||||||
|
if isinstance(raw_handles, list)
|
||||||
|
else (raw_handles or {})
|
||||||
|
)
|
||||||
|
|
||||||
method_params = method_info.get("params", [])
|
method_params = method_info.get("params", [])
|
||||||
|
|
||||||
@@ -979,7 +1021,10 @@ class Registry:
|
|||||||
"schema": schema,
|
"schema": schema,
|
||||||
"goal_default": goal_default,
|
"goal_default": goal_default,
|
||||||
"handles": handles,
|
"handles": handles,
|
||||||
"placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})},
|
"placeholder_keys": {
|
||||||
|
**detect_placeholder_keys(method_params),
|
||||||
|
**(action_args.get("placeholder_keys") or {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if action_args.get("always_free") or method_info.get("always_free"):
|
if action_args.get("always_free") or method_info.get("always_free"):
|
||||||
action_entry["always_free"] = True
|
action_entry["always_free"] = True
|
||||||
@@ -988,13 +1033,21 @@ class Registry:
|
|||||||
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
||||||
if nt:
|
if nt:
|
||||||
action_entry["node_type"] = nt
|
action_entry["node_type"] = nt
|
||||||
|
goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {})
|
||||||
|
self._apply_docstring_param_metadata(
|
||||||
|
goal_schema_for_docs,
|
||||||
|
parse_docstring(method_info.get("docstring")),
|
||||||
|
goal,
|
||||||
|
)
|
||||||
action_value_mappings[action_name] = action_entry
|
action_value_mappings[action_name] = action_entry
|
||||||
|
|
||||||
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
||||||
|
|
||||||
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
|
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
|
||||||
init_params = ast_meta.get("init_params", [])
|
init_params = ast_meta.get("init_params", [])
|
||||||
config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap)
|
config_schema = self._generate_schema_from_ast_params(
|
||||||
|
init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap
|
||||||
|
)
|
||||||
data_schema = self._generate_status_schema_from_ast(
|
data_schema = self._generate_status_schema_from_ast(
|
||||||
ast_meta.get("status_properties", {}), imap
|
ast_meta.get("status_properties", {}), imap
|
||||||
)
|
)
|
||||||
@@ -1042,7 +1095,6 @@ class Registry:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Generate JSON Schema from AST-extracted parameter list."""
|
"""Generate JSON Schema from AST-extracted parameter list."""
|
||||||
doc_info = parse_docstring(docstring)
|
doc_info = parse_docstring(docstring)
|
||||||
param_descs = doc_info.get("params", {})
|
|
||||||
|
|
||||||
schema: Dict[str, Any] = {
|
schema: Dict[str, Any] = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1072,12 +1124,10 @@ class Registry:
|
|||||||
pname, ptype, pdefault, import_map
|
pname, ptype, pdefault, import_map
|
||||||
)
|
)
|
||||||
|
|
||||||
if pname in param_descs:
|
|
||||||
schema["properties"][pname]["description"] = param_descs[pname]
|
|
||||||
|
|
||||||
if prequired:
|
if prequired:
|
||||||
schema["required"].append(pname)
|
schema["required"].append(pname)
|
||||||
|
|
||||||
|
self._apply_docstring_param_metadata(schema, doc_info)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def _generate_status_schema_from_ast(
|
def _generate_status_schema_from_ast(
|
||||||
@@ -1807,7 +1857,7 @@ class Registry:
|
|||||||
else:
|
else:
|
||||||
action_key = f"auto-{k}"
|
action_key = f"auto-{k}"
|
||||||
goal_schema = self._generate_unilab_json_command_schema(
|
goal_schema = self._generate_unilab_json_command_schema(
|
||||||
v["args"], import_map=enhanced_import_map
|
v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map
|
||||||
)
|
)
|
||||||
ret_type = v.get("return_type", "")
|
ret_type = v.get("return_type", "")
|
||||||
result_schema = None
|
result_schema = None
|
||||||
@@ -1816,7 +1866,13 @@ class Registry:
|
|||||||
"result", ret_type, None, import_map=enhanced_import_map
|
"result", ret_type, None, import_map=enhanced_import_map
|
||||||
)
|
)
|
||||||
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
|
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
|
||||||
new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema)
|
doc_info = parse_docstring(v.get("docstring"))
|
||||||
|
new_schema = wrap_action_schema(
|
||||||
|
goal_schema,
|
||||||
|
action_key,
|
||||||
|
description=doc_info.get("description", ""),
|
||||||
|
result_schema=result_schema,
|
||||||
|
)
|
||||||
old_schema = old_cfg.get("schema", {})
|
old_schema = old_cfg.get("schema", {})
|
||||||
if old_schema:
|
if old_schema:
|
||||||
preserve_field_descriptions(new_schema, old_schema)
|
preserve_field_descriptions(new_schema, old_schema)
|
||||||
@@ -1882,6 +1938,12 @@ class Registry:
|
|||||||
|
|
||||||
merged_pk = dict(old_cfg.get("placeholder_keys", {}))
|
merged_pk = dict(old_cfg.get("placeholder_keys", {}))
|
||||||
merged_pk.update(detect_placeholder_keys(v["args"]))
|
merged_pk.update(detect_placeholder_keys(v["args"]))
|
||||||
|
goal_schema_for_docs = (
|
||||||
|
entry_schema.get("properties", {}).get("goal", {})
|
||||||
|
if isinstance(entry_schema, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"type": entry_type,
|
"type": entry_type,
|
||||||
@@ -1902,7 +1964,8 @@ class Registry:
|
|||||||
|
|
||||||
device_config["init_param_schema"] = {}
|
device_config["init_param_schema"] = {}
|
||||||
init_schema = self._generate_unilab_json_command_schema(
|
init_schema = self._generate_unilab_json_command_schema(
|
||||||
enhanced_info["init_params"], "__init__",
|
enhanced_info["init_params"],
|
||||||
|
docstring=enhanced_info.get("init_docstring"),
|
||||||
import_map=enhanced_import_map,
|
import_map=enhanced_import_map,
|
||||||
)
|
)
|
||||||
device_config["init_param_schema"]["config"] = init_schema
|
device_config["init_param_schema"]["config"] = init_schema
|
||||||
@@ -1949,7 +2012,9 @@ class Registry:
|
|||||||
action_str_type_mapping[action_type_str] = target_type
|
action_str_type_mapping[action_type_str] = target_type
|
||||||
if target_type is not None:
|
if target_type is not None:
|
||||||
try:
|
try:
|
||||||
action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict()
|
action_config["goal_default"] = ROS2MessageInstance(
|
||||||
|
target_type.Goal()
|
||||||
|
).get_python_dict()
|
||||||
except Exception:
|
except Exception:
|
||||||
action_config["goal_default"] = {}
|
action_config["goal_default"] = {}
|
||||||
prev_schema = action_config.get("schema", {})
|
prev_schema = action_config.get("schema", {})
|
||||||
@@ -2141,10 +2206,15 @@ class Registry:
|
|||||||
"unilabos_device_id": {
|
"unilabos_device_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
|
"title": "设备ID",
|
||||||
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
||||||
},
|
},
|
||||||
**schema["properties"]["goal"]["properties"],
|
**schema["properties"]["goal"]["properties"],
|
||||||
}
|
}
|
||||||
|
for field_name, field_schema in schema["properties"]["goal"]["properties"].items():
|
||||||
|
if isinstance(field_schema, dict):
|
||||||
|
field_schema.setdefault("title", field_name)
|
||||||
|
field_schema.setdefault("description", "")
|
||||||
# 将 placeholder_keys 信息添加到 schema 中
|
# 将 placeholder_keys 信息添加到 schema 中
|
||||||
if "placeholder_keys" in action_config and action_config.get("schema", {}).get(
|
if "placeholder_keys" in action_config and action_config.get("schema", {}).get(
|
||||||
"properties", {}
|
"properties", {}
|
||||||
@@ -2212,7 +2282,14 @@ class Registry:
|
|||||||
lab_registry = Registry()
|
lab_registry = Registry()
|
||||||
|
|
||||||
|
|
||||||
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
|
def build_registry(
|
||||||
|
registry_paths=None,
|
||||||
|
devices_dirs=None,
|
||||||
|
upload_registry=False,
|
||||||
|
check_mode=False,
|
||||||
|
complete_registry=False,
|
||||||
|
external_only=False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
构建或获取Registry单例实例
|
构建或获取Registry单例实例
|
||||||
"""
|
"""
|
||||||
@@ -2226,7 +2303,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
|
|||||||
if path not in current_paths:
|
if path not in current_paths:
|
||||||
lab_registry.registry_paths.append(path)
|
lab_registry.registry_paths.append(path)
|
||||||
|
|
||||||
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
|
lab_registry.setup(
|
||||||
|
devices_dirs=devices_dirs,
|
||||||
|
upload_registry=upload_registry,
|
||||||
|
complete_registry=complete_registry,
|
||||||
|
external_only=external_only,
|
||||||
|
)
|
||||||
|
|
||||||
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
||||||
lab_registry.resolve_all_types()
|
lab_registry.resolve_all_types()
|
||||||
|
|||||||
@@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
||||||
|
_PARAM_HEADER_RE = re.compile(
|
||||||
|
r"^\s*(?P<name>\w[\w]*)\s*(?:\[(?P<display_name>[^\]]+)\])?(?:\s*\([^)]*\))?\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Parse ``name[display_name]`` or Google-style ``name (type)``."""
|
||||||
|
match = _PARAM_HEADER_RE.match(param_part.strip())
|
||||||
|
if not match:
|
||||||
|
return param_part.strip().split("(")[0].strip(), None
|
||||||
|
|
||||||
|
display_name = match.group("display_name")
|
||||||
|
if display_name is not None:
|
||||||
|
display_name = display_name.strip() or None
|
||||||
|
return match.group("name").strip(), display_name
|
||||||
|
|
||||||
|
|
||||||
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
解析 Google-style docstring,提取描述和参数说明。
|
解析 docstring,提取描述和参数说明。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- Google-style ``Args:`` / ``Parameters:`` 小节
|
||||||
|
- 直接参数行 ``field: desc``
|
||||||
|
- 带显示名参数行 ``field[Display Name]: desc``
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"description": "短描述", "params": {"param1": "参数1描述", ...}}
|
{
|
||||||
|
"description": "短描述",
|
||||||
|
"params": {"param1": "参数1描述", ...},
|
||||||
|
"param_display_names": {"param1": "显示名", ...},
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
result: Dict[str, Any] = {"description": "", "params": {}}
|
result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}}
|
||||||
if not docstring:
|
if not docstring:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
|||||||
if not lines:
|
if not lines:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["description"] = lines[0].strip()
|
|
||||||
|
|
||||||
in_args = False
|
in_args = False
|
||||||
|
current_section: Optional[str] = None
|
||||||
current_param: Optional[str] = None
|
current_param: Optional[str] = None
|
||||||
|
current_display_name: Optional[str] = None
|
||||||
current_desc_parts: list = []
|
current_desc_parts: list = []
|
||||||
|
|
||||||
for line in lines[1:]:
|
def flush_current_param() -> None:
|
||||||
|
nonlocal current_param, current_display_name, current_desc_parts
|
||||||
|
if current_param is None:
|
||||||
|
return
|
||||||
|
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||||
|
if current_display_name:
|
||||||
|
result["param_display_names"][current_param] = current_display_name
|
||||||
|
current_param = None
|
||||||
|
current_display_name = None
|
||||||
|
current_desc_parts = []
|
||||||
|
|
||||||
|
first_line = lines[0].strip()
|
||||||
|
start_index = 0
|
||||||
|
if not _SECTION_RE.match(first_line) and ":" not in first_line:
|
||||||
|
result["description"] = first_line
|
||||||
|
start_index = 1
|
||||||
|
|
||||||
|
for line in lines[start_index:]:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
if current_param is not None:
|
||||||
|
current_desc_parts.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
section_match = _SECTION_RE.match(stripped)
|
section_match = _SECTION_RE.match(stripped)
|
||||||
if section_match:
|
if section_match:
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
current_section = section_match.group(1).lower()
|
||||||
current_param = None
|
in_args = current_section in ("args", "arguments", "parameters", "params")
|
||||||
current_desc_parts = []
|
|
||||||
section_name = section_match.group(1).lower()
|
|
||||||
in_args = section_name in ("args", "arguments", "parameters", "params")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not in_args:
|
parse_as_param = in_args or current_section is None
|
||||||
|
if not parse_as_param:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if ":" in stripped and not stripped.startswith(" "):
|
if ":" in stripped:
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
|
||||||
param_part, _, desc_part = stripped.partition(":")
|
param_part, _, desc_part = stripped.partition(":")
|
||||||
param_name = param_part.strip().split("(")[0].strip()
|
param_name, display_name = _parse_docstring_param_header(param_part)
|
||||||
current_param = param_name
|
current_param = param_name
|
||||||
|
current_display_name = display_name
|
||||||
current_desc_parts = [desc_part.strip()]
|
current_desc_parts = [desc_part.strip()]
|
||||||
elif current_param is not None:
|
elif current_param is not None:
|
||||||
aline = line
|
aline = line
|
||||||
@@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
|||||||
aline = aline[1:]
|
aline = aline[1:]
|
||||||
current_desc_parts.append(aline.strip())
|
current_desc_parts.append(aline.strip())
|
||||||
|
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
|||||||
Returns:
|
Returns:
|
||||||
ResourceTreeSet: 标准化后的资源树集合
|
ResourceTreeSet: 标准化后的资源树集合
|
||||||
"""
|
"""
|
||||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||||
|
|
||||||
# 第一步:基本预处理(处理graphml的label字段)
|
# 第一步:基本预处理(处理graphml的label字段)
|
||||||
outer_host_node_id = None
|
outer_host_node_id = None
|
||||||
|
|||||||
@@ -1971,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
mapped_plr_resources = []
|
mapped_plr_resources = []
|
||||||
for uuid in uuids_list:
|
for uuid in uuids_list:
|
||||||
|
found = None
|
||||||
for plr_resource in figured_resources:
|
for plr_resource in figured_resources:
|
||||||
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
||||||
mapped_plr_resources.append(r)
|
if r is not None:
|
||||||
break
|
found = r
|
||||||
|
break
|
||||||
|
if found is None:
|
||||||
|
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
|
||||||
|
mapped_plr_resources.append(found)
|
||||||
|
|
||||||
return mapped_plr_resources
|
return mapped_plr_resources
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,13 @@ class EnvironmentChecker:
|
|||||||
"crcmod": "crcmod-plus",
|
"crcmod": "crcmod-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
|
# 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
|
||||||
|
pylabrobot_url = (
|
||||||
|
"git+https://gitee.com/xuwznln/pylabrobot.git"
|
||||||
|
if _is_chinese_locale()
|
||||||
|
else "git+https://github.com/Xuwznln/pylabrobot.git"
|
||||||
|
)
|
||||||
|
self.special_packages = {"pylabrobot": pylabrobot_url}
|
||||||
|
|
||||||
self.version_requirements = {
|
self.version_requirements = {
|
||||||
"msgcenterpy": "0.1.8",
|
"msgcenterpy": "0.1.8",
|
||||||
|
|||||||
@@ -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"?>
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
<package format="3">
|
<package format="3">
|
||||||
<name>unilabos_msgs</name>
|
<name>unilabos_msgs</name>
|
||||||
<version>0.10.19</version>
|
<version>0.11.1</version>
|
||||||
<description>ROS2 Messages package for unilabos devices</description>
|
<description>ROS2 Messages package for unilabos devices</description>
|
||||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user