mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 14:05:09 +00:00
Compare commits
20 Commits
aeeb36d075
...
v0.10.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.17
|
version: 0.10.18
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -46,13 +46,15 @@ requirements:
|
|||||||
- jinja2
|
- jinja2
|
||||||
- requests
|
- requests
|
||||||
- uvicorn
|
- uvicorn
|
||||||
- opcua
|
- if: not osx
|
||||||
|
then:
|
||||||
|
- opcua
|
||||||
- pyserial
|
- pyserial
|
||||||
- pandas
|
- pandas
|
||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.17
|
- uni-lab::unilabos-env ==0.10.18
|
||||||
|
|
||||||
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.17
|
version: 0.10.18
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.10.17
|
version: 0.10.18
|
||||||
|
|
||||||
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.17
|
- uni-lab::unilabos ==0.10.18
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM upgrade pip
|
|
||||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
REM install extra deps
|
|
||||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
# make sure pip is available
|
|
||||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
# install extra deps
|
|
||||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
.conda
|
|
||||||
# .github
|
|
||||||
.idea
|
|
||||||
# .vscode
|
|
||||||
output
|
|
||||||
pylabrobot_repo
|
|
||||||
recipes
|
|
||||||
scripts
|
|
||||||
service
|
|
||||||
temp
|
|
||||||
# unilabos/test
|
|
||||||
# unilabos/app/web
|
|
||||||
unilabos/device_mesh
|
|
||||||
unilabos_data
|
|
||||||
unilabos_msgs
|
|
||||||
unilabos.egg-info
|
|
||||||
CONTRIBUTORS
|
|
||||||
# LICENSE
|
|
||||||
MANIFEST.in
|
|
||||||
pyrightconfig.json
|
|
||||||
# README.md
|
|
||||||
# README_zh.md
|
|
||||||
setup.py
|
|
||||||
setup.cfg
|
|
||||||
.gitattrubutes
|
|
||||||
**/__pycache__
|
|
||||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "dev"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "06:00"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
reviewers:
|
||||||
|
- "msgcenterpy-team"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "github-actions"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
include: "scope"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ temp/
|
|||||||
output/
|
output/
|
||||||
unilabos_data/
|
unilabos_data/
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
|
.cursorignore
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
@@ -439,6 +439,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||||
2. 在"仪器设备"区域找到并添加上述设备
|
2. 在"仪器设备"区域找到并添加上述设备
|
||||||
3. 在"物料耗材"区域找到并添加容器
|
3. 在"物料耗材"区域找到并添加容器
|
||||||
|
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -449,8 +452,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**操作步骤:**
|
**操作步骤:**
|
||||||
|
|
||||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||||
3. 在画布上连接它们(建立父子关系)
|
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||||
|
4. 在画布上连接它们(建立父子关系)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
BIN
docs/user_guide/image/add_protocol.png
Normal file
BIN
docs/user_guide/image/add_protocol.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.17
|
version: 0.10.18
|
||||||
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.17"
|
version: "0.10.18"
|
||||||
|
|
||||||
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.17',
|
version='0.10.18',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Liquid handling 集成测试
|
|
||||||
|
|
||||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
|
||||||
|
|
||||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
|
||||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
|
||||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export UNILAB_SIM_TEST=1
|
|
||||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
|
||||||
```
|
|
||||||
|
|
||||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
|
||||||
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyContainer:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyContainer({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyTipSpot:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyTipSpot({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
|
||||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
|
||||||
for i in range(n):
|
|
||||||
yield [DummyTipSpot(f"tip_{i}")]
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
|
||||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
|
||||||
|
|
||||||
def __init__(self, channel_num: int = 8):
|
|
||||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
|
||||||
self.channel_num = channel_num
|
|
||||||
self.support_touch_tip = True
|
|
||||||
self.current_tip = iter(make_tip_iter())
|
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
|
||||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
|
||||||
|
|
||||||
async def aspirate(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
(
|
|
||||||
"aspirate",
|
|
||||||
{
|
|
||||||
"resources": list(resources),
|
|
||||||
"vols": list(vols),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def dispense(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
(
|
|
||||||
"dispense",
|
|
||||||
{
|
|
||||||
"resources": list(resources),
|
|
||||||
"vols": list(vols),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
|
||||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
|
||||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
|
||||||
|
|
||||||
async def custom_delay(self, seconds=0, msg=None):
|
|
||||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
|
||||||
|
|
||||||
async def touch_tip(self, targets):
|
|
||||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
|
||||||
self.calls.append(("touch_tip", {"targets": targets}))
|
|
||||||
|
|
||||||
def run(coro):
|
|
||||||
return asyncio.run(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_basic_calls():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 2, 3],
|
|
||||||
dis_vols=[4, 5, 6],
|
|
||||||
mix_times=None, # 应该仍能执行(不 mix)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
|
||||||
|
|
||||||
# 每次 aspirate/dispense 都是单孔列表
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [sources[0]]
|
|
||||||
assert aspirates[0]["vols"] == [1.0]
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[2]["resources"] == [targets[2]]
|
|
||||||
assert dispenses[2]["vols"] == [6.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(16))
|
|
||||||
|
|
||||||
source = DummyContainer("S0")
|
|
||||||
target = DummyContainer("T0")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5],
|
|
||||||
dis_vols=[5],
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=1,
|
|
||||||
mix_vol=3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 2
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [3]
|
|
||||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
|
||||||
assert transfer_payload["resources"] == [source]
|
|
||||||
assert mix_idx < transfer_idx
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_groups_by_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = list(range(1, 17))
|
|
||||||
dis_vols = list(range(101, 117))
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0, # 触发逻辑但不 mix
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert len(dispenses) == 2
|
|
||||||
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
|
||||||
assert dispenses[1]["resources"] == targets[8:16]
|
|
||||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="multiple of 8"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=[1] * 9,
|
|
||||||
dis_vols=[1] * 9,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(512))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = [i + 1 for i in range(16)]
|
|
||||||
dis_vols = [200 + i for i in range(16)]
|
|
||||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
|
||||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
|
||||||
offsets = [f"offset_{i}" for i in range(16)]
|
|
||||||
liquid_heights = [i * 0.5 for i in range(16)]
|
|
||||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
asp_flow_rates=asp_flow_rates,
|
|
||||||
dis_flow_rates=dis_flow_rates,
|
|
||||||
offsets=offsets,
|
|
||||||
liquid_height=liquid_heights,
|
|
||||||
blow_out_air_volume=blow_out_air_volume,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
|
|
||||||
for batch_idx in range(2):
|
|
||||||
start = batch_idx * 8
|
|
||||||
end = start + 8
|
|
||||||
asp_call = aspirates[batch_idx]
|
|
||||||
dis_call = dispenses[batch_idx]
|
|
||||||
assert asp_call["resources"] == sources[start:end]
|
|
||||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
|
||||||
assert asp_call["offsets"] == offsets[start:end]
|
|
||||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
|
||||||
assert dis_call["offsets"] == offsets[start:end]
|
|
||||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(1024))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
|
||||||
asp_vols = [i + 1 for i in range(32)]
|
|
||||||
dis_vols = [300 + i for i in range(32)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(pick_calls) == 4
|
|
||||||
assert len(aspirates) == len(dispenses) == 4
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[-1]["resources"] == sources[24:32]
|
|
||||||
assert dispenses[0]["resources"] == targets[0:8]
|
|
||||||
assert dispenses[-1]["resources"] == targets[24:32]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
dis_vols = [10, 20, 30] # sum=60
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 1
|
|
||||||
assert aspirates[0]["resources"] == [source]
|
|
||||||
assert aspirates[0]["vols"] == [60.0]
|
|
||||||
assert aspirates[0]["use_channels"] == [0]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
|
||||||
dis_vols = [i + 1 for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [source] * 8
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[0]["resources"] == targets
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
|
||||||
assert all(d["resources"] == [target] for d in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5, 6],
|
|
||||||
dis_vols=1,
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=2,
|
|
||||||
mix_vol=4,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 1
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [4]
|
|
||||||
# 第一個 mix 之後會真正開始吸 source
|
|
||||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
dis_vols = [1, 2, 3]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols, # 比例模式
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [10 + i for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert aspirates[0]["resources"] == sources
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
assert dispenses[0]["resources"] == [target] * 8
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1, 1],
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_single_target_produces_matching_cycles():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
target = DummyContainer("T_mix")
|
|
||||||
|
|
||||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
assert all(call["resources"] == [target] for call in aspirates)
|
|
||||||
assert all(call["vols"] == [5] for call in aspirates)
|
|
||||||
assert all(call["resources"] == [target] for call in dispenses)
|
|
||||||
assert all(call["vols"] == [5] for call in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
|
||||||
offsets = ["left", "right"]
|
|
||||||
heights = [0.1, 0.2]
|
|
||||||
rates = [0.5, 1.0]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.mix(
|
|
||||||
targets=targets,
|
|
||||||
mix_time=1,
|
|
||||||
mix_vol=3,
|
|
||||||
offsets=offsets,
|
|
||||||
height_to_bottom=heights,
|
|
||||||
mix_rate=rates,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert aspirates[0]["resources"] == [targets[0]]
|
|
||||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
|
||||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
|
||||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
|
||||||
assert aspirates[1]["resources"] == [targets[1]]
|
|
||||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
|
||||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
|
||||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
|
||||||
|
|
||||||
|
|
||||||
213
tests/workflow/test.json
Normal file
213
tests/workflow/test.json
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"workflow": [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines",
|
||||||
|
"targets": "Liquid_1",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines",
|
||||||
|
"targets": "Liquid_2",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines",
|
||||||
|
"targets": "Liquid_3",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_2",
|
||||||
|
"targets": "Liquid_4",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_2",
|
||||||
|
"targets": "Liquid_5",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_2",
|
||||||
|
"targets": "Liquid_6",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_3",
|
||||||
|
"targets": "dest_set",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_3",
|
||||||
|
"targets": "dest_set_2",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"action_args": {
|
||||||
|
"sources": "cell_lines_3",
|
||||||
|
"targets": "dest_set_3",
|
||||||
|
"asp_vol": 100.0,
|
||||||
|
"dis_vol": 74.75,
|
||||||
|
"asp_flow_rate": 94.0,
|
||||||
|
"dis_flow_rate": 95.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reagent": {
|
||||||
|
"Liquid_1": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A7",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 1"
|
||||||
|
},
|
||||||
|
"Liquid_4": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A7",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 1"
|
||||||
|
},
|
||||||
|
"dest_set": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A7",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 1"
|
||||||
|
},
|
||||||
|
"Liquid_2": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": [
|
||||||
|
"A3",
|
||||||
|
"A5",
|
||||||
|
"A8"
|
||||||
|
],
|
||||||
|
"labware": "rep 2"
|
||||||
|
},
|
||||||
|
"Liquid_5": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": [
|
||||||
|
"A3",
|
||||||
|
"A5",
|
||||||
|
"A8"
|
||||||
|
],
|
||||||
|
"labware": "rep 2"
|
||||||
|
},
|
||||||
|
"dest_set_2": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": [
|
||||||
|
"A3",
|
||||||
|
"A5",
|
||||||
|
"A8"
|
||||||
|
],
|
||||||
|
"labware": "rep 2"
|
||||||
|
},
|
||||||
|
"Liquid_3": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A6",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 3"
|
||||||
|
},
|
||||||
|
"Liquid_6": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A6",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 3"
|
||||||
|
},
|
||||||
|
"dest_set_3": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": [
|
||||||
|
"A4",
|
||||||
|
"A6",
|
||||||
|
"A10"
|
||||||
|
],
|
||||||
|
"labware": "rep 3"
|
||||||
|
},
|
||||||
|
"cell_lines": {
|
||||||
|
"slot": 4,
|
||||||
|
"well": [
|
||||||
|
"A1",
|
||||||
|
"A3",
|
||||||
|
"A5"
|
||||||
|
],
|
||||||
|
"labware": "DRUG + YOYO-MEDIA"
|
||||||
|
},
|
||||||
|
"cell_lines_2": {
|
||||||
|
"slot": 4,
|
||||||
|
"well": [
|
||||||
|
"A1",
|
||||||
|
"A3",
|
||||||
|
"A5"
|
||||||
|
],
|
||||||
|
"labware": "DRUG + YOYO-MEDIA"
|
||||||
|
},
|
||||||
|
"cell_lines_3": {
|
||||||
|
"slot": 4,
|
||||||
|
"well": [
|
||||||
|
"A1",
|
||||||
|
"A3",
|
||||||
|
"A5"
|
||||||
|
],
|
||||||
|
"labware": "DRUG + YOYO-MEDIA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.17"
|
__version__ = "0.10.18"
|
||||||
|
|||||||
6
unilabos/__main__.py
Normal file
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Entry point for `python -m unilabos`."""
|
||||||
|
|
||||||
|
from unilabos.app.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -171,6 +171,12 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable sending update feedback to server",
|
help="Disable sending update feedback to server",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test_mode",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||||
|
)
|
||||||
# workflow upload subcommand
|
# workflow upload subcommand
|
||||||
workflow_parser = subparsers.add_parser(
|
workflow_parser = subparsers.add_parser(
|
||||||
"workflow_upload",
|
"workflow_upload",
|
||||||
@@ -204,6 +210,12 @@ def parse_args():
|
|||||||
default=False,
|
default=False,
|
||||||
help="Whether to publish the workflow (default: False)",
|
help="Whether to publish the workflow (default: False)",
|
||||||
)
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--description",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
help="Workflow description, used when publishing the workflow",
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -231,52 +243,60 @@ def main():
|
|||||||
# 加载配置文件,优先加载config,然后从env读取
|
# 加载配置文件,优先加载config,然后从env读取
|
||||||
config_path = args_dict.get("config")
|
config_path = args_dict.get("config")
|
||||||
|
|
||||||
if check_mode:
|
# === 解析 working_dir ===
|
||||||
args_dict["working_dir"] = os.path.abspath(os.getcwd())
|
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||||
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
|
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||||
if skip_env_check and not args_dict.get("working_dir") and not config_path:
|
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||||
|
raw_working_dir = args_dict.get("working_dir")
|
||||||
|
if raw_working_dir:
|
||||||
|
working_dir = os.path.abspath(raw_working_dir)
|
||||||
|
elif config_path and os.path.exists(config_path):
|
||||||
|
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||||
|
else:
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
working_dir = os.path.abspath(os.getcwd())
|
||||||
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
|
|
||||||
# 检查当前目录是否有 local_config.py
|
# unilabos_data 子目录自动检测
|
||||||
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
|
if os.path.basename(working_dir) != "unilabos_data":
|
||||||
if os.path.exists(local_config_in_cwd):
|
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||||
config_path = local_config_in_cwd
|
if os.path.isdir(unilabos_data_sub):
|
||||||
|
working_dir = unilabos_data_sub
|
||||||
|
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||||
|
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||||
|
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||||
|
|
||||||
|
# === 解析 config_path ===
|
||||||
|
if config_path and not os.path.exists(config_path):
|
||||||
|
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||||
|
candidate = os.path.join(working_dir, "local_config.py")
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
config_path = candidate
|
||||||
|
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||||
|
else:
|
||||||
|
print_status(
|
||||||
|
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||||
|
f"请通过 --config 传入 local_config.py 文件路径",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
os._exit(1)
|
||||||
|
elif not config_path:
|
||||||
|
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||||
|
candidate = os.path.join(working_dir, "local_config.py")
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
config_path = candidate
|
||||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||||
else:
|
else:
|
||||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||||
elif os.getcwd().endswith("unilabos_data"):
|
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
if check_mode or input() != "n":
|
||||||
else:
|
os.makedirs(working_dir, exist_ok=True)
|
||||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
|
shutil.copy(
|
||||||
if args_dict.get("working_dir"):
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||||
working_dir = args_dict.get("working_dir", "")
|
config_path,
|
||||||
if config_path and not os.path.exists(config_path):
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
print_status(
|
|
||||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
|
||||||
"error",
|
|
||||||
)
|
)
|
||||||
|
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||||
|
else:
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
elif config_path and os.path.exists(config_path):
|
|
||||||
working_dir = os.path.dirname(config_path)
|
|
||||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
elif not skip_env_check and not config_path and (
|
|
||||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
|
||||||
):
|
|
||||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
|
||||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
|
||||||
if input() != "n":
|
|
||||||
os.makedirs(working_dir, exist_ok=True)
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
shutil.copy(
|
|
||||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
|
||||||
)
|
|
||||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
|
||||||
else:
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
# 加载配置文件 (check_mode 跳过)
|
# 加载配置文件 (check_mode 跳过)
|
||||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||||
@@ -288,7 +308,9 @@ def main():
|
|||||||
|
|
||||||
if hasattr(BasicConfig, "log_level"):
|
if hasattr(BasicConfig, "log_level"):
|
||||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||||
|
if file_path is not None:
|
||||||
|
logger.info(f"[LOG_FILE] {file_path}")
|
||||||
|
|
||||||
if args.addr != parser.get_default("addr"):
|
if args.addr != parser.get_default("addr"):
|
||||||
if args.addr == "test":
|
if args.addr == "test":
|
||||||
@@ -332,6 +354,9 @@ def main():
|
|||||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||||
|
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||||
|
if BasicConfig.test_mode:
|
||||||
|
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||||
BasicConfig.communication_protocol = "websocket"
|
BasicConfig.communication_protocol = "websocket"
|
||||||
machine_name = os.popen("hostname").read().strip()
|
machine_name = os.popen("hostname").read().strip()
|
||||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class JobAddReq(BaseModel):
|
|||||||
action_type: str = Field(
|
action_type: str = Field(
|
||||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||||
)
|
)
|
||||||
|
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||||
|
|
||||||
@@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
if response.status_code in [200, 201]:
|
if response.status_code in [200, 201]:
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -343,9 +343,10 @@ class HTTPClient:
|
|||||||
edges: List[Dict[str, Any]],
|
edges: List[Dict[str, Any]],
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
published: bool = False,
|
published: bool = False,
|
||||||
|
description: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
导入工作流到服务器
|
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 工作流名称(顶层)
|
name: 工作流名称(顶层)
|
||||||
@@ -355,13 +356,12 @@ class HTTPClient:
|
|||||||
edges: 工作流边列表
|
edges: 工作流边列表
|
||||||
tags: 工作流标签列表,默认为空列表
|
tags: 工作流标签列表,默认为空列表
|
||||||
published: 是否发布工作流,默认为False
|
published: 是否发布工作流,默认为False
|
||||||
|
description: 工作流描述,发布时使用
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||||
"""
|
"""
|
||||||
# target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取
|
|
||||||
payload = {
|
payload = {
|
||||||
"target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5",
|
|
||||||
"name": name,
|
"name": name,
|
||||||
"data": {
|
"data": {
|
||||||
"workflow_uuid": workflow_uuid,
|
"workflow_uuid": workflow_uuid,
|
||||||
@@ -369,7 +369,6 @@ class HTTPClient:
|
|||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"edges": edges,
|
"edges": edges,
|
||||||
"tags": tags if tags is not None else [],
|
"tags": tags if tags is not None else [],
|
||||||
"published": published,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# 保存请求到文件
|
# 保存请求到文件
|
||||||
@@ -390,11 +389,51 @@ class HTTPClient:
|
|||||||
res = response.json()
|
res = response.json()
|
||||||
if "code" in res and res["code"] != 0:
|
if "code" in res and res["code"] != 0:
|
||||||
logger.error(f"导入工作流失败: {response.text}")
|
logger.error(f"导入工作流失败: {response.text}")
|
||||||
|
return res
|
||||||
|
# 导入成功后,如果需要发布则额外发起发布请求
|
||||||
|
if published:
|
||||||
|
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||||
|
publish_res = self.workflow_publish(imported_uuid, description)
|
||||||
|
res["publish_result"] = publish_res
|
||||||
return res
|
return res
|
||||||
else:
|
else:
|
||||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||||
return {"code": response.status_code, "message": response.text}
|
return {"code": response.status_code, "message": response.text}
|
||||||
|
|
||||||
|
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
发布工作流
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow_uuid: 工作流UUID
|
||||||
|
description: 工作流描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: API响应数据
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"uuid": workflow_uuid,
|
||||||
|
"description": description,
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||||
|
response = requests.patch(
|
||||||
|
f"{self.remote_addr}/lab/workflow/owner",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
res = response.json()
|
||||||
|
if "code" in res and res["code"] != 0:
|
||||||
|
logger.error(f"发布工作流失败: {response.text}")
|
||||||
|
else:
|
||||||
|
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||||
|
return res
|
||||||
|
else:
|
||||||
|
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||||
|
return {"code": response.status_code, "message": response.text}
|
||||||
|
|
||||||
|
|
||||||
# 创建默认客户端实例
|
# 创建默认客户端实例
|
||||||
http_client = HTTPClient()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||||
|
|
||||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||||
"""获取并删除任务结果"""
|
"""获取并删除任务结果"""
|
||||||
with self._results_lock:
|
with self._results_lock:
|
||||||
result = self._results.pop(job_id, None)
|
result = self._results.pop(job_id, None)
|
||||||
if result:
|
if result:
|
||||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||||
@@ -327,6 +327,7 @@ def job_add(req: JobAddReq) -> JobData:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
action_kwargs=action_args,
|
action_kwargs=action_args,
|
||||||
|
sample_material=req.sample_material,
|
||||||
server_info=server_info,
|
server_info=server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from jedi.inference.gradual.typing import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.app.model import JobAddReq
|
from unilabos.app.model import JobAddReq
|
||||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||||
@@ -76,6 +76,7 @@ class JobInfo:
|
|||||||
start_time: float
|
start_time: float
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||||
|
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
||||||
|
|
||||||
def update_timestamp(self):
|
def update_timestamp(self):
|
||||||
"""更新最后更新时间"""
|
"""更新最后更新时间"""
|
||||||
@@ -127,6 +128,15 @@ class DeviceActionManager:
|
|||||||
# 总是将job添加到all_jobs中
|
# 总是将job添加到all_jobs中
|
||||||
self.all_jobs[job_info.job_id] = job_info
|
self.all_jobs[job_info.job_id] = job_info
|
||||||
|
|
||||||
|
# always_free的动作不受排队限制,直接设为READY
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.READY
|
||||||
|
job_info.update_timestamp()
|
||||||
|
job_info.set_ready_timeout(10)
|
||||||
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
|
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
||||||
|
return True
|
||||||
|
|
||||||
# 检查是否有正在执行或准备执行的任务
|
# 检查是否有正在执行或准备执行的任务
|
||||||
if device_key in self.active_jobs:
|
if device_key in self.active_jobs:
|
||||||
# 有正在执行或准备执行的任务,加入队列
|
# 有正在执行或准备执行的任务,加入队列
|
||||||
@@ -154,7 +164,7 @@ class DeviceActionManager:
|
|||||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||||
self.active_jobs[device_key] = job_info
|
self.active_jobs[device_key] = job_info
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def start_job(self, job_id: str) -> bool:
|
def start_job(self, job_id: str) -> bool:
|
||||||
@@ -176,11 +186,15 @@ class DeviceActionManager:
|
|||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查设备上是否是这个job
|
# always_free的job不需要检查active_jobs
|
||||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
if not job_info.always_free:
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
# 检查设备上是否是这个job
|
||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||||
return False
|
job_log = format_job_log(
|
||||||
|
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||||
|
)
|
||||||
|
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
# 开始执行任务,将状态从READY转换为STARTED
|
# 开始执行任务,将状态从READY转换为STARTED
|
||||||
job_info.status = JobStatus.STARTED
|
job_info.status = JobStatus.STARTED
|
||||||
@@ -203,6 +217,13 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
|
# always_free的job直接清理,不影响队列
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.ENDED
|
||||||
|
job_info.update_timestamp()
|
||||||
|
del self.all_jobs[job_id]
|
||||||
|
return None
|
||||||
|
|
||||||
# 移除活跃任务
|
# 移除活跃任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
del self.active_jobs[device_key]
|
del self.active_jobs[device_key]
|
||||||
@@ -210,8 +231,9 @@ class DeviceActionManager:
|
|||||||
job_info.update_timestamp()
|
job_info.update_timestamp()
|
||||||
# 从all_jobs中移除已结束的job
|
# 从all_jobs中移除已结束的job
|
||||||
del self.all_jobs[job_id]
|
del self.all_jobs[job_id]
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||||
@@ -227,15 +249,20 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||||
return next_job
|
return next_job
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_jobs(self) -> List[JobInfo]:
|
def get_active_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有正在执行的任务"""
|
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return list(self.active_jobs.values())
|
jobs = list(self.active_jobs.values())
|
||||||
|
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
||||||
|
for job in self.all_jobs.values():
|
||||||
|
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
||||||
|
jobs.append(job)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def get_queued_jobs(self) -> List[JobInfo]:
|
def get_queued_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有排队中的任务"""
|
"""获取所有排队中的任务"""
|
||||||
@@ -260,6 +287,14 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
|
# always_free的job直接清理
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.ENDED
|
||||||
|
del self.all_jobs[job_id]
|
||||||
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
|
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
||||||
|
return True
|
||||||
|
|
||||||
# 如果是正在执行的任务
|
# 如果是正在执行的任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
# 清理active job状态
|
# 清理active job状态
|
||||||
@@ -268,7 +303,7 @@ class DeviceActionManager:
|
|||||||
# 从all_jobs中移除
|
# 从all_jobs中移除
|
||||||
del self.all_jobs[job_id]
|
del self.all_jobs[job_id]
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||||
|
|
||||||
# 启动下一个任务
|
# 启动下一个任务
|
||||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||||
@@ -281,7 +316,7 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 如果是排队中的任务
|
# 如果是排队中的任务
|
||||||
@@ -295,7 +330,7 @@ class DeviceActionManager:
|
|||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||||
)
|
)
|
||||||
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
@@ -333,13 +368,18 @@ class DeviceActionManager:
|
|||||||
timeout_jobs = []
|
timeout_jobs = []
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# 统计READY状态的任务数量
|
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
||||||
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
ready_candidates = list(self.active_jobs.values())
|
||||||
|
for job in self.all_jobs.values():
|
||||||
|
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
||||||
|
ready_candidates.append(job)
|
||||||
|
|
||||||
|
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
||||||
if ready_jobs_count > 0:
|
if ready_jobs_count > 0:
|
||||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||||
|
|
||||||
# 找到所有超时的READY任务(只检测,不处理)
|
# 找到所有超时的READY任务(只检测,不处理)
|
||||||
for job_info in self.active_jobs.values():
|
for job_info in ready_candidates:
|
||||||
if job_info.is_ready_timeout():
|
if job_info.is_ready_timeout():
|
||||||
timeout_jobs.append(job_info)
|
timeout_jobs.append(job_info)
|
||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
@@ -438,7 +478,7 @@ class MessageProcessor:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动发送协程
|
# 启动发送协程
|
||||||
send_task = asyncio.create_task(self._send_handler())
|
send_task = asyncio.create_task(self._send_handler())
|
||||||
@@ -494,8 +534,12 @@ class MessageProcessor:
|
|||||||
await self._process_message(message_type, message_data)
|
await self._process_message(message_type, message_data)
|
||||||
else:
|
else:
|
||||||
if message_type.endswith("_material"):
|
if message_type.endswith("_material"):
|
||||||
logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}")
|
logger.trace(
|
||||||
logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}")
|
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await self._process_message(message_type, message_data)
|
await self._process_message(message_type, message_data)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -512,7 +556,7 @@ class MessageProcessor:
|
|||||||
|
|
||||||
async def _send_handler(self):
|
async def _send_handler(self):
|
||||||
"""处理发送队列中的消息"""
|
"""处理发送队列中的消息"""
|
||||||
logger.trace("[MessageProcessor] Send handler started")
|
logger.debug("[MessageProcessor] Send handler started")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.connected and self.websocket:
|
while self.connected and self.websocket:
|
||||||
@@ -540,7 +584,7 @@ class MessageProcessor:
|
|||||||
try:
|
try:
|
||||||
message_str = json.dumps(msg, ensure_ascii=False)
|
message_str = json.dumps(msg, ensure_ascii=False)
|
||||||
await self.websocket.send(message_str)
|
await self.websocket.send(message_str)
|
||||||
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -565,7 +609,7 @@ class MessageProcessor:
|
|||||||
|
|
||||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||||
"""处理收到的消息"""
|
"""处理收到的消息"""
|
||||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if message_type == "pong":
|
if message_type == "pong":
|
||||||
@@ -603,6 +647,24 @@ class MessageProcessor:
|
|||||||
if host_node:
|
if host_node:
|
||||||
host_node.handle_pong_response(pong_data)
|
host_node.handle_pong_response(pong_data)
|
||||||
|
|
||||||
|
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
||||||
|
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
||||||
|
try:
|
||||||
|
host_node = HostNode.get_instance(0)
|
||||||
|
if not host_node:
|
||||||
|
return False
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
action_mappings = host_node._action_value_mappings.get(device_id)
|
||||||
|
if not action_mappings:
|
||||||
|
return False
|
||||||
|
# 尝试直接匹配或 auto- 前缀匹配
|
||||||
|
for key in [action_name, f"auto-{action_name}"]:
|
||||||
|
if key in action_mappings:
|
||||||
|
return action_mappings[key].get("always_free", False)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||||
"""处理query_action_state消息"""
|
"""处理query_action_state消息"""
|
||||||
device_id = data.get("device_id", "")
|
device_id = data.get("device_id", "")
|
||||||
@@ -617,6 +679,9 @@ class MessageProcessor:
|
|||||||
|
|
||||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||||
|
|
||||||
|
# 检查action是否为always_free
|
||||||
|
action_always_free = self._check_action_always_free(device_id, action_name)
|
||||||
|
|
||||||
# 创建任务信息
|
# 创建任务信息
|
||||||
job_info = JobInfo(
|
job_info = JobInfo(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -626,6 +691,7 @@ class MessageProcessor:
|
|||||||
device_action_key=device_action_key,
|
device_action_key=device_action_key,
|
||||||
status=JobStatus.QUEUE,
|
status=JobStatus.QUEUE,
|
||||||
start_time=time.time(),
|
start_time=time.time(),
|
||||||
|
always_free=action_always_free,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加到设备管理器
|
# 添加到设备管理器
|
||||||
@@ -637,13 +703,13 @@ class MessageProcessor:
|
|||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||||
)
|
)
|
||||||
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||||
else:
|
else:
|
||||||
# 需要排队
|
# 需要排队
|
||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||||
)
|
)
|
||||||
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
||||||
|
|
||||||
# 通知QueueProcessor有新的队列更新
|
# 通知QueueProcessor有新的队列更新
|
||||||
if self.queue_processor:
|
if self.queue_processor:
|
||||||
@@ -652,6 +718,8 @@ class MessageProcessor:
|
|||||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||||
"""处理job_start消息"""
|
"""处理job_start消息"""
|
||||||
try:
|
try:
|
||||||
|
if not data.get("sample_material"):
|
||||||
|
data["sample_material"] = {}
|
||||||
req = JobAddReq(**data)
|
req = JobAddReq(**data)
|
||||||
|
|
||||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||||
@@ -683,6 +751,7 @@ class MessageProcessor:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=req.action_type,
|
action_type=req.action_type,
|
||||||
action_kwargs=req.action_args,
|
action_kwargs=req.action_args,
|
||||||
|
sample_material=req.sample_material,
|
||||||
server_info=req.server_info,
|
server_info=req.server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -847,9 +916,7 @@ class MessageProcessor:
|
|||||||
device_action_groups[key_add] = []
|
device_action_groups[key_add] = []
|
||||||
device_action_groups[key_add].append(item["uuid"])
|
device_action_groups[key_add].append(item["uuid"])
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
||||||
f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# 正常update
|
# 正常update
|
||||||
key = (device_id, "update")
|
key = (device_id, "update")
|
||||||
@@ -863,7 +930,9 @@ class MessageProcessor:
|
|||||||
device_action_groups[key] = []
|
device_action_groups[key] = []
|
||||||
device_action_groups[key].append(item["uuid"])
|
device_action_groups[key].append(item["uuid"])
|
||||||
|
|
||||||
logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
logger.trace(
|
||||||
|
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
||||||
|
)
|
||||||
|
|
||||||
# 为每个(device_id, action)创建独立的更新线程
|
# 为每个(device_id, action)创建独立的更新线程
|
||||||
for (device_id, actual_action), items in device_action_groups.items():
|
for (device_id, actual_action), items in device_action_groups.items():
|
||||||
@@ -902,42 +971,44 @@ class MessageProcessor:
|
|||||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
处理重启请求
|
处理重启请求
|
||||||
|
|
||||||
当LabGo发送request_restart时,执行清理并触发重启
|
当LabGo发送request_restart时,执行清理并触发重启
|
||||||
"""
|
"""
|
||||||
reason = data.get("reason", "unknown")
|
reason = data.get("reason", "unknown")
|
||||||
delay = data.get("delay", 2) # 默认延迟2秒
|
delay = data.get("delay", 2) # 默认延迟2秒
|
||||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||||
|
|
||||||
# 发送确认消息
|
# 发送确认消息
|
||||||
if self.websocket_client:
|
if self.websocket_client:
|
||||||
await self.websocket_client.send_message({
|
await self.websocket_client.send_message(
|
||||||
"action": "restart_acknowledged",
|
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||||
"data": {"reason": reason, "delay": delay}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
# 设置全局重启标志
|
# 设置全局重启标志
|
||||||
import unilabos.app.main as main_module
|
import unilabos.app.main as main_module
|
||||||
|
|
||||||
main_module._restart_requested = True
|
main_module._restart_requested = True
|
||||||
main_module._restart_reason = reason
|
main_module._restart_reason = reason
|
||||||
|
|
||||||
# 延迟后执行清理
|
# 延迟后执行清理
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
# 在新线程中执行清理,避免阻塞当前事件循环
|
# 在新线程中执行清理,避免阻塞当前事件循环
|
||||||
def do_cleanup():
|
def do_cleanup():
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(0.5) # 给当前消息处理完成的时间
|
time.sleep(0.5) # 给当前消息处理完成的时间
|
||||||
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
logger.info(f"[MessageProcessor] Starting cleanup for restart, reason: {reason}")
|
||||||
try:
|
try:
|
||||||
from unilabos.app.utils import cleanup_for_restart
|
from unilabos.app.utils import cleanup_for_restart
|
||||||
|
|
||||||
if cleanup_for_restart():
|
if cleanup_for_restart():
|
||||||
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
logger.info("[MessageProcessor] Cleanup successful, main() will restart")
|
||||||
else:
|
else:
|
||||||
logger.error("[MessageProcessor] Cleanup failed")
|
logger.error("[MessageProcessor] Cleanup failed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
logger.error(f"[MessageProcessor] Error during cleanup: {e}")
|
||||||
|
|
||||||
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
cleanup_thread = threading.Thread(target=do_cleanup, name="RestartCleanupThread", daemon=True)
|
||||||
cleanup_thread.start()
|
cleanup_thread.start()
|
||||||
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
logger.info(f"[MessageProcessor] Restart cleanup scheduled")
|
||||||
@@ -1019,7 +1090,7 @@ class QueueProcessor:
|
|||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行队列处理主循环"""
|
"""运行队列处理主循环"""
|
||||||
logger.trace("[QueueProcessor] Queue processor started")
|
logger.debug("[QueueProcessor] Queue processor started")
|
||||||
|
|
||||||
while self.is_running:
|
while self.is_running:
|
||||||
try:
|
try:
|
||||||
@@ -1113,6 +1184,11 @@ class QueueProcessor:
|
|||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||||
|
|
||||||
for job_info in queued_jobs:
|
for job_info in queued_jobs:
|
||||||
|
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||||
|
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
||||||
|
if job_info.status != JobStatus.QUEUE:
|
||||||
|
continue
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "report_action_state",
|
"action": "report_action_state",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1128,7 +1204,7 @@ class QueueProcessor:
|
|||||||
success = self.message_processor.send_message(message)
|
success = self.message_processor.send_message(message)
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
if success:
|
if success:
|
||||||
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||||
|
|
||||||
@@ -1151,7 +1227,7 @@ class QueueProcessor:
|
|||||||
job_info.action_name,
|
job_info.action_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||||
|
|
||||||
# 结束任务,获取下一个可执行的任务
|
# 结束任务,获取下一个可执行的任务
|
||||||
next_job = self.device_manager.end_job(job_id)
|
next_job = self.device_manager.end_job(job_id)
|
||||||
@@ -1171,8 +1247,8 @@ class QueueProcessor:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||||
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||||
|
|
||||||
# 立即触发下一轮状态检查
|
# 立即触发下一轮状态检查
|
||||||
self.notify_queue_update()
|
self.notify_queue_update()
|
||||||
@@ -1229,6 +1305,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
else:
|
else:
|
||||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||||
|
|
||||||
|
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -1241,11 +1318,13 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动两个核心线程
|
# 启动两个核心线程
|
||||||
self.message_processor.start()
|
self.message_processor.start()
|
||||||
self.queue_processor.start()
|
self.queue_processor.start()
|
||||||
|
|
||||||
logger.trace("[WebSocketClient] All threads started")
|
logger.info("[WebSocketClient] All threads started")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止WebSocket客户端"""
|
"""停止WebSocket客户端"""
|
||||||
@@ -1294,7 +1373,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||||
|
|
||||||
def publish_job_status(
|
def publish_job_status(
|
||||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||||
@@ -1314,7 +1393,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||||
|
|
||||||
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||||
|
|
||||||
# 通知队列处理器job完成(包括timeout的job)
|
# 通知队列处理器job完成(包括timeout的job)
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
@@ -1375,15 +1454,17 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
# 收集设备信息
|
# 收集设备信息
|
||||||
devices = []
|
devices = []
|
||||||
machine_name = BasicConfig.machine_name
|
machine_name = BasicConfig.machine_name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
host_node = HostNode.get_instance(0)
|
host_node = HostNode.get_instance(0)
|
||||||
if host_node:
|
if host_node:
|
||||||
# 获取设备信息
|
# 获取设备信息
|
||||||
for device_id, namespace in host_node.devices_names.items():
|
for device_id, namespace in host_node.devices_names.items():
|
||||||
device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
device_key = (
|
||||||
|
f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}"
|
||||||
|
)
|
||||||
is_online = device_key in host_node._online_devices
|
is_online = device_key in host_node._online_devices
|
||||||
|
|
||||||
# 获取设备的动作信息
|
# 获取设备的动作信息
|
||||||
actions = {}
|
actions = {}
|
||||||
for action_id, client in host_node._action_clients.items():
|
for action_id, client in host_node._action_clients.items():
|
||||||
@@ -1394,16 +1475,18 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
"action_path": action_id,
|
"action_path": action_id,
|
||||||
"action_type": str(type(client).__name__),
|
"action_type": str(type(client).__name__),
|
||||||
}
|
}
|
||||||
|
|
||||||
devices.append({
|
devices.append(
|
||||||
"device_id": device_id,
|
{
|
||||||
"namespace": namespace,
|
"device_id": device_id,
|
||||||
"device_key": device_key,
|
"namespace": namespace,
|
||||||
"is_online": is_online,
|
"device_key": device_key,
|
||||||
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
"is_online": is_online,
|
||||||
"actions": actions,
|
"machine_name": host_node.device_machine_names.get(device_id, machine_name),
|
||||||
})
|
"actions": actions,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
|
logger.warning(f"[WebSocketClient] Error collecting device info: {e}")
|
||||||
|
|||||||
@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
|||||||
return total_volume
|
return total_volume
|
||||||
|
|
||||||
|
|
||||||
def is_integrated_pump(node_name):
|
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||||
return "pump" in node_name and "valve" in node_name
|
"""
|
||||||
|
判断是否为泵阀一体设备
|
||||||
|
"""
|
||||||
|
class_lower = (node_class or "").lower()
|
||||||
|
name_lower = (node_name or "").lower()
|
||||||
|
|
||||||
|
if "pump" not in class_lower and "pump" not in name_lower:
|
||||||
|
return False
|
||||||
|
|
||||||
|
integrated_markers = [
|
||||||
|
"valve",
|
||||||
|
"pump_valve",
|
||||||
|
"pumpvalve",
|
||||||
|
"integrated",
|
||||||
|
"transfer_pump",
|
||||||
|
]
|
||||||
|
|
||||||
|
for marker in integrated_markers:
|
||||||
|
if marker in class_lower or marker in name_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def find_connected_pump(G, valve_node):
|
def find_connected_pump(G, valve_node):
|
||||||
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
|
|||||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||||
|
|
||||||
for node in filtered_backbone:
|
for node in filtered_backbone:
|
||||||
if is_integrated_pump(G.nodes[node]["class"]):
|
node_data = G.nodes.get(node, {})
|
||||||
|
node_class = node_data.get("class", "") or ""
|
||||||
|
if is_integrated_pump(node_class, node):
|
||||||
pumps_from_node[node] = node
|
pumps_from_node[node] = node
|
||||||
valve_from_node[node] = node
|
valve_from_node[node] = node
|
||||||
debug_print(f" - 集成泵-阀: {node}")
|
debug_print(f" - 集成泵-阀: {node}")
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class BasicConfig:
|
|||||||
startup_json_path = None # 填写绝对路径
|
startup_json_path = None # 填写绝对路径
|
||||||
disable_browser = False # 禁止浏览器自动打开
|
disable_browser = False # 禁止浏览器自动打开
|
||||||
port = 8002 # 本地HTTP服务
|
port = 8002 # 本地HTTP服务
|
||||||
|
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||||
|
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||||
|
|
||||||
@@ -144,5 +146,5 @@ def load_config(config_path=None):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
||||||
load_config(config_path)
|
load_config(config_path)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
|||||||
self._type = typ
|
self._type = typ
|
||||||
self._data_type = data_type
|
self._data_type = data_type
|
||||||
self._node: Optional[Node] = None
|
self._node: Optional[Node] = None
|
||||||
|
|
||||||
def _get_node(self) -> Node:
|
def _get_node(self) -> Node:
|
||||||
if self._node is None:
|
if self._node is None:
|
||||||
try:
|
try:
|
||||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
|||||||
# 直接以字符串形式处理
|
# 直接以字符串形式处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||||
# 提取括号内的内容
|
# 提取括号内的内容
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
|||||||
def read(self) -> Tuple[Any, bool]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""读取节点值,返回(值, 是否出错)"""
|
"""读取节点值,返回(值, 是否出错)"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""写入节点值,返回是否出错"""
|
"""写入节点值,返回是否出错"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> NodeType:
|
def type(self) -> NodeType:
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def node_id(self) -> str:
|
def node_id(self) -> str:
|
||||||
return self._node_id
|
return self._node_id
|
||||||
@@ -210,15 +210,15 @@ class Method(Base):
|
|||||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||||
self._parent_node_id = parent_node_id
|
self._parent_node_id = parent_node_id
|
||||||
self._parent_node = None
|
self._parent_node = None
|
||||||
|
|
||||||
def _get_parent_node(self) -> Node:
|
def _get_parent_node(self) -> Node:
|
||||||
if self._parent_node is None:
|
if self._parent_node is None:
|
||||||
try:
|
try:
|
||||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||||
import re
|
import re
|
||||||
|
|
||||||
nid = self._parent_node_id
|
nid = self._parent_node_id
|
||||||
|
|
||||||
# 如果已经是 NodeId 对象,直接使用
|
# 如果已经是 NodeId 对象,直接使用
|
||||||
try:
|
try:
|
||||||
from opcua.ua import NodeId as UaNodeId
|
from opcua.ua import NodeId as UaNodeId
|
||||||
@@ -227,16 +227,16 @@ class Method(Base):
|
|||||||
return self._parent_node
|
return self._parent_node
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 字符串处理
|
# 字符串处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式
|
# 处理包含类名的格式
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||||
if match_wrapped:
|
if match_wrapped:
|
||||||
nid = match_wrapped.group(2).strip()
|
nid = match_wrapped.group(2).strip()
|
||||||
|
|
||||||
# 常见短格式
|
# 常见短格式
|
||||||
if re.match(r'^ns=\d+;[is]=', nid):
|
if re.match(r'^ns=\d+;[is]=', nid):
|
||||||
self._parent_node = self._client.get_node(nid)
|
self._parent_node = self._client.get_node(nid)
|
||||||
@@ -271,7 +271,7 @@ class Method(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""方法节点不支持写入操作"""
|
"""方法节点不支持写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def call(self, *args) -> Tuple[Any, bool]:
|
def call(self, *args) -> Tuple[Any, bool]:
|
||||||
"""调用方法,返回(返回值, 是否出错)"""
|
"""调用方法,返回(返回值, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -285,7 +285,7 @@ class Method(Base):
|
|||||||
class Object(Base):
|
class Object(Base):
|
||||||
def __init__(self, client: Client, name: str, node_id: str):
|
def __init__(self, client: Client, name: str, node_id: str):
|
||||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||||
|
|
||||||
def read(self) -> Tuple[Any, bool]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""对象节点不支持直接读取操作"""
|
"""对象节点不支持直接读取操作"""
|
||||||
return None, True
|
return None, True
|
||||||
@@ -293,7 +293,7 @@ class Object(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""对象节点不支持直接写入操作"""
|
"""对象节点不支持直接写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_children(self) -> Tuple[List[Node], bool]:
|
def get_children(self) -> Tuple[List[Node], bool]:
|
||||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -301,4 +301,4 @@ class Object(Base):
|
|||||||
return children, False
|
return children, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||||
return [], True
|
return [], True
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,11 @@ from rclpy.node import Node
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
||||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
|
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
|
|||||||
@@ -15,35 +15,35 @@ class VirtualPumpMode(Enum):
|
|||||||
|
|
||||||
class VirtualTransferPump:
|
class VirtualTransferPump:
|
||||||
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
|
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
|
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
|
||||||
"""
|
"""
|
||||||
初始化虚拟转移泵
|
初始化虚拟转移泵
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_id: 设备ID
|
device_id: 设备ID
|
||||||
config: 配置字典,包含max_volume, port等参数
|
config: 配置字典,包含max_volume, port等参数
|
||||||
**kwargs: 其他参数,确保兼容性
|
**kwargs: 其他参数,确保兼容性
|
||||||
"""
|
"""
|
||||||
self.device_id = device_id or "virtual_transfer_pump"
|
self.device_id = device_id or "virtual_transfer_pump"
|
||||||
|
|
||||||
# 从config或kwargs中获取参数,确保类型正确
|
# 从config或kwargs中获取参数,确保类型正确
|
||||||
if config:
|
if config:
|
||||||
self.max_volume = float(config.get('max_volume', 25.0))
|
self.max_volume = float(config.get("max_volume", 25.0))
|
||||||
self.port = config.get('port', 'VIRTUAL')
|
self.port = config.get("port", "VIRTUAL")
|
||||||
else:
|
else:
|
||||||
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
||||||
self.port = kwargs.get('port', 'VIRTUAL')
|
self.port = kwargs.get("port", "VIRTUAL")
|
||||||
|
|
||||||
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
||||||
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
||||||
|
|
||||||
# 状态变量 - 确保都是正确类型
|
# 状态变量 - 确保都是正确类型
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._position = 0.0 # float
|
self._position = 0.0 # float
|
||||||
self._max_velocity = 5.0 # float
|
self._max_velocity = 5.0 # float
|
||||||
self._current_volume = 0.0 # float
|
self._current_volume = 0.0 # float
|
||||||
|
|
||||||
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
||||||
@@ -52,14 +52,16 @@ class VirtualTransferPump:
|
|||||||
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
|
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
|
||||||
|
|
||||||
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
||||||
|
|
||||||
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
||||||
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
|
print(
|
||||||
|
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
|
||||||
|
)
|
||||||
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
||||||
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""初始化虚拟泵 🚀"""
|
"""初始化虚拟泵 🚀"""
|
||||||
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨")
|
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨")
|
||||||
@@ -68,33 +70,33 @@ class VirtualTransferPump:
|
|||||||
self._current_volume = 0.0
|
self._current_volume = 0.0
|
||||||
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
|
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def cleanup(self) -> bool:
|
async def cleanup(self) -> bool:
|
||||||
"""清理虚拟泵 🧹"""
|
"""清理虚拟泵 🧹"""
|
||||||
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
|
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
|
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 基本属性
|
# 基本属性
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
"""当前柱塞位置 (ml) 📍"""
|
"""当前柱塞位置 (ml) 📍"""
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_volume(self) -> float:
|
def current_volume(self) -> float:
|
||||||
"""当前注射器中的体积 (ml) 💧"""
|
"""当前注射器中的体积 (ml) 💧"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_velocity(self) -> float:
|
def max_velocity(self) -> float:
|
||||||
return self._max_velocity
|
return self._max_velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def transfer_rate(self) -> float:
|
def transfer_rate(self) -> float:
|
||||||
return self._transfer_rate
|
return self._transfer_rate
|
||||||
@@ -103,17 +105,17 @@ class VirtualTransferPump:
|
|||||||
"""设置最大速度 (ml/s) 🌊"""
|
"""设置最大速度 (ml/s) 🌊"""
|
||||||
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
|
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
|
||||||
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
|
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
|
||||||
|
|
||||||
def get_status(self) -> str:
|
def get_status(self) -> str:
|
||||||
"""获取泵状态 📋"""
|
"""获取泵状态 📋"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
async def _simulate_operation(self, duration: float):
|
async def _simulate_operation(self, duration: float):
|
||||||
"""模拟操作延时 ⏱️"""
|
"""模拟操作延时 ⏱️"""
|
||||||
self._status = "Busy"
|
self._status = "Busy"
|
||||||
await self._ros_node.sleep(duration)
|
await self._ros_node.sleep(duration)
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
|
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
|
||||||
"""
|
"""
|
||||||
计算操作持续时间 ⏰
|
计算操作持续时间 ⏰
|
||||||
@@ -121,10 +123,10 @@ class VirtualTransferPump:
|
|||||||
"""
|
"""
|
||||||
if velocity is None:
|
if velocity is None:
|
||||||
velocity = self._max_velocity
|
velocity = self._max_velocity
|
||||||
|
|
||||||
# 📊 计算理论时间(用于日志显示)
|
# 📊 计算理论时间(用于日志显示)
|
||||||
theoretical_duration = abs(volume) / velocity
|
theoretical_duration = abs(volume) / velocity
|
||||||
|
|
||||||
# 🚀 如果启用快速模式,使用固定的快速时间
|
# 🚀 如果启用快速模式,使用固定的快速时间
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
# 根据操作类型选择快速时间
|
# 根据操作类型选择快速时间
|
||||||
@@ -132,13 +134,13 @@ class VirtualTransferPump:
|
|||||||
actual_duration = self._fast_move_time
|
actual_duration = self._fast_move_time
|
||||||
else: # 很小的操作
|
else: # 很小的操作
|
||||||
actual_duration = 0.5
|
actual_duration = 0.5
|
||||||
|
|
||||||
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
|
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
|
||||||
return actual_duration
|
return actual_duration
|
||||||
else:
|
else:
|
||||||
# 正常模式使用理论时间
|
# 正常模式使用理论时间
|
||||||
return theoretical_duration
|
return theoretical_duration
|
||||||
|
|
||||||
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
|
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
|
||||||
"""
|
"""
|
||||||
计算显示用的持续时间(用于日志) 📊
|
计算显示用的持续时间(用于日志) 📊
|
||||||
@@ -147,16 +149,16 @@ class VirtualTransferPump:
|
|||||||
if velocity is None:
|
if velocity is None:
|
||||||
velocity = self._max_velocity
|
velocity = self._max_velocity
|
||||||
return abs(volume) / velocity
|
return abs(volume) / velocity
|
||||||
|
|
||||||
# 新的set_position方法 - 专门用于SetPumpPosition动作
|
# 新的set_position方法 - 专门用于SetPumpPosition动作
|
||||||
async def set_position(self, position: float, max_velocity: float = None):
|
async def set_position(self, position: float, max_velocity: float = None):
|
||||||
"""
|
"""
|
||||||
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
|
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
position (float): 目标位置 (ml)
|
position (float): 目标位置 (ml)
|
||||||
max_velocity (float): 移动速度 (ml/s)
|
max_velocity (float): 移动速度 (ml/s)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: 符合SetPumpPosition.action定义的结果
|
dict: 符合SetPumpPosition.action定义的结果
|
||||||
"""
|
"""
|
||||||
@@ -164,19 +166,19 @@ class VirtualTransferPump:
|
|||||||
# 验证并转换参数
|
# 验证并转换参数
|
||||||
target_position = float(position)
|
target_position = float(position)
|
||||||
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
|
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
|
||||||
|
|
||||||
# 限制位置在有效范围内
|
# 限制位置在有效范围内
|
||||||
target_position = max(0.0, min(float(self.max_volume), target_position))
|
target_position = max(0.0, min(float(self.max_volume), target_position))
|
||||||
|
|
||||||
# 计算移动距离
|
# 计算移动距离
|
||||||
volume_to_move = abs(target_position - self._position)
|
volume_to_move = abs(target_position - self._position)
|
||||||
|
|
||||||
# 📊 计算显示用的时间(用于日志)
|
# 📊 计算显示用的时间(用于日志)
|
||||||
display_duration = self._calculate_display_duration(volume_to_move, velocity)
|
display_duration = self._calculate_display_duration(volume_to_move, velocity)
|
||||||
|
|
||||||
# ⚡ 计算实际执行时间(快速模式)
|
# ⚡ 计算实际执行时间(快速模式)
|
||||||
actual_duration = self._calculate_duration(volume_to_move, velocity)
|
actual_duration = self._calculate_duration(volume_to_move, velocity)
|
||||||
|
|
||||||
# 🎯 确定操作类型和emoji
|
# 🎯 确定操作类型和emoji
|
||||||
if target_position > self._position:
|
if target_position > self._position:
|
||||||
operation_type = "吸液"
|
operation_type = "吸液"
|
||||||
@@ -187,28 +189,34 @@ class VirtualTransferPump:
|
|||||||
else:
|
else:
|
||||||
operation_type = "保持"
|
operation_type = "保持"
|
||||||
operation_emoji = "📍"
|
operation_emoji = "📍"
|
||||||
|
|
||||||
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
|
self.logger.info(
|
||||||
|
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
|
||||||
|
)
|
||||||
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
# 🚀 模拟移动过程
|
# 🚀 模拟移动过程
|
||||||
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
|
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
|
||||||
start_position = self._position
|
start_position = self._position
|
||||||
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
|
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
|
||||||
step_duration = actual_duration / steps
|
step_duration = actual_duration / steps
|
||||||
|
|
||||||
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
|
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
|
||||||
|
|
||||||
for i in range(steps + 1):
|
for i in range(steps + 1):
|
||||||
# 计算当前位置和进度
|
# 计算当前位置和进度
|
||||||
progress = (i / steps) * 100 if steps > 0 else 100
|
progress = (i / steps) * 100 if steps > 0 else 100
|
||||||
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
|
current_pos = (
|
||||||
|
start_position + (target_position - start_position) * (i / steps)
|
||||||
|
if steps > 0
|
||||||
|
else target_position
|
||||||
|
)
|
||||||
|
|
||||||
# 更新状态
|
# 更新状态
|
||||||
if i < steps:
|
if i < steps:
|
||||||
self._status = f"{operation_type}中"
|
self._status = f"{operation_type}中"
|
||||||
@@ -216,10 +224,10 @@ class VirtualTransferPump:
|
|||||||
else:
|
else:
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
status_emoji = "✅"
|
status_emoji = "✅"
|
||||||
|
|
||||||
self._position = current_pos
|
self._position = current_pos
|
||||||
self._current_volume = current_pos
|
self._current_volume = current_pos
|
||||||
|
|
||||||
# 显示进度(每25%或最后一步)
|
# 显示进度(每25%或最后一步)
|
||||||
if i == 0:
|
if i == 0:
|
||||||
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
|
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
|
||||||
@@ -227,7 +235,7 @@ class VirtualTransferPump:
|
|||||||
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
|
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
|
||||||
elif i == steps:
|
elif i == steps:
|
||||||
self.logger.info(f" ✅ {operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
|
self.logger.info(f" ✅ {operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
|
||||||
|
|
||||||
# 等待一小步时间
|
# 等待一小步时间
|
||||||
if i < steps and step_duration > 0:
|
if i < steps and step_duration > 0:
|
||||||
await self._ros_node.sleep(step_duration)
|
await self._ros_node.sleep(step_duration)
|
||||||
@@ -236,25 +244,27 @@ class VirtualTransferPump:
|
|||||||
self._position = target_position
|
self._position = target_position
|
||||||
self._current_volume = target_position
|
self._current_volume = target_position
|
||||||
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
|
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
|
||||||
|
|
||||||
# 确保最终位置准确
|
# 确保最终位置准确
|
||||||
self._position = target_position
|
self._position = target_position
|
||||||
self._current_volume = target_position
|
self._current_volume = target_position
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
# 📊 最终状态日志
|
# 📊 最终状态日志
|
||||||
if volume_to_move > 0.01:
|
if volume_to_move > 0.01:
|
||||||
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(
|
||||||
|
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
|
||||||
|
)
|
||||||
|
|
||||||
# 返回符合action定义的结果
|
# 返回符合action定义的结果
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume,
|
"final_volume": self._current_volume,
|
||||||
"operation_type": operation_type
|
"operation_type": operation_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"❌ 设置位置失败: {str(e)}"
|
error_msg = f"❌ 设置位置失败: {str(e)}"
|
||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
@@ -262,134 +272,136 @@ class VirtualTransferPump:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": error_msg,
|
"message": error_msg,
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume
|
"final_volume": self._current_volume,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 其他泵操作方法
|
# 其他泵操作方法
|
||||||
async def pull_plunger(self, volume: float, velocity: float = None):
|
async def pull_plunger(self, volume: float, velocity: float = None):
|
||||||
"""
|
"""
|
||||||
拉取柱塞(吸液) 📥
|
拉取柱塞(吸液) 📥
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
volume (float): 要拉取的体积 (ml)
|
volume (float): 要拉取的体积 (ml)
|
||||||
velocity (float): 拉取速度 (ml/s)
|
velocity (float): 拉取速度 (ml/s)
|
||||||
"""
|
"""
|
||||||
new_position = min(self.max_volume, self._position + volume)
|
new_position = min(self.max_volume, self._position + volume)
|
||||||
actual_volume = new_position - self._position
|
actual_volume = new_position - self._position
|
||||||
|
|
||||||
if actual_volume <= 0:
|
if actual_volume <= 0:
|
||||||
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
|
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||||
|
|
||||||
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
|
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
await self._simulate_operation(actual_duration)
|
await self._simulate_operation(actual_duration)
|
||||||
|
|
||||||
self._position = new_position
|
self._position = new_position
|
||||||
self._current_volume = new_position
|
self._current_volume = new_position
|
||||||
|
|
||||||
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||||
|
|
||||||
async def push_plunger(self, volume: float, velocity: float = None):
|
async def push_plunger(self, volume: float, velocity: float = None):
|
||||||
"""
|
"""
|
||||||
推出柱塞(排液) 📤
|
推出柱塞(排液) 📤
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
volume (float): 要推出的体积 (ml)
|
volume (float): 要推出的体积 (ml)
|
||||||
velocity (float): 推出速度 (ml/s)
|
velocity (float): 推出速度 (ml/s)
|
||||||
"""
|
"""
|
||||||
new_position = max(0, self._position - volume)
|
new_position = max(0, self._position - volume)
|
||||||
actual_volume = self._position - new_position
|
actual_volume = self._position - new_position
|
||||||
|
|
||||||
if actual_volume <= 0:
|
if actual_volume <= 0:
|
||||||
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
|
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
|
||||||
return
|
return
|
||||||
|
|
||||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||||
|
|
||||||
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
|
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
if self._fast_mode:
|
if self._fast_mode:
|
||||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||||
|
|
||||||
await self._simulate_operation(actual_duration)
|
await self._simulate_operation(actual_duration)
|
||||||
|
|
||||||
self._position = new_position
|
self._position = new_position
|
||||||
self._current_volume = new_position
|
self._current_volume = new_position
|
||||||
|
|
||||||
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||||
|
|
||||||
# 便捷操作方法
|
# 便捷操作方法
|
||||||
async def aspirate(self, volume: float, velocity: float = None):
|
async def aspirate(self, volume: float, velocity: float = None):
|
||||||
"""吸液操作 📥"""
|
"""吸液操作 📥"""
|
||||||
await self.pull_plunger(volume, velocity)
|
await self.pull_plunger(volume, velocity)
|
||||||
|
|
||||||
async def dispense(self, volume: float, velocity: float = None):
|
async def dispense(self, volume: float, velocity: float = None):
|
||||||
"""排液操作 📤"""
|
"""排液操作 📤"""
|
||||||
await self.push_plunger(volume, velocity)
|
await self.push_plunger(volume, velocity)
|
||||||
|
|
||||||
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
|
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
|
||||||
"""转移操作(先吸后排) 🔄"""
|
"""转移操作(先吸后排) 🔄"""
|
||||||
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
|
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
|
||||||
|
|
||||||
# 吸液
|
# 吸液
|
||||||
await self.aspirate(volume, aspirate_velocity)
|
await self.aspirate(volume, aspirate_velocity)
|
||||||
|
|
||||||
# 短暂停顿
|
# 短暂停顿
|
||||||
self.logger.debug("⏸️ 短暂停顿...")
|
self.logger.debug("⏸️ 短暂停顿...")
|
||||||
await self._ros_node.sleep(0.1)
|
await self._ros_node.sleep(0.1)
|
||||||
|
|
||||||
# 排液
|
# 排液
|
||||||
await self.dispense(volume, dispense_velocity)
|
await self.dispense(volume, dispense_velocity)
|
||||||
|
|
||||||
async def empty_syringe(self, velocity: float = None):
|
async def empty_syringe(self, velocity: float = None):
|
||||||
"""清空注射器"""
|
"""清空注射器"""
|
||||||
await self.set_position(0, velocity)
|
await self.set_position(0, velocity)
|
||||||
|
|
||||||
async def fill_syringe(self, velocity: float = None):
|
async def fill_syringe(self, velocity: float = None):
|
||||||
"""充满注射器"""
|
"""充满注射器"""
|
||||||
await self.set_position(self.max_volume, velocity)
|
await self.set_position(self.max_volume, velocity)
|
||||||
|
|
||||||
async def stop_operation(self):
|
async def stop_operation(self):
|
||||||
"""停止当前操作"""
|
"""停止当前操作"""
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self.logger.info("Operation stopped")
|
self.logger.info("Operation stopped")
|
||||||
|
|
||||||
# 状态查询方法
|
# 状态查询方法
|
||||||
def get_position(self) -> float:
|
def get_position(self) -> float:
|
||||||
"""获取当前位置"""
|
"""获取当前位置"""
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
def get_current_volume(self) -> float:
|
def get_current_volume(self) -> float:
|
||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
def get_remaining_capacity(self) -> float:
|
||||||
"""获取剩余容量"""
|
"""获取剩余容量"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
"""检查是否为空"""
|
"""检查是否为空"""
|
||||||
return self._current_volume <= 0.01 # 允许小量误差
|
return self._current_volume <= 0.01 # 允许小量误差
|
||||||
|
|
||||||
def is_full(self) -> bool:
|
def is_full(self) -> bool:
|
||||||
"""检查是否已满"""
|
"""检查是否已满"""
|
||||||
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
return (
|
||||||
|
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
@@ -398,20 +410,20 @@ class VirtualTransferPump:
|
|||||||
async def demo():
|
async def demo():
|
||||||
"""虚拟泵使用示例"""
|
"""虚拟泵使用示例"""
|
||||||
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
|
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
|
||||||
|
|
||||||
await pump.initialize()
|
await pump.initialize()
|
||||||
|
|
||||||
print(f"Initial state: {pump}")
|
print(f"Initial state: {pump}")
|
||||||
|
|
||||||
# 测试set_position方法
|
# 测试set_position方法
|
||||||
result = await pump.set_position(10.0, max_velocity=2.0)
|
result = await pump.set_position(10.0, max_velocity=2.0)
|
||||||
print(f"Set position result: {result}")
|
print(f"Set position result: {result}")
|
||||||
print(f"After setting position to 10ml: {pump}")
|
print(f"After setting position to 10ml: {pump}")
|
||||||
|
|
||||||
# 吸液测试
|
# 吸液测试
|
||||||
await pump.aspirate(5.0, velocity=2.0)
|
await pump.aspirate(5.0, velocity=2.0)
|
||||||
print(f"After aspirating 5ml: {pump}")
|
print(f"After aspirating 5ml: {pump}")
|
||||||
|
|
||||||
# 清空测试
|
# 清空测试
|
||||||
result = await pump.set_position(0.0)
|
result = await pump.set_position(0.0)
|
||||||
print(f"Empty result: {result}")
|
print(f"Empty result: {result}")
|
||||||
|
|||||||
759
unilabos/devices/virtual/workbench.py
Normal file
759
unilabos/devices/virtual/workbench.py
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
"""
|
||||||
|
Virtual Workbench Device - 模拟工作台设备
|
||||||
|
包含:
|
||||||
|
- 1个机械臂 (每次操作3s, 独占锁)
|
||||||
|
- 3个加热台 (每次加热10s, 可并行)
|
||||||
|
|
||||||
|
工作流程:
|
||||||
|
1. A1-A5 物料同时启动,竞争机械臂
|
||||||
|
2. 机械臂将物料移动到空闲加热台
|
||||||
|
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||||
|
|
||||||
|
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from threading import Lock, RLock
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
from unilabos.utils.decorator import not_action, always_free
|
||||||
|
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||||
|
|
||||||
|
|
||||||
|
# ============ TypedDict 返回类型定义 ============
|
||||||
|
|
||||||
|
|
||||||
|
class MoveToHeatingStationResult(TypedDict):
|
||||||
|
"""move_to_heating_station 返回类型"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
station_id: int
|
||||||
|
material_id: str
|
||||||
|
material_number: int
|
||||||
|
message: str
|
||||||
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
|
class StartHeatingResult(TypedDict):
|
||||||
|
"""start_heating 返回类型"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
station_id: int
|
||||||
|
material_id: str
|
||||||
|
material_number: int
|
||||||
|
message: str
|
||||||
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
|
class MoveToOutputResult(TypedDict):
|
||||||
|
"""move_to_output 返回类型"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
station_id: int
|
||||||
|
material_id: str
|
||||||
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
|
class PrepareMaterialsResult(TypedDict):
|
||||||
|
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
count: int
|
||||||
|
material_1: int # 物料编号1
|
||||||
|
material_2: int # 物料编号2
|
||||||
|
material_3: int # 物料编号3
|
||||||
|
material_4: int # 物料编号4
|
||||||
|
material_5: int # 物料编号5
|
||||||
|
message: str
|
||||||
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 状态枚举 ============
|
||||||
|
|
||||||
|
|
||||||
|
class HeatingStationState(Enum):
|
||||||
|
"""加热台状态枚举"""
|
||||||
|
|
||||||
|
IDLE = "idle" # 空闲
|
||||||
|
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||||
|
HEATING = "heating" # 加热中
|
||||||
|
COMPLETED = "completed" # 加热完成,等待取走
|
||||||
|
|
||||||
|
|
||||||
|
class ArmState(Enum):
|
||||||
|
"""机械臂状态枚举"""
|
||||||
|
|
||||||
|
IDLE = "idle" # 空闲
|
||||||
|
BUSY = "busy" # 工作中
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeatingStation:
|
||||||
|
"""加热台数据结构"""
|
||||||
|
|
||||||
|
station_id: int
|
||||||
|
state: HeatingStationState = HeatingStationState.IDLE
|
||||||
|
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||||
|
material_number: Optional[int] = None # 物料编号 (1-5)
|
||||||
|
heating_start_time: Optional[float] = None
|
||||||
|
heating_progress: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualWorkbench:
|
||||||
|
"""
|
||||||
|
Virtual Workbench Device - 虚拟工作台设备
|
||||||
|
|
||||||
|
模拟一个包含1个机械臂和3个加热台的工作站
|
||||||
|
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||||
|
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||||
|
|
||||||
|
工作流:
|
||||||
|
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||||
|
2. 获取机械臂后,查找空闲加热台
|
||||||
|
3. 机械臂将物料放入加热台,开始加热
|
||||||
|
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
# 配置常量
|
||||||
|
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
|
||||||
|
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||||
|
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||||
|
|
||||||
|
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
# 处理可能的不同调用方式
|
||||||
|
if device_id is None and "id" in kwargs:
|
||||||
|
device_id = kwargs.pop("id")
|
||||||
|
if config is None and "config" in kwargs:
|
||||||
|
config = kwargs.pop("config")
|
||||||
|
|
||||||
|
self.device_id = device_id or "virtual_workbench"
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}")
|
||||||
|
self.data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# 从config中获取可配置参数
|
||||||
|
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.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||||
|
|
||||||
|
# 机械臂状态和锁 (使用threading.Lock)
|
||||||
|
self._arm_lock = Lock()
|
||||||
|
self._arm_state = ArmState.IDLE
|
||||||
|
self._arm_current_task: Optional[str] = None
|
||||||
|
|
||||||
|
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||||
|
self._heating_stations: Dict[int, HeatingStation] = {
|
||||||
|
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||||
|
}
|
||||||
|
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||||
|
|
||||||
|
# 任务追踪
|
||||||
|
self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info
|
||||||
|
self._tasks_lock = Lock()
|
||||||
|
|
||||||
|
# 处理其他kwargs参数
|
||||||
|
skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if key not in skip_keys and not hasattr(self, key):
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===")
|
||||||
|
self.logger.info(
|
||||||
|
f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | "
|
||||||
|
f"加热时间: {self.HEATING_TIME}s | "
|
||||||
|
f"加热台数量: {self.NUM_HEATING_STATIONS}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@not_action
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
"""ROS节点初始化后回调"""
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
@not_action
|
||||||
|
def initialize(self) -> bool:
|
||||||
|
"""初始化虚拟工作台"""
|
||||||
|
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||||
|
|
||||||
|
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||||
|
with self._stations_lock:
|
||||||
|
for station in self._heating_stations.values():
|
||||||
|
station.state = HeatingStationState.IDLE
|
||||||
|
station.current_material = None
|
||||||
|
station.material_number = None
|
||||||
|
station.heating_progress = 0.0
|
||||||
|
|
||||||
|
# 初始化状态
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"status": "Ready",
|
||||||
|
"arm_state": ArmState.IDLE.value,
|
||||||
|
"arm_current_task": None,
|
||||||
|
"heating_stations": self._get_stations_status(),
|
||||||
|
"active_tasks_count": 0,
|
||||||
|
"message": "工作台就绪",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@not_action
|
||||||
|
def cleanup(self) -> bool:
|
||||||
|
"""清理虚拟工作台"""
|
||||||
|
self.logger.info(f"清理虚拟工作台 {self.device_id}")
|
||||||
|
|
||||||
|
self._arm_state = ArmState.IDLE
|
||||||
|
self._arm_current_task = None
|
||||||
|
|
||||||
|
with self._stations_lock:
|
||||||
|
self._heating_stations.clear()
|
||||||
|
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks.clear()
|
||||||
|
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"status": "Offline",
|
||||||
|
"arm_state": ArmState.IDLE.value,
|
||||||
|
"heating_stations": {},
|
||||||
|
"message": "工作台已关闭",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""获取所有加热台状态"""
|
||||||
|
with self._stations_lock:
|
||||||
|
return {
|
||||||
|
station_id: {
|
||||||
|
"state": station.state.value,
|
||||||
|
"current_material": station.current_material,
|
||||||
|
"material_number": station.material_number,
|
||||||
|
"heating_progress": station.heating_progress,
|
||||||
|
}
|
||||||
|
for station_id, station in self._heating_stations.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_data_status(self, message: Optional[str] = None):
|
||||||
|
"""更新状态数据"""
|
||||||
|
self.data.update(
|
||||||
|
{
|
||||||
|
"arm_state": self._arm_state.value,
|
||||||
|
"arm_current_task": self._arm_current_task,
|
||||||
|
"heating_stations": self._get_stations_status(),
|
||||||
|
"active_tasks_count": len(self._active_tasks),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if message:
|
||||||
|
self.data["message"] = message
|
||||||
|
|
||||||
|
def _find_available_heating_station(self) -> Optional[int]:
|
||||||
|
"""查找空闲的加热台
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
空闲加热台ID,如果没有则返回None
|
||||||
|
"""
|
||||||
|
with self._stations_lock:
|
||||||
|
for station_id, station in self._heating_stations.items():
|
||||||
|
if station.state == HeatingStationState.IDLE:
|
||||||
|
return station_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _acquire_arm(self, task_description: str) -> bool:
|
||||||
|
"""获取机械臂使用权(阻塞直到获取)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_description: 任务描述,用于日志
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功获取
|
||||||
|
"""
|
||||||
|
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||||
|
|
||||||
|
# 阻塞等待获取锁
|
||||||
|
self._arm_lock.acquire()
|
||||||
|
|
||||||
|
self._arm_state = ArmState.BUSY
|
||||||
|
self._arm_current_task = task_description
|
||||||
|
self._update_data_status(f"机械臂执行: {task_description}")
|
||||||
|
|
||||||
|
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _release_arm(self):
|
||||||
|
"""释放机械臂"""
|
||||||
|
task = self._arm_current_task
|
||||||
|
self._arm_state = ArmState.IDLE
|
||||||
|
self._arm_current_task = None
|
||||||
|
self._arm_lock.release()
|
||||||
|
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||||
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
|
def prepare_materials(
|
||||||
|
self,
|
||||||
|
sample_uuids: SampleUUIDsType,
|
||||||
|
count: int = 5,
|
||||||
|
) -> PrepareMaterialsResult:
|
||||||
|
"""
|
||||||
|
批量准备物料 - 虚拟起始节点
|
||||||
|
|
||||||
|
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||||
|
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||||
|
"""
|
||||||
|
# 生成物料列表 A1 - A{count}
|
||||||
|
materials = [i for i in range(1, count + 1)]
|
||||||
|
|
||||||
|
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": count,
|
||||||
|
"material_1": materials[0] if len(materials) > 0 else 0,
|
||||||
|
"material_2": materials[1] if len(materials) > 1 else 0,
|
||||||
|
"material_3": materials[2] if len(materials) > 2 else 0,
|
||||||
|
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||||
|
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||||
|
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||||
|
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
def move_to_heating_station(
|
||||||
|
self,
|
||||||
|
sample_uuids: SampleUUIDsType,
|
||||||
|
material_number: int,
|
||||||
|
) -> MoveToHeatingStationResult:
|
||||||
|
"""
|
||||||
|
将物料从An位置移动到加热台
|
||||||
|
|
||||||
|
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_number: 物料编号 (1-5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||||
|
"""
|
||||||
|
# 根据物料编号生成物料ID
|
||||||
|
material_id = f"A{material_number}"
|
||||||
|
task_desc = f"移动{material_id}到加热台"
|
||||||
|
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||||
|
|
||||||
|
# 记录任务
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks[material_id] = {
|
||||||
|
"status": "waiting_for_arm",
|
||||||
|
"start_time": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||||
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
|
# 步骤2: 查找空闲加热台
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks[material_id]["status"] = "finding_station"
|
||||||
|
station_id = None
|
||||||
|
|
||||||
|
# 循环等待直到找到空闲加热台
|
||||||
|
while station_id is None:
|
||||||
|
station_id = self._find_available_heating_station()
|
||||||
|
if station_id is None:
|
||||||
|
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||||
|
# 释放机械臂,等待后重试
|
||||||
|
self._release_arm()
|
||||||
|
time.sleep(0.5)
|
||||||
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
|
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||||
|
with self._stations_lock:
|
||||||
|
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||||
|
self._heating_stations[station_id].current_material = material_id
|
||||||
|
self._heating_stations[station_id].material_number = material_number
|
||||||
|
|
||||||
|
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||||
|
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||||
|
self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...")
|
||||||
|
|
||||||
|
time.sleep(self.ARM_OPERATION_TIME)
|
||||||
|
|
||||||
|
# 步骤5: 放入加热台完成
|
||||||
|
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||||
|
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||||
|
|
||||||
|
# 释放机械臂
|
||||||
|
self._release_arm()
|
||||||
|
|
||||||
|
with self._tasks_lock:
|
||||||
|
self._active_tasks[material_id]["status"] = "placed_on_station"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"[{material_id}] 移动失败: {str(e)}")
|
||||||
|
if self._arm_lock.locked():
|
||||||
|
self._release_arm()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": -1,
|
||||||
|
"material_id": material_id,
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"移动失败: {str(e)}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
@always_free
|
||||||
|
def start_heating(
|
||||||
|
self,
|
||||||
|
sample_uuids: SampleUUIDsType,
|
||||||
|
station_id: int,
|
||||||
|
material_number: int,
|
||||||
|
) -> StartHeatingResult:
|
||||||
|
"""
|
||||||
|
启动指定加热台的加热程序
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入
|
||||||
|
material_number: 物料编号,从 move_to_heating_station 的 handle 传入
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||||
|
"""
|
||||||
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
|
if station_id not in self._heating_stations:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": "",
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"无效的加热台ID: {station_id}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations[station_id]
|
||||||
|
|
||||||
|
if station.current_material is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": "",
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"加热台{station_id}上没有物料",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
if station.state == HeatingStationState.HEATING:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": station.current_material,
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"加热台{station_id}已经在加热中",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
material_id = station.current_material
|
||||||
|
|
||||||
|
# 开始加热
|
||||||
|
station.state = HeatingStationState.HEATING
|
||||||
|
station.heating_start_time = time.time()
|
||||||
|
station.heating_progress = 0.0
|
||||||
|
|
||||||
|
with self._tasks_lock:
|
||||||
|
if material_id in self._active_tasks:
|
||||||
|
self._active_tasks[material_id]["status"] = "heating"
|
||||||
|
|
||||||
|
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||||
|
|
||||||
|
# 打印当前所有正在加热的台位
|
||||||
|
with self._stations_lock:
|
||||||
|
heating_list = [
|
||||||
|
f"加热台{sid}:{s.current_material}"
|
||||||
|
for sid, s in self._heating_stations.items()
|
||||||
|
if s.state == HeatingStationState.HEATING and s.current_material
|
||||||
|
]
|
||||||
|
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||||
|
|
||||||
|
# 模拟加热过程
|
||||||
|
start_time = time.time()
|
||||||
|
last_countdown_log = start_time
|
||||||
|
while True:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
remaining = max(0.0, self.HEATING_TIME - elapsed)
|
||||||
|
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
||||||
|
|
||||||
|
with self._stations_lock:
|
||||||
|
self._heating_stations[station_id].heating_progress = progress
|
||||||
|
|
||||||
|
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||||
|
|
||||||
|
# 每5秒打印一次倒计时
|
||||||
|
if time.time() - last_countdown_log >= 5.0:
|
||||||
|
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||||
|
last_countdown_log = time.time()
|
||||||
|
|
||||||
|
if elapsed >= self.HEATING_TIME:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 加热完成
|
||||||
|
with self._stations_lock:
|
||||||
|
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||||
|
self._heating_stations[station_id].heating_progress = 100.0
|
||||||
|
|
||||||
|
with self._tasks_lock:
|
||||||
|
if material_id in self._active_tasks:
|
||||||
|
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||||
|
|
||||||
|
self._update_data_status(f"加热台{station_id}加热完成")
|
||||||
|
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"material_number": material_number,
|
||||||
|
"message": f"加热台{station_id}加热完成",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
def move_to_output(
|
||||||
|
self,
|
||||||
|
sample_uuids: SampleUUIDsType,
|
||||||
|
station_id: int,
|
||||||
|
material_number: int,
|
||||||
|
) -> MoveToOutputResult:
|
||||||
|
"""
|
||||||
|
将物料从加热台移动到输出位置Cn
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id: 加热台ID (1-3),从 start_heating 的 handle 传入
|
||||||
|
material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MoveToOutputResult: 包含执行结果
|
||||||
|
"""
|
||||||
|
output_number = material_number # 物料编号决定输出位置
|
||||||
|
|
||||||
|
if station_id not in self._heating_stations:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": "",
|
||||||
|
"output_position": f"C{output_number}",
|
||||||
|
"message": f"无效的加热台ID: {station_id}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations[station_id]
|
||||||
|
material_id = station.current_material
|
||||||
|
|
||||||
|
if material_id is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": "",
|
||||||
|
"output_position": f"C{output_number}",
|
||||||
|
"message": f"加热台{station_id}上没有物料",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
if station.state != HeatingStationState.COMPLETED:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"output_position": f"C{output_number}",
|
||||||
|
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
output_position = f"C{output_number}"
|
||||||
|
task_desc = f"从加热台{station_id}移动{material_id}到{output_position}"
|
||||||
|
self.logger.info(f"[任务] {task_desc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._tasks_lock:
|
||||||
|
if material_id in self._active_tasks:
|
||||||
|
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||||
|
|
||||||
|
# 获取机械臂
|
||||||
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
|
with self._tasks_lock:
|
||||||
|
if material_id in self._active_tasks:
|
||||||
|
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||||
|
|
||||||
|
# 模拟机械臂操作 (3秒)
|
||||||
|
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||||
|
time.sleep(self.ARM_OPERATION_TIME)
|
||||||
|
|
||||||
|
# 清空加热台
|
||||||
|
with self._stations_lock:
|
||||||
|
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||||
|
self._heating_stations[station_id].current_material = None
|
||||||
|
self._heating_stations[station_id].material_number = None
|
||||||
|
self._heating_stations[station_id].heating_progress = 0.0
|
||||||
|
self._heating_stations[station_id].heating_start_time = None
|
||||||
|
|
||||||
|
# 释放机械臂
|
||||||
|
self._release_arm()
|
||||||
|
|
||||||
|
# 任务完成
|
||||||
|
with self._tasks_lock:
|
||||||
|
if material_id in self._active_tasks:
|
||||||
|
self._active_tasks[material_id]["status"] = "completed"
|
||||||
|
self._active_tasks[material_id]["end_time"] = time.time()
|
||||||
|
|
||||||
|
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||||
|
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"output_position": output_position,
|
||||||
|
"message": f"{material_id}已成功移动到{output_position}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"移动到输出位置失败: {str(e)}")
|
||||||
|
if self._arm_lock.locked():
|
||||||
|
self._release_arm()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"station_id": station_id,
|
||||||
|
"material_id": "",
|
||||||
|
"output_position": output_position,
|
||||||
|
"message": f"移动失败: {str(e)}",
|
||||||
|
"unilabos_samples": [
|
||||||
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
|
sample_uuid, content in sample_uuids.items()]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============ 状态属性 ============
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "Unknown")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arm_state(self) -> str:
|
||||||
|
return self._arm_state.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arm_current_task(self) -> str:
|
||||||
|
return self._arm_current_task or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_1_state(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(1)
|
||||||
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_1_material(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(1)
|
||||||
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_1_progress(self) -> float:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(1)
|
||||||
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_2_state(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(2)
|
||||||
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_2_material(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(2)
|
||||||
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_2_progress(self) -> float:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(2)
|
||||||
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_3_state(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(3)
|
||||||
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_3_material(self) -> str:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(3)
|
||||||
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def heating_station_3_progress(self) -> float:
|
||||||
|
with self._stations_lock:
|
||||||
|
station = self._heating_stations.get(3)
|
||||||
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_tasks_count(self) -> int:
|
||||||
|
with self._tasks_lock:
|
||||||
|
return len(self._active_tasks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message(self) -> str:
|
||||||
|
return self.data.get("message", "")
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
# 工作站抽象基类物料系统架构说明
|
# 工作站抽象基类物料系统架构说明
|
||||||
|
|
||||||
## 设计理念
|
|
||||||
|
|
||||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
|
||||||
|
|
||||||
### 核心原则
|
### 核心原则
|
||||||
|
|
||||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Bioyond Cell 工作站 - 多订单返回示例
|
||||||
|
|
||||||
|
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
修改后的 `create_orders` 函数现在会:
|
||||||
|
|
||||||
|
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||||
|
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||||
|
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||||
|
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||||
|
|
||||||
|
## 返回格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "all_completed",
|
||||||
|
"total_orders": 3,
|
||||||
|
"reports": [
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||||
|
"orderCode": "BSO2025122400024",
|
||||||
|
"orderName": "DP20251224001",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||||
|
"orderCode": "BSO2025122400025",
|
||||||
|
"orderName": "DP20251224002",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||||
|
"orderCode": "BSO2025122400026",
|
||||||
|
"orderName": "DP20251224003",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"original_response": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 调用 create_orders
|
||||||
|
result = workstation.create_orders("20251224.xlsx")
|
||||||
|
|
||||||
|
# 访问返回数据
|
||||||
|
print(f"总订单数: {result['total_orders']}")
|
||||||
|
print(f"状态: {result['status']}")
|
||||||
|
|
||||||
|
# 遍历所有订单的报文
|
||||||
|
for i, report in enumerate(result['reports'], 1):
|
||||||
|
order_data = report.get('data', {})
|
||||||
|
print(f"\n订单 {i}:")
|
||||||
|
print(f" orderCode: {order_data.get('orderCode')}")
|
||||||
|
print(f" orderName: {order_data.get('orderName')}")
|
||||||
|
print(f" status: {order_data.get('status')}")
|
||||||
|
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 控制台输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[create_orders] 即将提交订单数量: 3
|
||||||
|
[create_orders] 接口返回: {...}
|
||||||
|
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||||
|
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||||
|
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||||
|
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||||
|
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||||
|
实验记录本========================create_orders========================
|
||||||
|
返回报文数量: 3
|
||||||
|
报文 1: orderCode=BSO2025122400024, status=30
|
||||||
|
报文 2: orderCode=BSO2025122400025, status=30
|
||||||
|
报文 3: orderCode=BSO2025122400026, status=30
|
||||||
|
========================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键改进
|
||||||
|
|
||||||
|
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||||
|
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||||
|
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||||
|
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||||
|
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||||
Binary file not shown.
@@ -0,0 +1,204 @@
|
|||||||
|
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题背景
|
||||||
|
|
||||||
|
原系统通过 `config.py` 管理配置,导致:
|
||||||
|
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||||
|
2. 配置分散在代码中,不便于管理
|
||||||
|
3. 无法通过 JSON 统一配置所有参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解决方案:嵌套配置结构
|
||||||
|
|
||||||
|
### JSON 结构设计
|
||||||
|
|
||||||
|
**正确示例** (嵌套在 `config` 中):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"timeout": 30,
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"debug_mode": false,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||||
|
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 代码适配
|
||||||
|
|
||||||
|
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||||
|
|
||||||
|
**文件**: `bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
bioyond_config: 从 JSON 加载的配置字典
|
||||||
|
deck: Deck 配置
|
||||||
|
protocol_type: 协议类型
|
||||||
|
"""
|
||||||
|
# 验证配置
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
# 保存配置
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# 调用父类
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 替换全局变量引用
|
||||||
|
|
||||||
|
**修改前**(使用全局变量):
|
||||||
|
```python
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||||
|
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**(从配置读取):
|
||||||
|
```python
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 修复父类配置访问
|
||||||
|
|
||||||
|
在 `station.py` 中安全访问配置默认值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前(会 KeyError)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||||
|
}
|
||||||
|
|
||||||
|
# 修改后(安全访问)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
### ❌ 错误1:将配置放在 `data` 字段
|
||||||
|
```json
|
||||||
|
"config": {"deck": {...}},
|
||||||
|
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||||
|
虽然扁平化也能工作,但不推荐:
|
||||||
|
```json
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"api_host": "...", // ❌ 不够清晰
|
||||||
|
"api_key": "...",
|
||||||
|
"HTTP_host": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误3:忘记替换全局变量引用
|
||||||
|
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 云端同步注意事项
|
||||||
|
|
||||||
|
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||||
|
- 首次上传时确保 JSON 完整
|
||||||
|
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||||
|
- 调试时可暂时移除 `--upload_registry` 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
启动成功后应看到:
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://...
|
||||||
|
HTTP Service: ...
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with ... nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时不应出现:
|
||||||
|
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||||
|
- ❌ `KeyError: 'http_service_host'`
|
||||||
|
- ❌ `bioyond_config 缺少必需参数`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试经验
|
||||||
|
|
||||||
|
1. **添加调试日志**查看参数传递链路:
|
||||||
|
- `graphio.py`: JSON 加载后的 config 内容
|
||||||
|
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||||
|
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||||
|
|
||||||
|
2. **config vs data 区别**:
|
||||||
|
- `config`: 初始化参数,传递给 `__init__`
|
||||||
|
- `data`: 运行时状态,不传递给 `__init__`
|
||||||
|
|
||||||
|
3. **参数名必须匹配**:
|
||||||
|
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||||
|
|
||||||
|
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||||
|
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||||
|
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考代码位置
|
||||||
|
|
||||||
|
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||||
|
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||||
|
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||||
|
- HTTP 服务配置: `station.py` L629-L634
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# BioyondCell 配置迁移修改总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 修改概览
|
||||||
|
|
||||||
|
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||||
|
|
||||||
|
### 核心成果
|
||||||
|
|
||||||
|
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||||
|
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||||
|
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||||
|
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||||
|
- ✅ HTTP 服务去重机制完善
|
||||||
|
- ✅ 系统成功启动并正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 修改文件清单
|
||||||
|
|
||||||
|
### 1. JSON 配置文件
|
||||||
|
|
||||||
|
**文件**: `yibin_electrolyte_config.json`
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||||
|
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||||
|
|
||||||
|
**示例结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. bioyond_cell_workstation.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||||
|
# 从 kwargs 收集配置字段
|
||||||
|
self.bioyond_config = {}
|
||||||
|
for field in bioyond_field_names:
|
||||||
|
if field in kwargs:
|
||||||
|
self.bioyond_config[field] = kwargs.pop(field)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""直接接收 bioyond_config 参数"""
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: 替换全局变量引用 (7 处)
|
||||||
|
|
||||||
|
| 位置 | 原代码 | 修改后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||||
|
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||||
|
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||||
|
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||||
|
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||||
|
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||||
|
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. station.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L26-28)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||||
|
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
default_config = {
|
||||||
|
**API_CONFIG,
|
||||||
|
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||||
|
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||||
|
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||||
|
}
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = {**default_config, **config}
|
||||||
|
else:
|
||||||
|
self.bioyond_config = default_config
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""创建Bioyond通信模块"""
|
||||||
|
# 使用传入的 config 参数(来自 bioyond_config)
|
||||||
|
# 不再依赖全局变量 API_CONFIG 等
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = config
|
||||||
|
else:
|
||||||
|
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
|
||||||
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. bioyond_rpc.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L12)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||||
|
location_id = location_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键设计决策
|
||||||
|
|
||||||
|
### 1. 嵌套 vs 扁平配置
|
||||||
|
|
||||||
|
**选择**: 嵌套结构 `config.bioyond_config`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- ✅ 语义清晰,配置分组明确
|
||||||
|
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||||
|
- ✅ 易于维护,不需要硬编码字段列表
|
||||||
|
- ✅ 符合 UniLab 设计模式
|
||||||
|
|
||||||
|
### 2. HTTP 服务去重
|
||||||
|
|
||||||
|
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bioyond_cell_workstation.py
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# station.py (post_init)
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 全局变量替换策略
|
||||||
|
|
||||||
|
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||||
|
|
||||||
|
**模式**:
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证结果
|
||||||
|
|
||||||
|
### 启动成功日志
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://172.16.11.219:44388
|
||||||
|
HTTP Service: 172.16.11.206:8080
|
||||||
|
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
- ✅ 订单创建 (`create_orders_v2`)
|
||||||
|
- ✅ 质量比计算
|
||||||
|
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||||
|
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||||
|
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||||
|
- ✅ 仓库 UUID 映射
|
||||||
|
- ✅ 物料类型映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||||
|
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||||
|
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
### 其他工作站模块
|
||||||
|
|
||||||
|
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||||
|
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||||
|
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||||
|
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||||
|
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||||
|
|
||||||
|
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||||
|
|
||||||
|
### config.py 文件
|
||||||
|
|
||||||
|
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||||
|
- 保留作为其他模块的参考
|
||||||
|
- 或者完全删除(如果其他模块也迁移完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步建议
|
||||||
|
|
||||||
|
1. **清理调试代码** ✅ (已完成)
|
||||||
|
2. **提交代码到 Git**
|
||||||
|
3. **迁移其他工作站模块** (可选)
|
||||||
|
4. **更新文档和启动脚本**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修改完成日期**: 2026-01-13
|
||||||
|
**系统状态**: ✅ 稳定运行
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,157 @@
|
|||||||
|
# 批量出库 Excel 模板使用说明
|
||||||
|
|
||||||
|
**文件**: `outbound_template.xlsx`
|
||||||
|
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||||
|
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Excel 列说明
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 | 必填 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||||
|
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||||
|
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||||
|
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||||
|
|
||||||
|
### 📐 坐标说明
|
||||||
|
|
||||||
|
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||||
|
|
||||||
|
```
|
||||||
|
仓库(例如 WH4)
|
||||||
|
├── Z=1(第1层/加样头面)
|
||||||
|
│ ├── X=1, Y=1(位置 A)
|
||||||
|
│ ├── X=2, Y=1(位置 B)
|
||||||
|
│ ├── X=3, Y=1(位置 C)
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── Z=2(第2层/原液瓶面)
|
||||||
|
├── X=1, Y=1(位置 A)
|
||||||
|
├── X=2, Y=1(位置 B)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||||
|
- **x, y, z**: 在该仓库内的三维坐标
|
||||||
|
- **locationId**: 该坐标位置的唯一 UUID
|
||||||
|
|
||||||
|
### 🎯 起点与终点
|
||||||
|
|
||||||
|
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||||
|
|
||||||
|
```
|
||||||
|
出库流程:
|
||||||
|
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||||
|
↓
|
||||||
|
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||||
|
```
|
||||||
|
|
||||||
|
**终点由以下方式确定:**
|
||||||
|
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||||
|
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||||
|
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||||
|
|
||||||
|
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 如何获取 UUID?
|
||||||
|
|
||||||
|
### 方法 1:从配置文件获取
|
||||||
|
|
||||||
|
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"warehouse_mapping": {
|
||||||
|
"配液站内试剂仓库": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||||
|
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||||
|
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"手动堆栈": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||||
|
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2:通过 API 查询
|
||||||
|
|
||||||
|
```python
|
||||||
|
material_info = hardware_interface.material_id_query(workflow_id)
|
||||||
|
locations = material_info.get("locations", [])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 填写示例
|
||||||
|
|
||||||
|
### 示例 1:从配液站内试剂仓库出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||||
|
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||||
|
|
||||||
|
### 示例 2:从手动堆栈出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||||
|
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 使用方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||||
|
|
||||||
|
# 初始化工作站
|
||||||
|
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||||
|
|
||||||
|
# 调用批量出库方法
|
||||||
|
result = workstation.auto_batch_outbound_from_xlsx(
|
||||||
|
xlsx_path="outbound_template.xlsx"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||||
|
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||||
|
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||||
|
4. Excel 文件必须包含表头行
|
||||||
|
5. 空行会被自动跳过
|
||||||
|
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **配置文件**: `yibin_electrolyte_config.json`
|
||||||
|
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||||
|
- **生成脚本**: `create_outbound_template.py`
|
||||||
|
- **上料模板**: `material_template.xlsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 重新生成模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate newunilab
|
||||||
|
python create_outbound_template.py
|
||||||
|
```
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from unilabos.device_comms.rpc import BaseRequest
|
from unilabos.device_comms.rpc import BaseRequest
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
import json
|
import json
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleLogger:
|
class SimpleLogger:
|
||||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.api_key = config["api_key"]
|
self.api_key = config["api_key"]
|
||||||
self.host = config["api_host"]
|
self.host = config["api_host"]
|
||||||
|
|
||||||
|
# 初始化 location_mapping
|
||||||
|
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||||
|
self.location_mapping = {}
|
||||||
|
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||||
|
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||||
self._logger = SimpleLogger()
|
self._logger = SimpleLogger()
|
||||||
self.material_cache = {}
|
self.material_cache = {}
|
||||||
self._load_material_cache()
|
self._load_material_cache()
|
||||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
print(f"add material data: {response['data']}")
|
print(f"add material data: {response['data']}")
|
||||||
return response.get("data", {})
|
|
||||||
|
# 自动更新缓存
|
||||||
|
data = response.get("data", {})
|
||||||
|
if data:
|
||||||
|
if isinstance(data, str):
|
||||||
|
# 如果返回的是字符串,通常是ID
|
||||||
|
mat_id = data
|
||||||
|
name = params.get("name")
|
||||||
|
else:
|
||||||
|
# 如果返回的是字典,尝试获取name和id
|
||||||
|
name = data.get("name") or params.get("name")
|
||||||
|
mat_id = data.get("id")
|
||||||
|
|
||||||
|
if name and mat_id:
|
||||||
|
self.material_cache[name] = mat_id
|
||||||
|
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||||
|
|
||||||
|
# 处理返回数据中的 details (如果有)
|
||||||
|
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||||
|
details = data.get("details", []) if isinstance(data, dict) else []
|
||||||
|
if not details and isinstance(data, dict):
|
||||||
|
details = data.get("detail", [])
|
||||||
|
|
||||||
|
if details:
|
||||||
|
for detail in details:
|
||||||
|
d_name = detail.get("name")
|
||||||
|
# 尝试从不同字段获取 ID
|
||||||
|
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||||
|
|
||||||
|
if d_name and d_id:
|
||||||
|
self.material_cache[d_name] = d_id
|
||||||
|
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
def query_matial_type_id(self, data) -> list:
|
def query_matial_type_id(self, data) -> list:
|
||||||
"""查找物料typeid"""
|
"""查找物料typeid"""
|
||||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
params={
|
params={
|
||||||
"apiKey": self.api_key,
|
"apiKey": self.api_key,
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
"requestTime": self.get_current_time_iso8601(),
|
||||||
"data": {},
|
"data": 0,
|
||||||
})
|
})
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return []
|
return []
|
||||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
|
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# 自动更新缓存 - 移除被删除的物料
|
||||||
|
for name, mid in list(self.material_cache.items()):
|
||||||
|
if mid == material_id:
|
||||||
|
del self.material_cache[name]
|
||||||
|
print(f"已从缓存移除物料: {name}")
|
||||||
|
break
|
||||||
|
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
location_id = self.location_mapping.get(location_name, location_name)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"materialId": material_id,
|
"materialId": material_id,
|
||||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
for detail_material in detail_materials:
|
for detail_material in detail_materials:
|
||||||
detail_name = detail_material.get("name")
|
detail_name = detail_material.get("name")
|
||||||
detail_id = detail_material.get("detailMaterialId")
|
detail_id = detail_material.get("detailMaterialId")
|
||||||
|
if not detail_id:
|
||||||
|
# 尝试其他可能的字段
|
||||||
|
detail_id = detail_material.get("id")
|
||||||
|
|
||||||
if detail_name and detail_id:
|
if detail_name and detail_id:
|
||||||
self.material_cache[detail_name] = detail_id
|
self.material_cache[detail_name] = detail_id
|
||||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
return material_id
|
return material_id
|
||||||
|
|
||||||
|
# 如果缓存中没有,尝试刷新缓存
|
||||||
|
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||||
|
self.refresh_material_cache()
|
||||||
|
if material_name_or_id in self.material_cache:
|
||||||
|
material_id = self.material_cache[material_name_or_id]
|
||||||
|
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
|
return material_id
|
||||||
|
|
||||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||||
return material_name_or_id
|
return material_name_or_id
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# config.py
|
|
||||||
"""
|
|
||||||
配置文件 - 包含所有配置信息和映射关系
|
|
||||||
"""
|
|
||||||
|
|
||||||
# API配置
|
|
||||||
API_CONFIG = {
|
|
||||||
"api_key": "",
|
|
||||||
"api_host": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流映射配置
|
|
||||||
WORKFLOW_MAPPINGS = {
|
|
||||||
"reactor_taken_out": "",
|
|
||||||
"reactor_taken_in": "",
|
|
||||||
"Solid_feeding_vials": "",
|
|
||||||
"Liquid_feeding_vials(non-titration)": "",
|
|
||||||
"Liquid_feeding_solvents": "",
|
|
||||||
"Liquid_feeding(titration)": "",
|
|
||||||
"liquid_feeding_beaker": "",
|
|
||||||
"Drip_back": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流名称到DisplaySectionName的映射
|
|
||||||
WORKFLOW_TO_SECTION_MAP = {
|
|
||||||
'reactor_taken_in': '反应器放入',
|
|
||||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
|
||||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
|
||||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
|
||||||
'Solid_feeding_vials': '固体投料-小瓶',
|
|
||||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
|
||||||
'reactor_taken_out': '反应器取出'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 库位映射配置
|
|
||||||
WAREHOUSE_MAPPING = {
|
|
||||||
"粉末堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
# 样品板
|
|
||||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
|
||||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
|
||||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
|
||||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
|
||||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
|
||||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
|
||||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
|
||||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
|
||||||
# 分装板
|
|
||||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
|
||||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
|
||||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
|
||||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
|
||||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
|
||||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
|
||||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
|
||||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"溶液堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
|
||||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
|
||||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
|
||||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
|
||||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
|
||||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
|
||||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
|
||||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
|
||||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
|
||||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
|
||||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
|
||||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
|
||||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
|
||||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
|
||||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
|
||||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"试剂堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
|
||||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
|
||||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
|
||||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
|
||||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
|
||||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
|
||||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
|
||||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 物料类型配置
|
|
||||||
MATERIAL_TYPE_MAPPINGS = {
|
|
||||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
|
||||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
|
||||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
|
||||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
|
||||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
|
||||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
|
||||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 步骤参数配置(各工作流的步骤UUID)
|
|
||||||
WORKFLOW_STEP_IDS = {
|
|
||||||
"reactor_taken_in": {
|
|
||||||
"config": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_beaker": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_vials_non_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_solvents": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"solid_feeding_vials": {
|
|
||||||
"feeding": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"drip_back": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOCATION_MAPPING = {}
|
|
||||||
|
|
||||||
ACTION_NAMES = {}
|
|
||||||
|
|
||||||
HTTP_SERVICE_CONFIG = {}
|
|
||||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# config.py
|
||||||
|
"""
|
||||||
|
Bioyond工作站配置文件
|
||||||
|
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 基础配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
API_CONFIG = {
|
||||||
|
"api_key": "DE9BDDA0",
|
||||||
|
"api_host": "http://192.168.1.200:44402"
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP 报送服务配置
|
||||||
|
HTTP_SERVICE_CONFIG = {
|
||||||
|
"http_service_host": "127.0.0.1", # 监听地址
|
||||||
|
"http_service_port": 8080, # 监听端口
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deck配置 - 反应站工作台配置
|
||||||
|
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 工作流配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 工作流ID映射
|
||||||
|
WORKFLOW_MAPPINGS = {
|
||||||
|
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||||
|
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||||
|
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||||
|
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||||
|
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||||
|
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||||
|
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||||
|
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流名称到显示名称的映射
|
||||||
|
WORKFLOW_TO_SECTION_MAP = {
|
||||||
|
'reactor_taken_in': '反应器放入',
|
||||||
|
'reactor_taken_out': '反应器取出',
|
||||||
|
'Solid_feeding_vials': '固体投料-小瓶',
|
||||||
|
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||||
|
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||||
|
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||||
|
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||||
|
'Drip_back': '液体回滴'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流步骤ID配置
|
||||||
|
WORKFLOW_STEP_IDS = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||||
|
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||||
|
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||||
|
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||||
|
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||||
|
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||||
|
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流动作名称配置
|
||||||
|
ACTION_NAMES = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "通量-配置",
|
||||||
|
"stirring": "反应模块-开始搅拌"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "粉末加样模块-投料",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-液体投料",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-向下滴定结果观察"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 仓库配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 出库和入库操作都需要UUID
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
# ========== 反应站仓库 ==========
|
||||||
|
|
||||||
|
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||||
|
"堆栈1左": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||||
|
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||||
|
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||||
|
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||||
|
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||||
|
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||||
|
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||||
|
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||||
|
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||||
|
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||||
|
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||||
|
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||||
|
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||||
|
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||||
|
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||||
|
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||||
|
"堆栈1右": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||||
|
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||||
|
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||||
|
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||||
|
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||||
|
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||||
|
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||||
|
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||||
|
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||||
|
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||||
|
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||||
|
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||||
|
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||||
|
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||||
|
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||||
|
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内试剂存放堆栈
|
||||||
|
"站内试剂存放堆栈": {
|
||||||
|
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||||
|
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
# 测量小瓶仓库(测密度)
|
||||||
|
"测量小瓶仓库": {
|
||||||
|
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||||
|
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||||
|
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||||
|
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||||
|
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||||
|
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||||
|
"站内Tip盒堆栈": {
|
||||||
|
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||||
|
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||||
|
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||||
|
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||||
|
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||||
|
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# ========== 配液站仓库 ==========
|
||||||
|
"粉末堆栈": {
|
||||||
|
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||||
|
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||||
|
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||||
|
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||||
|
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||||
|
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||||
|
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||||
|
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||||
|
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||||
|
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||||
|
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||||
|
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||||
|
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||||
|
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||||
|
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||||
|
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"溶液堆栈": {
|
||||||
|
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||||
|
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||||
|
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||||
|
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||||
|
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||||
|
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||||
|
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||||
|
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||||
|
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||||
|
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||||
|
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||||
|
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||||
|
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||||
|
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||||
|
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||||
|
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"试剂堆栈": {
|
||||||
|
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||||
|
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||||
|
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||||
|
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||||
|
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||||
|
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||||
|
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||||
|
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||||
|
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||||
|
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||||
|
MATERIAL_TYPE_MAPPINGS = {
|
||||||
|
# ================================================配液站资源============================================================
|
||||||
|
# ==================================================样品===============================================================
|
||||||
|
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||||
|
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||||
|
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||||
|
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||||
|
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||||
|
# ==================================================试剂===============================================================
|
||||||
|
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||||
|
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
LOCATION_MAPPING = {}
|
||||||
|
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||||
|
# - 格式: 物料名称 → {参数字典}
|
||||||
|
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||||
|
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||||
|
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||||
|
|
||||||
|
MATERIAL_DEFAULT_PARAMETERS = {
|
||||||
|
# 溶剂类
|
||||||
|
"NMP": {
|
||||||
|
"unit": "毫升",
|
||||||
|
"density": "1.03",
|
||||||
|
"densityUnit": "g/mL",
|
||||||
|
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||||
|
},
|
||||||
|
# 可以继续添加其他物料...
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料类型(UUID)自动添加默认参数
|
||||||
|
# - 格式: Bioyond类型UUID → {参数字典}
|
||||||
|
# - 优先级低于按名称匹配的配置
|
||||||
|
MATERIAL_TYPE_PARAMETERS = {
|
||||||
|
# 示例:
|
||||||
|
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||||
|
# "unit": "个"
|
||||||
|
# }
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
import requests
|
import requests
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
import pint
|
||||||
|
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
|||||||
class BioyondDispensingStation(BioyondWorkstation):
|
class BioyondDispensingStation(BioyondWorkstation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config,
|
config: dict = None,
|
||||||
# 桌子
|
deck=None,
|
||||||
deck,
|
protocol_type=None,
|
||||||
*args,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(config, deck, *args, **kwargs)
|
"""初始化配液站
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 配置字典,应包含material_type_mappings等配置
|
||||||
|
deck: Deck对象
|
||||||
|
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||||
|
**kwargs: 其他可能的参数
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||||
|
config.update(kwargs)
|
||||||
|
|
||||||
|
if deck is None and config:
|
||||||
|
deck = config.get('deck')
|
||||||
|
|
||||||
|
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||||
|
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||||
|
if config and "warehouse_mapping" in config and deck:
|
||||||
|
warehouse_mapping = config["warehouse_mapping"]
|
||||||
|
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||||
|
|
||||||
|
user_deck = deck
|
||||||
|
# 初始化 warehouses 字典
|
||||||
|
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||||
|
user_deck.warehouses = {}
|
||||||
|
|
||||||
|
# 1. 尝试从 children 中查找匹配的资源
|
||||||
|
for child in user_deck.children:
|
||||||
|
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||||
|
if child.name in warehouse_mapping:
|
||||||
|
user_deck.warehouses[child.name] = child
|
||||||
|
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||||
|
|
||||||
|
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||||
|
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||||
|
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||||
|
try:
|
||||||
|
user_deck.setup()
|
||||||
|
# setup 后重新检查
|
||||||
|
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||||
|
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" - 调用 setup() 失败: {e}")
|
||||||
|
|
||||||
|
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||||
|
if not user_deck.warehouses:
|
||||||
|
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||||
|
|
||||||
|
for wh_name, wh_config in warehouse_mapping.items():
|
||||||
|
target_uuid = wh_config.get("uuid")
|
||||||
|
|
||||||
|
# 尝试在 deck.warehouses 中查找
|
||||||
|
wh_resource = None
|
||||||
|
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||||
|
wh_resource = user_deck.warehouses[wh_name]
|
||||||
|
|
||||||
|
# 如果没找到,尝试在所有子资源中查找
|
||||||
|
if not wh_resource:
|
||||||
|
wh_resource = user_deck.get_resource(wh_name)
|
||||||
|
|
||||||
|
if wh_resource:
|
||||||
|
if target_uuid:
|
||||||
|
current_uuid = getattr(wh_resource, "uuid", None)
|
||||||
|
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||||
|
|
||||||
|
# 动态添加 uuid 属性
|
||||||
|
wh_resource.uuid = target_uuid
|
||||||
|
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||||
|
# wh_resource.category = "warehouse"
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||||
|
else:
|
||||||
|
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=config, deck=deck)
|
||||||
|
|
||||||
# self.config = config
|
# self.config = config
|
||||||
# self.api_key = config["api_key"]
|
# self.api_key = config["api_key"]
|
||||||
# self.host = config["api_host"]
|
# self.host = config["api_host"]
|
||||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||||
self.order_completion_status = {}
|
self.order_completion_status = {}
|
||||||
|
|
||||||
|
# 初始化 pint 单位注册表
|
||||||
|
self.ureg = pint.UnitRegistry()
|
||||||
|
|
||||||
|
# 化合物信息
|
||||||
|
self.compound_info = {
|
||||||
|
"MolWt": {
|
||||||
|
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||||
|
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||||
|
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||||
|
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||||
|
},
|
||||||
|
"FuncGroup": {
|
||||||
|
"MDA": "Amine",
|
||||||
|
"TDA": "Amine",
|
||||||
|
"PAPP": "Amine",
|
||||||
|
"BTDA": "Anhydride",
|
||||||
|
"BPDA": "Anhydride",
|
||||||
|
"6FAP": "Amine",
|
||||||
|
"MPDA": "Amine",
|
||||||
|
"SIDA": "Amine",
|
||||||
|
"PMDA": "Anhydride",
|
||||||
|
"ODA": "Amine",
|
||||||
|
"4,4'-ODA": "Amine",
|
||||||
|
"134": "Amine",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||||
"""项目接口通用POST调用
|
"""项目接口通用POST调用
|
||||||
|
|
||||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
ratio = json.loads(ratio)
|
ratio = json.loads(ratio)
|
||||||
except Exception:
|
except Exception:
|
||||||
ratio = {}
|
ratio = {}
|
||||||
root = str(Path(__file__).resolve().parents[3])
|
|
||||||
if root not in sys.path:
|
|
||||||
sys.path.append(root)
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module("tem.compute")
|
|
||||||
except Exception as e:
|
|
||||||
raise BioyondException(f"无法导入计算模块: {e}")
|
|
||||||
try:
|
try:
|
||||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(f"参数解析失败: {e}")
|
raise BioyondException(f"参数解析失败: {e}")
|
||||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
|
||||||
|
# 2. 调用内部计算方法
|
||||||
|
res = self._generate_experiment_design(
|
||||||
|
ratio=ratio,
|
||||||
|
wt_percent=wp,
|
||||||
|
m_tot=mt,
|
||||||
|
titration_percent=tp
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 构造返回结果
|
||||||
out = {
|
out = {
|
||||||
"solutions": res.get("solutions", []),
|
"solutions": res.get("solutions", []),
|
||||||
"titration": res.get("titration", {}),
|
"titration": res.get("titration", {}),
|
||||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
"return_info": json.dumps(res, ensure_ascii=False)
|
"return_info": json.dumps(res, ensure_ascii=False)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
except BioyondException:
|
except BioyondException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(str(e))
|
raise BioyondException(str(e))
|
||||||
|
|
||||||
|
def _generate_experiment_design(
|
||||||
|
self,
|
||||||
|
ratio: dict,
|
||||||
|
wt_percent: float = 0.25,
|
||||||
|
m_tot: float = 70,
|
||||||
|
titration_percent: float = 0.03,
|
||||||
|
) -> dict:
|
||||||
|
"""内部方法:生成实验设计
|
||||||
|
|
||||||
|
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||||
|
wt_percent: 固体重量百分比
|
||||||
|
m_tot: 反应混合物总质量(g)
|
||||||
|
titration_percent: 滴定溶液百分比
|
||||||
|
|
||||||
|
返回:
|
||||||
|
包含实验设计详细参数的字典
|
||||||
|
"""
|
||||||
|
# 溶剂密度
|
||||||
|
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||||
|
# 二酐溶解度
|
||||||
|
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||||
|
# 投入固体时最小溶剂体积
|
||||||
|
V_min = 30 * self.ureg.ml
|
||||||
|
m_tot = m_tot * self.ureg.g
|
||||||
|
|
||||||
|
# 保持ratio中的顺序
|
||||||
|
compound_names = list(ratio.keys())
|
||||||
|
compound_ratios = list(ratio.values())
|
||||||
|
|
||||||
|
# 验证所有化合物是否在 compound_info 中定义
|
||||||
|
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||||
|
if undefined_compounds:
|
||||||
|
available = list(self.compound_info["MolWt"].keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||||
|
f"可用的化合物: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取各化合物的分子量和官能团类型
|
||||||
|
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||||
|
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||||
|
|
||||||
|
# 记录化合物信息用于调试
|
||||||
|
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||||
|
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||||
|
|
||||||
|
# 按原始顺序分离二胺和二酐
|
||||||
|
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||||
|
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||||
|
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||||
|
|
||||||
|
if not diamine_compounds or not anhydride_compounds:
|
||||||
|
raise ValueError(
|
||||||
|
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||||
|
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||||
|
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算加权平均分子量 (基于摩尔比)
|
||||||
|
total_molar_ratio = sum(compound_ratios)
|
||||||
|
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||||
|
|
||||||
|
# 取最后一个二酐用于滴定
|
||||||
|
titration_anhydride = anhydride_compounds[-1]
|
||||||
|
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||||
|
diamine_solutions = []
|
||||||
|
total_diamine_volume = 0 * self.ureg.ml
|
||||||
|
|
||||||
|
# 计算反应物的总摩尔量
|
||||||
|
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||||
|
|
||||||
|
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||||
|
# 跳过 SIDA
|
||||||
|
if name == "SIDA":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算该二胺需要的摩尔数
|
||||||
|
n_diamine_needed = n_reactant * ratio_val
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||||
|
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||||
|
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||||
|
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算该二胺溶液的浓度
|
||||||
|
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算需要移取的溶液体积
|
||||||
|
V_diamine_needed = n_diamine_needed / c_diamine
|
||||||
|
|
||||||
|
diamine_solutions.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"solid_mass": m_diamine_solid.magnitude,
|
||||||
|
"solvent_volume": V_solvent_for_this.magnitude,
|
||||||
|
"concentration": c_diamine.magnitude,
|
||||||
|
"volume_needed": V_diamine_needed.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
total_diamine_volume += V_diamine_needed
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
diamine_solutions.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算滴定二酐的质量
|
||||||
|
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||||
|
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||||
|
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||||
|
m_titration_10 = m_titration_anhydride * titration_percent
|
||||||
|
|
||||||
|
# 计算其他固体二酐的质量 (按顺序)
|
||||||
|
solid_anhydride_masses = []
|
||||||
|
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||||
|
mass = n_reactant * ratio_val * mw
|
||||||
|
solid_anhydride_masses.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"mass": mass.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算溶剂用量
|
||||||
|
total_diamine_solution_mass = sum(
|
||||||
|
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||||
|
) * self.ureg.ml
|
||||||
|
|
||||||
|
# 预估滴定溶剂量、计算补加溶剂量
|
||||||
|
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||||
|
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||||
|
|
||||||
|
# 检查最小溶剂体积要求
|
||||||
|
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||||
|
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||||
|
|
||||||
|
# 如果需要,按比例放大
|
||||||
|
scale_factor = 1.0
|
||||||
|
if m_tot_min > m_tot:
|
||||||
|
scale_factor = (m_tot_min / m_tot).magnitude
|
||||||
|
m_titration_90 *= scale_factor
|
||||||
|
m_titration_10 *= scale_factor
|
||||||
|
m_solvent_add *= scale_factor
|
||||||
|
m_solvent_titration *= scale_factor
|
||||||
|
|
||||||
|
# 更新二胺溶液用量
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
sol["volume_needed"] *= scale_factor
|
||||||
|
|
||||||
|
# 更新固体二酐用量
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
anhydride["mass"] *= scale_factor
|
||||||
|
|
||||||
|
m_tot = m_tot_min
|
||||||
|
|
||||||
|
# 生成投料顺序
|
||||||
|
feeding_order = []
|
||||||
|
|
||||||
|
# 1. 固体二酐 (按顺序)
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "solid_anhydride",
|
||||||
|
"name": anhydride["name"],
|
||||||
|
"amount": anhydride["mass"],
|
||||||
|
"order": anhydride["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 二胺溶液 (按顺序)
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "diamine_solution",
|
||||||
|
"name": sol["name"],
|
||||||
|
"amount": sol["volume_needed"],
|
||||||
|
"order": sol["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 主要二酐粉末
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "main_anhydride",
|
||||||
|
"name": titration_name,
|
||||||
|
"amount": m_titration_90.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 补加溶剂
|
||||||
|
if m_solvent_add > 0:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "additional_solvent",
|
||||||
|
"name": "溶剂",
|
||||||
|
"amount": m_solvent_add.magnitude,
|
||||||
|
"order": 999
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. 滴定二酐溶液
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "titration_anhydride",
|
||||||
|
"name": f"{titration_name} 滴定液",
|
||||||
|
"amount": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 返回实验设计结果
|
||||||
|
results = {
|
||||||
|
"total_mass": m_tot.magnitude,
|
||||||
|
"scale_factor": scale_factor,
|
||||||
|
"solutions": diamine_solutions,
|
||||||
|
"solids": solid_anhydride_masses,
|
||||||
|
"titration": {
|
||||||
|
"name": titration_name,
|
||||||
|
"main_portion": m_titration_90.magnitude,
|
||||||
|
"titration_portion": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
},
|
||||||
|
"solvents": {
|
||||||
|
"additional_solvent": m_solvent_add.magnitude,
|
||||||
|
"total_liquid_volume": total_liquid_volume.magnitude
|
||||||
|
},
|
||||||
|
"feeding_order": feeding_order,
|
||||||
|
"minimum_required_mass": m_tot_min.magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
# 90%10%小瓶投料任务创建方法
|
# 90%10%小瓶投料任务创建方法
|
||||||
def create_90_10_vial_feeding_task(self,
|
def create_90_10_vial_feeding_task(self,
|
||||||
order_name: str = None,
|
order_name: str = None,
|
||||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
'actualVolume': actual_volume
|
'actualVolume': actual_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||||
|
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
data = report.get('data', {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
# 提取关键信息
|
||||||
|
simplified = {
|
||||||
|
'name': data.get('name'),
|
||||||
|
'code': data.get('code'),
|
||||||
|
'requester': data.get('requester'),
|
||||||
|
'workflowName': data.get('workflowName'),
|
||||||
|
'workflowStep': data.get('workflowStep'),
|
||||||
|
'requestTime': data.get('requestTime'),
|
||||||
|
'startPreparationTime': data.get('startPreparationTime'),
|
||||||
|
'completeTime': data.get('completeTime'),
|
||||||
|
'useTime': data.get('useTime'),
|
||||||
|
'status': data.get('status'),
|
||||||
|
'statusName': data.get('statusName'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取物料信息(简化版)
|
||||||
|
pre_intakes = data.get('preIntakes', [])
|
||||||
|
if pre_intakes and isinstance(pre_intakes, list):
|
||||||
|
first_intake = pre_intakes[0]
|
||||||
|
sample_materials = first_intake.get('sampleMaterials', [])
|
||||||
|
|
||||||
|
# 简化物料信息
|
||||||
|
simplified_materials = []
|
||||||
|
for material in sample_materials:
|
||||||
|
if isinstance(material, dict):
|
||||||
|
mat_info = {
|
||||||
|
'materialName': material.get('materialName'),
|
||||||
|
'materialTypeName': material.get('materialTypeName'),
|
||||||
|
'materialCode': material.get('materialCode'),
|
||||||
|
'materialLocation': material.get('materialLocation'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||||
|
params_str = material.get('parameters', '{}')
|
||||||
|
try:
|
||||||
|
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||||
|
if isinstance(params, dict):
|
||||||
|
# 只保留关键参数
|
||||||
|
if 'density' in params:
|
||||||
|
mat_info['density'] = params['density']
|
||||||
|
if 'feedingHistory' in params:
|
||||||
|
mat_info['feedingHistory'] = params['feedingHistory']
|
||||||
|
if 'liquidVolume' in params:
|
||||||
|
mat_info['liquidVolume'] = params['liquidVolume']
|
||||||
|
if 'm_diamine_tot' in params:
|
||||||
|
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||||
|
if 'wt_diamine' in params:
|
||||||
|
mat_info['wt_diamine'] = params['wt_diamine']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
simplified_materials.append(mat_info)
|
||||||
|
|
||||||
|
simplified['sampleMaterials'] = simplified_materials
|
||||||
|
|
||||||
|
# 提取extraProperties中的实际值
|
||||||
|
extra_props = first_intake.get('extraProperties', {})
|
||||||
|
if isinstance(extra_props, dict):
|
||||||
|
simplified_extra = {}
|
||||||
|
for key, value in extra_props.items():
|
||||||
|
try:
|
||||||
|
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||||
|
simplified_extra[key] = parsed_value
|
||||||
|
except:
|
||||||
|
simplified_extra[key] = value
|
||||||
|
simplified['extraProperties'] = simplified_extra
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data': simplified,
|
||||||
|
'code': report.get('code'),
|
||||||
|
'message': report.get('message'),
|
||||||
|
'timestamp': report.get('timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
def scheduler_start(self) -> dict:
|
||||||
|
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BioyondException: 调度器启动失败时抛出异常
|
||||||
|
"""
|
||||||
|
result = self.hardware_interface.scheduler_start()
|
||||||
|
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||||
|
|
||||||
|
if result != 1:
|
||||||
|
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||||
|
self.hardware_interface._logger.error(error_msg)
|
||||||
|
raise BioyondException(error_msg)
|
||||||
|
|
||||||
|
return {"return_info": result}
|
||||||
|
|
||||||
# 等待多个任务完成并获取实验报告
|
# 等待多个任务完成并获取实验报告
|
||||||
def wait_for_multiple_orders_and_get_reports(self,
|
def wait_for_multiple_orders_and_get_reports(self,
|
||||||
batch_create_result: str = None,
|
batch_create_result: str = None,
|
||||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证batch_create_result参数
|
# 验证batch_create_result参数
|
||||||
if not batch_create_result or batch_create_result == "":
|
if not batch_create_result or batch_create_result == "":
|
||||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
raise BioyondException(
|
||||||
|
"batch_create_result参数为空,请确保:\n"
|
||||||
|
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||||
|
"2. batch_create节点成功执行并返回了结果\n"
|
||||||
|
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||||
|
)
|
||||||
|
|
||||||
# 解析batch_create_result JSON对象
|
# 解析batch_create_result JSON对象
|
||||||
try:
|
try:
|
||||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证提取的数据
|
# 验证提取的数据
|
||||||
if not order_codes:
|
if not order_codes:
|
||||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
self.hardware_interface._logger.error(
|
||||||
|
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||||
|
)
|
||||||
|
raise BioyondException(
|
||||||
|
"batch_create_result中未找到order_codes或为空。\n"
|
||||||
|
"可能的原因:\n"
|
||||||
|
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||||
|
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||||
|
"3. Bioyond系统状态异常\n"
|
||||||
|
f"请检查batch_create任务的执行结果"
|
||||||
|
)
|
||||||
if not order_ids:
|
if not order_ids:
|
||||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||||
|
|
||||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
self.hardware_interface._logger.info(
|
self.hardware_interface._logger.info(
|
||||||
f"成功获取任务 {order_code} 的实验报告"
|
f"成功获取任务 {order_code} 的实验报告"
|
||||||
)
|
)
|
||||||
|
# 简化报告,去除冗余信息
|
||||||
|
report = self._simplify_report(report)
|
||||||
|
|
||||||
reports.append({
|
reports.append({
|
||||||
"order_code": order_code,
|
"order_code": order_code,
|
||||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||||
results = []
|
results = []
|
||||||
successful_count = 0
|
successful_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
import json
|
import json
|
||||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|||||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
|
||||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
|
||||||
)
|
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionMonitor:
|
||||||
|
"""Bioyond连接监控器"""
|
||||||
|
def __init__(self, workstation, check_interval=30):
|
||||||
|
self.workstation = workstation
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self._running = False
|
||||||
|
self._thread = None
|
||||||
|
self._last_status = "unknown"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Bioyond连接监控器已启动")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
logger.info("Bioyond连接监控器已停止")
|
||||||
|
|
||||||
|
def _monitor_loop(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# 使用 lightweight API 检查连接
|
||||||
|
# query_matial_type_list 是比较快的查询
|
||||||
|
start_time = time.time()
|
||||||
|
result = self.workstation.hardware_interface.material_type_list()
|
||||||
|
|
||||||
|
status = "online" if result else "offline"
|
||||||
|
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||||
|
|
||||||
|
if status != self._last_status:
|
||||||
|
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||||
|
self._publish_event(status, msg)
|
||||||
|
self._last_status = status
|
||||||
|
|
||||||
|
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||||
|
# self._publish_event(status, msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bioyond连接检查异常: {e}")
|
||||||
|
if self._last_status != "error":
|
||||||
|
self._publish_event("error", str(e))
|
||||||
|
self._last_status = "error"
|
||||||
|
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
def _publish_event(self, status, message):
|
||||||
|
try:
|
||||||
|
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||||
|
event_data = {
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||||
|
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||||
|
# 话题: /<namespace>/events/device_status
|
||||||
|
ns = self.workstation._ros_node.namespace
|
||||||
|
topic = f"{ns}/events/device_status"
|
||||||
|
|
||||||
|
# 使用 ROS2DeviceNode 的发布功能
|
||||||
|
# 如果没有预定义的 publisher,需要动态创建
|
||||||
|
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||||
|
# 这里我们先尝试用 String json 发布
|
||||||
|
|
||||||
|
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||||
|
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||||
|
if not hasattr(self.workstation, "_device_status_pub"):
|
||||||
|
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self.workstation._device_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布设备状态事件失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||||
"""Bioyond资源同步器
|
"""Bioyond资源同步器
|
||||||
|
|
||||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||||
|
|
||||||
# 第1步:获取仓库配置
|
# 第1步:从配置中获取仓库配置
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
# 确定目标仓库名称
|
# 确定目标仓库名称
|
||||||
parent_name = None
|
parent_name = None
|
||||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
# 第2步:转换为 Bioyond 格式
|
# 第2步:转换为 Bioyond 格式
|
||||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||||
|
|
||||||
# 导入物料默认参数配置
|
# 从配置中获取物料默认参数
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
return material_bioyond_id
|
return material_bioyond_id
|
||||||
|
|
||||||
# 转换为 Bioyond 格式
|
# 转换为 Bioyond 格式
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
# 从配置中获取物料默认参数
|
||||||
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||||
|
|
||||||
# 获取仓库配置和目标库位 UUID
|
# 获取仓库配置和目标库位 UUID
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
parent_name = None
|
parent_name = None
|
||||||
target_location_uuid = None
|
target_location_uuid = None
|
||||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
集成Bioyond物料管理的工作站实现
|
集成Bioyond物料管理的工作站实现
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _publish_task_status(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
task_type: str,
|
||||||
|
status: str,
|
||||||
|
result: dict = None,
|
||||||
|
progress: float = 0.0,
|
||||||
|
task_code: str = None
|
||||||
|
):
|
||||||
|
"""发布任务状态事件"""
|
||||||
|
try:
|
||||||
|
if not getattr(self, "_ros_node", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_code": task_code,
|
||||||
|
"task_type": task_type,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
if result:
|
||||||
|
event_data["result"] = result
|
||||||
|
|
||||||
|
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||||
|
|
||||||
|
if not hasattr(self, "_task_status_pub"):
|
||||||
|
self._task_status_pub = self._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self._task_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布任务状态事件失败: {e}")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||||
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||||
|
|
||||||
# 初始化 warehouses 属性
|
# 初始化 warehouses 属性
|
||||||
self.deck.warehouses = {}
|
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||||
for resource in self.deck.children:
|
self.deck.warehouses = {}
|
||||||
if isinstance(resource, WareHouse):
|
|
||||||
self.deck.warehouses[resource.name] = resource
|
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||||
|
if not self.deck.warehouses:
|
||||||
|
for resource in self.deck.children:
|
||||||
|
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||||
|
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||||
|
|
||||||
|
# 如果配置中有定义,也可以认定为 warehouse
|
||||||
|
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||||
|
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||||
|
is_warehouse = True
|
||||||
|
|
||||||
|
if is_warehouse:
|
||||||
|
self.deck.warehouses[resource.name] = resource
|
||||||
|
# 确保 category 被正确设置,方便后续使用
|
||||||
|
if getattr(resource, "category", "") != "warehouse":
|
||||||
|
try:
|
||||||
|
resource.category = "warehouse"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# 创建通信模块
|
# 创建通信模块
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||||
|
|
||||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
# 从 bioyond_config 中的 http_service_config 获取
|
||||||
|
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||||
self._http_service_config = {
|
self._http_service_config = {
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
"port": http_service_cfg.get("http_service_port", 8080)
|
||||||
}
|
}
|
||||||
self.http_service = None # 将在 post_init 中启动
|
self.http_service = None # 将在 post_init 启动
|
||||||
|
self.connection_monitor = None # 将在 post_init 启动
|
||||||
|
|
||||||
logger.info(f"Bioyond工作站初始化完成")
|
logger.info(f"Bioyond工作站初始化完成")
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||||
try:
|
try:
|
||||||
|
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||||
|
self.connection_monitor.stop()
|
||||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||||
logger.info("正在停止 HTTP 报送服务...")
|
logger.info("正在停止 HTTP 报送服务...")
|
||||||
self.http_service.stop()
|
self.http_service.stop()
|
||||||
@@ -648,8 +802,19 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
# 启动连接监控
|
||||||
|
try:
|
||||||
|
self.connection_monitor = ConnectionMonitor(self)
|
||||||
|
self.connection_monitor.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动连接监控失败: {e}")
|
||||||
|
|
||||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||||
if hasattr(self, '_http_service_config'):
|
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||||
|
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||||
|
elif hasattr(self, '_http_service_config'):
|
||||||
try:
|
try:
|
||||||
self.http_service = WorkstationHTTPService(
|
self.http_service = WorkstationHTTPService(
|
||||||
workstation_instance=self,
|
workstation_instance=self,
|
||||||
@@ -688,19 +853,14 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""创建Bioyond通信模块"""
|
"""创建Bioyond通信模块"""
|
||||||
# 创建默认配置
|
# 直接使用传入的配置,不再使用默认值
|
||||||
default_config = {
|
# 所有配置必须从 JSON 文件中提供
|
||||||
**API_CONFIG,
|
|
||||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
|
||||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
|
||||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
|
||||||
}
|
|
||||||
|
|
||||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
|
||||||
if config:
|
if config:
|
||||||
self.bioyond_config = {**default_config, **config}
|
self.bioyond_config = config
|
||||||
else:
|
else:
|
||||||
self.bioyond_config = default_config
|
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||||
|
|
||||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
|
||||||
@@ -1014,7 +1174,15 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
workflow_id = self._get_workflow(actual_workflow_name)
|
workflow_id = self._get_workflow(actual_workflow_name)
|
||||||
if workflow_id:
|
if workflow_id:
|
||||||
self.workflow_sequence.append(workflow_id)
|
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||||
|
if isinstance(self.workflow_sequence, list):
|
||||||
|
self.workflow_sequence.append(workflow_id)
|
||||||
|
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||||
|
self._cached_workflow_sequence.append(workflow_id)
|
||||||
|
else:
|
||||||
|
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||||
|
return False
|
||||||
|
|
||||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1215,6 +1383,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||||
# 例如:更新数据库、触发后续流程等
|
# 例如:更新数据库、触发后续流程等
|
||||||
|
|
||||||
|
# 发布任务状态事件 (running/progress update)
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_step",
|
||||||
|
status="running",
|
||||||
|
progress=0.5, # 步骤完成视为任务进行中
|
||||||
|
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||||
|
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"step_id": data.get('stepId'),
|
"step_id": data.get('stepId'),
|
||||||
@@ -1249,6 +1433,17 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_sample",
|
||||||
|
status="running",
|
||||||
|
progress=0.7,
|
||||||
|
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"sample_id": data.get('sampleId'),
|
"sample_id": data.get('sampleId'),
|
||||||
@@ -1288,6 +1483,32 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||||
# 例如:更新物料库存、生成报表等
|
# 例如:更新物料库存、生成报表等
|
||||||
|
|
||||||
|
# 映射状态到事件状态
|
||||||
|
event_status = "completed"
|
||||||
|
if str(data.get('status')) in ["-11", "-12"]:
|
||||||
|
event_status = "error"
|
||||||
|
elif str(data.get('status')) == "30":
|
||||||
|
event_status = "completed"
|
||||||
|
else:
|
||||||
|
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_order",
|
||||||
|
status=event_status,
|
||||||
|
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||||
|
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||||
|
if event_status == "completed":
|
||||||
|
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"order_code": data.get('orderCode'),
|
"order_code": data.get('orderCode'),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Modbus CSV 地址映射说明
|
||||||
|
|
||||||
|
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||||
|
|
||||||
|
## 1. CSV 文件结构
|
||||||
|
|
||||||
|
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||||
|
|
||||||
|
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||||
|
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||||
|
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||||
|
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||||
|
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||||
|
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||||
|
|
||||||
|
### 示例行 (铝箔垫片)
|
||||||
|
|
||||||
|
```csv
|
||||||
|
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||||
|
```
|
||||||
|
|
||||||
|
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||||
|
- **类型**: `coil` (线圈,读写单个位)
|
||||||
|
- **地址**: `8340`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 加载与注册流程
|
||||||
|
|
||||||
|
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||||
|
|
||||||
|
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||||
|
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||||
|
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||||
|
self.client = modbus_client.register_node_list(self.nodes)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 代码中的使用方式
|
||||||
|
|
||||||
|
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||||
|
|
||||||
|
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||||
|
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||||
|
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||||
|
|
||||||
|
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||||
|
|
||||||
|
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||||
|
|
||||||
|
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||||
|
|
||||||
|
### 常见的转换规则 (示例)
|
||||||
|
|
||||||
|
- **Coil (线圈) %QX**:
|
||||||
|
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||||
|
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||||
|
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||||
|
|
||||||
|
- **Register (寄存器) %MW**:
|
||||||
|
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||||
|
|
||||||
|
### 验证方法
|
||||||
|
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||||
|
|
||||||
|
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||||
|
|
||||||
|
## 背景问题
|
||||||
|
|
||||||
|
### 原有流程
|
||||||
|
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||||
|
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||||
|
3. **需要人工手动点击**"是"或"否"按钮
|
||||||
|
4. PLC 继续完成初始化并启动
|
||||||
|
|
||||||
|
### 存在的问题
|
||||||
|
- 需要人工干预,无法实现全自动化
|
||||||
|
- 影响批量生产效率
|
||||||
|
- 容易遗忘点击导致流程卡住
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 新增 Modbus 地址配置
|
||||||
|
|
||||||
|
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||||
|
|
||||||
|
| Name | DeviceType | Address | 说明 |
|
||||||
|
|------|-----------|---------|------|
|
||||||
|
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||||
|
|
||||||
|
**Modbus 地址转换:**
|
||||||
|
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||||
|
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||||
|
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||||
|
|
||||||
|
## 代码修改详情
|
||||||
|
|
||||||
|
### 1. coin_cell_assembly.py
|
||||||
|
|
||||||
|
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||||
|
|
||||||
|
**位置:** 第 799-901 行
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||||
|
- 根据 `enable_search` 参数自动点击对应按钮
|
||||||
|
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||||
|
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||||
|
|
||||||
|
**逻辑流程:**
|
||||||
|
```python
|
||||||
|
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||||
|
2. 检测到弹窗出现 (Coil = True)
|
||||||
|
3. 选择按钮:
|
||||||
|
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||||
|
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||||
|
4. 执行脉冲操作:
|
||||||
|
- 写入 True (按下按钮)
|
||||||
|
- 等待 0.5 秒
|
||||||
|
- 写入 False (释放按钮)
|
||||||
|
- 验证状态
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||||
|
|
||||||
|
**位置:** 第 904-1115 行
|
||||||
|
|
||||||
|
**主要改动:**
|
||||||
|
|
||||||
|
1. **添加新参数**
|
||||||
|
```python
|
||||||
|
def func_pack_device_init_auto_start_combined(
|
||||||
|
self,
|
||||||
|
material_search_enable: bool = False # 新增参数
|
||||||
|
) -> bool:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **内联初始化逻辑并集成弹窗检测**
|
||||||
|
- 不再调用 `self.func_pack_device_init()`
|
||||||
|
- 将初始化逻辑直接实现在函数内
|
||||||
|
- **在等待初始化完成的循环中实时检测弹窗**
|
||||||
|
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||||
|
|
||||||
|
3. **关键代码片段**
|
||||||
|
```python
|
||||||
|
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||||
|
while (self._sys_init_status()) == False:
|
||||||
|
# 检查超时
|
||||||
|
if time.time() - start_wait > max_wait_time:
|
||||||
|
raise RuntimeError(f"初始化超时")
|
||||||
|
|
||||||
|
# 如果还没处理弹窗,检测弹窗是否出现
|
||||||
|
if not dialog_handled:
|
||||||
|
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||||
|
if dialog_actual: # 弹窗出现
|
||||||
|
# 执行脉冲按钮点击
|
||||||
|
button_node.write(True) # 按下
|
||||||
|
time.sleep(0.5) # 保持
|
||||||
|
button_node.write(False) # 释放
|
||||||
|
dialog_handled = True
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **步骤调整**
|
||||||
|
- 步骤 0: 前置条件检查
|
||||||
|
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||||
|
- 步骤 1.5: 已在步骤 1 中完成
|
||||||
|
- 步骤 2: 切换自动模式
|
||||||
|
- 步骤 3: 启动设备
|
||||||
|
|
||||||
|
### 2. coin_cell_workstation.yaml
|
||||||
|
|
||||||
|
**位置:** 第 292-312 行
|
||||||
|
|
||||||
|
**修改内容:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal_default:
|
||||||
|
material_search_enable: false # 新增默认值
|
||||||
|
|
||||||
|
schema:
|
||||||
|
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||||
|
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
material_search_enable: # 新增参数配置
|
||||||
|
default: false
|
||||||
|
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||||
|
type: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 测试脚本(已创建,用户已删除)
|
||||||
|
|
||||||
|
#### 3.1 test_material_search_dialog.py
|
||||||
|
- 从 CSV 动态加载 Modbus 地址
|
||||||
|
- 支持 4 种测试模式:
|
||||||
|
- `query` - 查询所有状态
|
||||||
|
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||||
|
- `yes` - 脉冲点击"是"按钮
|
||||||
|
- `no` - 脉冲点击"否"按钮
|
||||||
|
- 兼容 pymodbus 3.x API
|
||||||
|
|
||||||
|
#### 3.2 更新其他测试脚本
|
||||||
|
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||||
|
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||||
|
|
||||||
|
### 调用示例
|
||||||
|
|
||||||
|
#### 1. 不启用物料搜寻(默认)
|
||||||
|
```python
|
||||||
|
# 默认参数,点击"否"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined()
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
# 使用默认值 false,不启用物料搜寻
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 启用物料搜寻
|
||||||
|
```python
|
||||||
|
# 显式设置为 True,点击"是"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined(
|
||||||
|
material_search_enable=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true # 启用物料搜寻
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行日志示例
|
||||||
|
|
||||||
|
```
|
||||||
|
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||||
|
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||||
|
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||||
|
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||||
|
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||||
|
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||||
|
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||||
|
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||||
|
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||||
|
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||||
|
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||||
|
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||||
|
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||||
|
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||||
|
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
|
||||||
|
### 1. 脉冲模式按钮操作
|
||||||
|
模拟真实按钮按压过程:
|
||||||
|
1. 写入 `True` (按下)
|
||||||
|
2. 保持 0.5 秒
|
||||||
|
3. 写入 `False` (释放)
|
||||||
|
4. 验证状态
|
||||||
|
|
||||||
|
### 2. 避免死锁
|
||||||
|
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||||
|
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||||
|
|
||||||
|
### 3. 超时保护
|
||||||
|
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||||
|
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||||
|
|
||||||
|
### 4. PyModbus 3.x API 兼容
|
||||||
|
所有 Modbus 操作使用 keyword arguments:
|
||||||
|
```python
|
||||||
|
# 读取
|
||||||
|
client.read_coils(address=5176, count=1)
|
||||||
|
|
||||||
|
# 写入
|
||||||
|
client.write_coil(address=5184, value=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 向后兼容性
|
||||||
|
|
||||||
|
### 保留的原有函数
|
||||||
|
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||||
|
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||||
|
- 用于不需要自动处理弹窗的场景
|
||||||
|
|
||||||
|
### 新增的功能
|
||||||
|
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||||
|
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||||
|
|
||||||
|
## 验证测试
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
|
||||||
|
#### 场景 1:默认参数(不启用物料搜寻)
|
||||||
|
```bash
|
||||||
|
# 调用时不传参数
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"否"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
#### 场景 2:启用物料搜寻
|
||||||
|
```bash
|
||||||
|
# 设置 material_search_enable=true
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"是"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
### 实际测试结果
|
||||||
|
|
||||||
|
**测试时间:** 2026-01-13 21:32:43
|
||||||
|
**测试参数:** `material_search_enable: false`
|
||||||
|
**测试结果:** ✅ 成功
|
||||||
|
|
||||||
|
**关键时间节点:**
|
||||||
|
- 21:33:05 - 检测到弹窗
|
||||||
|
- 21:33:05 - 按下"否"按钮
|
||||||
|
- 21:33:06 - 释放"否"按钮
|
||||||
|
- 21:33:07 - 弹窗处理完成
|
||||||
|
- 21:33:08 - 初始化状态完成
|
||||||
|
- 21:33:18 - 整个流程完成
|
||||||
|
|
||||||
|
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **CSV 配置依赖**
|
||||||
|
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||||
|
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||||
|
|
||||||
|
2. **默认行为**
|
||||||
|
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||||
|
- 如需启用,必须显式设置为 `true`
|
||||||
|
|
||||||
|
3. **日志级别**
|
||||||
|
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||||
|
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||||
|
|
||||||
|
4. **原有函数保留**
|
||||||
|
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||||
|
- 如果单独调用此函数,仍需手动处理弹窗
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||||
|
- 新增 `_handle_material_search_dialog()` 方法
|
||||||
|
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||||
|
|
||||||
|
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||||
|
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||||
|
- 添加 `material_search_enable` 参数说明
|
||||||
|
|
||||||
|
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||||
|
- 第 69-71 行添加三个 coil 配置
|
||||||
|
|
||||||
|
### 创建的测试文件(已删除)
|
||||||
|
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||||
|
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||||
|
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||||
|
|
||||||
|
✅ **全自动化** - 无需人工干预
|
||||||
|
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||||
|
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||||
|
✅ **脉冲模式** - 模拟真实按钮操作
|
||||||
|
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||||
|
✅ **完整日志** - 详细记录每一步操作
|
||||||
|
✅ **超时保护** - 防止无限等待
|
||||||
|
|
||||||
|
该功能已通过实际测试验证,可投入生产使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** 1.0
|
||||||
|
**创建日期:** 2026-01-13
|
||||||
|
**作者:** Antigravity AI Assistant
|
||||||
|
**最后更新:** 2026-01-13 21:36
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""
|
||||||
|
纽扣电池组装工作站物料类定义
|
||||||
|
Button Battery Assembly Station Resource Classes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||||
|
|
||||||
|
from pylabrobot.resources.coordinate import Coordinate
|
||||||
|
from pylabrobot.resources.container import Container
|
||||||
|
from pylabrobot.resources.deck import Deck
|
||||||
|
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||||
|
from pylabrobot.resources.resource import Resource
|
||||||
|
from pylabrobot.resources.resource_stack import ResourceStack
|
||||||
|
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||||
|
from pylabrobot.resources.trash import Trash
|
||||||
|
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||||
|
|
||||||
|
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||||
|
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||||
|
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: 这个应该只能放一个极片
|
||||||
|
class MaterialHoleState(TypedDict):
|
||||||
|
diameter: int
|
||||||
|
depth: int
|
||||||
|
max_sheets: int
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialHole(Resource):
|
||||||
|
"""料板洞位类"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
category: str = "material_hole",
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||||
|
diameter=20,
|
||||||
|
depth=10,
|
||||||
|
max_sheets=1,
|
||||||
|
info=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_sheet_info(self):
|
||||||
|
info_list = []
|
||||||
|
for sheet in self.children:
|
||||||
|
info_list.append(sheet._unilabos_state["info"])
|
||||||
|
return info_list
|
||||||
|
|
||||||
|
#这个函数函数好像没用,一般不会集中赋值质量
|
||||||
|
def set_all_sheet_mass(self):
|
||||||
|
for sheet in self.children:
|
||||||
|
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
#移动极片前先取出对象
|
||||||
|
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||||
|
for sheet in self.children:
|
||||||
|
if sheet.name == name:
|
||||||
|
return sheet
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_electrode_sheet(self) -> bool:
|
||||||
|
"""检查洞位是否有极片"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: ElectrodeSheet,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||||
|
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||||
|
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||||
|
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||||
|
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||||
|
return self.children[index]
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialPlateState(TypedDict):
|
||||||
|
hole_spacing_x: float
|
||||||
|
hole_spacing_y: float
|
||||||
|
hole_diameter: float
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||||
|
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||||
|
|
||||||
|
children: List[MaterialHole]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||||
|
ordering: Optional[OrderedDict[str, str]] = None,
|
||||||
|
category: str = "material_plate",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
fill: bool = False
|
||||||
|
):
|
||||||
|
"""初始化料板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 料板名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
hole_diameter: 洞直径 (mm)
|
||||||
|
hole_depth: 洞深度 (mm)
|
||||||
|
hole_spacing_x: X方向洞位间距 (mm)
|
||||||
|
hole_spacing_y: Y方向洞位间距 (mm)
|
||||||
|
number: 编号
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||||
|
hole_spacing_x=24.0,
|
||||||
|
hole_spacing_y=24.0,
|
||||||
|
hole_diameter=20.0,
|
||||||
|
info="",
|
||||||
|
)
|
||||||
|
# 创建4x4的洞位
|
||||||
|
# TODO: 这里要改,对应不同形状
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
|
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
|
dz=size_z,
|
||||||
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
|
size_x = 16,
|
||||||
|
size_y = 16,
|
||||||
|
size_z = 16,
|
||||||
|
)
|
||||||
|
if fill:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=holes,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=ordered_items,
|
||||||
|
ordering=ordering,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_locations(self):
|
||||||
|
# TODO:调多次相加
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
|
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
|
dz=self._size_z,
|
||||||
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
|
size_x = 1,
|
||||||
|
size_y = 1,
|
||||||
|
size_z = 1,
|
||||||
|
)
|
||||||
|
for item, original_item in zip(holes.items(), self.children):
|
||||||
|
original_item.location = item[1].location
|
||||||
|
|
||||||
|
|
||||||
|
class PlateSlot(ResourceStack):
|
||||||
|
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
max_plates: int = 8,
|
||||||
|
category: str = "plate_slot",
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""初始化板槽位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 槽位名称
|
||||||
|
max_plates: 最大板数量
|
||||||
|
category: 类别
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
direction="z", # Z方向堆叠
|
||||||
|
resources=[],
|
||||||
|
)
|
||||||
|
self.max_plates = max_plates
|
||||||
|
self.category = category
|
||||||
|
|
||||||
|
def can_add_plate(self) -> bool:
|
||||||
|
"""检查是否可以添加板"""
|
||||||
|
return len(self.children) < self.max_plates
|
||||||
|
|
||||||
|
def add_plate(self, plate: MaterialPlate) -> None:
|
||||||
|
"""添加料板"""
|
||||||
|
if not self.can_add_plate():
|
||||||
|
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||||
|
self.assign_child_resource(plate)
|
||||||
|
|
||||||
|
def get_top_plate(self) -> MaterialPlate:
|
||||||
|
"""获取最上方的板"""
|
||||||
|
if len(self.children) == 0:
|
||||||
|
raise ValueError(f"槽位 {self.name} 为空")
|
||||||
|
return cast(MaterialPlate, self.get_top_item())
|
||||||
|
|
||||||
|
def take_top_plate(self) -> MaterialPlate:
|
||||||
|
"""取出最上方的板"""
|
||||||
|
top_plate = self.get_top_plate()
|
||||||
|
self.unassign_child_resource(top_plate)
|
||||||
|
return top_plate
|
||||||
|
|
||||||
|
def can_access_for_picking(self) -> bool:
|
||||||
|
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().serialize(),
|
||||||
|
"max_plates": self.max_plates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#是一种类型注解,不用self
|
||||||
|
class BatteryState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float
|
||||||
|
height: float
|
||||||
|
assembly_pressure: float
|
||||||
|
electrolyte_volume: float
|
||||||
|
electrolyte_name: str
|
||||||
|
|
||||||
|
class Battery(Resource):
|
||||||
|
"""电池类 - 可容纳极片"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category: str = "battery",
|
||||||
|
):
|
||||||
|
"""初始化电池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 电池名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
height: 高度 (mm)
|
||||||
|
max_volume: 最大容量 (μL)
|
||||||
|
barcode: 二维码编号
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryState = BatteryState(
|
||||||
|
diameter = 1.0,
|
||||||
|
height = 1.0,
|
||||||
|
assembly_pressure = 1.0,
|
||||||
|
electrolyte_volume = 1.0,
|
||||||
|
electrolyte_name = "DP001"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||||
|
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||||
|
if bottle.aspirate_electrolyte(10):
|
||||||
|
if self.add_electrolyte(to_add_name, 10):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||||
|
|
||||||
|
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||||
|
"""设置电解液信息"""
|
||||||
|
self._unilabos_state["electrolyte_name"] = name
|
||||||
|
self._unilabos_state["electrolyte_volume"] = volume
|
||||||
|
#这个应该没用,不会有加了后再加的事情
|
||||||
|
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||||
|
"""添加电解液信息"""
|
||||||
|
if name != self._unilabos_state["electrolyte_name"]:
|
||||||
|
return False
|
||||||
|
self._unilabos_state["electrolyte_volume"] += volume
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# 电解液作为属性放进去
|
||||||
|
|
||||||
|
class BatteryPressSlotState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float =20.0
|
||||||
|
depth: float = 4.0
|
||||||
|
|
||||||
|
class BatteryPressSlot(Resource):
|
||||||
|
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||||
|
children: List[Battery] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "BatteryPressSlot",
|
||||||
|
category: str = "battery_press_slot",
|
||||||
|
):
|
||||||
|
"""初始化电池压制槽
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 压制槽名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
depth: 深度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=10,
|
||||||
|
size_y=12,
|
||||||
|
size_z=13,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||||
|
|
||||||
|
def has_battery(self) -> bool:
|
||||||
|
"""检查是否有电池"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: Battery,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||||
|
if self.has_battery():
|
||||||
|
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_battery_info(self, index: int) -> Battery:
|
||||||
|
return self.children[0]
|
||||||
|
|
||||||
|
|
||||||
|
def TipBox64(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
category: str = "tip_rack",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""64孔枪头盒类"""
|
||||||
|
from pylabrobot.resources.tip import Tip
|
||||||
|
|
||||||
|
# 创建12x8=96个枪头位
|
||||||
|
def make_tip():
|
||||||
|
return Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=20.0,
|
||||||
|
maximal_volume=1000, # 1mL
|
||||||
|
fitting_depth=8.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
tip_spots = create_ordered_items_2d(
|
||||||
|
klass=TipSpot,
|
||||||
|
num_items_x=12,
|
||||||
|
num_items_y=8,
|
||||||
|
dx=8.0,
|
||||||
|
dy=8.0,
|
||||||
|
dz=0.0,
|
||||||
|
item_dx=9.0,
|
||||||
|
item_dy=9.0,
|
||||||
|
size_x=10,
|
||||||
|
size_y=10,
|
||||||
|
size_z=0.0,
|
||||||
|
make_tip=make_tip,
|
||||||
|
)
|
||||||
|
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||||
|
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||||
|
tip_rack = TipRack(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
# ordered_items=tip_spots_available,
|
||||||
|
ordered_items=tip_spots,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
with_tips=False,
|
||||||
|
)
|
||||||
|
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||||
|
return tip_rack
|
||||||
|
|
||||||
|
|
||||||
|
class WasteTipBoxstate(TypedDict):
|
||||||
|
""""废枪头盒状态字典"""
|
||||||
|
max_tips: int = 100
|
||||||
|
tip_count: int = 0
|
||||||
|
|
||||||
|
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||||
|
class WasteTipBox(Trash):
|
||||||
|
"""废枪头盒类 - 100个枪头容量"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
material_z_thickness=0,
|
||||||
|
max_volume=float("inf"),
|
||||||
|
category="trash",
|
||||||
|
model=None,
|
||||||
|
compute_volume_from_height=None,
|
||||||
|
compute_height_from_volume=None,
|
||||||
|
):
|
||||||
|
"""初始化废枪头盒
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 废枪头盒名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
max_tips: 最大枪头容量
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||||
|
|
||||||
|
def add_tip(self) -> None:
|
||||||
|
"""添加废枪头"""
|
||||||
|
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||||
|
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||||
|
self._unilabos_state["tip_count"] += 1
|
||||||
|
|
||||||
|
def get_tip_count(self) -> int:
|
||||||
|
"""获取枪头数量"""
|
||||||
|
return self._unilabos_state["tip_count"]
|
||||||
|
|
||||||
|
def empty(self) -> None:
|
||||||
|
"""清空废枪头盒"""
|
||||||
|
self._unilabos_state["tip_count"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CoincellDeck(Deck):
|
||||||
|
"""纽扣电池组装工作站台面类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "coin_cell_deck",
|
||||||
|
size_x: float = 1450.0, # 1m
|
||||||
|
size_y: float = 1450.0, # 1m
|
||||||
|
size_z: float = 100.0, # 0.9m
|
||||||
|
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||||
|
category: str = "coin_cell_deck",
|
||||||
|
setup: bool = False, # 是否自动执行 setup
|
||||||
|
):
|
||||||
|
"""初始化纽扣电池组装工作站台面
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 台面名称
|
||||||
|
size_x: 长度 (mm) - 1m
|
||||||
|
size_y: 宽度 (mm) - 1m
|
||||||
|
size_z: 高度 (mm) - 0.9m
|
||||||
|
origin: 原点坐标
|
||||||
|
category: 类别
|
||||||
|
setup: 是否自动执行 setup 配置标准布局
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1450.0,
|
||||||
|
size_y=1450.0,
|
||||||
|
size_z=100.0,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
if setup:
|
||||||
|
self.setup()
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||||
|
# ====================================== 子弹夹 ============================================
|
||||||
|
|
||||||
|
# 正极片(4个洞位,2x2布局)
|
||||||
|
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||||
|
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||||
|
|
||||||
|
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||||
|
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||||
|
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||||
|
|
||||||
|
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||||
|
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||||
|
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||||
|
|
||||||
|
# 成品弹夹(6个洞位,3x2布局)
|
||||||
|
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||||
|
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||||
|
|
||||||
|
# ====================================== 物料板 ============================================
|
||||||
|
# 创建物料板(料盘carrier)- 4x4布局
|
||||||
|
# 负极料盘
|
||||||
|
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
|
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||||
|
|
||||||
|
# 隔膜料盘
|
||||||
|
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
|
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||||
|
|
||||||
|
# ====================================== 瓶架、移液枪 ============================================
|
||||||
|
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||||
|
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||||
|
|
||||||
|
# bottle_rack_3x4 = BottleRack(
|
||||||
|
# name="bottle_rack_3x4",
|
||||||
|
# size_x=210.0,
|
||||||
|
# size_y=140.0,
|
||||||
|
# size_z=100.0,
|
||||||
|
# num_items_x=2,
|
||||||
|
# num_items_y=4,
|
||||||
|
# position_spacing=35.0,
|
||||||
|
# orientation="vertical",
|
||||||
|
# )
|
||||||
|
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||||
|
|
||||||
|
# 电解液缓存位 - 6x2布局
|
||||||
|
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||||
|
# 电解液回收位6x2
|
||||||
|
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||||
|
|
||||||
|
tip_box = TipBox64(name="tip_box_64")
|
||||||
|
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||||
|
|
||||||
|
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||||
|
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||||
|
|
||||||
|
|
||||||
|
def YH_Deck(name=""):
|
||||||
|
cd = CoincellDeck(name=name)
|
||||||
|
cd.setup()
|
||||||
|
return cd
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
deck = create_coin_cell_deck()
|
||||||
|
print(deck)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||||
|
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||||
|
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||||
|
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||||
|
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||||
|
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||||
|
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||||
|
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||||
|
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||||
|
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||||
|
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||||
|
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||||
|
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||||
|
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||||
|
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||||
|
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||||
|
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||||
|
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||||
|
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||||
|
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||||
|
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||||
|
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||||
|
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||||
|
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||||
|
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||||
|
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||||
|
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||||
|
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||||
|
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||||
|
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||||
|
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||||
|
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||||
|
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||||
|
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||||
|
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||||
|
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||||
|
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||||
|
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||||
|
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||||
|
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||||
|
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||||
|
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||||
|
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||||
|
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||||
|
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||||
|
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||||
|
,,,,,coil,8320,
|
||||||
|
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||||
|
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||||
|
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||||
|
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||||
|
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||||
|
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||||
|
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||||
|
,BOOL,,,,coil,8300,
|
||||||
|
,BOOL,,,,coil,8310,
|
||||||
|
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||||
|
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||||
|
,BOOL,,,,coil,8350,
|
||||||
|
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||||
|
,BOOL,,,,coil,8380,
|
||||||
|
,BOOL,,,,coil,8390,
|
||||||
|
,BOOL,,,,coil,8400,
|
||||||
|
,BOOL,,,,coil,8410,
|
||||||
|
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||||
|
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||||
|
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||||
|
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||||
|
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||||
|
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||||
|
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||||
|
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||||
|
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||||
|
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||||
|
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||||
|
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||||
|
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||||
|
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||||
|
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||||
|
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||||
|
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||||
|
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||||
|
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||||
|
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||||
|
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||||
|
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||||
|
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||||
|
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||||
|
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||||
|
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||||
|
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||||
|
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||||
|
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||||
|
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||||
|
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||||
|
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||||
|
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||||
|
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||||
|
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||||
|
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||||
|
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||||
|
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||||
|
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||||
|
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||||
|
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||||
|
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||||
|
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||||
|
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||||
|
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||||
|
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||||
|
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||||
|
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||||
|
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||||
|
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||||
|
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||||
|
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||||
|
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||||
|
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||||
|
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||||
|
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||||
|
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||||
|
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||||
|
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||||
|
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||||
|
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||||
|
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||||
|
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||||
|
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||||
|
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||||
|
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||||
|
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||||
|
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||||
|
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
|||||||
# 验证必需字段
|
# 验证必需字段
|
||||||
if 'brand' in request_data:
|
if 'brand' in request_data:
|
||||||
if request_data['brand'] == "bioyond": # 奔曜
|
if request_data['brand'] == "bioyond": # 奔曜
|
||||||
error_msg = request_data["text"]
|
material_data = request_data["text"]
|
||||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"错误处理报送已收到: {error_msg}",
|
message=f"物料变更报送已收到: {material_data}",
|
||||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||||
data=None
|
data=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -96,10 +96,13 @@ serial:
|
|||||||
type: string
|
type: string
|
||||||
port:
|
port:
|
||||||
type: string
|
type: string
|
||||||
|
registry_name:
|
||||||
|
type: string
|
||||||
resource_tracker:
|
resource_tracker:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- device_id
|
- device_id
|
||||||
|
- registry_name
|
||||||
- port
|
- port
|
||||||
type: object
|
type: object
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -1,589 +0,0 @@
|
|||||||
workstation.bioyond_dispensing_station:
|
|
||||||
category:
|
|
||||||
- workstation
|
|
||||||
- bioyond
|
|
||||||
class:
|
|
||||||
action_value_mappings:
|
|
||||||
auto-batch_create_90_10_vial_feeding_tasks:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
delay_time: null
|
|
||||||
hold_m_name: null
|
|
||||||
liquid_material_name: NMP
|
|
||||||
speed: null
|
|
||||||
temperature: null
|
|
||||||
titration: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
delay_time:
|
|
||||||
type: string
|
|
||||||
hold_m_name:
|
|
||||||
type: string
|
|
||||||
liquid_material_name:
|
|
||||||
default: NMP
|
|
||||||
type: string
|
|
||||||
speed:
|
|
||||||
type: string
|
|
||||||
temperature:
|
|
||||||
type: string
|
|
||||||
titration:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- titration
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: batch_create_90_10_vial_feeding_tasks参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-batch_create_diamine_solution_tasks:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
delay_time: null
|
|
||||||
liquid_material_name: NMP
|
|
||||||
solutions: null
|
|
||||||
speed: null
|
|
||||||
temperature: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
delay_time:
|
|
||||||
type: string
|
|
||||||
liquid_material_name:
|
|
||||||
default: NMP
|
|
||||||
type: string
|
|
||||||
solutions:
|
|
||||||
type: string
|
|
||||||
speed:
|
|
||||||
type: string
|
|
||||||
temperature:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- solutions
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: batch_create_diamine_solution_tasks参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-brief_step_parameters:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
data: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- data
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: brief_step_parameters参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-compute_experiment_design:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
m_tot: '70'
|
|
||||||
ratio: null
|
|
||||||
titration_percent: '0.03'
|
|
||||||
wt_percent: '0.25'
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
m_tot:
|
|
||||||
default: '70'
|
|
||||||
type: string
|
|
||||||
ratio:
|
|
||||||
type: object
|
|
||||||
titration_percent:
|
|
||||||
default: '0.03'
|
|
||||||
type: string
|
|
||||||
wt_percent:
|
|
||||||
default: '0.25'
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- ratio
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
properties:
|
|
||||||
feeding_order:
|
|
||||||
items: {}
|
|
||||||
title: Feeding Order
|
|
||||||
type: array
|
|
||||||
return_info:
|
|
||||||
title: Return Info
|
|
||||||
type: string
|
|
||||||
solutions:
|
|
||||||
items: {}
|
|
||||||
title: Solutions
|
|
||||||
type: array
|
|
||||||
solvents:
|
|
||||||
additionalProperties: true
|
|
||||||
title: Solvents
|
|
||||||
type: object
|
|
||||||
titration:
|
|
||||||
additionalProperties: true
|
|
||||||
title: Titration
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- solutions
|
|
||||||
- titration
|
|
||||||
- solvents
|
|
||||||
- feeding_order
|
|
||||||
- return_info
|
|
||||||
title: ComputeExperimentDesignReturn
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: compute_experiment_design参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-process_order_finish_report:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
report_request: null
|
|
||||||
used_materials: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
report_request:
|
|
||||||
type: string
|
|
||||||
used_materials:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- report_request
|
|
||||||
- used_materials
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: process_order_finish_report参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-project_order_report:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
order_id: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
order_id:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- order_id
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: project_order_report参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-query_resource_by_name:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
material_name: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
material_name:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- material_name
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: query_resource_by_name参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-transfer_materials_to_reaction_station:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
target_device_id: null
|
|
||||||
transfer_groups: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
target_device_id:
|
|
||||||
type: string
|
|
||||||
transfer_groups:
|
|
||||||
type: array
|
|
||||||
required:
|
|
||||||
- target_device_id
|
|
||||||
- transfer_groups
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: transfer_materials_to_reaction_station参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-wait_for_multiple_orders_and_get_reports:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
batch_create_result: null
|
|
||||||
check_interval: 10
|
|
||||||
timeout: 7200
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
batch_create_result:
|
|
||||||
type: string
|
|
||||||
check_interval:
|
|
||||||
default: 10
|
|
||||||
type: integer
|
|
||||||
timeout:
|
|
||||||
default: 7200
|
|
||||||
type: integer
|
|
||||||
required: []
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: wait_for_multiple_orders_and_get_reports参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-workflow_sample_locations:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
workflow_id: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
workflow_id:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- workflow_id
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: workflow_sample_locations参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
create_90_10_vial_feeding_task:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
delay_time: delay_time
|
|
||||||
hold_m_name: hold_m_name
|
|
||||||
order_name: order_name
|
|
||||||
percent_10_1_assign_material_name: percent_10_1_assign_material_name
|
|
||||||
percent_10_1_liquid_material_name: percent_10_1_liquid_material_name
|
|
||||||
percent_10_1_target_weigh: percent_10_1_target_weigh
|
|
||||||
percent_10_1_volume: percent_10_1_volume
|
|
||||||
percent_10_2_assign_material_name: percent_10_2_assign_material_name
|
|
||||||
percent_10_2_liquid_material_name: percent_10_2_liquid_material_name
|
|
||||||
percent_10_2_target_weigh: percent_10_2_target_weigh
|
|
||||||
percent_10_2_volume: percent_10_2_volume
|
|
||||||
percent_10_3_assign_material_name: percent_10_3_assign_material_name
|
|
||||||
percent_10_3_liquid_material_name: percent_10_3_liquid_material_name
|
|
||||||
percent_10_3_target_weigh: percent_10_3_target_weigh
|
|
||||||
percent_10_3_volume: percent_10_3_volume
|
|
||||||
percent_90_1_assign_material_name: percent_90_1_assign_material_name
|
|
||||||
percent_90_1_target_weigh: percent_90_1_target_weigh
|
|
||||||
percent_90_2_assign_material_name: percent_90_2_assign_material_name
|
|
||||||
percent_90_2_target_weigh: percent_90_2_target_weigh
|
|
||||||
percent_90_3_assign_material_name: percent_90_3_assign_material_name
|
|
||||||
percent_90_3_target_weigh: percent_90_3_target_weigh
|
|
||||||
speed: speed
|
|
||||||
temperature: temperature
|
|
||||||
goal_default:
|
|
||||||
delay_time: ''
|
|
||||||
hold_m_name: ''
|
|
||||||
order_name: ''
|
|
||||||
percent_10_1_assign_material_name: ''
|
|
||||||
percent_10_1_liquid_material_name: ''
|
|
||||||
percent_10_1_target_weigh: ''
|
|
||||||
percent_10_1_volume: ''
|
|
||||||
percent_10_2_assign_material_name: ''
|
|
||||||
percent_10_2_liquid_material_name: ''
|
|
||||||
percent_10_2_target_weigh: ''
|
|
||||||
percent_10_2_volume: ''
|
|
||||||
percent_10_3_assign_material_name: ''
|
|
||||||
percent_10_3_liquid_material_name: ''
|
|
||||||
percent_10_3_target_weigh: ''
|
|
||||||
percent_10_3_volume: ''
|
|
||||||
percent_90_1_assign_material_name: ''
|
|
||||||
percent_90_1_target_weigh: ''
|
|
||||||
percent_90_2_assign_material_name: ''
|
|
||||||
percent_90_2_target_weigh: ''
|
|
||||||
percent_90_3_assign_material_name: ''
|
|
||||||
percent_90_3_target_weigh: ''
|
|
||||||
speed: ''
|
|
||||||
temperature: ''
|
|
||||||
handles: {}
|
|
||||||
result:
|
|
||||||
return_info: return_info
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback:
|
|
||||||
properties: {}
|
|
||||||
required: []
|
|
||||||
title: DispenStationVialFeed_Feedback
|
|
||||||
type: object
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
delay_time:
|
|
||||||
type: string
|
|
||||||
hold_m_name:
|
|
||||||
type: string
|
|
||||||
order_name:
|
|
||||||
type: string
|
|
||||||
percent_10_1_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_1_liquid_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_1_target_weigh:
|
|
||||||
type: string
|
|
||||||
percent_10_1_volume:
|
|
||||||
type: string
|
|
||||||
percent_10_2_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_2_liquid_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_2_target_weigh:
|
|
||||||
type: string
|
|
||||||
percent_10_2_volume:
|
|
||||||
type: string
|
|
||||||
percent_10_3_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_3_liquid_material_name:
|
|
||||||
type: string
|
|
||||||
percent_10_3_target_weigh:
|
|
||||||
type: string
|
|
||||||
percent_10_3_volume:
|
|
||||||
type: string
|
|
||||||
percent_90_1_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_90_1_target_weigh:
|
|
||||||
type: string
|
|
||||||
percent_90_2_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_90_2_target_weigh:
|
|
||||||
type: string
|
|
||||||
percent_90_3_assign_material_name:
|
|
||||||
type: string
|
|
||||||
percent_90_3_target_weigh:
|
|
||||||
type: string
|
|
||||||
speed:
|
|
||||||
type: string
|
|
||||||
temperature:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- order_name
|
|
||||||
- percent_90_1_assign_material_name
|
|
||||||
- percent_90_1_target_weigh
|
|
||||||
- percent_90_2_assign_material_name
|
|
||||||
- percent_90_2_target_weigh
|
|
||||||
- percent_90_3_assign_material_name
|
|
||||||
- percent_90_3_target_weigh
|
|
||||||
- percent_10_1_assign_material_name
|
|
||||||
- percent_10_1_target_weigh
|
|
||||||
- percent_10_1_volume
|
|
||||||
- percent_10_1_liquid_material_name
|
|
||||||
- percent_10_2_assign_material_name
|
|
||||||
- percent_10_2_target_weigh
|
|
||||||
- percent_10_2_volume
|
|
||||||
- percent_10_2_liquid_material_name
|
|
||||||
- percent_10_3_assign_material_name
|
|
||||||
- percent_10_3_target_weigh
|
|
||||||
- percent_10_3_volume
|
|
||||||
- percent_10_3_liquid_material_name
|
|
||||||
- speed
|
|
||||||
- temperature
|
|
||||||
- delay_time
|
|
||||||
- hold_m_name
|
|
||||||
title: DispenStationVialFeed_Goal
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
properties:
|
|
||||||
return_info:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- return_info
|
|
||||||
title: DispenStationVialFeed_Result
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: DispenStationVialFeed
|
|
||||||
type: object
|
|
||||||
type: DispenStationVialFeed
|
|
||||||
create_diamine_solution_task:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
delay_time: delay_time
|
|
||||||
hold_m_name: hold_m_name
|
|
||||||
liquid_material_name: liquid_material_name
|
|
||||||
material_name: material_name
|
|
||||||
order_name: order_name
|
|
||||||
speed: speed
|
|
||||||
target_weigh: target_weigh
|
|
||||||
temperature: temperature
|
|
||||||
volume: volume
|
|
||||||
goal_default:
|
|
||||||
delay_time: ''
|
|
||||||
hold_m_name: ''
|
|
||||||
liquid_material_name: ''
|
|
||||||
material_name: ''
|
|
||||||
order_name: ''
|
|
||||||
speed: ''
|
|
||||||
target_weigh: ''
|
|
||||||
temperature: ''
|
|
||||||
volume: ''
|
|
||||||
handles: {}
|
|
||||||
result:
|
|
||||||
return_info: return_info
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback:
|
|
||||||
properties: {}
|
|
||||||
required: []
|
|
||||||
title: DispenStationSolnPrep_Feedback
|
|
||||||
type: object
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
delay_time:
|
|
||||||
type: string
|
|
||||||
hold_m_name:
|
|
||||||
type: string
|
|
||||||
liquid_material_name:
|
|
||||||
type: string
|
|
||||||
material_name:
|
|
||||||
type: string
|
|
||||||
order_name:
|
|
||||||
type: string
|
|
||||||
speed:
|
|
||||||
type: string
|
|
||||||
target_weigh:
|
|
||||||
type: string
|
|
||||||
temperature:
|
|
||||||
type: string
|
|
||||||
volume:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- order_name
|
|
||||||
- material_name
|
|
||||||
- target_weigh
|
|
||||||
- volume
|
|
||||||
- liquid_material_name
|
|
||||||
- speed
|
|
||||||
- temperature
|
|
||||||
- delay_time
|
|
||||||
- hold_m_name
|
|
||||||
title: DispenStationSolnPrep_Goal
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
properties:
|
|
||||||
return_info:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- return_info
|
|
||||||
title: DispenStationSolnPrep_Result
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: DispenStationSolnPrep
|
|
||||||
type: object
|
|
||||||
type: DispenStationSolnPrep
|
|
||||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
|
||||||
status_types: {}
|
|
||||||
type: python
|
|
||||||
config_info: []
|
|
||||||
description: ''
|
|
||||||
handles: []
|
|
||||||
icon: ''
|
|
||||||
init_param_schema:
|
|
||||||
config:
|
|
||||||
properties:
|
|
||||||
config:
|
|
||||||
type: string
|
|
||||||
deck:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- config
|
|
||||||
- deck
|
|
||||||
type: object
|
|
||||||
data:
|
|
||||||
properties: {}
|
|
||||||
required: []
|
|
||||||
type: object
|
|
||||||
version: 1.0.0
|
|
||||||
2144
unilabos/registry/devices/bioyond_cell.yaml
Normal file
2144
unilabos/registry/devices/bioyond_cell.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,71 +30,6 @@ bioyond_dispensing_station:
|
|||||||
title: brief_step_parameters参数
|
title: brief_step_parameters参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-compute_experiment_design:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
m_tot: '70'
|
|
||||||
ratio: null
|
|
||||||
titration_percent: '0.03'
|
|
||||||
wt_percent: '0.25'
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
m_tot:
|
|
||||||
default: '70'
|
|
||||||
type: string
|
|
||||||
ratio:
|
|
||||||
type: object
|
|
||||||
titration_percent:
|
|
||||||
default: '0.03'
|
|
||||||
type: string
|
|
||||||
wt_percent:
|
|
||||||
default: '0.25'
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- ratio
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
properties:
|
|
||||||
feeding_order:
|
|
||||||
items: {}
|
|
||||||
title: Feeding Order
|
|
||||||
type: array
|
|
||||||
return_info:
|
|
||||||
title: Return Info
|
|
||||||
type: string
|
|
||||||
solutions:
|
|
||||||
items: {}
|
|
||||||
title: Solutions
|
|
||||||
type: array
|
|
||||||
solvents:
|
|
||||||
additionalProperties: true
|
|
||||||
title: Solvents
|
|
||||||
type: object
|
|
||||||
titration:
|
|
||||||
additionalProperties: true
|
|
||||||
title: Titration
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- solutions
|
|
||||||
- titration
|
|
||||||
- solvents
|
|
||||||
- feeding_order
|
|
||||||
- return_info
|
|
||||||
title: ComputeExperimentDesignReturn
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: compute_experiment_design参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-process_order_finish_report:
|
auto-process_order_finish_report:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -174,35 +109,6 @@ bioyond_dispensing_station:
|
|||||||
title: query_resource_by_name参数
|
title: query_resource_by_name参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-transfer_materials_to_reaction_station:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
target_device_id: null
|
|
||||||
transfer_groups: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
target_device_id:
|
|
||||||
type: string
|
|
||||||
transfer_groups:
|
|
||||||
type: array
|
|
||||||
required:
|
|
||||||
- target_device_id
|
|
||||||
- transfer_groups
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: transfer_materials_to_reaction_station参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-workflow_sample_locations:
|
auto-workflow_sample_locations:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -394,6 +300,99 @@ bioyond_dispensing_station:
|
|||||||
title: BatchCreateDiamineSolutionTasks
|
title: BatchCreateDiamineSolutionTasks
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
compute_experiment_design:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
m_tot: m_tot
|
||||||
|
ratio: ratio
|
||||||
|
titration_percent: titration_percent
|
||||||
|
wt_percent: wt_percent
|
||||||
|
goal_default:
|
||||||
|
m_tot: '70'
|
||||||
|
ratio: ''
|
||||||
|
titration_percent: '0.03'
|
||||||
|
wt_percent: '0.25'
|
||||||
|
handles:
|
||||||
|
output:
|
||||||
|
- data_key: solutions
|
||||||
|
data_source: executor
|
||||||
|
data_type: array
|
||||||
|
handler_key: solutions
|
||||||
|
io_type: sink
|
||||||
|
label: Solution Data From Python
|
||||||
|
- data_key: titration
|
||||||
|
data_source: executor
|
||||||
|
data_type: object
|
||||||
|
handler_key: titration
|
||||||
|
io_type: sink
|
||||||
|
label: Titration Data From Calculation Node
|
||||||
|
- data_key: solvents
|
||||||
|
data_source: executor
|
||||||
|
data_type: object
|
||||||
|
handler_key: solvents
|
||||||
|
io_type: sink
|
||||||
|
label: Solvents Data From Calculation Node
|
||||||
|
- data_key: feeding_order
|
||||||
|
data_source: executor
|
||||||
|
data_type: array
|
||||||
|
handler_key: feeding_order
|
||||||
|
io_type: sink
|
||||||
|
label: Feeding Order Data From Calculation Node
|
||||||
|
result:
|
||||||
|
feeding_order: feeding_order
|
||||||
|
return_info: return_info
|
||||||
|
solutions: solutions
|
||||||
|
solvents: solvents
|
||||||
|
titration: titration
|
||||||
|
schema:
|
||||||
|
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
m_tot:
|
||||||
|
default: '70'
|
||||||
|
description: 总质量(g)
|
||||||
|
type: string
|
||||||
|
ratio:
|
||||||
|
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||||
|
type: string
|
||||||
|
titration_percent:
|
||||||
|
default: '0.03'
|
||||||
|
description: 滴定比例(10%部分)
|
||||||
|
type: string
|
||||||
|
wt_percent:
|
||||||
|
default: '0.25'
|
||||||
|
description: 目标固含质量分数
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- ratio
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
properties:
|
||||||
|
feeding_order:
|
||||||
|
type: array
|
||||||
|
return_info:
|
||||||
|
type: string
|
||||||
|
solutions:
|
||||||
|
type: array
|
||||||
|
solvents:
|
||||||
|
type: object
|
||||||
|
titration:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- solutions
|
||||||
|
- titration
|
||||||
|
- solvents
|
||||||
|
- feeding_order
|
||||||
|
- return_info
|
||||||
|
title: ComputeExperimentDesign_Result
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: ComputeExperimentDesign
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
create_90_10_vial_feeding_task:
|
create_90_10_vial_feeding_task:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -620,6 +619,89 @@ bioyond_dispensing_station:
|
|||||||
title: DispenStationSolnPrep
|
title: DispenStationSolnPrep
|
||||||
type: object
|
type: object
|
||||||
type: DispenStationSolnPrep
|
type: DispenStationSolnPrep
|
||||||
|
scheduler_start:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
result:
|
||||||
|
return_info: return_info
|
||||||
|
schema:
|
||||||
|
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
properties:
|
||||||
|
return_info:
|
||||||
|
description: 调度器启动结果,成功返回1,失败返回0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- return_info
|
||||||
|
title: scheduler_start结果
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: scheduler_start参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
transfer_materials_to_reaction_station:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
target_device_id: target_device_id
|
||||||
|
transfer_groups: transfer_groups
|
||||||
|
goal_default:
|
||||||
|
target_device_id: ''
|
||||||
|
transfer_groups: ''
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys:
|
||||||
|
target_device_id: unilabos_devices
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
target_device_id:
|
||||||
|
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
||||||
|
type: string
|
||||||
|
transfer_groups:
|
||||||
|
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
materials:
|
||||||
|
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||||
|
type: string
|
||||||
|
target_sites:
|
||||||
|
description: 目标库位(手动输入,如"A01")
|
||||||
|
type: string
|
||||||
|
target_stack:
|
||||||
|
description: 目标堆栈名称(从列表选择)
|
||||||
|
enum:
|
||||||
|
- 堆栈1左
|
||||||
|
- 堆栈1右
|
||||||
|
- 站内试剂存放堆栈
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- materials
|
||||||
|
- target_stack
|
||||||
|
- target_sites
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- target_device_id
|
||||||
|
- transfer_groups
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: transfer_materials_to_reaction_station参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
wait_for_multiple_orders_and_get_reports:
|
wait_for_multiple_orders_and_get_reports:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -688,7 +770,7 @@ bioyond_dispensing_station:
|
|||||||
title: WaitForMultipleOrdersAndGetReports
|
title: WaitForMultipleOrdersAndGetReports
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||||
status_types: {}
|
status_types: {}
|
||||||
type: python
|
type: python
|
||||||
config_info: []
|
config_info: []
|
||||||
@@ -699,15 +781,16 @@ bioyond_dispensing_station:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
type: string
|
type: object
|
||||||
deck:
|
deck:
|
||||||
type: string
|
type: string
|
||||||
required:
|
protocol_type:
|
||||||
- config
|
type: string
|
||||||
- deck
|
required: []
|
||||||
type: object
|
type: object
|
||||||
data:
|
data:
|
||||||
properties: {}
|
properties: {}
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
model: {}
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ camera:
|
|||||||
period:
|
period:
|
||||||
default: 0.1
|
default: 0.1
|
||||||
type: number
|
type: number
|
||||||
|
registry_name:
|
||||||
|
default: ''
|
||||||
|
type: string
|
||||||
resource_tracker:
|
resource_tracker:
|
||||||
type: object
|
type: object
|
||||||
required: []
|
required: []
|
||||||
|
|||||||
850
unilabos/registry/devices/coin_cell_workstation.yaml
Normal file
850
unilabos/registry/devices/coin_cell_workstation.yaml
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
coincellassemblyworkstation_device:
|
||||||
|
category:
|
||||||
|
- coin_cell_workstation
|
||||||
|
class:
|
||||||
|
action_value_mappings:
|
||||||
|
auto-change_hole_sheet_to_2:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
hole: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
hole:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- hole
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: change_hole_sheet_to_2参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
auto-fill_plate:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: fill_plate参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommandAsync
|
||||||
|
auto-fun_wuliao_test:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: fun_wuliao_test参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_allpack_cmd:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
assembly_pressure: 4200
|
||||||
|
assembly_type: 7
|
||||||
|
elec_num: null
|
||||||
|
elec_use_num: null
|
||||||
|
elec_vol: 50
|
||||||
|
file_path: /Users/sml/work
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
assembly_pressure:
|
||||||
|
default: 4200
|
||||||
|
type: integer
|
||||||
|
assembly_type:
|
||||||
|
default: 7
|
||||||
|
type: integer
|
||||||
|
elec_num:
|
||||||
|
type: string
|
||||||
|
elec_use_num:
|
||||||
|
type: string
|
||||||
|
elec_vol:
|
||||||
|
default: 50
|
||||||
|
type: integer
|
||||||
|
file_path:
|
||||||
|
default: /Users/sml/work
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- elec_num
|
||||||
|
- elec_use_num
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_allpack_cmd参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_allpack_cmd_simp:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
assembly_pressure: 4200
|
||||||
|
assembly_type: 7
|
||||||
|
battery_clean_ignore: false
|
||||||
|
battery_pressure_mode: true
|
||||||
|
dual_drop_first_volume: 25
|
||||||
|
dual_drop_mode: false
|
||||||
|
dual_drop_start_timing: false
|
||||||
|
dual_drop_suction_timing: false
|
||||||
|
elec_num: null
|
||||||
|
elec_use_num: null
|
||||||
|
elec_vol: 50
|
||||||
|
file_path: /Users/sml/work
|
||||||
|
fujipian_juzhendianwei: 0
|
||||||
|
fujipian_panshu: 0
|
||||||
|
gemo_juzhendianwei: 0
|
||||||
|
gemopanshu: 0
|
||||||
|
lvbodian: true
|
||||||
|
qiangtou_juzhendianwei: 0
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 简化版电池组装函数,整合了参数设置和双滴模式
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
assembly_pressure:
|
||||||
|
default: 4200
|
||||||
|
description: 电池压制力(N)
|
||||||
|
type: integer
|
||||||
|
assembly_type:
|
||||||
|
default: 7
|
||||||
|
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||||
|
type: integer
|
||||||
|
battery_clean_ignore:
|
||||||
|
default: false
|
||||||
|
description: 是否忽略电池清洁步骤
|
||||||
|
type: boolean
|
||||||
|
battery_pressure_mode:
|
||||||
|
default: true
|
||||||
|
description: 是否启用压力模式
|
||||||
|
type: boolean
|
||||||
|
dual_drop_first_volume:
|
||||||
|
default: 25
|
||||||
|
description: 二次滴液第一次排液体积(μL)
|
||||||
|
type: integer
|
||||||
|
dual_drop_mode:
|
||||||
|
default: false
|
||||||
|
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||||
|
type: boolean
|
||||||
|
dual_drop_start_timing:
|
||||||
|
default: false
|
||||||
|
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||||
|
type: boolean
|
||||||
|
dual_drop_suction_timing:
|
||||||
|
default: false
|
||||||
|
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||||
|
type: boolean
|
||||||
|
elec_num:
|
||||||
|
description: 电解液瓶数
|
||||||
|
type: string
|
||||||
|
elec_use_num:
|
||||||
|
description: 每瓶电解液组装电池数
|
||||||
|
type: string
|
||||||
|
elec_vol:
|
||||||
|
default: 50
|
||||||
|
description: 电解液吸液量(μL)
|
||||||
|
type: integer
|
||||||
|
file_path:
|
||||||
|
default: /Users/sml/work
|
||||||
|
description: 实验记录保存路径
|
||||||
|
type: string
|
||||||
|
fujipian_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
fujipian_panshu:
|
||||||
|
default: 0
|
||||||
|
description: 负极片盘数
|
||||||
|
type: integer
|
||||||
|
gemo_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
gemopanshu:
|
||||||
|
default: 0
|
||||||
|
description: 隔膜盘数
|
||||||
|
type: integer
|
||||||
|
lvbodian:
|
||||||
|
default: true
|
||||||
|
description: 是否使用铝箔垫片
|
||||||
|
type: boolean
|
||||||
|
qiangtou_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- elec_num
|
||||||
|
- elec_use_num
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_allpack_cmd_simp参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_get_csv_export_status:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_get_csv_export_status参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_device_auto:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_device_auto参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_device_init:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_device_init参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_device_init_auto_start_combined:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
material_search_enable: false
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
material_search_enable:
|
||||||
|
default: false
|
||||||
|
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||||
|
type: boolean
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_device_init_auto_start_combined参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_device_start:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_device_start参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_device_stop:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_device_stop参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_get_msg_cmd:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
file_path: D:\coin_cell_data
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
file_path:
|
||||||
|
default: D:\coin_cell_data
|
||||||
|
type: string
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_get_msg_cmd参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_send_bottle_num:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
bottle_num: null
|
||||||
|
handles:
|
||||||
|
input:
|
||||||
|
- data_key: bottle_num
|
||||||
|
data_source: workflow
|
||||||
|
data_type: integer
|
||||||
|
handler_key: bottle_count
|
||||||
|
io_type: source
|
||||||
|
label: 配液瓶数
|
||||||
|
required: true
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
bottle_num:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- bottle_num
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_send_bottle_num参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_send_finished_cmd:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_send_finished_cmd参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_pack_send_msg_cmd:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
assembly_pressure: null
|
||||||
|
assembly_type: null
|
||||||
|
elec_use_num: null
|
||||||
|
elec_vol: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
assembly_pressure:
|
||||||
|
type: string
|
||||||
|
assembly_type:
|
||||||
|
type: string
|
||||||
|
elec_use_num:
|
||||||
|
type: string
|
||||||
|
elec_vol:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- elec_use_num
|
||||||
|
- elec_vol
|
||||||
|
- assembly_type
|
||||||
|
- assembly_pressure
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_pack_send_msg_cmd参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_read_data_and_output:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
file_path: /Users/sml/work
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
file_path:
|
||||||
|
default: /Users/sml/work
|
||||||
|
type: string
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_read_data_and_output参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_sendbottle_allpack_multi:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
assembly_pressure: 4200
|
||||||
|
assembly_type: 7
|
||||||
|
battery_clean_ignore: false
|
||||||
|
battery_pressure_mode: true
|
||||||
|
dual_drop_first_volume: 25
|
||||||
|
dual_drop_mode: false
|
||||||
|
dual_drop_start_timing: false
|
||||||
|
dual_drop_suction_timing: false
|
||||||
|
elec_num: null
|
||||||
|
elec_use_num: null
|
||||||
|
elec_vol: 50
|
||||||
|
file_path: /Users/sml/work
|
||||||
|
fujipian_juzhendianwei: 0
|
||||||
|
fujipian_panshu: 0
|
||||||
|
gemo_juzhendianwei: 0
|
||||||
|
gemopanshu: 0
|
||||||
|
lvbodian: true
|
||||||
|
qiangtou_juzhendianwei: 0
|
||||||
|
handles:
|
||||||
|
input:
|
||||||
|
- data_key: elec_num
|
||||||
|
data_source: workflow
|
||||||
|
data_type: integer
|
||||||
|
handler_key: bottle_count
|
||||||
|
io_type: source
|
||||||
|
label: 配液瓶数
|
||||||
|
required: true
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 发送瓶数+简化组装函数(适用于第二批次及后续批次),合并了发送瓶数和简化组装流程
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
assembly_pressure:
|
||||||
|
default: 4200
|
||||||
|
description: 电池压制力(N)
|
||||||
|
type: integer
|
||||||
|
assembly_type:
|
||||||
|
default: 7
|
||||||
|
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫)
|
||||||
|
type: integer
|
||||||
|
battery_clean_ignore:
|
||||||
|
default: false
|
||||||
|
description: 是否忽略电池清洁步骤
|
||||||
|
type: boolean
|
||||||
|
battery_pressure_mode:
|
||||||
|
default: true
|
||||||
|
description: 是否启用压力模式
|
||||||
|
type: boolean
|
||||||
|
dual_drop_first_volume:
|
||||||
|
default: 25
|
||||||
|
description: 二次滴液第一次排液体积(μL)
|
||||||
|
type: integer
|
||||||
|
dual_drop_mode:
|
||||||
|
default: false
|
||||||
|
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
||||||
|
type: boolean
|
||||||
|
dual_drop_start_timing:
|
||||||
|
default: false
|
||||||
|
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
||||||
|
type: boolean
|
||||||
|
dual_drop_suction_timing:
|
||||||
|
default: false
|
||||||
|
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
||||||
|
type: boolean
|
||||||
|
elec_num:
|
||||||
|
description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
|
||||||
|
type: string
|
||||||
|
elec_use_num:
|
||||||
|
description: 每瓶电解液组装电池数
|
||||||
|
type: string
|
||||||
|
elec_vol:
|
||||||
|
default: 50
|
||||||
|
description: 电解液吸液量(μL)
|
||||||
|
type: integer
|
||||||
|
file_path:
|
||||||
|
default: /Users/sml/work
|
||||||
|
description: 实验记录保存路径
|
||||||
|
type: string
|
||||||
|
fujipian_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
fujipian_panshu:
|
||||||
|
default: 0
|
||||||
|
description: 负极片盘数
|
||||||
|
type: integer
|
||||||
|
gemo_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
gemopanshu:
|
||||||
|
default: 0
|
||||||
|
description: 隔膜盘数
|
||||||
|
type: integer
|
||||||
|
lvbodian:
|
||||||
|
default: true
|
||||||
|
description: 是否使用铝箔垫片
|
||||||
|
type: boolean
|
||||||
|
qiangtou_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- elec_num
|
||||||
|
- elec_use_num
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_sendbottle_allpack_multi参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-func_stop_read_data:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: func_stop_read_data参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-modify_deck_name:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
resource_name: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
resource_name:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- resource_name
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: modify_deck_name参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-post_init:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
ros_node: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
ros_node:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- ros_node
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: post_init参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-qiming_coin_cell_code:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
battery_clean_ignore: false
|
||||||
|
battery_pressure: 4000
|
||||||
|
battery_pressure_mode: true
|
||||||
|
fujipian_juzhendianwei: 0
|
||||||
|
fujipian_panshu: null
|
||||||
|
gemo_juzhendianwei: 0
|
||||||
|
gemopanshu: 0
|
||||||
|
lvbodian: true
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
battery_clean_ignore:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
battery_pressure:
|
||||||
|
default: 4000
|
||||||
|
type: integer
|
||||||
|
battery_pressure_mode:
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
fujipian_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
type: integer
|
||||||
|
fujipian_panshu:
|
||||||
|
type: integer
|
||||||
|
gemo_juzhendianwei:
|
||||||
|
default: 0
|
||||||
|
type: integer
|
||||||
|
gemopanshu:
|
||||||
|
default: 0
|
||||||
|
type: integer
|
||||||
|
lvbodian:
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- fujipian_panshu
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: qiming_coin_cell_code参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||||
|
status_types:
|
||||||
|
data_assembly_coin_cell_num: int
|
||||||
|
data_assembly_pressure: int
|
||||||
|
data_assembly_time: float
|
||||||
|
data_axis_x_pos: float
|
||||||
|
data_axis_y_pos: float
|
||||||
|
data_axis_z_pos: float
|
||||||
|
data_coin_cell_code: str
|
||||||
|
data_coin_num: int
|
||||||
|
data_electrolyte_code: str
|
||||||
|
data_electrolyte_volume: int
|
||||||
|
data_glove_box_o2_content: float
|
||||||
|
data_glove_box_pressure: float
|
||||||
|
data_glove_box_water_content: float
|
||||||
|
data_open_circuit_voltage: float
|
||||||
|
data_pole_weight: float
|
||||||
|
request_rec_msg_status: bool
|
||||||
|
request_send_msg_status: bool
|
||||||
|
sys_mode: str
|
||||||
|
sys_status: str
|
||||||
|
type: python
|
||||||
|
config_info: []
|
||||||
|
description: ''
|
||||||
|
handles: []
|
||||||
|
icon: koudian.webp
|
||||||
|
init_param_schema:
|
||||||
|
config:
|
||||||
|
properties:
|
||||||
|
address:
|
||||||
|
default: 172.16.28.102
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
type: object
|
||||||
|
debug_mode:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
deck:
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
default: '502'
|
||||||
|
type: string
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
data:
|
||||||
|
properties:
|
||||||
|
data_assembly_coin_cell_num:
|
||||||
|
type: integer
|
||||||
|
data_assembly_pressure:
|
||||||
|
type: integer
|
||||||
|
data_assembly_time:
|
||||||
|
type: number
|
||||||
|
data_axis_x_pos:
|
||||||
|
type: number
|
||||||
|
data_axis_y_pos:
|
||||||
|
type: number
|
||||||
|
data_axis_z_pos:
|
||||||
|
type: number
|
||||||
|
data_coin_cell_code:
|
||||||
|
type: string
|
||||||
|
data_coin_num:
|
||||||
|
type: integer
|
||||||
|
data_electrolyte_code:
|
||||||
|
type: string
|
||||||
|
data_electrolyte_volume:
|
||||||
|
type: integer
|
||||||
|
data_glove_box_o2_content:
|
||||||
|
type: number
|
||||||
|
data_glove_box_pressure:
|
||||||
|
type: number
|
||||||
|
data_glove_box_water_content:
|
||||||
|
type: number
|
||||||
|
data_open_circuit_voltage:
|
||||||
|
type: number
|
||||||
|
data_pole_weight:
|
||||||
|
type: number
|
||||||
|
request_rec_msg_status:
|
||||||
|
type: boolean
|
||||||
|
request_send_msg_status:
|
||||||
|
type: boolean
|
||||||
|
sys_mode:
|
||||||
|
type: string
|
||||||
|
sys_status:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- sys_status
|
||||||
|
- sys_mode
|
||||||
|
- request_rec_msg_status
|
||||||
|
- request_send_msg_status
|
||||||
|
- data_assembly_coin_cell_num
|
||||||
|
- data_assembly_time
|
||||||
|
- data_open_circuit_voltage
|
||||||
|
- data_axis_x_pos
|
||||||
|
- data_axis_y_pos
|
||||||
|
- data_axis_z_pos
|
||||||
|
- data_pole_weight
|
||||||
|
- data_assembly_pressure
|
||||||
|
- data_electrolyte_volume
|
||||||
|
- data_coin_num
|
||||||
|
- data_coin_cell_code
|
||||||
|
- data_electrolyte_code
|
||||||
|
- data_glove_box_pressure
|
||||||
|
- data_glove_box_o2_content
|
||||||
|
- data_glove_box_water_content
|
||||||
|
type: object
|
||||||
|
registry_type: device
|
||||||
|
version: 1.0.0
|
||||||
@@ -638,7 +638,7 @@ liquid_handler:
|
|||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
description: 吸头迭代函数。用于自动管理和切换吸头架中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
description: 吸头迭代函数。用于自动管理和切换枪头盒中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
||||||
properties:
|
properties:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -712,6 +712,43 @@ liquid_handler:
|
|||||||
title: set_group参数
|
title: set_group参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-set_liquid_from_plate:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
liquid_names: null
|
||||||
|
plate: null
|
||||||
|
volumes: null
|
||||||
|
well_names: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
liquid_names:
|
||||||
|
type: string
|
||||||
|
plate:
|
||||||
|
type: string
|
||||||
|
volumes:
|
||||||
|
type: string
|
||||||
|
well_names:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- plate
|
||||||
|
- well_names
|
||||||
|
- liquid_names
|
||||||
|
- volumes
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: set_liquid_from_plate参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-set_tiprack:
|
auto-set_tiprack:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -721,7 +758,7 @@ liquid_handler:
|
|||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
description: 吸头架设置函数。用于配置和初始化液体处理系统的吸头架信息,包括吸头架位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、吸头架更换、实验配置等需要吸头资源管理的操作场景。
|
description: 枪头盒设置函数。用于配置和初始化液体处理系统的枪头盒信息,包括枪头盒位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、枪头盒更换、实验配置等需要吸头资源管理的操作场景。
|
||||||
properties:
|
properties:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -4019,8 +4056,7 @@ liquid_handler:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -4094,32 +4130,32 @@ liquid_handler:
|
|||||||
- 0
|
- 0
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: liquid
|
- data_key: sources
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources
|
||||||
label: sources
|
label: 待移动液体
|
||||||
- data_key: liquid
|
- data_key: targets
|
||||||
data_source: executor
|
|
||||||
data_type: resource
|
|
||||||
handler_key: targets
|
|
||||||
label: targets
|
|
||||||
- data_key: liquid
|
|
||||||
data_source: executor
|
|
||||||
data_type: resource
|
|
||||||
handler_key: tip_rack
|
|
||||||
label: tip_rack
|
|
||||||
output:
|
|
||||||
- data_key: liquid
|
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
|
handler_key: targets
|
||||||
|
label: 转移目标
|
||||||
|
- data_key: tip_racks
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: tip_rack
|
||||||
|
label: 枪头盒
|
||||||
|
output:
|
||||||
|
- data_key: sources.@flatten
|
||||||
|
data_source: executor
|
||||||
|
data_type: resource
|
||||||
handler_key: sources_out
|
handler_key: sources_out
|
||||||
label: sources
|
label: 移液后源孔
|
||||||
- data_key: liquid
|
- data_key: targets.@flatten
|
||||||
data_source: executor
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets_out
|
handler_key: targets_out
|
||||||
label: targets
|
label: 移液后目标孔
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
sources: unilabos_resources
|
sources: unilabos_resources
|
||||||
targets: unilabos_resources
|
targets: unilabos_resources
|
||||||
@@ -4176,11 +4212,9 @@ liquid_handler:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
@@ -5043,8 +5077,7 @@ liquid_handler.biomek:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -5118,19 +5151,34 @@ liquid_handler.biomek:
|
|||||||
- 0
|
- 0
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: liquid
|
- data_key: sources
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: liquid-input
|
handler_key: sources
|
||||||
io_type: target
|
io_type: target
|
||||||
label: Liquid Input
|
label: 待移动液体
|
||||||
|
- data_key: targets
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: targets
|
||||||
|
label: 转移目标
|
||||||
|
- data_key: tip_racks
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: tip_rack
|
||||||
|
label: 枪头盒
|
||||||
output:
|
output:
|
||||||
- data_key: liquid
|
- data_key: sources.@flatten
|
||||||
data_source: executor
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: liquid-output
|
handler_key: sources_out
|
||||||
io_type: source
|
io_type: source
|
||||||
label: Liquid Output
|
label: 移液后源孔
|
||||||
|
- data_key: targets.@flatten
|
||||||
|
data_source: executor
|
||||||
|
data_type: resource
|
||||||
|
handler_key: targets_out
|
||||||
|
label: 移液后目标孔
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
sources: unilabos_resources
|
sources: unilabos_resources
|
||||||
targets: unilabos_resources
|
targets: unilabos_resources
|
||||||
@@ -5187,11 +5235,9 @@ liquid_handler.biomek:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
@@ -9290,7 +9336,13 @@ liquid_handler.prcxi:
|
|||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: input_wells
|
handler_key: input_wells
|
||||||
label: InputWells
|
label: 待设定液体孔
|
||||||
|
output:
|
||||||
|
- data_key: wells.@flatten
|
||||||
|
data_source: executor
|
||||||
|
data_type: resource
|
||||||
|
handler_key: output_wells
|
||||||
|
label: 已设定液体孔
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
wells: unilabos_resources
|
wells: unilabos_resources
|
||||||
result: {}
|
result: {}
|
||||||
@@ -9406,6 +9458,352 @@ liquid_handler.prcxi:
|
|||||||
title: LiquidHandlerSetLiquid
|
title: LiquidHandlerSetLiquid
|
||||||
type: object
|
type: object
|
||||||
type: LiquidHandlerSetLiquid
|
type: LiquidHandlerSetLiquid
|
||||||
|
set_liquid_from_plate:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
liquid_names: null
|
||||||
|
plate: null
|
||||||
|
volumes: null
|
||||||
|
well_names: null
|
||||||
|
handles:
|
||||||
|
input:
|
||||||
|
- data_key: '@this.0@@@plate'
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: input_plate
|
||||||
|
label: 待设定液体板
|
||||||
|
output:
|
||||||
|
- data_key: plate.@flatten
|
||||||
|
data_source: executor
|
||||||
|
data_type: resource
|
||||||
|
handler_key: output_plate
|
||||||
|
label: 已设定液体板
|
||||||
|
- data_key: wells.@flatten
|
||||||
|
data_source: executor
|
||||||
|
data_type: resource
|
||||||
|
handler_key: output_wells
|
||||||
|
label: 已设定液体孔
|
||||||
|
- data_key: volumes
|
||||||
|
data_source: executor
|
||||||
|
data_type: number_array
|
||||||
|
handler_key: output_volumes
|
||||||
|
label: 各孔设定体积
|
||||||
|
placeholder_keys:
|
||||||
|
plate: unilabos_resources
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
liquid_names:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
plate:
|
||||||
|
properties:
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
children:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
config:
|
||||||
|
type: string
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
parent:
|
||||||
|
type: string
|
||||||
|
pose:
|
||||||
|
properties:
|
||||||
|
orientation:
|
||||||
|
properties:
|
||||||
|
w:
|
||||||
|
type: number
|
||||||
|
x:
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- x
|
||||||
|
- y
|
||||||
|
- z
|
||||||
|
- w
|
||||||
|
title: orientation
|
||||||
|
type: object
|
||||||
|
position:
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- x
|
||||||
|
- y
|
||||||
|
- z
|
||||||
|
title: position
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- position
|
||||||
|
- orientation
|
||||||
|
title: pose
|
||||||
|
type: object
|
||||||
|
sample_id:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- sample_id
|
||||||
|
- children
|
||||||
|
- parent
|
||||||
|
- type
|
||||||
|
- category
|
||||||
|
- pose
|
||||||
|
- config
|
||||||
|
- data
|
||||||
|
title: plate
|
||||||
|
type: object
|
||||||
|
volumes:
|
||||||
|
items:
|
||||||
|
type: number
|
||||||
|
type: array
|
||||||
|
well_names:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- plate
|
||||||
|
- well_names
|
||||||
|
- liquid_names
|
||||||
|
- volumes
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
$defs:
|
||||||
|
ResourceDict:
|
||||||
|
properties:
|
||||||
|
class:
|
||||||
|
description: Resource class name
|
||||||
|
title: Class
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource configuration
|
||||||
|
title: Config
|
||||||
|
type: object
|
||||||
|
data:
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Resource data, eg: container liquid data'
|
||||||
|
title: Data
|
||||||
|
type: object
|
||||||
|
description:
|
||||||
|
default: ''
|
||||||
|
description: Resource description
|
||||||
|
title: Description
|
||||||
|
type: string
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Extra data, eg: slot index'
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
icon:
|
||||||
|
default: ''
|
||||||
|
description: Resource icon
|
||||||
|
title: Icon
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
description: Resource ID
|
||||||
|
title: Id
|
||||||
|
type: string
|
||||||
|
model:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource model
|
||||||
|
title: Model
|
||||||
|
type: object
|
||||||
|
name:
|
||||||
|
description: Resource name
|
||||||
|
title: Name
|
||||||
|
type: string
|
||||||
|
parent:
|
||||||
|
anyOf:
|
||||||
|
- $ref: '#/$defs/ResourceDict'
|
||||||
|
- type: 'null'
|
||||||
|
default: null
|
||||||
|
description: Parent resource object
|
||||||
|
parent_uuid:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
default: null
|
||||||
|
description: Parent resource uuid
|
||||||
|
title: Parent Uuid
|
||||||
|
pose:
|
||||||
|
$ref: '#/$defs/ResourceDictPosition'
|
||||||
|
description: Resource position
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource schema
|
||||||
|
title: Schema
|
||||||
|
type: object
|
||||||
|
type:
|
||||||
|
anyOf:
|
||||||
|
- const: device
|
||||||
|
type: string
|
||||||
|
- type: string
|
||||||
|
description: Resource type
|
||||||
|
title: Type
|
||||||
|
uuid:
|
||||||
|
description: Resource UUID
|
||||||
|
title: Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- uuid
|
||||||
|
- name
|
||||||
|
- type
|
||||||
|
- class
|
||||||
|
- config
|
||||||
|
- data
|
||||||
|
- extra
|
||||||
|
title: ResourceDict
|
||||||
|
type: object
|
||||||
|
ResourceDictPosition:
|
||||||
|
properties:
|
||||||
|
cross_section_type:
|
||||||
|
default: rectangle
|
||||||
|
description: Cross section type
|
||||||
|
enum:
|
||||||
|
- rectangle
|
||||||
|
- circle
|
||||||
|
- rounded_rectangle
|
||||||
|
title: Cross Section Type
|
||||||
|
type: string
|
||||||
|
layout:
|
||||||
|
default: x-y
|
||||||
|
description: Resource layout
|
||||||
|
enum:
|
||||||
|
- 2d
|
||||||
|
- x-y
|
||||||
|
- z-y
|
||||||
|
- x-z
|
||||||
|
title: Layout
|
||||||
|
type: string
|
||||||
|
position:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource position
|
||||||
|
position3d:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource position in 3D space
|
||||||
|
rotation:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource rotation
|
||||||
|
scale:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionScale'
|
||||||
|
description: Resource scale
|
||||||
|
size:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionSize'
|
||||||
|
description: Resource size
|
||||||
|
title: ResourceDictPosition
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionObject:
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
default: 0.0
|
||||||
|
description: X coordinate
|
||||||
|
title: X
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
default: 0.0
|
||||||
|
description: Y coordinate
|
||||||
|
title: Y
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
default: 0.0
|
||||||
|
description: Z coordinate
|
||||||
|
title: Z
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionObject
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionScale:
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
default: 0.0
|
||||||
|
description: x scale
|
||||||
|
title: X
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
default: 0.0
|
||||||
|
description: y scale
|
||||||
|
title: Y
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
default: 0.0
|
||||||
|
description: z scale
|
||||||
|
title: Z
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionScale
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionSize:
|
||||||
|
properties:
|
||||||
|
depth:
|
||||||
|
default: 0.0
|
||||||
|
description: Depth
|
||||||
|
title: Depth
|
||||||
|
type: number
|
||||||
|
height:
|
||||||
|
default: 0.0
|
||||||
|
description: Height
|
||||||
|
title: Height
|
||||||
|
type: number
|
||||||
|
width:
|
||||||
|
default: 0.0
|
||||||
|
description: Width
|
||||||
|
title: Width
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionSize
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
plate:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/ResourceDict'
|
||||||
|
type: array
|
||||||
|
title: Plate
|
||||||
|
type: array
|
||||||
|
volumes:
|
||||||
|
items:
|
||||||
|
type: number
|
||||||
|
title: Volumes
|
||||||
|
type: array
|
||||||
|
wells:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/ResourceDict'
|
||||||
|
type: array
|
||||||
|
title: Wells
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- plate
|
||||||
|
- wells
|
||||||
|
- volumes
|
||||||
|
title: SetLiquidFromPlateReturn
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: set_liquid_from_plate参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
set_tiprack:
|
set_tiprack:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -9677,8 +10075,7 @@ liquid_handler.prcxi:
|
|||||||
mix_liquid_height: 0.0
|
mix_liquid_height: 0.0
|
||||||
mix_rate: 0
|
mix_rate: 0
|
||||||
mix_stage: ''
|
mix_stage: ''
|
||||||
mix_times:
|
mix_times: 0
|
||||||
- 0
|
|
||||||
mix_vol: 0
|
mix_vol: 0
|
||||||
none_keys:
|
none_keys:
|
||||||
- ''
|
- ''
|
||||||
@@ -9766,18 +10163,18 @@ liquid_handler.prcxi:
|
|||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: tip_rack_identifier
|
handler_key: tip_rack_identifier
|
||||||
label: 墙头盒
|
label: 枪头盒
|
||||||
output:
|
output:
|
||||||
- data_key: liquid
|
- data_key: sources.@flatten
|
||||||
data_source: handle
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources_out
|
handler_key: sources_out
|
||||||
label: sources
|
label: 移液后源孔
|
||||||
- data_key: liquid
|
- data_key: targets.@flatten
|
||||||
data_source: executor
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets_out
|
handler_key: targets_out
|
||||||
label: targets
|
label: 移液后目标孔
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
sources: unilabos_resources
|
sources: unilabos_resources
|
||||||
targets: unilabos_resources
|
targets: unilabos_resources
|
||||||
@@ -9834,11 +10231,9 @@ liquid_handler.prcxi:
|
|||||||
mix_stage:
|
mix_stage:
|
||||||
type: string
|
type: string
|
||||||
mix_times:
|
mix_times:
|
||||||
items:
|
maximum: 2147483647
|
||||||
maximum: 2147483647
|
minimum: -2147483648
|
||||||
minimum: -2147483648
|
type: integer
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
mix_vol:
|
mix_vol:
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
|
|||||||
@@ -49,32 +49,7 @@ opcua_example:
|
|||||||
title: load_config参数
|
title: load_config参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-post_init:
|
auto-refresh_node_values:
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
ros_node: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
ros_node:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- ros_node
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: post_init参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-print_cache_stats:
|
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
goal_default: {}
|
goal_default: {}
|
||||||
@@ -92,32 +67,7 @@ opcua_example:
|
|||||||
result: {}
|
result: {}
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: print_cache_stats参数
|
title: refresh_node_values参数
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-read_node:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
node_name: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
node_name:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- node_name
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: read_node参数
|
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-set_node_value:
|
auto-set_node_value:
|
||||||
@@ -149,9 +99,50 @@ opcua_example:
|
|||||||
title: set_node_value参数
|
title: set_node_value参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-start_node_refresh:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: start_node_refresh参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-stop_node_refresh:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: stop_node_refresh参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||||
status_types:
|
status_types:
|
||||||
cache_stats: dict
|
|
||||||
node_value: String
|
node_value: String
|
||||||
type: python
|
type: python
|
||||||
config_info: []
|
config_info: []
|
||||||
@@ -161,23 +152,15 @@ opcua_example:
|
|||||||
init_param_schema:
|
init_param_schema:
|
||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
cache_timeout:
|
|
||||||
default: 5.0
|
|
||||||
type: number
|
|
||||||
config_path:
|
config_path:
|
||||||
type: string
|
type: string
|
||||||
deck:
|
|
||||||
type: string
|
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
subscription_interval:
|
refresh_interval:
|
||||||
default: 500
|
default: 1.0
|
||||||
type: integer
|
type: number
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
use_subscription:
|
|
||||||
default: true
|
|
||||||
type: boolean
|
|
||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
@@ -185,12 +168,9 @@ opcua_example:
|
|||||||
type: object
|
type: object
|
||||||
data:
|
data:
|
||||||
properties:
|
properties:
|
||||||
cache_stats:
|
|
||||||
type: object
|
|
||||||
node_value:
|
node_value:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- node_value
|
- node_value
|
||||||
- cache_stats
|
|
||||||
type: object
|
type: object
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -4,6 +4,81 @@ reaction_station.bioyond:
|
|||||||
- reaction_station_bioyond
|
- reaction_station_bioyond
|
||||||
class:
|
class:
|
||||||
action_value_mappings:
|
action_value_mappings:
|
||||||
|
add_time_constraint:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
duration: duration
|
||||||
|
end_point: end_point
|
||||||
|
end_step_key: end_step_key
|
||||||
|
start_point: start_point
|
||||||
|
start_step_key: start_step_key
|
||||||
|
goal_default:
|
||||||
|
duration: 0
|
||||||
|
end_point: 0
|
||||||
|
end_step_key: ''
|
||||||
|
start_point: 0
|
||||||
|
start_step_key: ''
|
||||||
|
handles: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: 添加时间约束 - 在两个工作流之间添加时间约束
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
duration:
|
||||||
|
description: 时间(秒)
|
||||||
|
type: integer
|
||||||
|
end_point:
|
||||||
|
default: Start
|
||||||
|
description: 终点计时点 (Start=开始前, End=结束后)
|
||||||
|
enum:
|
||||||
|
- Start
|
||||||
|
- End
|
||||||
|
type: string
|
||||||
|
end_step_key:
|
||||||
|
description: 终点步骤Key (可选, 默认为空则自动选择)
|
||||||
|
type: string
|
||||||
|
start_point:
|
||||||
|
default: Start
|
||||||
|
description: 起点计时点 (Start=开始前, End=结束后)
|
||||||
|
enum:
|
||||||
|
- Start
|
||||||
|
- End
|
||||||
|
type: string
|
||||||
|
start_step_key:
|
||||||
|
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- duration
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: add_time_constraint参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
|
auto-clear_workflows:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: clear_workflows参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-create_order:
|
auto-create_order:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -131,6 +206,35 @@ reaction_station.bioyond:
|
|||||||
title: process_web_workflows参数
|
title: process_web_workflows参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-set_reactor_temperature:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
reactor_id: null
|
||||||
|
temperature: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
reactor_id:
|
||||||
|
type: integer
|
||||||
|
temperature:
|
||||||
|
type: number
|
||||||
|
required:
|
||||||
|
- reactor_id
|
||||||
|
- temperature
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: set_reactor_temperature参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-skip_titration_steps:
|
auto-skip_titration_steps:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -156,6 +260,27 @@ reaction_station.bioyond:
|
|||||||
title: skip_titration_steps参数
|
title: skip_titration_steps参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-sync_workflow_sequence_from_bioyond:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: sync_workflow_sequence_from_bioyond参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-wait_for_multiple_orders_and_get_reports:
|
auto-wait_for_multiple_orders_and_get_reports:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -188,6 +313,33 @@ reaction_station.bioyond:
|
|||||||
title: wait_for_multiple_orders_and_get_reports参数
|
title: wait_for_multiple_orders_and_get_reports参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-workflow_sequence:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
value: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
value:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- value
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: workflow_sequence参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-workflow_step_query:
|
auto-workflow_step_query:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -213,6 +365,36 @@ reaction_station.bioyond:
|
|||||||
title: workflow_step_query参数
|
title: workflow_step_query参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
clean_all_server_workflows:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
result:
|
||||||
|
code: code
|
||||||
|
message: message
|
||||||
|
schema:
|
||||||
|
description: 清空服务端所有非核心工作流 (保留核心流程)
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
description: 操作结果代码(1表示成功)
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
description: 结果描述
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: clean_all_server_workflows参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
drip_back:
|
drip_back:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -247,13 +429,19 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟)
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
description: 是否滴定(1=否, 2=是)
|
description: 是否滴定(NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
description: 是否观察 (1=否, 2=是)
|
description: 是否观察 (NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
volume:
|
volume:
|
||||||
description: 分液公式(μL)
|
description: 分液公式(mL)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -353,13 +541,19 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟)
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
description: 是否滴定(1=否, 2=是)
|
description: 是否滴定(NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
description: 是否观察 (1=否, 2=是)
|
description: 是否观察 (NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
volume:
|
volume:
|
||||||
description: 分液公式(μL)
|
description: 分液公式(mL)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -403,7 +597,7 @@ reaction_station.bioyond:
|
|||||||
label: Solvents Data From Calculation Node
|
label: Solvents Data From Calculation Node
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。
|
||||||
properties:
|
properties:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -423,15 +617,21 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟),默认360
|
description: 观察时间(分钟),默认360
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '1'
|
default: 'NO'
|
||||||
description: 是否滴定(1=否, 2=是),默认1
|
description: 是否滴定(NO=否, YES=是),默认NO
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: '2'
|
default: 'YES'
|
||||||
description: 是否观察 (1=否, 2=是),默认2
|
description: 是否观察 (NO=否, YES=是),默认YES
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
volume:
|
volume:
|
||||||
description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
|
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -504,15 +704,21 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟),默认90
|
description: 观察时间(分钟),默认90
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '2'
|
default: 'YES'
|
||||||
description: 是否滴定(1=否, 2=是),默认2
|
description: 是否滴定(NO=否, YES=是),默认YES
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: '2'
|
default: 'YES'
|
||||||
description: 是否观察 (1=否, 2=是),默认2
|
description: 是否观察 (NO=否, YES=是),默认YES
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
volume_formula:
|
volume_formula:
|
||||||
description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
||||||
type: string
|
type: string
|
||||||
x_value:
|
x_value:
|
||||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
||||||
@@ -560,13 +766,19 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟)
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
description: 是否滴定(1=否, 2=是)
|
description: 是否滴定(NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
description: 是否观察 (1=否, 2=是)
|
description: 是否观察 (NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
volume_formula:
|
volume_formula:
|
||||||
description: 分液公式(μL)
|
description: 分液公式(mL)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- volume_formula
|
- volume_formula
|
||||||
@@ -680,6 +892,35 @@ reaction_station.bioyond:
|
|||||||
title: reactor_taken_out参数
|
title: reactor_taken_out参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
scheduler_start:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default: {}
|
||||||
|
handles: {}
|
||||||
|
result:
|
||||||
|
return_info: return_info
|
||||||
|
schema:
|
||||||
|
description: 启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties: {}
|
||||||
|
required: []
|
||||||
|
type: object
|
||||||
|
result:
|
||||||
|
properties:
|
||||||
|
return_info:
|
||||||
|
description: 调度器启动结果,成功返回1,失败返回0
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- return_info
|
||||||
|
title: scheduler_start结果
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: scheduler_start参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
solid_feeding_vials:
|
solid_feeding_vials:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal:
|
goal:
|
||||||
@@ -706,7 +947,11 @@ reaction_station.bioyond:
|
|||||||
description: 物料名称(用于获取试剂瓶位ID)
|
description: 物料名称(用于获取试剂瓶位ID)
|
||||||
type: string
|
type: string
|
||||||
material_id:
|
material_id:
|
||||||
description: 粉末类型ID,1=盐(21分钟),2=面粉(27分钟),3=BTDA(38分钟)
|
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
||||||
|
enum:
|
||||||
|
- Salt
|
||||||
|
- Flour
|
||||||
|
- BTDA
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
description: 温度设定(°C)
|
description: 温度设定(°C)
|
||||||
@@ -715,7 +960,10 @@ reaction_station.bioyond:
|
|||||||
description: 观察时间(分钟)
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
description: 是否观察 (1=否, 2=是)
|
description: 是否观察 (NO=否, YES=是)
|
||||||
|
enum:
|
||||||
|
- 'NO'
|
||||||
|
- 'YES'
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -730,10 +978,10 @@ reaction_station.bioyond:
|
|||||||
title: solid_feeding_vials参数
|
title: solid_feeding_vials参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
|
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactionStation
|
||||||
protocol_type: []
|
protocol_type: []
|
||||||
status_types:
|
status_types:
|
||||||
workflow_sequence: String
|
workflow_sequence: str
|
||||||
type: python
|
type: python
|
||||||
config_info: []
|
config_info: []
|
||||||
description: Bioyond反应站
|
description: Bioyond反应站
|
||||||
@@ -753,9 +1001,7 @@ reaction_station.bioyond:
|
|||||||
data:
|
data:
|
||||||
properties:
|
properties:
|
||||||
workflow_sequence:
|
workflow_sequence:
|
||||||
items:
|
type: string
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
required:
|
required:
|
||||||
- workflow_sequence
|
- workflow_sequence
|
||||||
type: object
|
type: object
|
||||||
@@ -791,7 +1037,7 @@ reaction_station.reactor:
|
|||||||
title: update_metrics参数
|
title: update_metrics参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
|
module: unilabos.devices.workstation.bioyond_studio.reaction_station.reaction_station:BioyondReactor
|
||||||
status_types: {}
|
status_types: {}
|
||||||
type: python
|
type: python
|
||||||
config_info: []
|
config_info: []
|
||||||
|
|||||||
@@ -5835,6 +5835,25 @@ virtual_workbench:
|
|||||||
- material_number
|
- material_number
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
|
$defs:
|
||||||
|
LabSample:
|
||||||
|
properties:
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
oss_path:
|
||||||
|
title: Oss Path
|
||||||
|
type: string
|
||||||
|
sample_uuid:
|
||||||
|
title: Sample Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- sample_uuid
|
||||||
|
- oss_path
|
||||||
|
- extra
|
||||||
|
title: LabSample
|
||||||
|
type: object
|
||||||
description: move_to_heating_station 返回类型
|
description: move_to_heating_station 返回类型
|
||||||
properties:
|
properties:
|
||||||
material_id:
|
material_id:
|
||||||
@@ -5853,12 +5872,18 @@ virtual_workbench:
|
|||||||
success:
|
success:
|
||||||
title: Success
|
title: Success
|
||||||
type: boolean
|
type: boolean
|
||||||
|
unilabos_samples:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/LabSample'
|
||||||
|
title: Unilabos Samples
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- success
|
- success
|
||||||
- station_id
|
- station_id
|
||||||
- material_id
|
- material_id
|
||||||
- material_number
|
- material_number
|
||||||
- message
|
- message
|
||||||
|
- unilabos_samples
|
||||||
title: MoveToHeatingStationResult
|
title: MoveToHeatingStationResult
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -5903,6 +5928,25 @@ virtual_workbench:
|
|||||||
- material_number
|
- material_number
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
|
$defs:
|
||||||
|
LabSample:
|
||||||
|
properties:
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
oss_path:
|
||||||
|
title: Oss Path
|
||||||
|
type: string
|
||||||
|
sample_uuid:
|
||||||
|
title: Sample Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- sample_uuid
|
||||||
|
- oss_path
|
||||||
|
- extra
|
||||||
|
title: LabSample
|
||||||
|
type: object
|
||||||
description: move_to_output 返回类型
|
description: move_to_output 返回类型
|
||||||
properties:
|
properties:
|
||||||
material_id:
|
material_id:
|
||||||
@@ -5914,10 +5958,16 @@ virtual_workbench:
|
|||||||
success:
|
success:
|
||||||
title: Success
|
title: Success
|
||||||
type: boolean
|
type: boolean
|
||||||
|
unilabos_samples:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/LabSample'
|
||||||
|
title: Unilabos Samples
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- success
|
- success
|
||||||
- station_id
|
- station_id
|
||||||
- material_id
|
- material_id
|
||||||
|
- unilabos_samples
|
||||||
title: MoveToOutputResult
|
title: MoveToOutputResult
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -5972,6 +6022,25 @@ virtual_workbench:
|
|||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
|
$defs:
|
||||||
|
LabSample:
|
||||||
|
properties:
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
oss_path:
|
||||||
|
title: Oss Path
|
||||||
|
type: string
|
||||||
|
sample_uuid:
|
||||||
|
title: Sample Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- sample_uuid
|
||||||
|
- oss_path
|
||||||
|
- extra
|
||||||
|
title: LabSample
|
||||||
|
type: object
|
||||||
description: prepare_materials 返回类型 - 批量准备物料
|
description: prepare_materials 返回类型 - 批量准备物料
|
||||||
properties:
|
properties:
|
||||||
count:
|
count:
|
||||||
@@ -5998,6 +6067,11 @@ virtual_workbench:
|
|||||||
success:
|
success:
|
||||||
title: Success
|
title: Success
|
||||||
type: boolean
|
type: boolean
|
||||||
|
unilabos_samples:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/LabSample'
|
||||||
|
title: Unilabos Samples
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- success
|
- success
|
||||||
- count
|
- count
|
||||||
@@ -6007,6 +6081,7 @@ virtual_workbench:
|
|||||||
- material_4
|
- material_4
|
||||||
- material_5
|
- material_5
|
||||||
- message
|
- message
|
||||||
|
- unilabos_samples
|
||||||
title: PrepareMaterialsResult
|
title: PrepareMaterialsResult
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -6015,6 +6090,7 @@ virtual_workbench:
|
|||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
auto-start_heating:
|
auto-start_heating:
|
||||||
|
always_free: true
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
goal_default:
|
goal_default:
|
||||||
@@ -6062,6 +6138,25 @@ virtual_workbench:
|
|||||||
- material_number
|
- material_number
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
|
$defs:
|
||||||
|
LabSample:
|
||||||
|
properties:
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
oss_path:
|
||||||
|
title: Oss Path
|
||||||
|
type: string
|
||||||
|
sample_uuid:
|
||||||
|
title: Sample Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- sample_uuid
|
||||||
|
- oss_path
|
||||||
|
- extra
|
||||||
|
title: LabSample
|
||||||
|
type: object
|
||||||
description: start_heating 返回类型
|
description: start_heating 返回类型
|
||||||
properties:
|
properties:
|
||||||
material_id:
|
material_id:
|
||||||
@@ -6079,12 +6174,18 @@ virtual_workbench:
|
|||||||
success:
|
success:
|
||||||
title: Success
|
title: Success
|
||||||
type: boolean
|
type: boolean
|
||||||
|
unilabos_samples:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/LabSample'
|
||||||
|
title: Unilabos Samples
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- success
|
- success
|
||||||
- station_id
|
- station_id
|
||||||
- material_id
|
- material_id
|
||||||
- material_number
|
- material_number
|
||||||
- message
|
- message
|
||||||
|
- unilabos_samples
|
||||||
title: StartHeatingResult
|
title: StartHeatingResult
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import inspect
|
import inspect
|
||||||
import importlib
|
import importlib
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Union, Tuple
|
from typing import Any, Dict, List, Union, Tuple
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ class Registry:
|
|||||||
self.device_module_to_registry = {}
|
self.device_module_to_registry = {}
|
||||||
self.resource_type_registry = {}
|
self.resource_type_registry = {}
|
||||||
self._setup_called = False # 跟踪setup是否已调用
|
self._setup_called = False # 跟踪setup是否已调用
|
||||||
|
self._registry_lock = threading.Lock() # 多线程加载时的锁
|
||||||
# 其他状态变量
|
# 其他状态变量
|
||||||
# self.is_host_mode = False # 移至BasicConfig中
|
# self.is_host_mode = False # 移至BasicConfig中
|
||||||
|
|
||||||
@@ -71,6 +75,28 @@ class Registry:
|
|||||||
|
|
||||||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||||||
|
|
||||||
|
# 获取 HostNode 类的增强信息,用于自动生成 action schema
|
||||||
|
host_node_enhanced_info = get_enhanced_class_info(
|
||||||
|
"unilabos.ros.nodes.presets.host_node:HostNode", use_dynamic=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 为 test_latency 生成 schema,保留原有 description
|
||||||
|
test_latency_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_latency", {})
|
||||||
|
test_latency_schema = self._generate_unilab_json_command_schema(
|
||||||
|
test_latency_method_info.get("args", []),
|
||||||
|
"test_latency",
|
||||||
|
test_latency_method_info.get("return_annotation"),
|
||||||
|
)
|
||||||
|
test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。"
|
||||||
|
|
||||||
|
test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {})
|
||||||
|
test_resource_schema = self._generate_unilab_json_command_schema(
|
||||||
|
test_resource_method_info.get("args", []),
|
||||||
|
"test_resource",
|
||||||
|
test_resource_method_info.get("return_annotation"),
|
||||||
|
)
|
||||||
|
test_resource_schema["description"] = "用于测试物料、设备和样本。"
|
||||||
|
|
||||||
self.device_type_registry.update(
|
self.device_type_registry.update(
|
||||||
{
|
{
|
||||||
"host_node": {
|
"host_node": {
|
||||||
@@ -153,14 +179,18 @@ class Registry:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"test_latency": {
|
"test_latency": {
|
||||||
"type": self.EmptyIn,
|
"type": (
|
||||||
|
"UniLabJsonCommandAsync"
|
||||||
|
if test_latency_method_info.get("is_async", False)
|
||||||
|
else "UniLabJsonCommand"
|
||||||
|
),
|
||||||
"goal": {},
|
"goal": {},
|
||||||
"feedback": {},
|
"feedback": {},
|
||||||
"result": {},
|
"result": {},
|
||||||
"schema": ros_action_to_json_schema(
|
"schema": test_latency_schema,
|
||||||
self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。"
|
"goal_default": {
|
||||||
),
|
arg["name"]: arg["default"] for arg in test_latency_method_info.get("args", [])
|
||||||
"goal_default": {},
|
},
|
||||||
"handles": {},
|
"handles": {},
|
||||||
},
|
},
|
||||||
"auto-test_resource": {
|
"auto-test_resource": {
|
||||||
@@ -168,32 +198,7 @@ class Registry:
|
|||||||
"goal": {},
|
"goal": {},
|
||||||
"feedback": {},
|
"feedback": {},
|
||||||
"result": {},
|
"result": {},
|
||||||
"schema": {
|
"schema": test_resource_schema,
|
||||||
"description": "",
|
|
||||||
"properties": {
|
|
||||||
"feedback": {},
|
|
||||||
"goal": {
|
|
||||||
"properties": {
|
|
||||||
"resource": ros_message_to_json_schema(Resource, "resource"),
|
|
||||||
"resources": {
|
|
||||||
"items": {
|
|
||||||
"properties": ros_message_to_json_schema(
|
|
||||||
Resource, "resources"
|
|
||||||
),
|
|
||||||
"type": "object",
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
},
|
|
||||||
"device": {"type": "string"},
|
|
||||||
"devices": {"items": {"type": "string"}, "type": "array"},
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
},
|
|
||||||
"result": {},
|
|
||||||
},
|
|
||||||
"title": "test_resource",
|
|
||||||
"type": "object",
|
|
||||||
},
|
|
||||||
"placeholder_keys": {
|
"placeholder_keys": {
|
||||||
"device": "unilabos_devices",
|
"device": "unilabos_devices",
|
||||||
"devices": "unilabos_devices",
|
"devices": "unilabos_devices",
|
||||||
@@ -243,67 +248,115 @@ class Registry:
|
|||||||
# 标记setup已被调用
|
# 标记setup已被调用
|
||||||
self._setup_called = True
|
self._setup_called = True
|
||||||
|
|
||||||
|
def _load_single_resource_file(
|
||||||
|
self, file: Path, complete_registry: bool, upload_registry: bool
|
||||||
|
) -> Tuple[Dict[str, Any], Dict[str, Any], bool]:
|
||||||
|
"""
|
||||||
|
加载单个资源文件 (线程安全)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(data, complete_data, is_valid): 资源数据, 完整数据, 是否有效
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file, encoding="utf-8", mode="r") as f:
|
||||||
|
data = yaml.safe_load(io.StringIO(f.read()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[UniLab Registry] 读取资源文件失败: {file}, 错误: {e}")
|
||||||
|
return {}, {}, False
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {}, {}, False
|
||||||
|
|
||||||
|
complete_data = {}
|
||||||
|
for resource_id, resource_info in data.items():
|
||||||
|
if "version" not in resource_info:
|
||||||
|
resource_info["version"] = "1.0.0"
|
||||||
|
if "category" not in resource_info:
|
||||||
|
resource_info["category"] = [file.stem]
|
||||||
|
elif file.stem not in resource_info["category"]:
|
||||||
|
resource_info["category"].append(file.stem)
|
||||||
|
elif not isinstance(resource_info.get("category"), list):
|
||||||
|
resource_info["category"] = [resource_info["category"]]
|
||||||
|
if "config_info" not in resource_info:
|
||||||
|
resource_info["config_info"] = []
|
||||||
|
if "icon" not in resource_info:
|
||||||
|
resource_info["icon"] = ""
|
||||||
|
if "handles" not in resource_info:
|
||||||
|
resource_info["handles"] = []
|
||||||
|
if "init_param_schema" not in resource_info:
|
||||||
|
resource_info["init_param_schema"] = {}
|
||||||
|
if "config_info" in resource_info:
|
||||||
|
del resource_info["config_info"]
|
||||||
|
if "file_path" in resource_info:
|
||||||
|
del resource_info["file_path"]
|
||||||
|
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
||||||
|
if upload_registry:
|
||||||
|
class_info = resource_info.get("class", {})
|
||||||
|
if len(class_info) and "module" in class_info:
|
||||||
|
if class_info.get("type") == "pylabrobot":
|
||||||
|
res_class = get_class(class_info["module"])
|
||||||
|
if callable(res_class) and not isinstance(res_class, type):
|
||||||
|
res_instance = res_class(res_class.__name__)
|
||||||
|
res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)])
|
||||||
|
resource_info["config_info"] = res_ulr
|
||||||
|
resource_info["registry_type"] = "resource"
|
||||||
|
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
||||||
|
|
||||||
|
complete_data = dict(sorted(complete_data.items()))
|
||||||
|
complete_data = copy.deepcopy(complete_data)
|
||||||
|
|
||||||
|
if complete_registry:
|
||||||
|
try:
|
||||||
|
with open(file, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[UniLab Registry] 写入资源文件失败: {file}, 错误: {e}")
|
||||||
|
|
||||||
|
return data, complete_data, True
|
||||||
|
|
||||||
def load_resource_types(self, path: os.PathLike, complete_registry: bool, upload_registry: bool):
|
def load_resource_types(self, path: os.PathLike, complete_registry: bool, upload_registry: bool):
|
||||||
abs_path = Path(path).absolute()
|
abs_path = Path(path).absolute()
|
||||||
resource_path = abs_path / "resources"
|
resource_path = abs_path / "resources"
|
||||||
files = list(resource_path.glob("*/*.yaml"))
|
files = list(resource_path.glob("*/*.yaml"))
|
||||||
logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}")
|
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
|
||||||
current_resource_number = len(self.resource_type_registry) + 1
|
|
||||||
for i, file in enumerate(files):
|
|
||||||
with open(file, encoding="utf-8", mode="r") as f:
|
|
||||||
data = yaml.safe_load(io.StringIO(f.read()))
|
|
||||||
complete_data = {}
|
|
||||||
if data:
|
|
||||||
# 为每个资源添加文件路径信息
|
|
||||||
for resource_id, resource_info in data.items():
|
|
||||||
if "version" not in resource_info:
|
|
||||||
resource_info["version"] = "1.0.0"
|
|
||||||
if "category" not in resource_info:
|
|
||||||
resource_info["category"] = [file.stem]
|
|
||||||
elif file.stem not in resource_info["category"]:
|
|
||||||
resource_info["category"].append(file.stem)
|
|
||||||
elif not isinstance(resource_info.get("category"), list):
|
|
||||||
resource_info["category"] = [resource_info["category"]]
|
|
||||||
if "config_info" not in resource_info:
|
|
||||||
resource_info["config_info"] = []
|
|
||||||
if "icon" not in resource_info:
|
|
||||||
resource_info["icon"] = ""
|
|
||||||
if "handles" not in resource_info:
|
|
||||||
resource_info["handles"] = []
|
|
||||||
if "init_param_schema" not in resource_info:
|
|
||||||
resource_info["init_param_schema"] = {}
|
|
||||||
if "config_info" in resource_info:
|
|
||||||
del resource_info["config_info"]
|
|
||||||
if "file_path" in resource_info:
|
|
||||||
del resource_info["file_path"]
|
|
||||||
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
|
||||||
if upload_registry:
|
|
||||||
class_info = resource_info.get("class", {})
|
|
||||||
if len(class_info) and "module" in class_info:
|
|
||||||
if class_info.get("type") == "pylabrobot":
|
|
||||||
res_class = get_class(class_info["module"])
|
|
||||||
if callable(res_class) and not isinstance(
|
|
||||||
res_class, type
|
|
||||||
): # 有的是类,有的是函数,这里暂时只登记函数类的
|
|
||||||
res_instance = res_class(res_class.__name__)
|
|
||||||
res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)])
|
|
||||||
resource_info["config_info"] = res_ulr
|
|
||||||
resource_info["registry_type"] = "resource"
|
|
||||||
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
|
||||||
complete_data = dict(sorted(complete_data.items()))
|
|
||||||
complete_data = copy.deepcopy(complete_data)
|
|
||||||
if complete_registry:
|
|
||||||
with open(file, "w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用线程池并行加载
|
||||||
|
max_workers = min(8, len(files))
|
||||||
|
results = []
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_file = {
|
||||||
|
executor.submit(self._load_single_resource_file, file, complete_registry, upload_registry): file
|
||||||
|
for file in files
|
||||||
|
}
|
||||||
|
for future in as_completed(future_to_file):
|
||||||
|
file = future_to_file[future]
|
||||||
|
try:
|
||||||
|
data, complete_data, is_valid = future.result()
|
||||||
|
if is_valid:
|
||||||
|
results.append((file, data))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[UniLab Registry] 处理资源文件异常: {file}, 错误: {e}")
|
||||||
|
|
||||||
|
# 线程安全地更新注册表
|
||||||
|
current_resource_number = len(self.resource_type_registry) + 1
|
||||||
|
with self._registry_lock:
|
||||||
|
for i, (file, data) in enumerate(results):
|
||||||
self.resource_type_registry.update(data)
|
self.resource_type_registry.update(data)
|
||||||
logger.trace( # type: ignore
|
logger.trace(
|
||||||
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
|
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(results)} "
|
||||||
+ f"Add {list(data.keys())}"
|
+ f"Add {list(data.keys())}"
|
||||||
)
|
)
|
||||||
current_resource_number += 1
|
current_resource_number += 1
|
||||||
else:
|
|
||||||
logger.debug(f"[UniLab Registry] Res File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}")
|
# 记录无效文件
|
||||||
|
valid_files = {r[0] for r in results}
|
||||||
|
for file in files:
|
||||||
|
if file not in valid_files:
|
||||||
|
logger.debug(f"[UniLab Registry] Res File Not Valid YAML File: {file.absolute()}")
|
||||||
|
|
||||||
def _extract_class_docstrings(self, module_string: str) -> Dict[str, str]:
|
def _extract_class_docstrings(self, module_string: str) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
@@ -480,7 +533,11 @@ class Registry:
|
|||||||
return status_schema
|
return status_schema
|
||||||
|
|
||||||
def _generate_unilab_json_command_schema(
|
def _generate_unilab_json_command_schema(
|
||||||
self, method_args: List[Dict[str, Any]], method_name: str, return_annotation: Any = None
|
self,
|
||||||
|
method_args: List[Dict[str, Any]],
|
||||||
|
method_name: str,
|
||||||
|
return_annotation: Any = None,
|
||||||
|
previous_schema: Dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
根据UniLabJsonCommand方法信息生成JSON Schema,暂不支持嵌套类型
|
根据UniLabJsonCommand方法信息生成JSON Schema,暂不支持嵌套类型
|
||||||
@@ -489,6 +546,7 @@ class Registry:
|
|||||||
method_args: 方法信息字典,包含args等
|
method_args: 方法信息字典,包含args等
|
||||||
method_name: 方法名称
|
method_name: 方法名称
|
||||||
return_annotation: 返回类型注解,用于生成result schema(仅支持TypedDict)
|
return_annotation: 返回类型注解,用于生成result schema(仅支持TypedDict)
|
||||||
|
previous_schema: 之前的 schema,用于保留 goal/feedback/result 下一级字段的 description
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON Schema格式的参数schema
|
JSON Schema格式的参数schema
|
||||||
@@ -522,7 +580,7 @@ class Registry:
|
|||||||
if return_annotation is not None and self._is_typed_dict(return_annotation):
|
if return_annotation is not None and self._is_typed_dict(return_annotation):
|
||||||
result_schema = self._generate_typed_dict_result_schema(return_annotation)
|
result_schema = self._generate_typed_dict_result_schema(return_annotation)
|
||||||
|
|
||||||
return {
|
final_schema = {
|
||||||
"title": f"{method_name}参数",
|
"title": f"{method_name}参数",
|
||||||
"description": f"",
|
"description": f"",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -530,6 +588,40 @@ class Registry:
|
|||||||
"required": ["goal"],
|
"required": ["goal"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 保留之前 schema 中 goal/feedback/result 下一级字段的 description
|
||||||
|
if previous_schema:
|
||||||
|
self._preserve_field_descriptions(final_schema, previous_schema)
|
||||||
|
|
||||||
|
return final_schema
|
||||||
|
|
||||||
|
def _preserve_field_descriptions(self, new_schema: Dict[str, Any], previous_schema: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_schema: 新生成的 schema(会被修改)
|
||||||
|
previous_schema: 之前的 schema
|
||||||
|
"""
|
||||||
|
for section in ["goal", "feedback", "result"]:
|
||||||
|
new_section = new_schema.get("properties", {}).get(section, {})
|
||||||
|
prev_section = previous_schema.get("properties", {}).get(section, {})
|
||||||
|
|
||||||
|
if not new_section or not prev_section:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_props = new_section.get("properties", {})
|
||||||
|
prev_props = prev_section.get("properties", {})
|
||||||
|
|
||||||
|
for field_name, field_schema in new_props.items():
|
||||||
|
if field_name in prev_props:
|
||||||
|
prev_field = prev_props[field_name]
|
||||||
|
# 保留字段的 description
|
||||||
|
if "description" in prev_field and prev_field["description"]:
|
||||||
|
field_schema["description"] = prev_field["description"]
|
||||||
|
# 保留字段的 title(用户自定义的中文名)
|
||||||
|
if "title" in prev_field and prev_field["title"]:
|
||||||
|
field_schema["title"] = prev_field["title"]
|
||||||
|
|
||||||
def _is_typed_dict(self, annotation: Any) -> bool:
|
def _is_typed_dict(self, annotation: Any) -> bool:
|
||||||
"""
|
"""
|
||||||
检查类型注解是否是TypedDict
|
检查类型注解是否是TypedDict
|
||||||
@@ -616,209 +708,246 @@ class Registry:
|
|||||||
"handles": {},
|
"handles": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _load_single_device_file(
|
||||||
|
self, file: Path, complete_registry: bool, get_yaml_from_goal_type
|
||||||
|
) -> Tuple[Dict[str, Any], Dict[str, Any], bool, List[str]]:
|
||||||
|
"""
|
||||||
|
加载单个设备文件 (线程安全)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(data, complete_data, is_valid, device_ids): 设备数据, 完整数据, 是否有效, 设备ID列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file, encoding="utf-8", mode="r") as f:
|
||||||
|
data = yaml.safe_load(io.StringIO(f.read()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[UniLab Registry] 读取设备文件失败: {file}, 错误: {e}")
|
||||||
|
return {}, {}, False, []
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {}, {}, False, []
|
||||||
|
|
||||||
|
complete_data = {}
|
||||||
|
action_str_type_mapping = {
|
||||||
|
"UniLabJsonCommand": "UniLabJsonCommand",
|
||||||
|
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
|
||||||
|
}
|
||||||
|
status_str_type_mapping = {}
|
||||||
|
device_ids = []
|
||||||
|
|
||||||
|
for device_id, device_config in data.items():
|
||||||
|
if "version" not in device_config:
|
||||||
|
device_config["version"] = "1.0.0"
|
||||||
|
if "category" not in device_config:
|
||||||
|
device_config["category"] = [file.stem]
|
||||||
|
elif file.stem not in device_config["category"]:
|
||||||
|
device_config["category"].append(file.stem)
|
||||||
|
if "config_info" not in device_config:
|
||||||
|
device_config["config_info"] = []
|
||||||
|
if "description" not in device_config:
|
||||||
|
device_config["description"] = ""
|
||||||
|
if "icon" not in device_config:
|
||||||
|
device_config["icon"] = ""
|
||||||
|
if "handles" not in device_config:
|
||||||
|
device_config["handles"] = []
|
||||||
|
if "init_param_schema" not in device_config:
|
||||||
|
device_config["init_param_schema"] = {}
|
||||||
|
if "class" in device_config:
|
||||||
|
if "status_types" not in device_config["class"] or device_config["class"]["status_types"] is None:
|
||||||
|
device_config["class"]["status_types"] = {}
|
||||||
|
if (
|
||||||
|
"action_value_mappings" not in device_config["class"]
|
||||||
|
or device_config["class"]["action_value_mappings"] is None
|
||||||
|
):
|
||||||
|
device_config["class"]["action_value_mappings"] = {}
|
||||||
|
enhanced_info = {}
|
||||||
|
if complete_registry:
|
||||||
|
device_config["class"]["status_types"].clear()
|
||||||
|
enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True)
|
||||||
|
if not enhanced_info.get("dynamic_import_success", False):
|
||||||
|
continue
|
||||||
|
device_config["class"]["status_types"].update(
|
||||||
|
{k: v["return_type"] for k, v in enhanced_info["status_methods"].items()}
|
||||||
|
)
|
||||||
|
for status_name, status_type in device_config["class"]["status_types"].items():
|
||||||
|
if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]:
|
||||||
|
status_type = "String"
|
||||||
|
device_config["class"]["status_types"][status_name] = status_type
|
||||||
|
try:
|
||||||
|
target_type = self._replace_type_with_class(status_type, device_id, f"状态 {status_name}")
|
||||||
|
except ROSMsgNotFound:
|
||||||
|
continue
|
||||||
|
if target_type in [dict, list]:
|
||||||
|
target_type = String
|
||||||
|
status_str_type_mapping[status_type] = target_type
|
||||||
|
device_config["class"]["status_types"] = dict(sorted(device_config["class"]["status_types"].items()))
|
||||||
|
if complete_registry:
|
||||||
|
old_action_configs = {}
|
||||||
|
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||||||
|
old_action_configs[action_name] = action_config
|
||||||
|
|
||||||
|
device_config["class"]["action_value_mappings"] = {
|
||||||
|
k: v
|
||||||
|
for k, v in device_config["class"]["action_value_mappings"].items()
|
||||||
|
if not k.startswith("auto-")
|
||||||
|
}
|
||||||
|
device_config["class"]["action_value_mappings"].update(
|
||||||
|
{
|
||||||
|
f"auto-{k}": {
|
||||||
|
"type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand",
|
||||||
|
"goal": {},
|
||||||
|
"feedback": {},
|
||||||
|
"result": {},
|
||||||
|
"schema": self._generate_unilab_json_command_schema(
|
||||||
|
v["args"],
|
||||||
|
k,
|
||||||
|
v.get("return_annotation"),
|
||||||
|
old_action_configs.get(f"auto-{k}", {}).get("schema"),
|
||||||
|
),
|
||||||
|
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||||
|
"handles": old_action_configs.get(f"auto-{k}", {}).get("handles", []),
|
||||||
|
"placeholder_keys": {
|
||||||
|
i["name"]: (
|
||||||
|
"unilabos_resources"
|
||||||
|
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
|
||||||
|
or i["type"] == ("list", "unilabos.registry.placeholder_type:ResourceSlot")
|
||||||
|
else "unilabos_devices"
|
||||||
|
)
|
||||||
|
for i in v["args"]
|
||||||
|
if i.get("type", "")
|
||||||
|
in [
|
||||||
|
"unilabos.registry.placeholder_type:ResourceSlot",
|
||||||
|
"unilabos.registry.placeholder_type:DeviceSlot",
|
||||||
|
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
|
||||||
|
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
**({"always_free": True} if v.get("always_free") else {}),
|
||||||
|
}
|
||||||
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
|
if k not in device_config["class"]["action_value_mappings"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for action_name, old_config in old_action_configs.items():
|
||||||
|
if action_name in device_config["class"]["action_value_mappings"]:
|
||||||
|
old_schema = old_config.get("schema", {})
|
||||||
|
if "description" in old_schema and old_schema["description"]:
|
||||||
|
device_config["class"]["action_value_mappings"][action_name]["schema"][
|
||||||
|
"description"
|
||||||
|
] = old_schema["description"]
|
||||||
|
device_config["init_param_schema"] = {}
|
||||||
|
device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema(
|
||||||
|
enhanced_info["init_params"], "__init__"
|
||||||
|
)["properties"]["goal"]
|
||||||
|
device_config["init_param_schema"]["data"] = self._generate_status_types_schema(
|
||||||
|
enhanced_info["status_methods"]
|
||||||
|
)
|
||||||
|
|
||||||
|
device_config.pop("schema", None)
|
||||||
|
device_config["class"]["action_value_mappings"] = dict(
|
||||||
|
sorted(device_config["class"]["action_value_mappings"].items())
|
||||||
|
)
|
||||||
|
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||||||
|
if "handles" not in action_config:
|
||||||
|
action_config["handles"] = {}
|
||||||
|
elif isinstance(action_config["handles"], list):
|
||||||
|
if len(action_config["handles"]):
|
||||||
|
logger.error(f"设备{device_id} {action_name} 的handles配置错误,应该是字典类型")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
action_config["handles"] = {}
|
||||||
|
if "type" in action_config:
|
||||||
|
action_type_str: str = action_config["type"]
|
||||||
|
if not action_type_str.startswith("UniLabJsonCommand"):
|
||||||
|
try:
|
||||||
|
target_type = self._replace_type_with_class(
|
||||||
|
action_type_str, device_id, f"动作 {action_name}"
|
||||||
|
)
|
||||||
|
except ROSMsgNotFound:
|
||||||
|
continue
|
||||||
|
action_str_type_mapping[action_type_str] = target_type
|
||||||
|
if target_type is not None:
|
||||||
|
action_config["goal_default"] = yaml.safe_load(
|
||||||
|
io.StringIO(get_yaml_from_goal_type(target_type.Goal))
|
||||||
|
)
|
||||||
|
action_config["schema"] = ros_action_to_json_schema(target_type)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
|
||||||
|
)
|
||||||
|
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items())))
|
||||||
|
for status_name, status_type in device_config["class"]["status_types"].items():
|
||||||
|
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
|
||||||
|
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||||||
|
if action_config["type"] not in action_str_type_mapping:
|
||||||
|
continue
|
||||||
|
action_config["type"] = action_str_type_mapping[action_config["type"]]
|
||||||
|
self._add_builtin_actions(device_config, device_id)
|
||||||
|
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
|
||||||
|
device_config["registry_type"] = "device"
|
||||||
|
device_ids.append(device_id)
|
||||||
|
|
||||||
|
complete_data = dict(sorted(complete_data.items()))
|
||||||
|
complete_data = copy.deepcopy(complete_data)
|
||||||
|
try:
|
||||||
|
with open(file, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[UniLab Registry] 写入设备文件失败: {file}, 错误: {e}")
|
||||||
|
|
||||||
|
return data, complete_data, True, device_ids
|
||||||
|
|
||||||
def load_device_types(self, path: os.PathLike, complete_registry: bool):
|
def load_device_types(self, path: os.PathLike, complete_registry: bool):
|
||||||
# return
|
|
||||||
abs_path = Path(path).absolute()
|
abs_path = Path(path).absolute()
|
||||||
devices_path = abs_path / "devices"
|
devices_path = abs_path / "devices"
|
||||||
device_comms_path = abs_path / "device_comms"
|
device_comms_path = abs_path / "device_comms"
|
||||||
files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml"))
|
files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml"))
|
||||||
logger.trace( # type: ignore
|
logger.trace(
|
||||||
f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, "
|
f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, "
|
||||||
+ f"total: {len(files)}"
|
+ f"total: {len(files)}"
|
||||||
)
|
)
|
||||||
current_device_number = len(self.device_type_registry) + 1
|
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
|
||||||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||||||
|
|
||||||
for i, file in enumerate(files):
|
# 使用线程池并行加载
|
||||||
with open(file, encoding="utf-8", mode="r") as f:
|
max_workers = min(8, len(files))
|
||||||
data = yaml.safe_load(io.StringIO(f.read()))
|
results = []
|
||||||
complete_data = {}
|
|
||||||
action_str_type_mapping = {
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
"UniLabJsonCommand": "UniLabJsonCommand",
|
future_to_file = {
|
||||||
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
|
executor.submit(self._load_single_device_file, file, complete_registry, get_yaml_from_goal_type): file
|
||||||
|
for file in files
|
||||||
}
|
}
|
||||||
status_str_type_mapping = {}
|
for future in as_completed(future_to_file):
|
||||||
if data:
|
file = future_to_file[future]
|
||||||
# 在添加到注册表前处理类型替换
|
try:
|
||||||
for device_id, device_config in data.items():
|
data, complete_data, is_valid, device_ids = future.result()
|
||||||
# 添加文件路径信息 - 使用规范化的完整文件路径
|
if is_valid:
|
||||||
if "version" not in device_config:
|
results.append((file, data, device_ids))
|
||||||
device_config["version"] = "1.0.0"
|
except Exception as e:
|
||||||
if "category" not in device_config:
|
traceback.print_exc()
|
||||||
device_config["category"] = [file.stem]
|
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
|
||||||
elif file.stem not in device_config["category"]:
|
|
||||||
device_config["category"].append(file.stem)
|
|
||||||
if "config_info" not in device_config:
|
|
||||||
device_config["config_info"] = []
|
|
||||||
if "description" not in device_config:
|
|
||||||
device_config["description"] = ""
|
|
||||||
if "icon" not in device_config:
|
|
||||||
device_config["icon"] = ""
|
|
||||||
if "handles" not in device_config:
|
|
||||||
device_config["handles"] = []
|
|
||||||
if "init_param_schema" not in device_config:
|
|
||||||
device_config["init_param_schema"] = {}
|
|
||||||
if "class" in device_config:
|
|
||||||
if (
|
|
||||||
"status_types" not in device_config["class"]
|
|
||||||
or device_config["class"]["status_types"] is None
|
|
||||||
):
|
|
||||||
device_config["class"]["status_types"] = {}
|
|
||||||
if (
|
|
||||||
"action_value_mappings" not in device_config["class"]
|
|
||||||
or device_config["class"]["action_value_mappings"] is None
|
|
||||||
):
|
|
||||||
device_config["class"]["action_value_mappings"] = {}
|
|
||||||
enhanced_info = {}
|
|
||||||
if complete_registry:
|
|
||||||
device_config["class"]["status_types"].clear()
|
|
||||||
enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True)
|
|
||||||
if not enhanced_info.get("dynamic_import_success", False):
|
|
||||||
continue
|
|
||||||
device_config["class"]["status_types"].update(
|
|
||||||
{k: v["return_type"] for k, v in enhanced_info["status_methods"].items()}
|
|
||||||
)
|
|
||||||
for status_name, status_type in device_config["class"]["status_types"].items():
|
|
||||||
if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]:
|
|
||||||
status_type = "String" # 替换成ROS的String,便于显示
|
|
||||||
device_config["class"]["status_types"][status_name] = status_type
|
|
||||||
try:
|
|
||||||
target_type = self._replace_type_with_class(
|
|
||||||
status_type, device_id, f"状态 {status_name}"
|
|
||||||
)
|
|
||||||
except ROSMsgNotFound:
|
|
||||||
continue
|
|
||||||
if target_type in [
|
|
||||||
dict,
|
|
||||||
list,
|
|
||||||
]: # 对于嵌套类型返回的对象,暂时处理成字符串,无法直接进行转换
|
|
||||||
target_type = String
|
|
||||||
status_str_type_mapping[status_type] = target_type
|
|
||||||
device_config["class"]["status_types"] = dict(
|
|
||||||
sorted(device_config["class"]["status_types"].items())
|
|
||||||
)
|
|
||||||
if complete_registry:
|
|
||||||
# 保存原有的description信息
|
|
||||||
old_descriptions = {}
|
|
||||||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
|
||||||
if "description" in action_config.get("schema", {}):
|
|
||||||
description = action_config["schema"]["description"]
|
|
||||||
if len(description):
|
|
||||||
old_descriptions[action_name] = action_config["schema"]["description"]
|
|
||||||
|
|
||||||
device_config["class"]["action_value_mappings"] = {
|
# 线程安全地更新注册表
|
||||||
k: v
|
current_device_number = len(self.device_type_registry) + 1
|
||||||
for k, v in device_config["class"]["action_value_mappings"].items()
|
with self._registry_lock:
|
||||||
if not k.startswith("auto-")
|
for file, data, device_ids in results:
|
||||||
}
|
self.device_type_registry.update(data)
|
||||||
# 处理动作值映射
|
for device_id in device_ids:
|
||||||
device_config["class"]["action_value_mappings"].update(
|
logger.trace(
|
||||||
{
|
f"[UniLab Registry] Device-{current_device_number} Add {device_id} "
|
||||||
f"auto-{k}": {
|
|
||||||
"type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand",
|
|
||||||
"goal": {},
|
|
||||||
"feedback": {},
|
|
||||||
"result": {},
|
|
||||||
"schema": self._generate_unilab_json_command_schema(
|
|
||||||
v["args"], k, v.get("return_annotation")
|
|
||||||
),
|
|
||||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
|
||||||
"handles": [],
|
|
||||||
"placeholder_keys": {
|
|
||||||
i["name"]: (
|
|
||||||
"unilabos_resources"
|
|
||||||
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
|
|
||||||
or i["type"]
|
|
||||||
== ("list", "unilabos.registry.placeholder_type:ResourceSlot")
|
|
||||||
else "unilabos_devices"
|
|
||||||
)
|
|
||||||
for i in v["args"]
|
|
||||||
if i.get("type", "")
|
|
||||||
in [
|
|
||||||
"unilabos.registry.placeholder_type:ResourceSlot",
|
|
||||||
"unilabos.registry.placeholder_type:DeviceSlot",
|
|
||||||
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
|
|
||||||
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
|
||||||
# 不生成已配置action的动作
|
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
|
||||||
if k not in device_config["class"]["action_value_mappings"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# 恢复原有的description信息(auto开头的不修改)
|
|
||||||
for action_name, description in old_descriptions.items():
|
|
||||||
if action_name in device_config["class"]["action_value_mappings"]: # 有一些会被删除
|
|
||||||
device_config["class"]["action_value_mappings"][action_name]["schema"][
|
|
||||||
"description"
|
|
||||||
] = description
|
|
||||||
device_config["init_param_schema"] = {}
|
|
||||||
device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema(
|
|
||||||
enhanced_info["init_params"], "__init__"
|
|
||||||
)["properties"]["goal"]
|
|
||||||
device_config["init_param_schema"]["data"] = self._generate_status_types_schema(
|
|
||||||
enhanced_info["status_methods"]
|
|
||||||
)
|
|
||||||
|
|
||||||
device_config.pop("schema", None)
|
|
||||||
device_config["class"]["action_value_mappings"] = dict(
|
|
||||||
sorted(device_config["class"]["action_value_mappings"].items())
|
|
||||||
)
|
|
||||||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
|
||||||
if "handles" not in action_config:
|
|
||||||
action_config["handles"] = {}
|
|
||||||
elif isinstance(action_config["handles"], list):
|
|
||||||
if len(action_config["handles"]):
|
|
||||||
logger.error(f"设备{device_id} {action_name} 的handles配置错误,应该是字典类型")
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
action_config["handles"] = {}
|
|
||||||
if "type" in action_config:
|
|
||||||
action_type_str: str = action_config["type"]
|
|
||||||
# 通过Json发放指令,而不是通过特殊的ros action进行处理
|
|
||||||
if not action_type_str.startswith("UniLabJsonCommand"):
|
|
||||||
try:
|
|
||||||
target_type = self._replace_type_with_class(
|
|
||||||
action_type_str, device_id, f"动作 {action_name}"
|
|
||||||
)
|
|
||||||
except ROSMsgNotFound:
|
|
||||||
continue
|
|
||||||
action_str_type_mapping[action_type_str] = target_type
|
|
||||||
if target_type is not None:
|
|
||||||
action_config["goal_default"] = yaml.safe_load(
|
|
||||||
io.StringIO(get_yaml_from_goal_type(target_type.Goal))
|
|
||||||
)
|
|
||||||
action_config["schema"] = ros_action_to_json_schema(target_type)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
|
|
||||||
)
|
|
||||||
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items()))) # 稍后dump到文件
|
|
||||||
for status_name, status_type in device_config["class"]["status_types"].items():
|
|
||||||
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
|
|
||||||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
|
||||||
if action_config["type"] not in action_str_type_mapping:
|
|
||||||
continue
|
|
||||||
action_config["type"] = action_str_type_mapping[action_config["type"]]
|
|
||||||
# 添加内置的驱动命令动作
|
|
||||||
self._add_builtin_actions(device_config, device_id)
|
|
||||||
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
|
|
||||||
device_config["registry_type"] = "device"
|
|
||||||
logger.trace( # type: ignore
|
|
||||||
f"[UniLab Registry] Device-{current_device_number} File-{i+1}/{len(files)} Add {device_id} "
|
|
||||||
+ f"[{data[device_id].get('name', '未命名设备')}]"
|
+ f"[{data[device_id].get('name', '未命名设备')}]"
|
||||||
)
|
)
|
||||||
current_device_number += 1
|
current_device_number += 1
|
||||||
complete_data = dict(sorted(complete_data.items()))
|
|
||||||
complete_data = copy.deepcopy(complete_data)
|
# 记录无效文件
|
||||||
with open(file, "w", encoding="utf-8") as f:
|
valid_files = {r[0] for r in results}
|
||||||
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
for file in files:
|
||||||
self.device_type_registry.update(data)
|
if file not in valid_files:
|
||||||
else:
|
logger.debug(f"[UniLab Registry] Device File Not Valid YAML File: {file.absolute()}")
|
||||||
logger.debug(
|
|
||||||
f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def obtain_registry_device_info(self):
|
def obtain_registry_device_info(self):
|
||||||
devices = []
|
devices = []
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# UniLabOS 资源注册架构详解
|
||||||
|
|
||||||
|
> **目标受众**: 主要开发 `unilabos/registry/devices` 抽象层的开发者
|
||||||
|
> **最后更新**: 2026-01-11
|
||||||
|
> **维护者**: Uni-Lab-OS 开发团队
|
||||||
|
|
||||||
|
本文档详细说明 UniLabOS 资源注册系统的架构、资源的完整生命周期,以及如何实现动态物料位置追踪。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 目录
|
||||||
|
|
||||||
|
- [核心概念](#核心概念)
|
||||||
|
- [三层架构详解](#三层架构详解)
|
||||||
|
- [资源注册机制](#资源注册机制)
|
||||||
|
- [物料生命周期管理](#物料生命周期管理)
|
||||||
|
- [动态物料位置追踪](#动态物料位置追踪)
|
||||||
|
- [实战案例](#实战案例)
|
||||||
|
- [常见问题排查](#常见问题排查)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 1. Resources vs Registry
|
||||||
|
|
||||||
|
UniLabOS 采用**声明式注册**模式,将资源的**定义**(Python)与**注册信息**(YAML)分离:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ unilabos/resources (Python 实现) │
|
||||||
|
│ - 定义资源的物理属性、行为和创建逻辑 │
|
||||||
|
│ - 例如: 瓶子的尺寸、容量、材质 │
|
||||||
|
├──────────────────────────────────────────────────────────┤
|
||||||
|
│ unilabos/registry/resources (YAML 注册表) │
|
||||||
|
│ - 声明哪些资源可以被前端使用 │
|
||||||
|
│ - 定义资源的分类、图标、初始化参数 │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么要分离?**
|
||||||
|
|
||||||
|
1. **解耦**: Python 代码可以定义无限多的资源类,但只有在 YAML 中注册的才能被前端识别
|
||||||
|
2. **灵活性**: 无需修改 Python 代码,只需修改 YAML 就能添加/移除可用资源
|
||||||
|
3. **可扩展性**: 第三方开发者可以通过 YAML 注册自己的资源,无需修改核心代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三层架构详解
|
||||||
|
|
||||||
|
UniLabOS 资源系统采用**三层架构**,实现从前端UI到底层硬件的完整映射:
|
||||||
|
|
||||||
|
### 架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 第1层: YAML 注册表 (registry/resources) │
|
||||||
|
│ - 告诉系统"哪些资源可用" │
|
||||||
|
│ - 前端通过此层获取可用资源列表 │
|
||||||
|
│ - 文件: YB_bottle.yaml, YB_bottle_carriers.yaml │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ 第2层: Python 实现 (resources/bioyond) │
|
||||||
|
│ - 定义资源的具体属性和行为 │
|
||||||
|
│ - 创建资源实例的工厂函数 │
|
||||||
|
│ - 文件: YB_bottles.py, YB_bottle_carriers.py │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ 第3层: Hardware/API 集成 (devices/workstation) │
|
||||||
|
│ - 连接 Bioyond 系统 API │
|
||||||
|
│ - 同步物料位置和状态 │
|
||||||
|
│ - 文件: station.py, bioyond_rpc.py, config.py │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第1层: YAML 注册表
|
||||||
|
|
||||||
|
#### YB_bottle.yaml - 单个瓶子注册
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
YB_5ml_fenyeping:
|
||||||
|
category:
|
||||||
|
- yb3 # 系统分类
|
||||||
|
- YB_bottle # 资源类型
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping # Python 函数路径
|
||||||
|
type: pylabrobot # 框架类型
|
||||||
|
description: YB_5ml_fenyeping # 前端显示名称
|
||||||
|
handles: []
|
||||||
|
icon: '' # 图标路径
|
||||||
|
init_param_schema: {} # 初始化参数 schema
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用:**
|
||||||
|
- 前端通过读取此文件知道有一个叫 "YB_5ml_fenyeping" 的资源
|
||||||
|
- 用户拖拽时,系统会调用 `YB_bottles:YB_5ml_fenyeping()` 创建实例
|
||||||
|
|
||||||
|
#### YB_bottle_carriers.yaml - 载架(容器)注册
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
YB_5ml_fenyepingban:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_5ml_fenyepingban # 5ml分液瓶板
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用:**
|
||||||
|
- 载架是容器,里面可以放多个瓶子
|
||||||
|
- 例如: `YB_5ml_fenyepingban` 是一个 4x2 布局的板,可以放 8 个 5ml 瓶子
|
||||||
|
|
||||||
|
### 第2层: Python 实现
|
||||||
|
|
||||||
|
#### YB_bottles.py - 瓶子工厂函数
|
||||||
|
|
||||||
|
```python
|
||||||
|
def YB_5ml_fenyeping(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 20.0, # 直径 (mm)
|
||||||
|
height: float = 50.0, # 高度 (mm)
|
||||||
|
max_volume: float = 5000.0, # 最大容量 (μL)
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
\"\"\"创建5ml分液瓶\"\"\"
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_5ml_fenyeping", # ⭐ 与 YAML 中的名称对应
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点:**
|
||||||
|
- 函数名 `YB_5ml_fenyeping` 必须与 YAML 中的 `module` 路径末尾一致
|
||||||
|
- 返回一个 `Bottle` 对象(PyLabRobot 资源类型)
|
||||||
|
- `model` 字段用于在 Bioyond 系统中识别资源类型
|
||||||
|
|
||||||
|
**详细文档请参考完整版 README**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关文件索引
|
||||||
|
|
||||||
|
### 核心文件
|
||||||
|
|
||||||
|
| 文件 | 功能 | 路径 |
|
||||||
|
|------|------|------|
|
||||||
|
| `YB_bottle.yaml` | 瓶子注册表 | `unilabos/registry/resources/bioyond/` |
|
||||||
|
| `YB_bottle_carriers.yaml` | 载架注册表 | `unilabos/registry/resources/bioyond/` |
|
||||||
|
| `deck.yaml` | Deck注册表 | `unilabos/registry/resources/bioyond/` |
|
||||||
|
| `YB_bottles.py` | 瓶子实现 | `unilabos/resources/bioyond/` |
|
||||||
|
| `YB_bottle_carriers.py` | 载架实现 | `unilabos/resources/bioyond/` |
|
||||||
|
| `YB_warehouses.py` | 仓库实现 | `unilabos/resources/bioyond/` |
|
||||||
|
| `decks.py` | Deck布局 | `unilabos/resources/bioyond/` |
|
||||||
|
| `station.py` | 物料同步 | `unilabos/devices/workstation/bioyond_studio/` |
|
||||||
|
| `config.py` | UUID映射 | `unilabos/devices/workstation/bioyond_studio/` |
|
||||||
|
|
||||||
|
### 仓库相关文档
|
||||||
|
|
||||||
|
- [README_WAREHOUSE.md](../../resources/bioyond/README_WAREHOUSE.md) - 仓库系统开发指南
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**维护者:** Uni-Lab-OS 开发团队
|
||||||
|
**最后更新:** 2026-01-11
|
||||||
92
unilabos/registry/resources/bioyond/YB_bottle.yaml
Normal file
92
unilabos/registry/resources/bioyond/YB_bottle.yaml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
YB_20ml_fenyeping:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_20ml_fenyeping
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_5ml_fenyeping:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_5ml_fenyeping
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_jia_yang_tou_da:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_jia_yang_tou_da
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_pei_ye_da_Bottle:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_pei_ye_da_Bottle
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_pei_ye_xiao_Bottle:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_pei_ye_xiao_Bottle
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_qiang_tou:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_qiang_tou
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_ye_Bottle:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
- YB_bottle
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_ye_Bottle
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
182
unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml
Normal file
182
unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
YB_100ml_yeti:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_100ml_yeti
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_20ml_fenyepingban:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_20ml_fenyepingban
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_5ml_fenyepingban:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_5ml_fenyepingban
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_6StockCarrier:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_6StockCarrier
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_6VialCarrier:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_6VialCarrier
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_gao_nian_ye_Bottle:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_gao_nian_ye_Bottle
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_gaonianye:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_gaonianye
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_jia_yang_tou_da_Carrier:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_jia_yang_tou_da_Carrier
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_peiyepingdaban:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_peiyepingdaban
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_peiyepingxiaoban:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_peiyepingxiaoban
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_qiang_tou_he:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_qiang_tou_he
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_shi_pei_qi_kuai:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_shi_pei_qi_kuai
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_ye:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_ye_Bottle_Carrier
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
YB_ye_100ml_Bottle:
|
||||||
|
category:
|
||||||
|
- yb3
|
||||||
|
- YB_bottle_carriers
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle
|
||||||
|
type: pylabrobot
|
||||||
|
description: YB_ye_100ml_Bottle
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
@@ -20,6 +20,17 @@ BIOYOND_PolymerStation_Liquid_Vial:
|
|||||||
icon: ''
|
icon: ''
|
||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
BIOYOND_PolymerStation_Measurement_Vial:
|
||||||
|
category:
|
||||||
|
- bottles
|
||||||
|
class:
|
||||||
|
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
|
||||||
|
type: pylabrobot
|
||||||
|
description: 聚合站-测量小瓶(测密度)
|
||||||
|
handles: []
|
||||||
|
icon: ''
|
||||||
|
init_param_schema: {}
|
||||||
|
version: 1.0.0
|
||||||
BIOYOND_PolymerStation_Reactor:
|
BIOYOND_PolymerStation_Reactor:
|
||||||
category:
|
category:
|
||||||
- bottles
|
- bottles
|
||||||
|
|||||||
@@ -22,15 +22,27 @@ BIOYOND_PolymerReactionStation_Deck:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
YB_Deck11:
|
BIOYOND_YB_Deck:
|
||||||
category:
|
category:
|
||||||
- deck
|
- deck
|
||||||
class:
|
class:
|
||||||
module: unilabos.resources.bioyond.decks:YB_Deck
|
module: unilabos.resources.bioyond.decks:YB_Deck
|
||||||
type: pylabrobot
|
type: pylabrobot
|
||||||
description: BIOYOND PolymerReactionStation Deck
|
description: BIOYOND ElectrolyteFormulationStation Deck
|
||||||
handles: []
|
handles: []
|
||||||
icon: 配液站.webp
|
icon: 配液站.webp
|
||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
registry_type: resource
|
registry_type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
CoincellDeck:
|
||||||
|
category:
|
||||||
|
- deck
|
||||||
|
class:
|
||||||
|
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck
|
||||||
|
type: pylabrobot
|
||||||
|
description: YIHUA CoinCellAssembly Deck
|
||||||
|
handles: []
|
||||||
|
icon: koudian.webp
|
||||||
|
init_param_schema: {}
|
||||||
|
registry_type: resource
|
||||||
|
version: 1.0.0
|
||||||
|
|||||||
56
unilabos/resources/battery/bottle_carriers.py
Normal file
56
unilabos/resources/battery/bottle_carriers.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||||
|
|
||||||
|
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||||
|
from unilabos.resources.bioyond.YB_bottles import (
|
||||||
|
YB_pei_ye_xiao_Bottle,
|
||||||
|
)
|
||||||
|
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||||
|
|
||||||
|
|
||||||
|
def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
|
||||||
|
"""12瓶载架 - 2x6布局"""
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 120.0
|
||||||
|
carrier_size_y = 250.0
|
||||||
|
carrier_size_z = 50.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 35.0
|
||||||
|
bottle_spacing_x = 35.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=2,
|
||||||
|
num_items_y=6,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="Electrolyte_12VialCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 2
|
||||||
|
carrier.num_items_y = 6
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
for i in range(12):
|
||||||
|
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||||
|
return carrier
|
||||||
195
unilabos/resources/battery/electrode_sheet.py
Normal file
195
unilabos/resources/battery/electrode_sheet.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
from typing import Any, Dict, Optional, TypedDict
|
||||||
|
|
||||||
|
from pylabrobot.resources import Resource as ResourcePLR
|
||||||
|
from pylabrobot.resources import Container
|
||||||
|
|
||||||
|
|
||||||
|
electrode_colors = {
|
||||||
|
"PositiveCan": "#ff0000",
|
||||||
|
"PositiveElectrode": "#cc3333",
|
||||||
|
"NegativeCan": "#000000",
|
||||||
|
"NegativeElectrode": "#666666",
|
||||||
|
"SpringWasher": "#8b7355",
|
||||||
|
"FlatWasher": "a9a9a9",
|
||||||
|
"AluminumFoil": "#ffcccc",
|
||||||
|
"Battery": "#00ff00",
|
||||||
|
}
|
||||||
|
|
||||||
|
class ElectrodeSheetState(TypedDict):
|
||||||
|
diameter: float # 直径 (mm)
|
||||||
|
thickness: float # 厚度 (mm)
|
||||||
|
mass: float # 质量 (g)
|
||||||
|
material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等)
|
||||||
|
color: str # 材料类型对应的颜色
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
|
||||||
|
class ElectrodeSheet(ResourcePLR):
|
||||||
|
"""极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "极片",
|
||||||
|
size_x: float = 10,
|
||||||
|
size_y: float = 10,
|
||||||
|
size_z: float = 10,
|
||||||
|
category: str = "electrode_sheet",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""初始化极片
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 极片名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
**kwargs: 其他参数传递给父类
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState(
|
||||||
|
diameter=14,
|
||||||
|
thickness=0.1,
|
||||||
|
mass=0.5,
|
||||||
|
material_type="copper",
|
||||||
|
color="#8b4513",
|
||||||
|
info=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: 这个还要不要?给self._unilabos_state赋值的?
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
#序列化
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def PositiveCan(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建正极壳"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan")
|
||||||
|
sheet.load_state({"diameter": 20.0, "thickness": 0.5, "mass": 0.5, "material_type": "aluminum", "color": electrode_colors["PositiveCan"], "info": None})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def PositiveElectrode(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建正极片"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode")
|
||||||
|
sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def NegativeCan(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建负极壳"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan")
|
||||||
|
sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def NegativeElectrode(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建负极片"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode")
|
||||||
|
sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def SpringWasher(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建弹片"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher")
|
||||||
|
sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def FlatWasher(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建垫片"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher")
|
||||||
|
sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def AluminumFoil(name: str) -> ElectrodeSheet:
|
||||||
|
"""创建铝箔"""
|
||||||
|
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil")
|
||||||
|
sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]})
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryState(TypedDict):
|
||||||
|
color: str # 材料类型对应的颜色
|
||||||
|
electrolyte_name: str
|
||||||
|
data_electrolyte_code: str
|
||||||
|
open_circuit_voltage: float
|
||||||
|
assembly_pressure: float
|
||||||
|
electrolyte_volume: float
|
||||||
|
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
|
||||||
|
class Battery(Container):
|
||||||
|
"""电池类 - 包含组装好的电池"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "电池",
|
||||||
|
size_x: float = 12,
|
||||||
|
size_y: float = 12,
|
||||||
|
size_z: float = 6,
|
||||||
|
category: str = "battery",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""初始化电池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 电池名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
**kwargs: 其他参数传递给父类
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryState = BatteryState(
|
||||||
|
color=electrode_colors["Battery"],
|
||||||
|
electrolyte_name="无",
|
||||||
|
data_electrolyte_code="",
|
||||||
|
open_circuit_voltage=0.0,
|
||||||
|
assembly_pressure=0.0,
|
||||||
|
electrolyte_volume=0.0,
|
||||||
|
info=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
#序列化
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
344
unilabos/resources/battery/magazine.py
Normal file
344
unilabos/resources/battery/magazine.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
from typing import Dict, List, Optional, OrderedDict, Union, Callable
|
||||||
|
import math
|
||||||
|
|
||||||
|
from pylabrobot.resources.coordinate import Coordinate
|
||||||
|
from pylabrobot.resources import Resource, ResourceStack, ItemizedResource
|
||||||
|
from pylabrobot.resources.carrier import create_homogeneous_resources
|
||||||
|
|
||||||
|
from unilabos.resources.battery.electrode_sheet import (
|
||||||
|
PositiveCan, PositiveElectrode,
|
||||||
|
NegativeCan, NegativeElectrode,
|
||||||
|
SpringWasher, FlatWasher,
|
||||||
|
AluminumFoil,
|
||||||
|
Battery
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Magazine(ResourceStack):
|
||||||
|
"""子弹夹洞位类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
direction: str = 'z',
|
||||||
|
resources: Optional[List[Resource]] = None,
|
||||||
|
max_sheets: int = 100,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""初始化子弹夹洞位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 洞位名称
|
||||||
|
direction: 堆叠方向
|
||||||
|
resources: 资源列表
|
||||||
|
max_sheets: 最大极片数量
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
direction=direction,
|
||||||
|
resources=resources,
|
||||||
|
)
|
||||||
|
self.max_sheets = max_sheets
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_x(self) -> float:
|
||||||
|
return self.get_size_x()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_y(self) -> float:
|
||||||
|
return self.get_size_y()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_z(self) -> float:
|
||||||
|
return self.get_size_z()
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().serialize(),
|
||||||
|
"size_x": self.size_x or 10.0,
|
||||||
|
"size_y": self.size_y or 10.0,
|
||||||
|
"size_z": self.size_z or 10.0,
|
||||||
|
"max_sheets": self.max_sheets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MagazineHolder(ItemizedResource):
|
||||||
|
"""子弹夹类 - 有多个洞位,每个洞位放多个极片"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
ordered_items: Optional[Dict[str, Magazine]] = None,
|
||||||
|
ordering: Optional[OrderedDict[str, str]] = None,
|
||||||
|
hole_diameter: float = 14.0,
|
||||||
|
hole_depth: float = 10.0,
|
||||||
|
max_sheets_per_hole: int = 100,
|
||||||
|
cross_section_type: str = "circle",
|
||||||
|
category: str = "magazine_holder",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""初始化子弹夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 子弹夹名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
hole_diameter: 洞直径 (mm)
|
||||||
|
hole_depth: 洞深度 (mm)
|
||||||
|
max_sheets_per_hole: 每个洞位最大极片数量
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=ordered_items,
|
||||||
|
ordering=ordering,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存洞位的直径和深度
|
||||||
|
self.hole_diameter = hole_diameter
|
||||||
|
self.hole_depth = hole_depth
|
||||||
|
self.max_sheets_per_hole = max_sheets_per_hole
|
||||||
|
self.cross_section_type = cross_section_type
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().serialize(),
|
||||||
|
"hole_diameter": self.hole_diameter,
|
||||||
|
"hole_depth": self.hole_depth,
|
||||||
|
"max_sheets_per_hole": self.max_sheets_per_hole,
|
||||||
|
"cross_section_type": self.cross_section_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def magazine_factory(
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
locations: List[Coordinate],
|
||||||
|
klasses: Optional[List[Callable[[str], str]]] = None,
|
||||||
|
hole_diameter: float = 14.0,
|
||||||
|
hole_depth: float = 10.0,
|
||||||
|
max_sheets_per_hole: int = 100,
|
||||||
|
category: str = "magazine_holder",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
) -> 'MagazineHolder':
|
||||||
|
"""工厂函数:创建子弹夹
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 子弹夹名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
locations: 洞位坐标列表
|
||||||
|
klasses: 每个洞位中极片的类列表
|
||||||
|
hole_diameter: 洞直径 (mm)
|
||||||
|
hole_depth: 洞深度 (mm)
|
||||||
|
max_sheets_per_hole: 每个洞位最大极片数量
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
for loc in locations:
|
||||||
|
loc.x -= hole_diameter / 2
|
||||||
|
loc.y -= hole_diameter / 2
|
||||||
|
|
||||||
|
# 创建洞位
|
||||||
|
_sites = create_homogeneous_resources(
|
||||||
|
klass=Magazine,
|
||||||
|
locations=locations,
|
||||||
|
resource_size_x=hole_diameter,
|
||||||
|
resource_size_y=hole_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
max_sheets=max_sheets_per_hole,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成编号键
|
||||||
|
keys = [f"A{i+1}" for i in range(len(locations))]
|
||||||
|
sites = dict(zip(keys, _sites.values()))
|
||||||
|
|
||||||
|
holder = MagazineHolder(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=sites,
|
||||||
|
hole_diameter=hole_diameter,
|
||||||
|
hole_depth=hole_depth,
|
||||||
|
max_sheets_per_hole=max_sheets_per_hole,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
if klasses is not None:
|
||||||
|
for i, klass in enumerate(klasses):
|
||||||
|
hole_key = keys[i]
|
||||||
|
hole = holder.children[i]
|
||||||
|
for j in reversed(range(max_sheets_per_hole)):
|
||||||
|
item_name = f"{hole_key}_sheet{j+1}"
|
||||||
|
item = klass(name=item_name)
|
||||||
|
hole.assign_child_resource(item)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
|
||||||
|
def MagazineHolder_6_Cathode(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 80.0,
|
||||||
|
size_y: float = 80.0,
|
||||||
|
size_z: float = 40.0,
|
||||||
|
hole_diameter: float = 14.0,
|
||||||
|
hole_depth: float = 10.0,
|
||||||
|
hole_spacing: float = 20.0,
|
||||||
|
max_sheets_per_hole: int = 100,
|
||||||
|
) -> MagazineHolder:
|
||||||
|
"""创建6孔子弹夹 - 六边形排布"""
|
||||||
|
center_x = size_x / 2
|
||||||
|
center_y = size_y / 2
|
||||||
|
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
# 周围6个孔,按六边形排布
|
||||||
|
for i in range(6):
|
||||||
|
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||||
|
x = center_x + hole_spacing * math.cos(angle)
|
||||||
|
y = center_y + hole_spacing * math.sin(angle)
|
||||||
|
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||||
|
|
||||||
|
return magazine_factory(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
locations=locations,
|
||||||
|
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||||
|
hole_diameter=hole_diameter,
|
||||||
|
hole_depth=hole_depth,
|
||||||
|
max_sheets_per_hole=max_sheets_per_hole,
|
||||||
|
category="magazine_holder",
|
||||||
|
model="MagazineHolder_6_Cathode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def MagazineHolder_6_Anode(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 80.0,
|
||||||
|
size_y: float = 80.0,
|
||||||
|
size_z: float = 40.0,
|
||||||
|
hole_diameter: float = 14.0,
|
||||||
|
hole_depth: float = 10.0,
|
||||||
|
hole_spacing: float = 20.0,
|
||||||
|
max_sheets_per_hole: int = 100,
|
||||||
|
) -> MagazineHolder:
|
||||||
|
"""创建6孔子弹夹 - 六边形排布"""
|
||||||
|
center_x = size_x / 2
|
||||||
|
center_y = size_y / 2
|
||||||
|
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
# 周围6个孔,按六边形排布
|
||||||
|
for i in range(6):
|
||||||
|
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||||
|
x = center_x + hole_spacing * math.cos(angle)
|
||||||
|
y = center_y + hole_spacing * math.sin(angle)
|
||||||
|
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||||
|
|
||||||
|
return magazine_factory(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
locations=locations,
|
||||||
|
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
|
||||||
|
hole_diameter=hole_diameter,
|
||||||
|
hole_depth=hole_depth,
|
||||||
|
max_sheets_per_hole=max_sheets_per_hole,
|
||||||
|
category="magazine_holder",
|
||||||
|
model="MagazineHolder_6_Anode",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def MagazineHolder_6_Battery(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 80.0,
|
||||||
|
size_y: float = 80.0,
|
||||||
|
size_z: float = 40.0,
|
||||||
|
hole_diameter: float = 14.0,
|
||||||
|
hole_depth: float = 10.0,
|
||||||
|
hole_spacing: float = 20.0,
|
||||||
|
max_sheets_per_hole: int = 100,
|
||||||
|
) -> MagazineHolder:
|
||||||
|
"""创建6孔子弹夹 - 六边形排布"""
|
||||||
|
center_x = size_x / 2
|
||||||
|
center_y = size_y / 2
|
||||||
|
|
||||||
|
locations = []
|
||||||
|
|
||||||
|
# 周围6个孔,按六边形排布
|
||||||
|
for i in range(6):
|
||||||
|
angle = i * 60 * math.pi / 180 # 每60度一个孔
|
||||||
|
x = center_x + hole_spacing * math.cos(angle)
|
||||||
|
y = center_y + hole_spacing * math.sin(angle)
|
||||||
|
locations.append(Coordinate(x, y, size_z - hole_depth))
|
||||||
|
|
||||||
|
return magazine_factory(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
locations=locations,
|
||||||
|
klasses=None, # 初始化时,不放入装好的电池
|
||||||
|
hole_diameter=hole_diameter,
|
||||||
|
hole_depth=hole_depth,
|
||||||
|
max_sheets_per_hole=max_sheets_per_hole,
|
||||||
|
category="magazine_holder",
|
||||||
|
model="MagazineHolder_6_Battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def MagazineHolder_4_Cathode(
|
||||||
|
name: str,
|
||||||
|
) -> MagazineHolder:
|
||||||
|
"""创建4孔子弹夹 - 正方形四角排布"""
|
||||||
|
size_x: float = 80.0
|
||||||
|
size_y: float = 80.0
|
||||||
|
size_z: float = 10.0
|
||||||
|
hole_diameter: float = 14.0
|
||||||
|
hole_depth: float = 10.0
|
||||||
|
hole_spacing: float = 25.0
|
||||||
|
max_sheets_per_hole: int = 100
|
||||||
|
|
||||||
|
# 计算4个洞位的坐标(正方形四角排布)
|
||||||
|
center_x = size_x / 2
|
||||||
|
center_y = size_y / 2
|
||||||
|
offset = hole_spacing / 2
|
||||||
|
|
||||||
|
locations = [
|
||||||
|
Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下
|
||||||
|
Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下
|
||||||
|
Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上
|
||||||
|
Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上
|
||||||
|
]
|
||||||
|
|
||||||
|
return magazine_factory(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
locations=locations,
|
||||||
|
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
|
||||||
|
hole_diameter=hole_diameter,
|
||||||
|
hole_depth=hole_depth,
|
||||||
|
max_sheets_per_hole=max_sheets_per_hole,
|
||||||
|
category="magazine_holder",
|
||||||
|
model="MagazineHolder_4_Cathode",
|
||||||
|
)
|
||||||
548
unilabos/resources/bioyond/README_WAREHOUSE.md
Normal file
548
unilabos/resources/bioyond/README_WAREHOUSE.md
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
# Bioyond 仓库系统开发指南
|
||||||
|
|
||||||
|
本文档详细说明 Bioyond 仓库(Warehouse)系统的架构、配置和使用方法,帮助开发者快速理解和维护仓库相关代码。
|
||||||
|
|
||||||
|
## 📚 目录
|
||||||
|
|
||||||
|
- [系统架构](#系统架构)
|
||||||
|
- [核心概念](#核心概念)
|
||||||
|
- [三层映射关系](#三层映射关系)
|
||||||
|
- [warehouse_factory 详解](#warehouse_factory-详解)
|
||||||
|
- [创建新仓库](#创建新仓库)
|
||||||
|
- [常见问题](#常见问题)
|
||||||
|
- [调试技巧](#调试技巧)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
Bioyond 仓库系统采用**三层架构**,实现从前端显示到后端 API 的完整映射:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 前端显示层 (YB_warehouses.py) │
|
||||||
|
│ - warehouse_factory 自动生成库位网格 │
|
||||||
|
│ - 生成库位名称:A01, B02, C03... │
|
||||||
|
│ - 存储在 WareHouse.sites 字典中 │
|
||||||
|
└────────────────┬────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Deck 布局层 (decks.py) │
|
||||||
|
│ - 定义仓库在 Deck 上的物理位置 │
|
||||||
|
│ - 组织多个仓库形成完整布局 │
|
||||||
|
└────────────────┬────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ UUID 映射层 (config.py) │
|
||||||
|
│ - 将库位名称映射到 Bioyond 系统 UUID │
|
||||||
|
│ - 用于 API 调用时的物料入库操作 │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 仓库(Warehouse)
|
||||||
|
|
||||||
|
仓库是一个**三维网格**,用于存放物料。由以下参数定义:
|
||||||
|
|
||||||
|
- **num_items_x**: 列数(X 轴)
|
||||||
|
- **num_items_y**: 行数(Y 轴)
|
||||||
|
- **num_items_z**: 层数(Z 轴)
|
||||||
|
|
||||||
|
例如:`5行×3列×1层` = 5×3×1 = 15个库位
|
||||||
|
|
||||||
|
### 库位(Site)
|
||||||
|
|
||||||
|
库位是仓库中的单个存储位置,由**字母行+数字列**命名:
|
||||||
|
|
||||||
|
- **字母行**:A, B, C, D, E, F...(对应 Y 轴)
|
||||||
|
- **数字列**:01, 02, 03, 04...(对应 X 轴或 Z 轴)
|
||||||
|
|
||||||
|
示例:`A01`, `B02`, `C03`
|
||||||
|
|
||||||
|
### 布局模式(Layout)
|
||||||
|
|
||||||
|
控制库位的排序和 Y 坐标计算:
|
||||||
|
|
||||||
|
| 模式 | 说明 | 生成顺序 | Y 坐标计算 | 显示效果 |
|
||||||
|
|------|------|----------|-----------|---------|
|
||||||
|
| `col-major` | 列优先(默认) | A01, B01, C01, A02... | `dy + (num_y - row - 1) * item_dy` | A 可能在下 |
|
||||||
|
| `row-major` | 行优先 | A01, A02, A03, B01... | `dy + row * item_dy` | **A 在上** ✓ |
|
||||||
|
|
||||||
|
**重要:** 使用 `row-major` 可以避免上下颠倒问题!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三层映射关系
|
||||||
|
|
||||||
|
### 示例:手动传递窗右(A01-E03)
|
||||||
|
|
||||||
|
#### 1️⃣ 前端显示层 - [`YB_warehouses.py`](YB_warehouses.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||||
|
"""创建 5行×3列×1层 仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, # 3列
|
||||||
|
num_items_y=5, # 5行
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
row_offset=row_offset,
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**自动生成的库位:** A01, A02, A03, B01, B02, B03, ..., E01, E02, E03
|
||||||
|
|
||||||
|
#### 2️⃣ Deck 布局层 - [`decks.py`](decks.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.warehouses = {
|
||||||
|
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0),
|
||||||
|
}
|
||||||
|
self.warehouse_locations = {
|
||||||
|
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用:**
|
||||||
|
- 创建仓库实例
|
||||||
|
- 设置在 Deck 上的物理坐标
|
||||||
|
|
||||||
|
#### 3️⃣ UUID 映射层 - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
"手动传递窗右": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||||
|
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
|
||||||
|
# ... 其他库位
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**作用:**
|
||||||
|
- 用户拖拽物料到"手动传递窗右"的"A01"位置时
|
||||||
|
- 系统查找 `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]`
|
||||||
|
- 获取 UUID `"3a19deae-2c7a-36f5-5e41-02c5b66feaea"`
|
||||||
|
- 调用 Bioyond API 将物料入库到该 UUID 位置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实际配置案例
|
||||||
|
|
||||||
|
### 案例:手动传递窗左/右的完整配置
|
||||||
|
|
||||||
|
本案例展示如何为"手动传递窗右"和"手动传递窗左"建立完整的三层映射。
|
||||||
|
|
||||||
|
#### 背景需求
|
||||||
|
- **手动传递窗右**: 需要 A01-E03(5行×3列=15个库位)
|
||||||
|
- **手动传递窗左**: 需要 F01-J03(5行×3列=15个库位)
|
||||||
|
- 这两个仓库共享同一个物理堆栈的 UUID("手动堆栈")
|
||||||
|
|
||||||
|
#### 实施步骤
|
||||||
|
|
||||||
|
**1️⃣ 修复前端布局** - [`YB_warehouses.py`](YB_warehouses.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 创建新的 5×3×1 仓库函数(之前是错误的 1×3×3)
|
||||||
|
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||||
|
"""创建5行×3列×1层仓库,支持行偏移生成不同字母行"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, # 3列
|
||||||
|
num_items_y=5, # 5行 ← 修正
|
||||||
|
num_items_z=1, # 1层 ← 修正
|
||||||
|
row_offset=row_offset, # ← 支持 F-J 行
|
||||||
|
layout="row-major", # ← 避免上下颠倒
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2️⃣ 更新 Deck 配置** - [`decks.py`](decks.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.resources.bioyond.YB_warehouses import (
|
||||||
|
bioyond_warehouse_5x3x1, # 新增导入
|
||||||
|
)
|
||||||
|
|
||||||
|
class BIOYOND_YB_Deck(Deck):
|
||||||
|
def setup(self) -> None:
|
||||||
|
self.warehouses = {
|
||||||
|
# 修改前: bioyond_warehouse_1x3x3 (错误尺寸)
|
||||||
|
# 修改后: bioyond_warehouse_5x3x1 (正确尺寸)
|
||||||
|
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
|
||||||
|
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3️⃣ 添加 UUID 映射** - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
# 保持原有的"手动堆栈"配置不变(A01-J03共30个库位)
|
||||||
|
"手动堆栈": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||||
|
# ... A02-E03 共15个
|
||||||
|
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a",
|
||||||
|
# ... F02-J03 共15个
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# [新增] 手动传递窗右 - 复用"手动堆栈"的 A01-E03 UUID
|
||||||
|
"手动传递窗右": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea", # ← 与手动堆栈A01相同
|
||||||
|
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
|
||||||
|
"A03": "3a19deae-2c7a-5876-c454-6b7e224ca927",
|
||||||
|
"B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1",
|
||||||
|
"B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3",
|
||||||
|
"B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
|
||||||
|
"C01": "3a19deae-2c7a-32bc-768e-556647e292f3",
|
||||||
|
"C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4",
|
||||||
|
"C03": "3a19deae-2c7a-3056-6504-10dc73fbc276",
|
||||||
|
"D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440",
|
||||||
|
"D02": "3a19deae-2c7a-61be-601c-b6fb5610499a",
|
||||||
|
"D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560",
|
||||||
|
"E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363",
|
||||||
|
"E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910",
|
||||||
|
"E03": "3a19deae-2c7a-b163-2219-23df15200311",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# [新增] 手动传递窗左 - 复用"手动堆栈"的 F01-J03 UUID
|
||||||
|
"手动传递窗左": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a", # ← 与手动堆栈F01相同
|
||||||
|
"F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679",
|
||||||
|
"F03": "3a19deae-2c7a-f7c4-12bd-425799425698",
|
||||||
|
"G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955",
|
||||||
|
"G02": "3a19deae-2c7a-204e-95ed-1f1950f28343",
|
||||||
|
"G03": "3a19deae-2c7a-392b-62f1-4907c66343f8",
|
||||||
|
"H01": "3a19deae-2c7a-5602-e876-d27aca4e3201",
|
||||||
|
"H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702",
|
||||||
|
"H03": "3a19deae-2c7a-780b-8965-2e1345f7e834",
|
||||||
|
"I01": "3a19deae-2c7a-8849-e172-07de14ede928",
|
||||||
|
"I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0",
|
||||||
|
"I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4",
|
||||||
|
"J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6",
|
||||||
|
"J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205",
|
||||||
|
"J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 关键要点
|
||||||
|
|
||||||
|
1. **UUID 可以复用**: 三个仓库(手动堆栈、手动传递窗右、手动传递窗左)可以共享相同的物理库位 UUID
|
||||||
|
2. **库位名称必须匹配**: 前端生成的库位名称(如 F01)必须与 config.py 中的键名完全一致
|
||||||
|
3. **row_offset 的妙用**:
|
||||||
|
- `row_offset=0` → 生成 A-E 行
|
||||||
|
- `row_offset=5` → 生成 F-J 行(跳过前5个字母)
|
||||||
|
|
||||||
|
#### 验证结果
|
||||||
|
|
||||||
|
配置完成后,拖拽测试:
|
||||||
|
|
||||||
|
| 拖拽位置 | 前端库位 | 查找路径 | UUID | 结果 |
|
||||||
|
|---------|---------|---------|------|------|
|
||||||
|
| 手动传递窗右/A01 | A01 | `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 正确入库 |
|
||||||
|
| 手动传递窗左/F01 | F01 | `WAREHOUSE_MAPPING["手动传递窗左"]["site_uuids"]["F01"]` | `3a19...c4a` | ✅ 正确入库 |
|
||||||
|
| 手动堆栈/A01 | A01 | `WAREHOUSE_MAPPING["手动堆栈"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 仍然正常 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## warehouse_factory 详解
|
||||||
|
|
||||||
|
### 函数签名
|
||||||
|
|
||||||
|
```python
|
||||||
|
def warehouse_factory(
|
||||||
|
name: str,
|
||||||
|
num_items_x: int = 1, # 列数
|
||||||
|
num_items_y: int = 4, # 行数
|
||||||
|
num_items_z: int = 4, # 层数
|
||||||
|
dx: float = 137.0, # X 起始偏移
|
||||||
|
dy: float = 96.0, # Y 起始偏移
|
||||||
|
dz: float = 120.0, # Z 起始偏移
|
||||||
|
item_dx: float = 10.0, # X 间距
|
||||||
|
item_dy: float = 10.0, # Y 间距
|
||||||
|
item_dz: float = 10.0, # Z 间距
|
||||||
|
col_offset: int = 0, # 列偏移(影响数字)
|
||||||
|
row_offset: int = 0, # 行偏移(影响字母)
|
||||||
|
layout: str = "col-major", # 布局模式
|
||||||
|
) -> WareHouse:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
#### 尺寸参数
|
||||||
|
- **num_items_x, y, z**: 定义仓库的网格尺寸
|
||||||
|
- **注意**: 当 `num_items_z > 1` 时,Z 轴会被映射为数字列
|
||||||
|
|
||||||
|
#### 位置参数
|
||||||
|
- **dx, dy, dz**: 第一个库位的起始坐标
|
||||||
|
- **item_dx, dy, dz**: 库位之间的间距
|
||||||
|
|
||||||
|
#### 偏移参数
|
||||||
|
- **col_offset**: 列起始偏移,用于生成 A05-D08 等命名
|
||||||
|
```python
|
||||||
|
col_offset=4 # 生成 A05, A06, A07, A08
|
||||||
|
```
|
||||||
|
|
||||||
|
- **row_offset**: 行起始偏移,用于生成 F01-J03 等命名
|
||||||
|
```python
|
||||||
|
row_offset=5 # 生成 F01, F02, F03(跳过 A-E)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 布局参数
|
||||||
|
- **layout**:
|
||||||
|
- `"col-major"`: 列优先(默认),可能导致上下颠倒
|
||||||
|
- `"row-major"`: 行优先,**推荐使用**,A 显示在上
|
||||||
|
|
||||||
|
### 库位生成逻辑
|
||||||
|
|
||||||
|
```python
|
||||||
|
# row-major 模式(推荐)
|
||||||
|
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}"
|
||||||
|
for j in range(num_y)
|
||||||
|
for i in range(num_x)]
|
||||||
|
|
||||||
|
# 示例:num_y=2, num_x=3, row_offset=0, col_offset=0
|
||||||
|
# 生成:A01, A02, A03, B01, B02, B03
|
||||||
|
```
|
||||||
|
|
||||||
|
### Y 坐标计算
|
||||||
|
|
||||||
|
```python
|
||||||
|
if layout == "row-major":
|
||||||
|
# A 在上(Y 较小)
|
||||||
|
y = dy + row * item_dy
|
||||||
|
else:
|
||||||
|
# A 在下(Y 较大)- 不推荐
|
||||||
|
y = dy + (num_items_y - row - 1) * item_dy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 创建新仓库
|
||||||
|
|
||||||
|
### 步骤 1: 在 YB_warehouses.py 中创建函数
|
||||||
|
|
||||||
|
```python
|
||||||
|
def bioyond_warehouse_3x4x1(name: str) -> WareHouse:
|
||||||
|
"""创建 3行×4列×1层 仓库
|
||||||
|
|
||||||
|
布局:
|
||||||
|
A01 | A02 | A03 | A04
|
||||||
|
B01 | B02 | B03 | B04
|
||||||
|
C01 | C02 | C03 | C04
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, # 4列
|
||||||
|
num_items_y=3, # 3行
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=120.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
layout="row-major", # ⭐ 推荐使用
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2: 在 decks.py 中使用
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. 导入函数
|
||||||
|
from unilabos.resources.bioyond.YB_warehouses import (
|
||||||
|
bioyond_warehouse_3x4x1, # 新增
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 在 setup() 中添加
|
||||||
|
self.warehouses = {
|
||||||
|
"我的新仓库": bioyond_warehouse_3x4x1("我的新仓库"),
|
||||||
|
}
|
||||||
|
self.warehouse_locations = {
|
||||||
|
"我的新仓库": Coordinate(100.0, 200.0, 0.0),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3: 在 config.py 中配置 UUID(可选)
|
||||||
|
|
||||||
|
```python
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
"我的新仓库": {
|
||||||
|
"uuid": "",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "从 Bioyond 系统获取的 UUID",
|
||||||
|
"A02": "从 Bioyond 系统获取的 UUID",
|
||||||
|
# ... 其他 11 个库位
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意:** 如果不需要拖拽入库功能,可跳过此步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 为什么库位显示上下颠倒(C 在上,A 在下)?
|
||||||
|
|
||||||
|
**原因:** 使用了默认的 `col-major` 布局。
|
||||||
|
|
||||||
|
**解决:** 在 `warehouse_factory` 中添加 `layout="row-major"`
|
||||||
|
|
||||||
|
```python
|
||||||
|
return warehouse_factory(
|
||||||
|
...
|
||||||
|
layout="row-major", # ← 添加这行
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q2: 我需要 1×3×3 还是 3×3×1?
|
||||||
|
|
||||||
|
**判断方法:**
|
||||||
|
- **1×3×3**: 1列×3行×3**层**(垂直堆叠,有高度)
|
||||||
|
- **3×3×1**: 3行×3列×1**层**(平面网格)
|
||||||
|
|
||||||
|
**推荐:** 大多数情况使用 `X×Y×1`(平面网格)更直观。
|
||||||
|
|
||||||
|
### Q3: 如何生成 F01-J03 而非 A01-E03?
|
||||||
|
|
||||||
|
**方法:** 使用 `row_offset` 参数
|
||||||
|
|
||||||
|
```python
|
||||||
|
bioyond_warehouse_5x3x1("仓库名", row_offset=5)
|
||||||
|
# row_offset=5 跳过 A-E,从 F 开始
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q4: 拖拽物料后找不到 UUID 怎么办?
|
||||||
|
|
||||||
|
**检查清单:**
|
||||||
|
1. `config.py` 中是否有该仓库的配置?
|
||||||
|
2. 仓库名称是否完全匹配?
|
||||||
|
3. 库位名称(如 A01)是否在 `site_uuids` 中?
|
||||||
|
|
||||||
|
**示例错误:**
|
||||||
|
```python
|
||||||
|
# decks.py
|
||||||
|
"手动传递窗右": bioyond_warehouse_5x3x1(...)
|
||||||
|
|
||||||
|
# config.py - ❌ 名称不匹配
|
||||||
|
"手动传递窗": { ... } # 缺少"右"字
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q5: 库位重叠怎么办?
|
||||||
|
|
||||||
|
**原因:** 间距(`item_dx/dy/dz`)太小。
|
||||||
|
|
||||||
|
**解决:** 增大间距参数
|
||||||
|
|
||||||
|
```python
|
||||||
|
item_dx=150.0, # 增大 X 间距
|
||||||
|
item_dy=130.0, # 增大 Y 间距
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 1. 查看生成的库位
|
||||||
|
|
||||||
|
```python
|
||||||
|
warehouse = bioyond_warehouse_5x3x1("测试仓库")
|
||||||
|
print(list(warehouse.sites.keys()))
|
||||||
|
# 输出:['A01', 'A02', 'A03', 'B01', 'B02', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 检查库位坐标
|
||||||
|
|
||||||
|
```python
|
||||||
|
for name, site in warehouse.sites.items():
|
||||||
|
print(f"{name}: {site.location}")
|
||||||
|
# 输出:
|
||||||
|
# A01: Coordinate(x=10.0, y=10.0, z=120.0)
|
||||||
|
# A02: Coordinate(x=147.0, y=10.0, z=120.0)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 验证 UUID 映射
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import WAREHOUSE_MAPPING
|
||||||
|
|
||||||
|
warehouse_name = "手动传递窗右"
|
||||||
|
location_code = "A01"
|
||||||
|
|
||||||
|
if warehouse_name in WAREHOUSE_MAPPING:
|
||||||
|
uuid = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"].get(location_code)
|
||||||
|
print(f"{warehouse_name}/{location_code} → {uuid}")
|
||||||
|
else:
|
||||||
|
print(f"❌ 未找到仓库: {warehouse_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
unilabos/
|
||||||
|
├── resources/
|
||||||
|
│ ├── warehouse.py # warehouse_factory 核心实现
|
||||||
|
│ └── bioyond/
|
||||||
|
│ ├── YB_warehouses.py # ⭐ 仓库函数定义
|
||||||
|
│ ├── decks.py # ⭐ Deck 布局配置
|
||||||
|
│ └── README_WAREHOUSE.md # 📖 本文档
|
||||||
|
└── devices/
|
||||||
|
└── workstation/
|
||||||
|
└── bioyond_studio/
|
||||||
|
├── config.py # ⭐ UUID 映射配置
|
||||||
|
└── bioyond_cell/
|
||||||
|
└── bioyond_cell_workstation.py # 业务逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
- **v1.1** (2026-01-08): 补充实际配置案例
|
||||||
|
- 添加"手动传递窗右"和"手动传递窗左"的完整配置示例
|
||||||
|
- 展示 UUID 复用的实际应用
|
||||||
|
- 说明三个仓库共享物理堆栈的配置方法
|
||||||
|
|
||||||
|
- **v1.0** (2026-01-07): 初始版本
|
||||||
|
- 新增 `row_offset` 参数支持
|
||||||
|
- 创建 `bioyond_warehouse_5x3x1` 和 `bioyond_warehouse_2x2x1`
|
||||||
|
- 修复多个仓库的上下颠倒问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关资源
|
||||||
|
|
||||||
|
- [warehouse.py](../warehouse.py) - 核心工厂函数实现
|
||||||
|
- [YB_warehouses.py](YB_warehouses.py) - 所有仓库定义
|
||||||
|
- [decks.py](decks.py) - Deck 布局配置
|
||||||
|
- [config.py](../../devices/workstation/bioyond_studio/config.py) - UUID 映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**维护者:** Uni-Lab-OS 开发团队
|
||||||
|
**最后更新:** 2026-01-07
|
||||||
653
unilabos/resources/bioyond/YB_bottle_carriers.py
Normal file
653
unilabos/resources/bioyond/YB_bottle_carriers.py
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
|
||||||
|
|
||||||
|
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||||
|
from unilabos.resources.bioyond.YB_bottles import (
|
||||||
|
YB_jia_yang_tou_da,
|
||||||
|
YB_ye_Bottle,
|
||||||
|
YB_ye_100ml_Bottle,
|
||||||
|
YB_gao_nian_ye_Bottle,
|
||||||
|
YB_5ml_fenyeping,
|
||||||
|
YB_20ml_fenyeping,
|
||||||
|
YB_pei_ye_xiao_Bottle,
|
||||||
|
YB_pei_ye_da_Bottle,
|
||||||
|
YB_qiang_tou,
|
||||||
|
)
|
||||||
|
# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||||
|
|
||||||
|
|
||||||
|
def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier:
|
||||||
|
"""6瓶载架 - 2x3布局"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 50.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 30.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=3,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="Electrolyte_6VialCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 3
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
# for i in range(6):
|
||||||
|
# carrier[i] = YB_Solid_Vial(f"{name}_vial_{i+1}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
|
||||||
|
"""1瓶载架 - 单个中央位置"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 100.0
|
||||||
|
|
||||||
|
# 烧杯尺寸
|
||||||
|
beaker_diameter = 80.0
|
||||||
|
|
||||||
|
# 计算中央位置
|
||||||
|
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||||
|
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||||
|
center_z = 5.0
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[Coordinate(center_x, center_y, center_z)],
|
||||||
|
resource_size_x=beaker_diameter,
|
||||||
|
resource_size_y=beaker_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
),
|
||||||
|
model="Electrolyte_1BottleCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
# carrier[0] = YB_Solution_Beaker(f"{name}_beaker_1")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
def YB_6StockCarrier(name: str) -> BottleCarrier:
|
||||||
|
"""6瓶载架 - 2x3布局"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 50.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 20.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=3,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="6StockCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 3
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
|
||||||
|
# for i in range(6):
|
||||||
|
# carrier[i] = YB_Solid_Stock(f"{name}_vial_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
def YB_6VialCarrier(name: str) -> BottleCarrier:
|
||||||
|
"""6瓶载架 - 2x3布局"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 50.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 30.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=3,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="6VialCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 3
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
|
||||||
|
# for i in range(3):
|
||||||
|
# carrier[i] = YB_Solid_Vial(f"{name}_solidvial_{ordering[i]}")
|
||||||
|
# for i in range(3, 6):
|
||||||
|
# carrier[i] = YB_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
# 1瓶载架 - 单个中央位置
|
||||||
|
def YB_ye(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 20.0
|
||||||
|
|
||||||
|
# 烧杯尺寸
|
||||||
|
beaker_diameter = 60.0
|
||||||
|
|
||||||
|
# 计算中央位置
|
||||||
|
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||||
|
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||||
|
center_z = 5.0
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[Coordinate(center_x, center_y, center_z)],
|
||||||
|
resource_size_x=beaker_diameter,
|
||||||
|
resource_size_y=beaker_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
),
|
||||||
|
model="YB_ye",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
# 高粘液瓶载架 - 单个中央位置
|
||||||
|
def YB_gaonianye(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 20.0
|
||||||
|
|
||||||
|
# 烧杯尺寸
|
||||||
|
beaker_diameter = 60.0
|
||||||
|
|
||||||
|
# 计算中央位置
|
||||||
|
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||||
|
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||||
|
center_z = 5.0
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[Coordinate(center_x, center_y, center_z)],
|
||||||
|
resource_size_x=beaker_diameter,
|
||||||
|
resource_size_y=beaker_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
),
|
||||||
|
model="YB_gaonianye",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
# 100ml液体瓶载架 - 单个中央位置
|
||||||
|
def YB_100ml_yeti(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 20.0
|
||||||
|
|
||||||
|
# 烧杯尺寸
|
||||||
|
beaker_diameter = 60.0
|
||||||
|
|
||||||
|
# 计算中央位置
|
||||||
|
center_x = (carrier_size_x - beaker_diameter) / 2
|
||||||
|
center_y = (carrier_size_y - beaker_diameter) / 2
|
||||||
|
center_z = 5.0
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[Coordinate(center_x, center_y, center_z)],
|
||||||
|
resource_size_x=beaker_diameter,
|
||||||
|
resource_size_y=beaker_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
),
|
||||||
|
model="YB_100ml_yeti",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
# 5ml分液瓶板 - 4x2布局,8个位置
|
||||||
|
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 50.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 15.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_5ml_fenyepingban",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 4
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||||
|
for i in range(8):
|
||||||
|
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
# 20ml分液瓶板 - 4x2布局,8个位置
|
||||||
|
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 70.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 20.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_20ml_fenyepingban",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 4
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||||
|
for i in range(8):
|
||||||
|
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
# 配液瓶(小)板 - 4x2布局,8个位置
|
||||||
|
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 65.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 35.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_peiyepingxiaoban",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 4
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
|
||||||
|
for i in range(8):
|
||||||
|
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
# 配液瓶(大)板 - 2x2布局,4个位置
|
||||||
|
def YB_peiyepingdaban(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 95.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 55.0
|
||||||
|
bottle_spacing_x = 60.0 # X方向间距
|
||||||
|
bottle_spacing_y = 60.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=2,
|
||||||
|
num_items_y=2,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_peiyepingdaban",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 2
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
ordering = ["A1", "A2", "B1", "B2"]
|
||||||
|
for i in range(4):
|
||||||
|
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
# 加样头(大)板 - 1x1布局,1个位置
|
||||||
|
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 95.0
|
||||||
|
|
||||||
|
# 瓶位尺寸
|
||||||
|
bottle_diameter = 35.0
|
||||||
|
bottle_spacing_x = 42.0 # X方向间距
|
||||||
|
bottle_spacing_y = 35.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (1 - 1) * bottle_spacing_x - bottle_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (1 - 1) * bottle_spacing_y - bottle_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=1,
|
||||||
|
num_items_y=1,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=bottle_spacing_x,
|
||||||
|
item_dy=bottle_spacing_y,
|
||||||
|
size_x=bottle_diameter,
|
||||||
|
size_y=bottle_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_jia_yang_tou_da_Carrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
|
||||||
|
"""适配器块 - 单个中央位置"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 30.0
|
||||||
|
|
||||||
|
# 适配器尺寸
|
||||||
|
adapter_diameter = 80.0
|
||||||
|
|
||||||
|
# 计算中央位置
|
||||||
|
center_x = (carrier_size_x - adapter_diameter) / 2
|
||||||
|
center_y = (carrier_size_y - adapter_diameter) / 2
|
||||||
|
center_z = 0.0
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[Coordinate(center_x, center_y, center_z)],
|
||||||
|
resource_size_x=adapter_diameter,
|
||||||
|
resource_size_y=adapter_diameter,
|
||||||
|
name_prefix=name,
|
||||||
|
),
|
||||||
|
model="YB_shi_pei_qi_kuai",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 1
|
||||||
|
carrier.num_items_y = 1
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
# 适配器块本身不包含瓶子,只是一个支撑结构
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
def YB_qiang_tou_he(name: str) -> BottleCarrier:
|
||||||
|
"""枪头盒 - 8x12布局,96个位置"""
|
||||||
|
|
||||||
|
# 载架尺寸 (mm)
|
||||||
|
carrier_size_x = 127.8
|
||||||
|
carrier_size_y = 85.5
|
||||||
|
carrier_size_z = 55.0
|
||||||
|
|
||||||
|
# 枪头尺寸
|
||||||
|
tip_diameter = 10.0
|
||||||
|
tip_spacing_x = 9.0 # X方向间距
|
||||||
|
tip_spacing_y = 9.0 # Y方向间距
|
||||||
|
|
||||||
|
# 计算起始位置 (居中排列)
|
||||||
|
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
|
||||||
|
start_y = (carrier_size_y - (8 - 1) * tip_spacing_y - tip_diameter) / 2
|
||||||
|
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=12,
|
||||||
|
num_items_y=8,
|
||||||
|
dx=start_x,
|
||||||
|
dy=start_y,
|
||||||
|
dz=5.0,
|
||||||
|
item_dx=tip_spacing_x,
|
||||||
|
item_dy=tip_spacing_y,
|
||||||
|
size_x=tip_diameter,
|
||||||
|
size_y=tip_diameter,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
)
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name,
|
||||||
|
size_x=carrier_size_x,
|
||||||
|
size_y=carrier_size_y,
|
||||||
|
size_z=carrier_size_z,
|
||||||
|
sites=sites,
|
||||||
|
model="YB_qiang_tou_he",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 12
|
||||||
|
carrier.num_items_y = 8
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
# 创建96个枪头
|
||||||
|
for i in range(96):
|
||||||
|
row = chr(65 + i // 12) # A-H
|
||||||
|
col = (i % 12) + 1 # 1-12
|
||||||
|
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
|
||||||
|
return carrier
|
||||||
|
|
||||||
163
unilabos/resources/bioyond/YB_bottles.py
Normal file
163
unilabos/resources/bioyond/YB_bottles.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
|
||||||
|
# 工厂函数
|
||||||
|
"""加样头(大)"""
|
||||||
|
def YB_jia_yang_tou_da(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 20.0,
|
||||||
|
height: float = 100.0,
|
||||||
|
max_volume: float = 30000.0, # 30mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建粉末瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,# 未知
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_jia_yang_tou_da",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""液1x1"""
|
||||||
|
def YB_ye_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 40.0,
|
||||||
|
height: float = 70.0,
|
||||||
|
max_volume: float = 50000.0, # 50mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建液体瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_ye_Bottle",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""100ml液体"""
|
||||||
|
def YB_ye_100ml_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 50.0,
|
||||||
|
height: float = 90.0,
|
||||||
|
max_volume: float = 100000.0, # 100mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建100ml液体瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_100ml_yeti",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""高粘液"""
|
||||||
|
def YB_gao_nian_ye_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 40.0,
|
||||||
|
height: float = 70.0,
|
||||||
|
max_volume: float = 50000.0, # 50mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建高粘液瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="High_Viscosity_Liquid",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""5ml分液瓶"""
|
||||||
|
def YB_5ml_fenyeping(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 20.0,
|
||||||
|
height: float = 50.0,
|
||||||
|
max_volume: float = 5000.0, # 5mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建5ml分液瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_5ml_fenyeping",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""20ml分液瓶"""
|
||||||
|
def YB_20ml_fenyeping(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 30.0,
|
||||||
|
height: float = 65.0,
|
||||||
|
max_volume: float = 20000.0, # 20mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建20ml分液瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_20ml_fenyeping",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""配液瓶(小)"""
|
||||||
|
def YB_pei_ye_xiao_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 35.0,
|
||||||
|
height: float = 60.0,
|
||||||
|
max_volume: float = 30000.0, # 30mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建配液瓶(小)"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_pei_ye_xiao_Bottle",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""配液瓶(大)"""
|
||||||
|
def YB_pei_ye_da_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 55.0,
|
||||||
|
height: float = 100.0,
|
||||||
|
max_volume: float = 150000.0, # 150mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建配液瓶(大)"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_pei_ye_da_Bottle",
|
||||||
|
)
|
||||||
|
|
||||||
|
"""枪头"""
|
||||||
|
def YB_qiang_tou(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 10.0,
|
||||||
|
height: float = 50.0,
|
||||||
|
max_volume: float = 1000.0, # 1mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建枪头"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="YB_qiang_tou",
|
||||||
|
)
|
||||||
384
unilabos/resources/bioyond/YB_warehouses.py
Normal file
384
unilabos/resources/bioyond/YB_warehouses.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
from unilabos.resources.warehouse import WareHouse, warehouse_factory
|
||||||
|
|
||||||
|
# ================ 反应站相关堆栈 ================
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 4x4x1仓库 (左侧堆栈: A01~D04)
|
||||||
|
|
||||||
|
使用行优先排序,前端展示为:
|
||||||
|
A01 | A02 | A03 | A04
|
||||||
|
B01 | B02 | B03 | B04
|
||||||
|
C01 | C02 | C03 | C04
|
||||||
|
D01 | D02 | D03 | D04
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, # 4列
|
||||||
|
num_items_y=4, # 4行
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=147.0,
|
||||||
|
item_dy=106.0,
|
||||||
|
item_dz=130.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0, # 从01开始: A01, A02, A03, A04
|
||||||
|
layout="row-major", # ⭐ 改为行优先排序
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 4x4x1仓库 (右侧堆栈: A05~D08)"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=147.0,
|
||||||
|
item_dy=106.0,
|
||||||
|
item_dz=130.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=4, # 从05开始: A05, A06, A07, A08
|
||||||
|
layout="row-major", # ⭐ 改为行优先排序
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||||
|
"""创建测量小瓶仓库(测密度) A01~B03"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, # 3列(01-03)
|
||||||
|
num_items_y=2, # 2行(A-B)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=40.0,
|
||||||
|
item_dy=40.0,
|
||||||
|
item_dz=50.0,
|
||||||
|
# 用更小的 resource_size 来表现 "小点的孔位"
|
||||||
|
resource_size_x=30.0,
|
||||||
|
resource_size_y=30.0,
|
||||||
|
resource_size_z=12.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0,
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=2, # 2列(01-02)
|
||||||
|
num_items_y=1, # 1行(A)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond站内Tip盒堆栈(A01~B03, 2行×3列)"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, # 3列(01-03)
|
||||||
|
num_items_y=2, # 2行(A-B)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0,
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse:
|
||||||
|
"""已弃用,创建BioYond移液站内10%分装液体准备仓库(A01~B04)"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, # 4列(01-04)
|
||||||
|
num_items_y=2, # 2行(A-B)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0,
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ================ 配液站相关堆栈 ================
|
||||||
|
|
||||||
|
def bioyond_warehouse_reagent_stack(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04)
|
||||||
|
|
||||||
|
使用行优先排序,前端展示为:
|
||||||
|
A01 | A02 | A03 | A04
|
||||||
|
B01 | B02 | B03 | B04
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, # 4列 (01-04)
|
||||||
|
num_items_y=2, # 2行 (A-B)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=147.0,
|
||||||
|
item_dy=106.0,
|
||||||
|
item_dz=130.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0, # 从01开始
|
||||||
|
layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04
|
||||||
|
)
|
||||||
|
|
||||||
|
# 定义bioyond的堆栈
|
||||||
|
|
||||||
|
# =================== Other ===================
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 4x2x1仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=1,
|
||||||
|
num_items_y=4,
|
||||||
|
num_items_z=2,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
removed_positions=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x2x2(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 1x2x2仓库(1列×2行×2层)- 旧版本,已弃用
|
||||||
|
|
||||||
|
布局(2层):
|
||||||
|
层1: A01
|
||||||
|
B01
|
||||||
|
层2: A02
|
||||||
|
B02
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=1,
|
||||||
|
num_items_y=2,
|
||||||
|
num_items_z=2,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
layout="row-major", # 使用行优先避免上下颠倒
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_2x2x1(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 2x2x1仓库(2行×2列×1层)
|
||||||
|
|
||||||
|
布局:
|
||||||
|
A01 | A02
|
||||||
|
B01 | B02
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=2, # 2列
|
||||||
|
num_items_y=2, # 2行
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
layout="row-major", # 使用行优先避免上下颠倒
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bioyond_warehouse_10x1x1(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 10x1x1仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=10,
|
||||||
|
num_items_y=1,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x3x3(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 1x3x3仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=1,
|
||||||
|
num_items_y=3,
|
||||||
|
num_items_z=3,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=120.0, # 增大Y方向间距以避免重叠
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||||
|
"""创建BioYond 5x3x1仓库(5行×3列×1层)
|
||||||
|
|
||||||
|
标准布局(row_offset=0):
|
||||||
|
A01 | A02 | A03
|
||||||
|
B01 | B02 | B03
|
||||||
|
C01 | C02 | C03
|
||||||
|
D01 | D02 | D03
|
||||||
|
E01 | E02 | E03
|
||||||
|
|
||||||
|
带偏移布局(row_offset=5):
|
||||||
|
F01 | F02 | F03
|
||||||
|
G01 | G02 | G03
|
||||||
|
H01 | H02 | H03
|
||||||
|
I01 | I02 | I03
|
||||||
|
J01 | J02 | J03
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, # 3列
|
||||||
|
num_items_y=5, # 5行
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=120.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0,
|
||||||
|
row_offset=row_offset, # 支持行偏移
|
||||||
|
layout="row-major", # 使用行优先避免颠倒
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bioyond_warehouse_3x3x1(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 3x3x1仓库(3行×3列×1层)
|
||||||
|
|
||||||
|
布局:
|
||||||
|
A01 | A02 | A03
|
||||||
|
B01 | B02 | B03
|
||||||
|
C01 | C02 | C03
|
||||||
|
"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3,
|
||||||
|
num_items_y=3,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
layout="row-major", # ⭐ 使用行优先避免上下颠倒
|
||||||
|
)
|
||||||
|
def bioyond_warehouse_2x1x3(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 2x1x3仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=2,
|
||||||
|
num_items_y=1,
|
||||||
|
num_items_z=3,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bioyond_warehouse_5x1x1(name: str) -> WareHouse:
|
||||||
|
"""已弃用:创建BioYond 5x1x1仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=5,
|
||||||
|
num_items_y=1,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_3x3x1_2(name: str) -> WareHouse:
|
||||||
|
"""已弃用:创建BioYond 3x3x1仓库"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3,
|
||||||
|
num_items_y=3,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=12.0,
|
||||||
|
dy=12.0,
|
||||||
|
dz=12.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond开关盖加液模块台面"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=2,
|
||||||
|
num_items_y=5,
|
||||||
|
num_items_z=1,
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
removed_positions=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_1x8x4(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond 8x4x1反应站堆栈(A01~D08)"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=8, # 8列(01-08)
|
||||||
|
num_items_y=4, # 4行(A-D)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=147.0,
|
||||||
|
item_dy=106.0,
|
||||||
|
item_dz=130.0,
|
||||||
|
category="warehouse",
|
||||||
|
)
|
||||||
@@ -193,3 +193,20 @@ def BIOYOND_PolymerStation_Flask(
|
|||||||
barcode=barcode,
|
barcode=barcode,
|
||||||
model="BIOYOND_PolymerStation_Flask",
|
model="BIOYOND_PolymerStation_Flask",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def BIOYOND_PolymerStation_Measurement_Vial(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 25.0,
|
||||||
|
height: float = 60.0,
|
||||||
|
max_volume: float = 20000.0, # 20mL
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
"""创建测量小瓶"""
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="BIOYOND_PolymerStation_Measurement_Vial",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from os import name
|
from os import name
|
||||||
from pylabrobot.resources import Deck, Coordinate, Rotation
|
from pylabrobot.resources import Deck, Coordinate, Rotation
|
||||||
|
|
||||||
from unilabos.resources.bioyond.warehouses import (
|
from unilabos.resources.bioyond.YB_warehouses import (
|
||||||
bioyond_warehouse_1x4x4,
|
bioyond_warehouse_1x4x4,
|
||||||
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08)
|
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08)
|
||||||
bioyond_warehouse_1x4x2,
|
bioyond_warehouse_1x4x2,
|
||||||
bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4)
|
bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4)
|
||||||
bioyond_warehouse_liquid_and_lid_handling,
|
bioyond_warehouse_liquid_and_lid_handling,
|
||||||
bioyond_warehouse_1x2x2,
|
bioyond_warehouse_1x2x2,
|
||||||
|
bioyond_warehouse_2x2x1, # 新增:321和43窗口 (2行×2列)
|
||||||
bioyond_warehouse_1x3x3,
|
bioyond_warehouse_1x3x3,
|
||||||
|
bioyond_warehouse_5x3x1, # 新增:手动传递窗仓库 (5行×3列)
|
||||||
bioyond_warehouse_10x1x1,
|
bioyond_warehouse_10x1x1,
|
||||||
bioyond_warehouse_3x3x1,
|
bioyond_warehouse_3x3x1,
|
||||||
bioyond_warehouse_3x3x1_2,
|
bioyond_warehouse_3x3x1_2,
|
||||||
@@ -16,9 +18,12 @@ from unilabos.resources.bioyond.warehouses import (
|
|||||||
bioyond_warehouse_1x8x4,
|
bioyond_warehouse_1x8x4,
|
||||||
bioyond_warehouse_reagent_storage,
|
bioyond_warehouse_reagent_storage,
|
||||||
# bioyond_warehouse_liquid_preparation,
|
# bioyond_warehouse_liquid_preparation,
|
||||||
bioyond_warehouse_tipbox_storage, # 新增:Tip盒堆栈
|
|
||||||
bioyond_warehouse_density_vial,
|
bioyond_warehouse_density_vial,
|
||||||
)
|
)
|
||||||
|
from unilabos.resources.bioyond.warehouses import (
|
||||||
|
bioyond_warehouse_tipbox_storage_left, # 新增:Tip盒堆栈(左)
|
||||||
|
bioyond_warehouse_tipbox_storage_right, # 新增:Tip盒堆栈(右)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BIOYOND_PolymerReactionStation_Deck(Deck):
|
class BIOYOND_PolymerReactionStation_Deck(Deck):
|
||||||
@@ -45,24 +50,22 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
|
|||||||
"堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08
|
"堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08
|
||||||
"站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02
|
"站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02
|
||||||
# "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04
|
# "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04
|
||||||
"站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01~B03, 存放枪头盒.
|
"站内Tip盒堆栈(左)": bioyond_warehouse_tipbox_storage_left("站内Tip盒堆栈(左)"), # A02~B03
|
||||||
|
"站内Tip盒堆栈(右)": bioyond_warehouse_tipbox_storage_right("站内Tip盒堆栈(右)"), # A01~B01
|
||||||
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03
|
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03
|
||||||
}
|
}
|
||||||
self.warehouse_locations = {
|
self.warehouse_locations = {
|
||||||
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
|
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 左侧位置
|
||||||
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
|
"堆栈1右": Coordinate(2350.0, 400.0, 0.0), # 右侧位置
|
||||||
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
|
"站内试剂存放堆栈": Coordinate(640.0, 400.0, 0.0),
|
||||||
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
|
"站内Tip盒堆栈(左)": Coordinate(300.0, 100.0, 0.0),
|
||||||
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
|
"站内Tip盒堆栈(右)": Coordinate(2250.0, 100.0, 0.0), # 向右偏移 2 * item_dx (137.0)
|
||||||
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
|
"测量小瓶仓库(测密度)": Coordinate(1000.0, 530.0, 0.0),
|
||||||
}
|
}
|
||||||
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
|
|
||||||
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
|
|
||||||
|
|
||||||
for warehouse_name, warehouse in self.warehouses.items():
|
for warehouse_name, warehouse in self.warehouses.items():
|
||||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||||
|
|
||||||
|
|
||||||
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -90,9 +93,9 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
|
|||||||
"溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04)
|
"溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04)
|
||||||
}
|
}
|
||||||
self.warehouse_locations = {
|
self.warehouse_locations = {
|
||||||
"粉末堆栈": Coordinate(0.0, 450.0, 0.0),
|
"粉末堆栈": Coordinate(-200.0, 400.0, 0.0),
|
||||||
"试剂堆栈": Coordinate(1850.0, 200.0, 0.0),
|
"试剂堆栈": Coordinate(1750.0, 160.0, 0.0),
|
||||||
"溶液堆栈": Coordinate(2500.0, 450.0, 0.0),
|
"溶液堆栈": Coordinate(2350.0, 400.0, 0.0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for warehouse_name, warehouse in self.warehouses.items():
|
for warehouse_name, warehouse in self.warehouses.items():
|
||||||
@@ -115,10 +118,10 @@ class BIOYOND_YB_Deck(Deck):
|
|||||||
def setup(self) -> None:
|
def setup(self) -> None:
|
||||||
# 添加仓库
|
# 添加仓库
|
||||||
self.warehouses = {
|
self.warehouses = {
|
||||||
"321窗口": bioyond_warehouse_1x2x2("321窗口"),
|
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
|
||||||
"43窗口": bioyond_warehouse_1x2x2("43窗口"),
|
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
|
||||||
"手动传递窗左": bioyond_warehouse_1x3x3("手动传递窗左"),
|
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
|
||||||
"手动传递窗右": bioyond_warehouse_1x3x3("手动传递窗右"),
|
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
|
||||||
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
|
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
|
||||||
"加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"),
|
"加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"),
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ class BIOYOND_YB_Deck(Deck):
|
|||||||
"母液加样右": bioyond_warehouse_3x3x1_2("母液加样右"),
|
"母液加样右": bioyond_warehouse_3x3x1_2("母液加样右"),
|
||||||
"大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"),
|
"大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"),
|
||||||
"大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"),
|
"大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"),
|
||||||
|
"2号手套箱内部堆栈": bioyond_warehouse_3x3x1("2号手套箱内部堆栈"), # 新增:3行×3列 (A01-C03)
|
||||||
}
|
}
|
||||||
# warehouse 的位置
|
# warehouse 的位置
|
||||||
self.warehouse_locations = {
|
self.warehouse_locations = {
|
||||||
@@ -140,10 +144,12 @@ class BIOYOND_YB_Deck(Deck):
|
|||||||
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
|
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
|
||||||
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
|
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
|
||||||
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
|
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
|
||||||
|
"2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整
|
||||||
}
|
}
|
||||||
|
|
||||||
for warehouse_name, warehouse in self.warehouses.items():
|
for warehouse_name, warehouse in self.warehouses.items():
|
||||||
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
|
||||||
|
|
||||||
def YB_Deck(name: str) -> Deck:
|
def YB_Deck(name: str) -> Deck:
|
||||||
by=BIOYOND_YB_Deck(name=name)
|
by=BIOYOND_YB_Deck(name=name)
|
||||||
by.setup()
|
by.setup()
|
||||||
|
|||||||
@@ -46,48 +46,62 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
|
||||||
"""创建测量小瓶仓库(测密度) A01~B03"""
|
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
|
||||||
|
布局(从下到上,从左到右):
|
||||||
|
| A03 | B03 | ← 顶部
|
||||||
|
| A02 | B02 | ← 中部
|
||||||
|
| A01 | B01 | ← 底部
|
||||||
|
"""
|
||||||
return warehouse_factory(
|
return warehouse_factory(
|
||||||
name=name,
|
name=name,
|
||||||
num_items_x=3, # 3列(01-03)
|
num_items_x=2, # 2列(A, B)
|
||||||
num_items_y=2, # 2行(A-B)
|
num_items_y=3, # 3行(01-03,从下到上)
|
||||||
num_items_z=1, # 1层
|
num_items_z=1, # 1层
|
||||||
dx=10.0,
|
dx=10.0,
|
||||||
dy=10.0,
|
dy=10.0,
|
||||||
dz=10.0,
|
dz=10.0,
|
||||||
item_dx=40.0,
|
item_dx=40.0, # 列间距(A到B的横向距离)
|
||||||
item_dy=40.0,
|
item_dy=40.0, # 行间距(01到02到03的竖向距离)
|
||||||
item_dz=50.0,
|
item_dz=50.0,
|
||||||
# 用更小的 resource_size 来表现 "小点的孔位"
|
# ⭐ 竖向warehouse:槽位尺寸也是竖向的(小瓶已经是正方形,无需调整)
|
||||||
resource_size_x=30.0,
|
resource_size_x=30.0,
|
||||||
resource_size_y=30.0,
|
resource_size_y=30.0,
|
||||||
resource_size_z=12.0,
|
resource_size_z=12.0,
|
||||||
category="warehouse",
|
category="warehouse",
|
||||||
col_offset=0,
|
col_offset=0,
|
||||||
layout="row-major",
|
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||||
)
|
)
|
||||||
|
|
||||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||||
"""创建BioYond站内试剂存放堆栈(A01~A02, 1行×2列)"""
|
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
|
||||||
|
布局(竖向,从下到上):
|
||||||
|
| A02 | ← 顶部
|
||||||
|
| A01 | ← 底部
|
||||||
|
"""
|
||||||
return warehouse_factory(
|
return warehouse_factory(
|
||||||
name=name,
|
name=name,
|
||||||
num_items_x=2, # 2列(01-02)
|
num_items_x=1, # 1列
|
||||||
num_items_y=1, # 1行(A)
|
num_items_y=2, # 2行(01-02,从下到上)
|
||||||
num_items_z=1, # 1层
|
num_items_z=1, # 1层
|
||||||
dx=10.0,
|
dx=10.0,
|
||||||
dy=10.0,
|
dy=10.0,
|
||||||
dz=10.0,
|
dz=10.0,
|
||||||
item_dx=137.0,
|
item_dx=96.0, # 列间距(这里只有1列,不重要)
|
||||||
item_dy=96.0,
|
item_dy=137.0, # 行间距(A01到A02的竖向距离)
|
||||||
item_dz=120.0,
|
item_dz=120.0,
|
||||||
|
# ⭐ 竖向warehouse:交换槽位尺寸,使槽位框也是竖向的
|
||||||
|
resource_size_x=86.0, # 原来的 resource_size_y
|
||||||
|
resource_size_y=127.0, # 原来的 resource_size_x
|
||||||
|
resource_size_z=25.0,
|
||||||
category="warehouse",
|
category="warehouse",
|
||||||
|
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
|
||||||
)
|
)
|
||||||
|
|
||||||
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
def bioyond_warehouse_tipbox_storage_left(name: str) -> WareHouse:
|
||||||
"""创建BioYond站内Tip盒堆栈(A01~B03),用于存放枪头盒"""
|
"""创建BioYond站内Tip盒堆栈左侧部分(A02~B03),2列2行"""
|
||||||
return warehouse_factory(
|
return warehouse_factory(
|
||||||
name=name,
|
name=name,
|
||||||
num_items_x=3, # 3列(01-03)
|
num_items_x=2, # 2列
|
||||||
num_items_y=2, # 2行(A-B)
|
num_items_y=2, # 2行(A-B)
|
||||||
num_items_z=1, # 1层
|
num_items_z=1, # 1层
|
||||||
dx=10.0,
|
dx=10.0,
|
||||||
@@ -97,7 +111,25 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
|
|||||||
item_dy=96.0,
|
item_dy=96.0,
|
||||||
item_dz=120.0,
|
item_dz=120.0,
|
||||||
category="warehouse",
|
category="warehouse",
|
||||||
col_offset=0,
|
col_offset=1, # 从02开始: A02, A03
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
def bioyond_warehouse_tipbox_storage_right(name: str) -> WareHouse:
|
||||||
|
"""创建BioYond站内Tip盒堆栈右侧部分(A01~B01),1列2行"""
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=1, # 1列
|
||||||
|
num_items_y=2, # 2行(A-B)
|
||||||
|
num_items_z=1, # 1层
|
||||||
|
dx=10.0,
|
||||||
|
dy=10.0,
|
||||||
|
dz=10.0,
|
||||||
|
item_dx=137.0,
|
||||||
|
item_dy=96.0,
|
||||||
|
item_dz=120.0,
|
||||||
|
category="warehouse",
|
||||||
|
col_offset=0, # 从01开始: A01
|
||||||
layout="row-major",
|
layout="row-major",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -151,12 +151,40 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
|||||||
"""
|
"""
|
||||||
# 构建 id 到 uuid 的映射
|
# 构建 id 到 uuid 的映射
|
||||||
id_to_uuid: Dict[str, str] = {}
|
id_to_uuid: Dict[str, str] = {}
|
||||||
|
uuid_to_id: Dict[str, str] = {}
|
||||||
for node in resource_tree_set.all_nodes:
|
for node in resource_tree_set.all_nodes:
|
||||||
id_to_uuid[node.res_content.id] = node.res_content.uuid
|
id_to_uuid[node.res_content.id] = node.res_content.uuid
|
||||||
|
uuid_to_id[node.res_content.uuid] = node.res_content.id
|
||||||
|
|
||||||
|
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||||
|
for link in links:
|
||||||
|
source_id = link.get("source")
|
||||||
|
target_id = link.get("target")
|
||||||
|
|
||||||
|
# 添加 source_uuid
|
||||||
|
if source_id and source_id in id_to_uuid:
|
||||||
|
link["source_uuid"] = id_to_uuid[source_id]
|
||||||
|
|
||||||
|
# 添加 target_uuid
|
||||||
|
if target_id and target_id in id_to_uuid:
|
||||||
|
link["target_uuid"] = id_to_uuid[target_id]
|
||||||
|
|
||||||
|
source_uuid = link.get("source_uuid")
|
||||||
|
target_uuid = link.get("target_uuid")
|
||||||
|
|
||||||
|
# 添加 source_uuid
|
||||||
|
if source_uuid and source_uuid in uuid_to_id:
|
||||||
|
link["source"] = uuid_to_id[source_uuid]
|
||||||
|
|
||||||
|
# 添加 target_uuid
|
||||||
|
if target_uuid and target_uuid in uuid_to_id:
|
||||||
|
link["target"] = uuid_to_id[target_uuid]
|
||||||
|
|
||||||
# 第一遍处理:将字符串类型的port转换为字典格式
|
# 第一遍处理:将字符串类型的port转换为字典格式
|
||||||
for link in links:
|
for link in links:
|
||||||
port = link.get("port")
|
port = link.get("port")
|
||||||
|
if port is None:
|
||||||
|
continue
|
||||||
if link.get("type", "physical") == "physical":
|
if link.get("type", "physical") == "physical":
|
||||||
link["type"] = "fluid"
|
link["type"] = "fluid"
|
||||||
if isinstance(port, int):
|
if isinstance(port, int):
|
||||||
@@ -179,13 +207,15 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
|||||||
link["port"] = {link["source"]: None, link["target"]: None}
|
link["port"] = {link["source"]: None, link["target"]: None}
|
||||||
|
|
||||||
# 构建边字典,键为(source节点, target节点),值为对应的port信息
|
# 构建边字典,键为(source节点, target节点),值为对应的port信息
|
||||||
edges = {(link["source"], link["target"]): link["port"] for link in links}
|
edges = {(link["source"], link["target"]): link["port"] for link in links if link.get("port")}
|
||||||
|
|
||||||
# 第二遍处理:填充反向边的dest信息
|
# 第二遍处理:填充反向边的dest信息
|
||||||
delete_reverses = []
|
delete_reverses = []
|
||||||
for i, link in enumerate(links):
|
for i, link in enumerate(links):
|
||||||
s, t = link["source"], link["target"]
|
s, t = link["source"], link["target"]
|
||||||
current_port = link["port"]
|
current_port = link.get("port")
|
||||||
|
if current_port is None:
|
||||||
|
continue
|
||||||
if current_port.get(t) is None:
|
if current_port.get(t) is None:
|
||||||
reverse_key = (t, s)
|
reverse_key = (t, s)
|
||||||
reverse_port = edges.get(reverse_key)
|
reverse_port = edges.get(reverse_key)
|
||||||
@@ -200,20 +230,6 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
|||||||
current_port[t] = current_port[s]
|
current_port[t] = current_port[s]
|
||||||
# 删除已被使用反向端口信息的反向边
|
# 删除已被使用反向端口信息的反向边
|
||||||
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
|
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
|
||||||
|
|
||||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
|
||||||
for link in standardized_links:
|
|
||||||
source_id = link.get("source")
|
|
||||||
target_id = link.get("target")
|
|
||||||
|
|
||||||
# 添加 source_uuid
|
|
||||||
if source_id and source_id in id_to_uuid:
|
|
||||||
link["source_uuid"] = id_to_uuid[source_id]
|
|
||||||
|
|
||||||
# 添加 target_uuid
|
|
||||||
if target_id and target_id in id_to_uuid:
|
|
||||||
link["target_uuid"] = id_to_uuid[target_id]
|
|
||||||
|
|
||||||
return standardized_links
|
return standardized_links
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +276,7 @@ def read_node_link_json(
|
|||||||
resource_tree_set = canonicalize_nodes_data(nodes)
|
resource_tree_set = canonicalize_nodes_data(nodes)
|
||||||
|
|
||||||
# 标准化边数据
|
# 标准化边数据
|
||||||
links = data.get("links", [])
|
links = data.get("links", data.get("edges", []))
|
||||||
standardized_links = canonicalize_links_ports(links, resource_tree_set)
|
standardized_links = canonicalize_links_ports(links, resource_tree_set)
|
||||||
|
|
||||||
# 构建 NetworkX 图(需要转换回 dict 格式)
|
# 构建 NetworkX 图(需要转换回 dict 格式)
|
||||||
@@ -284,6 +300,8 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|||||||
edge["sourceHandle"] = port[source]
|
edge["sourceHandle"] = port[source]
|
||||||
elif "source_port" in edge:
|
elif "source_port" in edge:
|
||||||
edge["sourceHandle"] = edge.pop("source_port")
|
edge["sourceHandle"] = edge.pop("source_port")
|
||||||
|
elif "source_handle" in edge:
|
||||||
|
edge["sourceHandle"] = edge.pop("source_handle")
|
||||||
else:
|
else:
|
||||||
typ = edge.get("type")
|
typ = edge.get("type")
|
||||||
if typ == "communication":
|
if typ == "communication":
|
||||||
@@ -292,6 +310,8 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|||||||
edge["targetHandle"] = port[target]
|
edge["targetHandle"] = port[target]
|
||||||
elif "target_port" in edge:
|
elif "target_port" in edge:
|
||||||
edge["targetHandle"] = edge.pop("target_port")
|
edge["targetHandle"] = edge.pop("target_port")
|
||||||
|
elif "target_handle" in edge:
|
||||||
|
edge["targetHandle"] = edge.pop("target_handle")
|
||||||
else:
|
else:
|
||||||
typ = edge.get("type")
|
typ = edge.get("type")
|
||||||
if typ == "communication":
|
if typ == "communication":
|
||||||
@@ -781,6 +801,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
|||||||
if not locations:
|
if not locations:
|
||||||
logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置")
|
logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置")
|
||||||
|
|
||||||
|
# ⭐ 预先检查:如果物料的任何location在竖向warehouse中,提前交换尺寸
|
||||||
|
# 这样可以避免多个location时尺寸不一致的问题
|
||||||
|
needs_size_swap = False
|
||||||
|
for loc in locations:
|
||||||
|
wh_name_check = loc.get("whName")
|
||||||
|
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||||
|
needs_size_swap = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
|
||||||
|
original_x = plr_material.size_x
|
||||||
|
original_y = plr_material.size_y
|
||||||
|
plr_material.size_x = original_y
|
||||||
|
plr_material.size_y = original_x
|
||||||
|
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse,预先交换尺寸: {original_x}×{original_y} → {plr_material.size_x}×{plr_material.size_y}")
|
||||||
|
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
wh_name = loc.get("whName")
|
wh_name = loc.get("whName")
|
||||||
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
|
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
|
||||||
@@ -797,12 +833,20 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
|||||||
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右")
|
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
|
||||||
|
if wh_name == "站内Tip盒堆栈":
|
||||||
|
y_val = loc.get("y", 1)
|
||||||
|
if y_val == 1:
|
||||||
|
wh_name = "站内Tip盒堆栈(右)"
|
||||||
|
elif y_val in [2, 3]:
|
||||||
|
wh_name = "站内Tip盒堆栈(左)"
|
||||||
|
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
|
||||||
|
|
||||||
if hasattr(deck, "warehouses") and wh_name in deck.warehouses:
|
if hasattr(deck, "warehouses") and wh_name in deck.warehouses:
|
||||||
warehouse = deck.warehouses[wh_name]
|
warehouse = deck.warehouses[wh_name]
|
||||||
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
|
||||||
|
|
||||||
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
|
||||||
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
|
|
||||||
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
|
||||||
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
|
||||||
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
|
||||||
@@ -811,12 +855,23 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
|||||||
if wh_name == "堆栈1右":
|
if wh_name == "堆栈1右":
|
||||||
y = y - 4 # 将5-8映射到1-4
|
y = y - 4 # 将5-8映射到1-4
|
||||||
|
|
||||||
# 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈)
|
# 特殊处理竖向warehouse(站内试剂存放堆栈、测量小瓶仓库)
|
||||||
# Bioyond的y坐标表示线性位置序号,而不是列号
|
# 这些warehouse使用 vertical-col-major 布局
|
||||||
if warehouse.num_items_y == 1:
|
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||||
# 1行warehouse: 直接用y作为线性索引
|
# vertical-col-major 布局的坐标映射:
|
||||||
idx = y - 1
|
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
|
||||||
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
|
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
|
||||||
|
# vertical-col-major 中: row=0 对应底部,row=n-1 对应顶部
|
||||||
|
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
|
||||||
|
# 索引计算: idx = row * num_cols + col
|
||||||
|
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
|
||||||
|
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
|
||||||
|
layer_idx = z - 1
|
||||||
|
|
||||||
|
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_y + col_idx
|
||||||
|
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
|
||||||
|
|
||||||
|
# 普通横向warehouse的处理
|
||||||
else:
|
else:
|
||||||
# 多行warehouse: 根据 layout 使用不同的索引计算
|
# 多行warehouse: 根据 layout 使用不同的索引计算
|
||||||
row_idx = x - 1 # x表示行: 转为0-based
|
row_idx = x - 1 # x表示行: 转为0-based
|
||||||
@@ -840,6 +895,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
|||||||
|
|
||||||
if 0 <= idx < warehouse.capacity:
|
if 0 <= idx < warehouse.capacity:
|
||||||
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
|
||||||
|
# 物料尺寸已在放入warehouse前根据需要进行了交换
|
||||||
warehouse[idx] = plr_material
|
warehouse[idx] = plr_material
|
||||||
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
|
||||||
else:
|
else:
|
||||||
@@ -1013,11 +1069,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
|||||||
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
|
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
|
||||||
|
|
||||||
# 🎯 处理物料默认参数和单位
|
# 🎯 处理物料默认参数和单位
|
||||||
# 检查是否有该物料名称的默认参数配置
|
# 优先级: typeId参数 > 物料名称参数 > 默认值
|
||||||
default_unit = "个" # 默认单位
|
default_unit = "个" # 默认单位
|
||||||
material_parameters = {}
|
material_parameters = {}
|
||||||
|
|
||||||
if material_name in material_params:
|
# 1️⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取,key 格式为 "type:<typeId>")
|
||||||
|
type_params_key = f"type:{type_id}"
|
||||||
|
if type_params_key in material_params:
|
||||||
|
params_config = material_params[type_params_key].copy()
|
||||||
|
|
||||||
|
# 提取 unit 字段(如果有)
|
||||||
|
if "unit" in params_config:
|
||||||
|
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
|
||||||
|
|
||||||
|
# 剩余的字段放入 Parameters
|
||||||
|
material_parameters = params_config
|
||||||
|
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||||
|
# 2️⃣ 其次检查是否有该物料名称的默认参数配置
|
||||||
|
elif material_name in material_params:
|
||||||
params_config = material_params[material_name].copy()
|
params_config = material_params[material_name].copy()
|
||||||
|
|
||||||
# 提取 unit 字段(如果有)
|
# 提取 unit 字段(如果有)
|
||||||
@@ -1026,7 +1095,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
|||||||
|
|
||||||
# 剩余的字段放入 Parameters
|
# 剩余的字段放入 Parameters
|
||||||
material_parameters = params_config
|
material_parameters = params_config
|
||||||
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
|
||||||
|
|
||||||
# 转换为 JSON 字符串
|
# 转换为 JSON 字符串
|
||||||
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
|
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class Bottle(Well):
|
|||||||
size_x: float = 0.0,
|
size_x: float = 0.0,
|
||||||
size_y: float = 0.0,
|
size_y: float = 0.0,
|
||||||
size_z: float = 0.0,
|
size_z: float = 0.0,
|
||||||
barcode: Optional[str] = "",
|
barcode: Optional[str] = None,
|
||||||
category: str = "container",
|
category: str = "container",
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -50,13 +50,45 @@ class Bottle(Well):
|
|||||||
self.barcode = barcode
|
self.barcode = barcode
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
|
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
|
||||||
|
# We temporarily unset it to avoid AttributeError in super().serialize().
|
||||||
|
_barcode = self.barcode
|
||||||
|
self.barcode = None
|
||||||
|
try:
|
||||||
|
data = super().serialize()
|
||||||
|
finally:
|
||||||
|
self.barcode = _barcode
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**super().serialize(),
|
**data,
|
||||||
"diameter": self.diameter,
|
"diameter": self.diameter,
|
||||||
"height": self.height,
|
"height": self.height,
|
||||||
"barcode": self.barcode,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize(cls, data: dict, allow_marshal: bool = False):
|
||||||
|
# Extract barcode before calling parent deserialize to avoid type error
|
||||||
|
barcode_data = data.pop("barcode", None)
|
||||||
|
|
||||||
|
# Call parent deserialize
|
||||||
|
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
|
||||||
|
|
||||||
|
# Set barcode as string (not as Barcode object)
|
||||||
|
if barcode_data:
|
||||||
|
if isinstance(barcode_data, str):
|
||||||
|
instance.barcode = barcode_data
|
||||||
|
elif isinstance(barcode_data, dict):
|
||||||
|
# If it's a dict (Barcode serialized format), extract the data field
|
||||||
|
instance.barcode = barcode_data.get("data", "")
|
||||||
|
else:
|
||||||
|
instance.barcode = ""
|
||||||
|
|
||||||
|
# Set additional attributes
|
||||||
|
instance.diameter = data.get("diameter", instance._size_x)
|
||||||
|
instance.height = data.get("height", instance._size_z)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
T = TypeVar("T", bound=ResourceHolder)
|
T = TypeVar("T", bound=ResourceHolder)
|
||||||
|
|
||||||
S = TypeVar("S", bound=ResourceHolder)
|
S = TypeVar("S", bound=ResourceHolder)
|
||||||
@@ -79,7 +111,6 @@ class ItemizedCarrier(ResourcePLR):
|
|||||||
category: Optional[str] = "carrier",
|
category: Optional[str] = "carrier",
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
invisible_slots: Optional[str] = None,
|
invisible_slots: Optional[str] = None,
|
||||||
content_type: Optional[List[str]] = ["bottle", "container", "tube", "bottle_carrier", "tip_rack"],
|
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -93,7 +124,6 @@ class ItemizedCarrier(ResourcePLR):
|
|||||||
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
|
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
|
||||||
self.invisible_slots = [] if invisible_slots is None else invisible_slots
|
self.invisible_slots = [] if invisible_slots is None else invisible_slots
|
||||||
self.layout = "z-y" if self.num_items_z > 1 and self.num_items_x == 1 else "x-z" if self.num_items_z > 1 and self.num_items_y == 1 else "x-y"
|
self.layout = "z-y" if self.num_items_z > 1 and self.num_items_x == 1 else "x-z" if self.num_items_z > 1 and self.num_items_y == 1 else "x-y"
|
||||||
self.content_type = content_type
|
|
||||||
|
|
||||||
if isinstance(sites, dict):
|
if isinstance(sites, dict):
|
||||||
sites = sites or {}
|
sites = sites or {}
|
||||||
@@ -421,7 +451,7 @@ class ItemizedCarrier(ResourcePLR):
|
|||||||
self[identifier] if isinstance(self[identifier], str) else None,
|
self[identifier] if isinstance(self[identifier], str) else None,
|
||||||
"position": {"x": location.x, "y": location.y, "z": location.z},
|
"position": {"x": location.x, "y": location.y, "z": location.z},
|
||||||
"size": self.child_size[identifier],
|
"size": self.child_size[identifier],
|
||||||
"content_type": self.content_type
|
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]
|
||||||
} for identifier, location in self.child_locations.items()]
|
} for identifier, location in self.child_locations.items()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.resources.plr_additional_res_reg import register
|
from unilabos.resources.plr_additional_res_reg import register
|
||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
|
|
||||||
@@ -13,24 +15,75 @@ if TYPE_CHECKING:
|
|||||||
from pylabrobot.resources import Resource as PLRResource
|
from pylabrobot.resources import Resource as PLRResource
|
||||||
|
|
||||||
|
|
||||||
|
EXTRA_CLASS = "unilabos_resource_class"
|
||||||
|
EXTRA_SAMPLE_UUID = "sample_uuid"
|
||||||
|
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
|
||||||
|
|
||||||
|
# 函数参数名常量 - 用于自动注入 sample_uuids 列表
|
||||||
|
PARAM_SAMPLE_UUIDS = "sample_uuids"
|
||||||
|
|
||||||
|
# JSON Command 中的系统参数字段名
|
||||||
|
JSON_UNILABOS_PARAM = "unilabos_param"
|
||||||
|
|
||||||
|
# 返回值中的 samples 字段名
|
||||||
|
RETURN_UNILABOS_SAMPLES = "unilabos_samples"
|
||||||
|
|
||||||
|
# sample_uuids 参数类型 (用于 virtual bench 等设备添加 sample_uuids 参数)
|
||||||
|
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
||||||
|
|
||||||
|
|
||||||
|
class LabSample(TypedDict):
|
||||||
|
sample_uuid: str
|
||||||
|
oss_path: str
|
||||||
|
extra: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDictPositionSizeType(TypedDict):
|
||||||
|
depth: float
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
|
||||||
|
|
||||||
class ResourceDictPositionSize(BaseModel):
|
class ResourceDictPositionSize(BaseModel):
|
||||||
depth: float = Field(description="Depth", default=0.0) # z
|
depth: float = Field(description="Depth", default=0.0) # z
|
||||||
width: float = Field(description="Width", default=0.0) # x
|
width: float = Field(description="Width", default=0.0) # x
|
||||||
height: float = Field(description="Height", default=0.0) # y
|
height: float = Field(description="Height", default=0.0) # y
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDictPositionScaleType(TypedDict):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
|
||||||
|
|
||||||
class ResourceDictPositionScale(BaseModel):
|
class ResourceDictPositionScale(BaseModel):
|
||||||
x: float = Field(description="x scale", default=0.0)
|
x: float = Field(description="x scale", default=0.0)
|
||||||
y: float = Field(description="y scale", default=0.0)
|
y: float = Field(description="y scale", default=0.0)
|
||||||
z: float = Field(description="z scale", default=0.0)
|
z: float = Field(description="z scale", default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDictPositionObjectType(TypedDict):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
z: float
|
||||||
|
|
||||||
|
|
||||||
class ResourceDictPositionObject(BaseModel):
|
class ResourceDictPositionObject(BaseModel):
|
||||||
x: float = Field(description="X coordinate", default=0.0)
|
x: float = Field(description="X coordinate", default=0.0)
|
||||||
y: float = Field(description="Y coordinate", default=0.0)
|
y: float = Field(description="Y coordinate", default=0.0)
|
||||||
z: float = Field(description="Z coordinate", default=0.0)
|
z: float = Field(description="Z coordinate", default=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDictPositionType(TypedDict):
|
||||||
|
size: ResourceDictPositionSizeType
|
||||||
|
scale: ResourceDictPositionScaleType
|
||||||
|
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||||
|
position: ResourceDictPositionObjectType
|
||||||
|
position3d: ResourceDictPositionObjectType
|
||||||
|
rotation: ResourceDictPositionObjectType
|
||||||
|
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||||
|
|
||||||
|
|
||||||
class ResourceDictPosition(BaseModel):
|
class ResourceDictPosition(BaseModel):
|
||||||
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
|
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
|
||||||
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
|
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
|
||||||
@@ -49,6 +102,24 @@ class ResourceDictPosition(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDictType(TypedDict):
|
||||||
|
id: str
|
||||||
|
uuid: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
resource_schema: Dict[str, Any]
|
||||||
|
model: Dict[str, Any]
|
||||||
|
icon: str
|
||||||
|
parent_uuid: Optional[str]
|
||||||
|
parent: Optional["ResourceDictType"]
|
||||||
|
type: Union[Literal["device"], str]
|
||||||
|
klass: str
|
||||||
|
pose: ResourceDictPositionType
|
||||||
|
config: Dict[str, Any]
|
||||||
|
data: Dict[str, Any]
|
||||||
|
extra: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
# 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化
|
# 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化
|
||||||
class ResourceDict(BaseModel):
|
class ResourceDict(BaseModel):
|
||||||
id: str = Field(description="Resource ID")
|
id: str = Field(description="Resource ID")
|
||||||
@@ -66,8 +137,8 @@ class ResourceDict(BaseModel):
|
|||||||
klass: str = Field(alias="class", description="Resource class name")
|
klass: str = Field(alias="class", description="Resource class name")
|
||||||
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||||
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
|
data: Dict[str, Any] = Field(description="Resource data")
|
||||||
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
|
extra: Dict[str, Any] = Field(description="Extra data")
|
||||||
|
|
||||||
@field_serializer("parent_uuid")
|
@field_serializer("parent_uuid")
|
||||||
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
||||||
@@ -393,7 +464,7 @@ class ResourceTreeSet(object):
|
|||||||
"parent": parent_resource, # 直接传入 ResourceDict 对象
|
"parent": parent_resource, # 直接传入 ResourceDict 对象
|
||||||
"parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象
|
"parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象
|
||||||
"type": replace_plr_type(d.get("category", "")),
|
"type": replace_plr_type(d.get("category", "")),
|
||||||
"class": d.get("class", ""),
|
"class": extra.get(EXTRA_CLASS, ""),
|
||||||
"position": pos,
|
"position": pos,
|
||||||
"pose": pos,
|
"pose": pos,
|
||||||
"config": {
|
"config": {
|
||||||
@@ -443,7 +514,7 @@ class ResourceTreeSet(object):
|
|||||||
trees.append(tree_instance)
|
trees.append(tree_instance)
|
||||||
return cls(trees)
|
return cls(trees)
|
||||||
|
|
||||||
def to_plr_resources(self) -> List["PLRResource"]:
|
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
|
||||||
"""
|
"""
|
||||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||||
|
|
||||||
@@ -468,6 +539,7 @@ class ResourceTreeSet(object):
|
|||||||
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
||||||
all_states[node.res_content.name] = node.res_content.data
|
all_states[node.res_content.name] = node.res_content.data
|
||||||
name_to_extra[node.res_content.name] = node.res_content.extra
|
name_to_extra[node.res_content.name] = node.res_content.extra
|
||||||
|
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
|
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
|
||||||
|
|
||||||
@@ -512,7 +584,10 @@ class ResourceTreeSet(object):
|
|||||||
plr_dict = node_to_plr_dict(tree.root_node, has_model)
|
plr_dict = node_to_plr_dict(tree.root_node, has_model)
|
||||||
try:
|
try:
|
||||||
sub_cls = find_subclass(plr_dict["type"], PLRResource)
|
sub_cls = find_subclass(plr_dict["type"], PLRResource)
|
||||||
if sub_cls is None:
|
if skip_devices and plr_dict["type"] == "device":
|
||||||
|
logger.info(f"跳过更新 {plr_dict['name']} 设备是class")
|
||||||
|
continue
|
||||||
|
elif sub_cls is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
||||||
)
|
)
|
||||||
@@ -520,6 +595,11 @@ class ResourceTreeSet(object):
|
|||||||
if "category" not in spec.parameters:
|
if "category" not in spec.parameters:
|
||||||
plr_dict.pop("category", None)
|
plr_dict.pop("category", None)
|
||||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||||
|
from pylabrobot.resources import Coordinate
|
||||||
|
from pylabrobot.serializer import deserialize
|
||||||
|
|
||||||
|
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||||
|
plr_resource.location = location
|
||||||
plr_resource.load_all_state(all_states)
|
plr_resource.load_all_state(all_states)
|
||||||
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
|
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
|
||||||
tracker.loop_set_uuid(plr_resource, name_to_uuid)
|
tracker.loop_set_uuid(plr_resource, name_to_uuid)
|
||||||
@@ -621,6 +701,16 @@ class ResourceTreeSet(object):
|
|||||||
"""
|
"""
|
||||||
return [tree.root_node for tree in self.trees]
|
return [tree.root_node for tree in self.trees]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root_nodes_uuid(self) -> List[ResourceDictInstance]:
|
||||||
|
"""
|
||||||
|
获取所有树的根节点
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
所有根节点的资源实例列表
|
||||||
|
"""
|
||||||
|
return [tree.root_node.res_content.uuid for tree in self.trees]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all_nodes(self) -> List[ResourceDictInstance]:
|
def all_nodes(self) -> List[ResourceDictInstance]:
|
||||||
"""
|
"""
|
||||||
@@ -976,7 +1066,7 @@ class DeviceNodeResourceTracker(object):
|
|||||||
extra = name_to_extra_map[resource_name]
|
extra = name_to_extra_map[resource_name]
|
||||||
self.set_resource_extra(res, extra)
|
self.set_resource_extra(res, extra)
|
||||||
if len(extra):
|
if len(extra):
|
||||||
logger.debug(f"设置资源Extra: {resource_name} -> {extra}")
|
logger.trace(f"设置资源Extra: {resource_name} -> {extra}")
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def warehouse_factory(
|
|||||||
category: str = "warehouse",
|
category: str = "warehouse",
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
col_offset: int = 0, # 列起始偏移量,用于生成A05-D08等命名
|
col_offset: int = 0, # 列起始偏移量,用于生成A05-D08等命名
|
||||||
|
row_offset: int = 0, # 行起始偏移量,用于生成F01-J03等命名
|
||||||
layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先
|
layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先
|
||||||
):
|
):
|
||||||
# 创建位置坐标
|
# 创建位置坐标
|
||||||
@@ -42,6 +43,10 @@ def warehouse_factory(
|
|||||||
if layout == "row-major":
|
if layout == "row-major":
|
||||||
# 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值
|
# 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值
|
||||||
y = dy + row * item_dy
|
y = dy + row * item_dy
|
||||||
|
elif layout == "vertical-col-major":
|
||||||
|
# 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大)
|
||||||
|
# 但标签 01 应该在底部,所以使用反向映射
|
||||||
|
y = dy + (num_items_y - row - 1) * item_dy
|
||||||
else:
|
else:
|
||||||
# 列优先:保持原逻辑(row=0 对应较大的 y)
|
# 列优先:保持原逻辑(row=0 对应较大的 y)
|
||||||
y = dy + (num_items_y - row - 1) * item_dy
|
y = dy + (num_items_y - row - 1) * item_dy
|
||||||
@@ -65,10 +70,10 @@ def warehouse_factory(
|
|||||||
if layout == "row-major":
|
if layout == "row-major":
|
||||||
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
|
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
|
||||||
# locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01
|
# locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01
|
||||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
|
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
|
||||||
else:
|
else:
|
||||||
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
|
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
|
||||||
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
|
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
|
||||||
|
|
||||||
sites = {i: site for i, site in zip(keys, _sites.values())}
|
sites = {i: site for i, site in zip(keys, _sites.values())}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ def ros2_device_node(
|
|||||||
# 从属性中自动发现可发布状态
|
# 从属性中自动发现可发布状态
|
||||||
if status_types is None:
|
if status_types is None:
|
||||||
status_types = {}
|
status_types = {}
|
||||||
if device_config is None:
|
assert device_config is not None, "device_config cannot be None"
|
||||||
raise ValueError("device_config cannot be None")
|
|
||||||
if action_value_mappings is None:
|
if action_value_mappings is None:
|
||||||
action_value_mappings = {}
|
action_value_mappings = {}
|
||||||
if hardware_interface is None:
|
if hardware_interface is None:
|
||||||
|
|||||||
@@ -361,14 +361,7 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
|
|||||||
if hasattr(ros_msg, key):
|
if hasattr(ros_msg, key):
|
||||||
attr = getattr(ros_msg, key)
|
attr = getattr(ros_msg, key)
|
||||||
if isinstance(attr, (float, int, str, bool)):
|
if isinstance(attr, (float, int, str, bool)):
|
||||||
# 处理list类型的值,取第一个元素或抛出错误
|
setattr(ros_msg, key, type(attr)(value))
|
||||||
if isinstance(value, list):
|
|
||||||
if len(value) > 0:
|
|
||||||
setattr(ros_msg, key, type(attr)(value[0]))
|
|
||||||
else:
|
|
||||||
setattr(ros_msg, key, type(attr)()) # 使用默认值
|
|
||||||
else:
|
|
||||||
setattr(ros_msg, key, type(attr)(value))
|
|
||||||
elif isinstance(attr, (list, tuple)) and isinstance(value, Iterable):
|
elif isinstance(attr, (list, tuple)) and isinstance(value, Iterable):
|
||||||
td = ros_msg.SLOT_TYPES[ind].value_type
|
td = ros_msg.SLOT_TYPES[ind].value_type
|
||||||
if isinstance(td, NamespacedType):
|
if isinstance(td, NamespacedType):
|
||||||
@@ -381,35 +374,9 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
|
|||||||
setattr(ros_msg, key, []) # FIXME
|
setattr(ros_msg, key, []) # FIXME
|
||||||
elif "array.array" in str(type(attr)):
|
elif "array.array" in str(type(attr)):
|
||||||
if attr.typecode == "f" or attr.typecode == "d":
|
if attr.typecode == "f" or attr.typecode == "d":
|
||||||
# 如果是单个值,转换为列表
|
|
||||||
if value is None:
|
|
||||||
value = []
|
|
||||||
elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
|
||||||
value = [value]
|
|
||||||
setattr(ros_msg, key, [float(i) for i in value])
|
setattr(ros_msg, key, [float(i) for i in value])
|
||||||
else:
|
else:
|
||||||
# 对于整数数组,需要确保是序列且每个值在有效范围内
|
setattr(ros_msg, key, value)
|
||||||
if value is None:
|
|
||||||
value = []
|
|
||||||
elif not isinstance(value, Iterable) or isinstance(value, (str, bytes)):
|
|
||||||
# 如果是单个值,转换为列表
|
|
||||||
value = [value]
|
|
||||||
# 确保每个整数值在有效范围内(-2147483648 到 2147483647)
|
|
||||||
converted_value = []
|
|
||||||
for i in value:
|
|
||||||
if i is None:
|
|
||||||
continue # 跳过 None 值
|
|
||||||
if isinstance(i, (int, float)):
|
|
||||||
int_val = int(i)
|
|
||||||
# 确保在 int32 范围内
|
|
||||||
if int_val < -2147483648:
|
|
||||||
int_val = -2147483648
|
|
||||||
elif int_val > 2147483647:
|
|
||||||
int_val = 2147483647
|
|
||||||
converted_value.append(int_val)
|
|
||||||
else:
|
|
||||||
converted_value.append(i)
|
|
||||||
setattr(ros_msg, key, converted_value)
|
|
||||||
else:
|
else:
|
||||||
nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
|
nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
|
||||||
setattr(ros_msg, key, nested_ros_msg)
|
setattr(ros_msg, key, nested_ros_msg)
|
||||||
@@ -803,13 +770,16 @@ def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any
|
|||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, Any]:
|
def ros_action_to_json_schema(
|
||||||
|
action_class: Any, description="", previous_schema: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
将 ROS Action 类转换为 JSON Schema
|
将 ROS Action 类转换为 JSON Schema
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action_class: ROS Action 类
|
action_class: ROS Action 类
|
||||||
description: 描述
|
description: 描述
|
||||||
|
previous_schema: 之前的 schema,用于保留 goal/feedback/result 下一级字段的 description
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
完整的 JSON Schema 定义
|
完整的 JSON Schema 定义
|
||||||
@@ -843,9 +813,44 @@ def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, An
|
|||||||
"required": ["goal"],
|
"required": ["goal"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 保留之前 schema 中 goal/feedback/result 下一级字段的 description
|
||||||
|
if previous_schema:
|
||||||
|
_preserve_field_descriptions(schema, previous_schema)
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _preserve_field_descriptions(
|
||||||
|
new_schema: Dict[str, Any], previous_schema: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_schema: 新生成的 schema(会被修改)
|
||||||
|
previous_schema: 之前的 schema
|
||||||
|
"""
|
||||||
|
for section in ["goal", "feedback", "result"]:
|
||||||
|
new_section = new_schema.get("properties", {}).get(section, {})
|
||||||
|
prev_section = previous_schema.get("properties", {}).get(section, {})
|
||||||
|
|
||||||
|
if not new_section or not prev_section:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_props = new_section.get("properties", {})
|
||||||
|
prev_props = prev_section.get("properties", {})
|
||||||
|
|
||||||
|
for field_name, field_schema in new_props.items():
|
||||||
|
if field_name in prev_props:
|
||||||
|
prev_field = prev_props[field_name]
|
||||||
|
# 保留字段的 description
|
||||||
|
if "description" in prev_field and prev_field["description"]:
|
||||||
|
field_schema["description"] = prev_field["description"]
|
||||||
|
# 保留字段的 title(用户自定义的中文名)
|
||||||
|
if "title" in prev_field and prev_field["title"]:
|
||||||
|
field_schema["title"] = prev_field["title"]
|
||||||
|
|
||||||
|
|
||||||
def convert_ros_action_to_jsonschema(
|
def convert_ros_action_to_jsonschema(
|
||||||
action_name_or_type: Union[str, Type], output_file: Optional[str] = None, format: str = "json"
|
action_name_or_type: Union[str, Type], output_file: Optional[str] = None, format: str = "json"
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -4,8 +4,20 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \
|
from typing import (
|
||||||
Tuple
|
get_type_hints,
|
||||||
|
TypeVar,
|
||||||
|
Generic,
|
||||||
|
Dict,
|
||||||
|
Any,
|
||||||
|
Type,
|
||||||
|
TypedDict,
|
||||||
|
Optional,
|
||||||
|
List,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Union,
|
||||||
|
Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -48,8 +60,10 @@ from unilabos.resources.resource_tracker import (
|
|||||||
ResourceTreeSet,
|
ResourceTreeSet,
|
||||||
ResourceTreeInstance,
|
ResourceTreeInstance,
|
||||||
ResourceDictInstance,
|
ResourceDictInstance,
|
||||||
|
EXTRA_SAMPLE_UUID,
|
||||||
|
PARAM_SAMPLE_UUIDS,
|
||||||
|
JSON_UNILABOS_PARAM,
|
||||||
)
|
)
|
||||||
from unilabos.ros.x.rclpyx import get_event_loop
|
|
||||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||||
from rclpy.task import Task, Future
|
from rclpy.task import Task, Future
|
||||||
from unilabos.utils.import_manager import default_manager
|
from unilabos.utils.import_manager import default_manager
|
||||||
@@ -132,7 +146,7 @@ def init_wrapper(
|
|||||||
device_id: str,
|
device_id: str,
|
||||||
device_uuid: str,
|
device_uuid: str,
|
||||||
driver_class: type[T],
|
driver_class: type[T],
|
||||||
device_config: ResourceTreeInstance,
|
device_config: ResourceDictInstance,
|
||||||
status_types: Dict[str, Any],
|
status_types: Dict[str, Any],
|
||||||
action_value_mappings: Dict[str, Any],
|
action_value_mappings: Dict[str, Any],
|
||||||
hardware_interface: Dict[str, Any],
|
hardware_interface: Dict[str, Any],
|
||||||
@@ -185,7 +199,7 @@ class PropertyPublisher:
|
|||||||
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
||||||
self.__loop = get_event_loop()
|
self.__loop = ROS2DeviceNode.get_asyncio_loop()
|
||||||
str_msg_type = str(msg_type)[8:-2]
|
str_msg_type = str(msg_type)[8:-2]
|
||||||
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}")
|
self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}")
|
||||||
|
|
||||||
@@ -217,14 +231,15 @@ class PropertyPublisher:
|
|||||||
|
|
||||||
def publish_property(self):
|
def publish_property(self):
|
||||||
try:
|
try:
|
||||||
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
# self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||||
value = self.get_property()
|
value = self.get_property()
|
||||||
if self.print_publish:
|
if self.print_publish:
|
||||||
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
pass
|
||||||
|
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||||
if value is not None:
|
if value is not None:
|
||||||
msg = convert_to_ros_msg(self.msg_type, value)
|
msg = convert_to_ros_msg(self.msg_type, value)
|
||||||
self.publisher_.publish(msg)
|
self.publisher_.publish(msg)
|
||||||
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
# self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.node.lab_logger().error(
|
self.node.lab_logger().error(
|
||||||
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
||||||
@@ -264,6 +279,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
self,
|
self,
|
||||||
driver_instance: T,
|
driver_instance: T,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
registry_name: str,
|
||||||
device_uuid: str,
|
device_uuid: str,
|
||||||
status_types: Dict[str, Any],
|
status_types: Dict[str, Any],
|
||||||
action_value_mappings: Dict[str, Any],
|
action_value_mappings: Dict[str, Any],
|
||||||
@@ -285,6 +301,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
"""
|
"""
|
||||||
self.driver_instance = driver_instance
|
self.driver_instance = driver_instance
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
|
self.registry_name = registry_name
|
||||||
self.uuid = device_uuid
|
self.uuid = device_uuid
|
||||||
self.publish_high_frequency = False
|
self.publish_high_frequency = False
|
||||||
self.callback_group = ReentrantCallbackGroup()
|
self.callback_group = ReentrantCallbackGroup()
|
||||||
@@ -362,6 +379,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
from pylabrobot.resources.deck import Deck
|
from pylabrobot.resources.deck import Deck
|
||||||
from pylabrobot.resources import Coordinate
|
from pylabrobot.resources import Coordinate
|
||||||
from pylabrobot.resources import Plate
|
from pylabrobot.resources import Plate
|
||||||
|
|
||||||
# 物料传输到对应的node节点
|
# 物料传输到对应的node节点
|
||||||
client = self._resource_clients["c2s_update_resource_tree"]
|
client = self._resource_clients["c2s_update_resource_tree"]
|
||||||
request = SerialCommand.Request()
|
request = SerialCommand.Request()
|
||||||
@@ -389,33 +407,29 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
||||||
parent_resource = None
|
parent_resource = None
|
||||||
if bind_parent_id != self.node_name:
|
if bind_parent_id != self.node_name:
|
||||||
parent_resource = self.resource_tracker.figure_resource(
|
parent_resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
|
||||||
{"name": bind_parent_id}
|
|
||||||
)
|
|
||||||
for r in rts.root_nodes:
|
for r in rts.root_nodes:
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||||
else:
|
else:
|
||||||
for r in rts.root_nodes:
|
for r in rts.root_nodes:
|
||||||
r.res_content.parent_uuid = self.uuid
|
r.res_content.parent_uuid = self.uuid
|
||||||
|
rts_plr_instances = rts.to_plr_resources()
|
||||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
container_instance: RegularContainer = rts.root_nodes[0]
|
container_instance: RegularContainer = rts_plr_instances[0]
|
||||||
found_resources = self.resource_tracker.figure_resource(
|
found_resources = self.resource_tracker.figure_resource(
|
||||||
{"id": container_instance.name}, try_mode=True
|
{"name": container_instance.name}, try_mode=True
|
||||||
)
|
)
|
||||||
if not len(found_resources):
|
if not len(found_resources):
|
||||||
self.resource_tracker.add_resource(container_instance)
|
self.resource_tracker.add_resource(container_instance)
|
||||||
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
||||||
else:
|
else:
|
||||||
assert (
|
assert len(found_resources) == 1, f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||||
len(found_resources) == 1
|
|
||||||
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
|
||||||
found_resource = found_resources[0]
|
found_resource = found_resources[0]
|
||||||
if isinstance(found_resource, RegularContainer):
|
if isinstance(found_resource, RegularContainer):
|
||||||
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
|
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
|
||||||
found_resource.state.update(json.loads(container_instance.state))
|
found_resource.state.update(container_instance.state)
|
||||||
elif isinstance(found_resource, dict):
|
elif isinstance(found_resource, dict):
|
||||||
raise ValueError("已不支持 字典 版本的RegularContainer")
|
raise ValueError("已不支持 字典 版本的RegularContainer")
|
||||||
else:
|
else:
|
||||||
@@ -423,14 +437,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
||||||
)
|
)
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
request.command = json.dumps({
|
request.command = json.dumps(
|
||||||
"action": "add",
|
{
|
||||||
"data": {
|
"action": "add",
|
||||||
"data": rts.dump(),
|
"data": {
|
||||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "",
|
"data": rts.dump(),
|
||||||
"first_add": False,
|
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
|
||||||
},
|
"first_add": False,
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||||
uuid_maps = json.loads(tree_response.response)
|
uuid_maps = json.loads(tree_response.response)
|
||||||
plr_instances = rts.to_plr_resources()
|
plr_instances = rts.to_plr_resources()
|
||||||
@@ -444,7 +460,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
}
|
}
|
||||||
res.response = json.dumps(final_response)
|
res.response = json.dumps(final_response)
|
||||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||||
if hasattr(self.driver_instance, "create_resource"):
|
if hasattr(self.driver_instance, "create_resource") and self.node_name != "host_node":
|
||||||
create_resource_func = getattr(self.driver_instance, "create_resource")
|
create_resource_func = getattr(self.driver_instance, "create_resource")
|
||||||
try:
|
try:
|
||||||
ret = create_resource_func(
|
ret = create_resource_func(
|
||||||
@@ -472,7 +488,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||||
self.lab_logger().warning(f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个")
|
self.lab_logger().warning(
|
||||||
|
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||||
|
)
|
||||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||||
):
|
):
|
||||||
@@ -491,9 +509,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
input_wells = []
|
input_wells = []
|
||||||
for r in LIQUID_INPUT_SLOT:
|
for r in LIQUID_INPUT_SLOT:
|
||||||
input_wells.append(plr_instance.children[r])
|
input_wells.append(plr_instance.children[r])
|
||||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||||
|
input_wells
|
||||||
|
).dump()
|
||||||
res.response = json.dumps(final_response)
|
res.response = json.dumps(final_response)
|
||||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
if (
|
||||||
|
issubclass(parent_resource.__class__, Deck)
|
||||||
|
and hasattr(parent_resource, "assign_child_at_slot")
|
||||||
|
and "slot" in other_calling_param
|
||||||
|
):
|
||||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||||
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
|
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
|
||||||
else:
|
else:
|
||||||
@@ -508,14 +532,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||||
request.command = json.dumps({
|
request.command = json.dumps(
|
||||||
"action": "add",
|
{
|
||||||
"data": {
|
"action": "add",
|
||||||
"data": rts_with_parent.dump(),
|
"data": {
|
||||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
"data": rts_with_parent.dump(),
|
||||||
"first_add": False,
|
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||||
},
|
"first_add": False,
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||||
uuid_maps = json.loads(tree_response.response)
|
uuid_maps = json.loads(tree_response.response)
|
||||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||||
@@ -625,7 +651,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
) # type: ignore
|
) # type: ignore
|
||||||
raw_nodes = json.loads(response.response)
|
raw_nodes = json.loads(response.response)
|
||||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes)
|
||||||
self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
|
self.lab_logger().trace(f"获取资源结果: {len(tree_set.trees)} 个资源树 {tree_set.root_nodes}")
|
||||||
return tree_set
|
return tree_set
|
||||||
|
|
||||||
async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR":
|
async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR":
|
||||||
@@ -812,7 +838,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _handle_update(
|
def _handle_update(
|
||||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]],
|
||||||
|
tree_set: ResourceTreeSet,
|
||||||
|
additional_add_params: Dict[str, Any],
|
||||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||||
"""
|
"""
|
||||||
处理资源更新操作的内部函数
|
处理资源更新操作的内部函数
|
||||||
@@ -837,7 +865,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
original_parent_resource = original_instance.parent
|
original_parent_resource = original_instance.parent
|
||||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
not_same_parent = (
|
||||||
|
original_parent_resource_uuid != target_parent_resource_uuid
|
||||||
|
and original_parent_resource is not None
|
||||||
|
)
|
||||||
old_name = original_instance.name
|
old_name = original_instance.name
|
||||||
new_name = plr_resource.name
|
new_name = plr_resource.name
|
||||||
parent_appended = False
|
parent_appended = False
|
||||||
@@ -873,8 +904,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
else:
|
else:
|
||||||
# 判断是否变更了resource_site,重新登记
|
# 判断是否变更了resource_site,重新登记
|
||||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
sites = (
|
||||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
original_instance.parent.sites
|
||||||
|
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
site_names = (
|
||||||
|
list(original_instance.parent._ordering.keys())
|
||||||
|
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||||
|
else []
|
||||||
|
)
|
||||||
if target_site is not None and sites is not None and site_names is not None:
|
if target_site is not None and sites is not None and site_names is not None:
|
||||||
site_index = sites.index(original_instance)
|
site_index = sites.index(original_instance)
|
||||||
site_name = site_names[site_index]
|
site_name = site_names[site_index]
|
||||||
@@ -885,6 +924,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
parent_appended = True
|
parent_appended = True
|
||||||
|
|
||||||
# 加载状态
|
# 加载状态
|
||||||
|
original_instance.location = plr_resource.location
|
||||||
|
original_instance.rotation = plr_resource.rotation
|
||||||
|
original_instance.barcode = plr_resource.barcode
|
||||||
original_instance.load_all_state(states)
|
original_instance.load_all_state(states)
|
||||||
child_count = len(original_instance.get_all_children())
|
child_count = len(original_instance.get_all_children())
|
||||||
self.lab_logger().info(
|
self.lab_logger().info(
|
||||||
@@ -908,9 +950,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
action = i.get("action") # remove, add, update
|
action = i.get("action") # remove, add, update
|
||||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||||
self.lab_logger().trace(
|
self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}")
|
||||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
|
||||||
)
|
|
||||||
tree_set = None
|
tree_set = None
|
||||||
if action in ["add", "update"]:
|
if action in ["add", "update"]:
|
||||||
tree_set = await self.get_resource(
|
tree_set = await self.get_resource(
|
||||||
@@ -937,9 +977,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
tree.root_node.res_content.parent_uuid = self.uuid
|
tree.root_node.res_content.parent_uuid = self.uuid
|
||||||
r = SerialCommand.Request()
|
r = SerialCommand.Request()
|
||||||
r.command = json.dumps(
|
r.command = json.dumps(
|
||||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||||
|
) # 和Update Resource一致
|
||||||
response: SerialCommand_Response = await self._resource_clients[
|
response: SerialCommand_Response = await self._resource_clients[
|
||||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
"c2s_update_resource_tree"
|
||||||
|
].call_async(
|
||||||
|
r
|
||||||
|
) # type: ignore
|
||||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||||
results.append(result)
|
results.append(result)
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
@@ -959,9 +1003,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
tree.root_node.res_content.parent_uuid = self.uuid
|
tree.root_node.res_content.parent_uuid = self.uuid
|
||||||
r = SerialCommand.Request()
|
r = SerialCommand.Request()
|
||||||
r.command = json.dumps(
|
r.command = json.dumps(
|
||||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||||
|
) # 和Update Resource一致
|
||||||
response: SerialCommand_Response = await self._resource_clients[
|
response: SerialCommand_Response = await self._resource_clients[
|
||||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
"c2s_update_resource_tree"
|
||||||
|
].call_async(
|
||||||
|
r
|
||||||
|
) # type: ignore
|
||||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||||
results.append(result)
|
results.append(result)
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
@@ -1108,6 +1156,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
"machine_name": BasicConfig.machine_name,
|
"machine_name": BasicConfig.machine_name,
|
||||||
"type": "slave",
|
"type": "slave",
|
||||||
"edge_device_id": self.device_id,
|
"edge_device_id": self.device_id,
|
||||||
|
"registry_name": self.registry_name,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
@@ -1331,7 +1380,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
resource_id=resource_data["id"], with_children=True
|
resource_id=resource_data["id"], with_children=True
|
||||||
)
|
)
|
||||||
if "sample_id" in resource_data:
|
if "sample_id" in resource_data:
|
||||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||||
queried_resources[idx] = plr_resource
|
queried_resources[idx] = plr_resource
|
||||||
else:
|
else:
|
||||||
uuid_indices.append((idx, unilabos_uuid, resource_data))
|
uuid_indices.append((idx, unilabos_uuid, resource_data))
|
||||||
@@ -1344,7 +1393,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||||
plr_resource = plr_resources[i]
|
plr_resource = plr_resources[i]
|
||||||
if "sample_id" in resource_data:
|
if "sample_id" in resource_data:
|
||||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||||
queried_resources[idx] = plr_resource
|
queried_resources[idx] = plr_resource
|
||||||
|
|
||||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||||
@@ -1352,7 +1401,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
# 通过资源跟踪器获取本地实例
|
# 通过资源跟踪器获取本地实例
|
||||||
final_resources = queried_resources if is_sequence else queried_resources[0]
|
final_resources = queried_resources if is_sequence else queried_resources[0]
|
||||||
if not is_sequence:
|
if not is_sequence:
|
||||||
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
plr = self.resource_tracker.figure_resource(
|
||||||
|
{"name": final_resources.name}, try_mode=False
|
||||||
|
)
|
||||||
# 保留unilabos_extra
|
# 保留unilabos_extra
|
||||||
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||||
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
||||||
@@ -1391,8 +1442,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
execution_success = True
|
execution_success = True
|
||||||
except Exception as _:
|
except Exception as _:
|
||||||
execution_error = traceback.format_exc()
|
execution_error = traceback.format_exc()
|
||||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
error(
|
||||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||||
|
)
|
||||||
|
trace(
|
||||||
|
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||||
|
)
|
||||||
|
|
||||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||||
future.add_done_callback(_handle_future_exception)
|
future.add_done_callback(_handle_future_exception)
|
||||||
@@ -1412,9 +1467,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
except Exception as _:
|
except Exception as _:
|
||||||
execution_error = traceback.format_exc()
|
execution_error = traceback.format_exc()
|
||||||
error(
|
error(
|
||||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||||
|
)
|
||||||
trace(
|
trace(
|
||||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||||
|
)
|
||||||
|
|
||||||
future.add_done_callback(_handle_future_exception)
|
future.add_done_callback(_handle_future_exception)
|
||||||
|
|
||||||
@@ -1481,13 +1538,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if isinstance(rs, list):
|
if isinstance(rs, list):
|
||||||
for r in rs:
|
for r in rs:
|
||||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||||
elif type(rs).__name__ == "ResourceHolder":
|
if res is None:
|
||||||
pass
|
res = rs
|
||||||
|
if id(res) not in seen:
|
||||||
|
seen.add(id(res))
|
||||||
|
unique_resources.append(res)
|
||||||
else:
|
else:
|
||||||
res = self.resource_tracker.parent_resource(rs)
|
res = self.resource_tracker.parent_resource(rs)
|
||||||
if id(res) not in seen:
|
if res is None:
|
||||||
seen.add(id(res))
|
res = rs
|
||||||
unique_resources.append(res)
|
if id(res) not in seen:
|
||||||
|
seen.add(id(res))
|
||||||
|
unique_resources.append(res)
|
||||||
|
|
||||||
# 使用新的资源树接口
|
# 使用新的资源树接口
|
||||||
if unique_resources:
|
if unique_resources:
|
||||||
@@ -1539,20 +1601,37 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
try:
|
try:
|
||||||
function_name = target["function_name"]
|
function_name = target["function_name"]
|
||||||
function_args = target["function_args"]
|
function_args = target["function_args"]
|
||||||
|
# 获取 unilabos 系统参数
|
||||||
|
unilabos_param: Dict[str, Any] = target[JSON_UNILABOS_PARAM]
|
||||||
|
|
||||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||||
function = getattr(self.driver_instance, function_name)
|
function = getattr(self.driver_instance, function_name)
|
||||||
assert callable(
|
assert callable(
|
||||||
function
|
function
|
||||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||||
|
|
||||||
# 处理 ResourceSlot 类型参数
|
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||||
for arg in args_list:
|
for arg in args_list:
|
||||||
arg_name = arg["name"]
|
arg_name = arg["name"]
|
||||||
arg_type = arg["type"]
|
arg_type = arg["type"]
|
||||||
|
|
||||||
# 跳过不在 function_args 中的参数
|
# 跳过不在 function_args 中的参数
|
||||||
if arg_name not in function_args:
|
if arg_name not in function_args:
|
||||||
|
# 处理 sample_uuids 参数注入
|
||||||
|
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||||
|
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||||
|
# 将 material uuid 转换为 resource 实例
|
||||||
|
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||||
|
resolved_sample_uuids: Dict[str, Any] = {}
|
||||||
|
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||||
|
if material_uuid and self.resource_tracker:
|
||||||
|
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||||
|
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||||
|
else:
|
||||||
|
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||||
|
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||||
|
self.lab_logger().debug(f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 处理单个 ResourceSlot
|
# 处理单个 ResourceSlot
|
||||||
@@ -1582,6 +1661,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
)
|
)
|
||||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||||
|
|
||||||
|
# todo: 默认反报送
|
||||||
return function(**function_args)
|
return function(**function_args)
|
||||||
except KeyError as ex:
|
except KeyError as ex:
|
||||||
raise JsonCommandInitError(
|
raise JsonCommandInitError(
|
||||||
@@ -1601,21 +1681,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
raise ValueError("至少需要提供一个 UUID")
|
raise ValueError("至少需要提供一个 UUID")
|
||||||
|
|
||||||
uuids_list = list(uuids)
|
uuids_list = list(uuids)
|
||||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||||
command=json.dumps(
|
SerialCommand.Request(
|
||||||
{
|
command=json.dumps(
|
||||||
"data": {"data": uuids_list, "with_children": True},
|
{
|
||||||
"action": "get",
|
"data": {"data": uuids_list, "with_children": True},
|
||||||
}
|
"action": "get",
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
))
|
)
|
||||||
|
|
||||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||||
timeout = 30.0
|
timeout = 30.0
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
while not future.done() and elapsed < timeout:
|
while not future.done() and elapsed < timeout:
|
||||||
time.sleep(0.05)
|
time.sleep(0.02)
|
||||||
elapsed += 0.05
|
elapsed += 0.02
|
||||||
|
|
||||||
if not future.done():
|
if not future.done():
|
||||||
raise Exception(f"资源查询超时: {uuids_list}")
|
raise Exception(f"资源查询超时: {uuids_list}")
|
||||||
@@ -1666,6 +1748,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
try:
|
try:
|
||||||
function_name = target["function_name"]
|
function_name = target["function_name"]
|
||||||
function_args = target["function_args"]
|
function_args = target["function_args"]
|
||||||
|
# 获取 unilabos 系统参数
|
||||||
|
unilabos_param: Dict[str, Any] = target.get(JSON_UNILABOS_PARAM, {})
|
||||||
|
|
||||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||||
function = getattr(self.driver_instance, function_name)
|
function = getattr(self.driver_instance, function_name)
|
||||||
assert callable(
|
assert callable(
|
||||||
@@ -1675,14 +1760,30 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
function
|
function
|
||||||
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||||
|
|
||||||
# 处理 ResourceSlot 类型参数
|
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||||
for arg in args_list:
|
for arg in args_list:
|
||||||
arg_name = arg["name"]
|
arg_name = arg["name"]
|
||||||
arg_type = arg["type"]
|
arg_type = arg["type"]
|
||||||
|
|
||||||
# 跳过不在 function_args 中的参数
|
# 跳过不在 function_args 中的参数
|
||||||
if arg_name not in function_args:
|
if arg_name not in function_args:
|
||||||
|
# 处理 sample_uuids 参数注入
|
||||||
|
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||||
|
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||||
|
# 将 material uuid 转换为 resource 实例
|
||||||
|
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||||
|
resolved_sample_uuids: Dict[str, Any] = {}
|
||||||
|
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||||
|
if material_uuid and self.resource_tracker:
|
||||||
|
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||||
|
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||||
|
else:
|
||||||
|
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||||
|
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||||
|
self.lab_logger().debug(
|
||||||
|
f"[JsonCommandAsync] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 处理单个 ResourceSlot
|
# 处理单个 ResourceSlot
|
||||||
@@ -1772,6 +1873,15 @@ class ROS2DeviceNode:
|
|||||||
它不继承设备类,而是通过代理模式访问设备类的属性和方法。
|
它不继承设备类,而是通过代理模式访问设备类的属性和方法。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 类变量,用于循环管理
|
||||||
|
_asyncio_loop = None
|
||||||
|
_asyncio_loop_running = False
|
||||||
|
_asyncio_loop_thread = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_asyncio_loop(cls):
|
||||||
|
return cls._asyncio_loop
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def safe_task_wrapper(trace_callback, func, **kwargs):
|
async def safe_task_wrapper(trace_callback, func, **kwargs):
|
||||||
try:
|
try:
|
||||||
@@ -1848,6 +1958,11 @@ class ROS2DeviceNode:
|
|||||||
print_publish: 是否打印发布信息
|
print_publish: 是否打印发布信息
|
||||||
driver_is_ros:
|
driver_is_ros:
|
||||||
"""
|
"""
|
||||||
|
# 在初始化时检查循环状态
|
||||||
|
if ROS2DeviceNode._asyncio_loop_running and ROS2DeviceNode._asyncio_loop_thread is not None:
|
||||||
|
pass
|
||||||
|
elif ROS2DeviceNode._asyncio_loop_thread is None:
|
||||||
|
self._start_loop()
|
||||||
|
|
||||||
# 保存设备类是否支持异步上下文
|
# 保存设备类是否支持异步上下文
|
||||||
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
|
self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__")
|
||||||
@@ -1893,6 +2008,7 @@ class ROS2DeviceNode:
|
|||||||
|
|
||||||
if driver_is_ros:
|
if driver_is_ros:
|
||||||
driver_params["device_id"] = device_id
|
driver_params["device_id"] = device_id
|
||||||
|
driver_params["registry_name"] = device_config.res_content.klass
|
||||||
driver_params["resource_tracker"] = self.resource_tracker
|
driver_params["resource_tracker"] = self.resource_tracker
|
||||||
self._driver_instance = self._driver_creator.create_instance(driver_params)
|
self._driver_instance = self._driver_creator.create_instance(driver_params)
|
||||||
if self._driver_instance is None:
|
if self._driver_instance is None:
|
||||||
@@ -1910,6 +2026,7 @@ class ROS2DeviceNode:
|
|||||||
children=children,
|
children=children,
|
||||||
driver_instance=self._driver_instance, # type: ignore
|
driver_instance=self._driver_instance, # type: ignore
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=device_config.res_content.klass,
|
||||||
device_uuid=device_uuid,
|
device_uuid=device_uuid,
|
||||||
status_types=status_types,
|
status_types=status_types,
|
||||||
action_value_mappings=action_value_mappings,
|
action_value_mappings=action_value_mappings,
|
||||||
@@ -1921,6 +2038,7 @@ class ROS2DeviceNode:
|
|||||||
self._ros_node = BaseROS2DeviceNode(
|
self._ros_node = BaseROS2DeviceNode(
|
||||||
driver_instance=self._driver_instance,
|
driver_instance=self._driver_instance,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=device_config.res_content.klass,
|
||||||
device_uuid=device_uuid,
|
device_uuid=device_uuid,
|
||||||
status_types=status_types,
|
status_types=status_types,
|
||||||
action_value_mappings=action_value_mappings,
|
action_value_mappings=action_value_mappings,
|
||||||
@@ -1929,6 +2047,7 @@ class ROS2DeviceNode:
|
|||||||
resource_tracker=self.resource_tracker,
|
resource_tracker=self.resource_tracker,
|
||||||
)
|
)
|
||||||
self._ros_node: BaseROS2DeviceNode
|
self._ros_node: BaseROS2DeviceNode
|
||||||
|
# 将注册表类型名传递给BaseROS2DeviceNode,用于slave上报
|
||||||
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
|
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
|
||||||
self.driver_instance._ros_node = self._ros_node # type: ignore
|
self.driver_instance._ros_node = self._ros_node # type: ignore
|
||||||
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
|
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
|
||||||
@@ -1939,6 +2058,19 @@ class ROS2DeviceNode:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._ros_node.lab_logger().error(f"设备后初始化失败: {e}")
|
self._ros_node.lab_logger().error(f"设备后初始化失败: {e}")
|
||||||
|
|
||||||
|
def _start_loop(self):
|
||||||
|
def run_event_loop():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
ROS2DeviceNode._asyncio_loop = loop
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_forever()
|
||||||
|
|
||||||
|
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(
|
||||||
|
target=run_event_loop, daemon=True, name="ROS2DeviceNode"
|
||||||
|
)
|
||||||
|
ROS2DeviceNode._asyncio_loop_thread.start()
|
||||||
|
logger.info(f"循环线程已启动")
|
||||||
|
|
||||||
|
|
||||||
class DeviceInfoType(TypedDict):
|
class DeviceInfoType(TypedDict):
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ from cv_bridge import CvBridge
|
|||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
|
||||||
|
|
||||||
class VideoPublisher(BaseROS2DeviceNode):
|
class VideoPublisher(BaseROS2DeviceNode):
|
||||||
def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
def __init__(self, device_id='video_publisher', registry_name="", device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
||||||
# 初始化BaseROS2DeviceNode,使用自身作为driver_instance
|
# 初始化BaseROS2DeviceNode,使用自身作为driver_instance
|
||||||
BaseROS2DeviceNode.__init__(
|
BaseROS2DeviceNode.__init__(
|
||||||
self,
|
self,
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
device_uuid=device_uuid,
|
device_uuid=device_uuid,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ControllerNode(BaseROS2DeviceNode):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
registry_name: str,
|
||||||
controller_func: Callable,
|
controller_func: Callable,
|
||||||
update_rate: float,
|
update_rate: float,
|
||||||
inputs: Dict[str, Dict[str, type | str]],
|
inputs: Dict[str, Dict[str, type | str]],
|
||||||
@@ -51,6 +52,7 @@ class ControllerNode(BaseROS2DeviceNode):
|
|||||||
self,
|
self,
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
status_types=status_types,
|
status_types=status_types,
|
||||||
action_value_mappings=action_value_mappings,
|
action_value_mappings=action_value_mappings,
|
||||||
hardware_interface=hardware_interface,
|
hardware_interface=hardware_interface,
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import collections
|
import collections
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, TypedDict, Union
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||||
|
|
||||||
from action_msgs.msg import GoalStatus
|
from action_msgs.msg import GoalStatus
|
||||||
from geometry_msgs.msg import Point
|
from geometry_msgs.msg import Point
|
||||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||||
from rclpy.service import Service
|
from rclpy.service import Service
|
||||||
|
from typing_extensions import TypedDict
|
||||||
from unilabos_msgs.msg import Resource # type: ignore
|
from unilabos_msgs.msg import Resource # type: ignore
|
||||||
from unilabos_msgs.srv import (
|
from unilabos_msgs.srv import (
|
||||||
ResourceAdd,
|
ResourceAdd,
|
||||||
@@ -22,10 +23,20 @@ from unilabos_msgs.srv import (
|
|||||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||||
from unique_identifier_msgs.msg import UUID
|
from unique_identifier_msgs.msg import UUID
|
||||||
|
|
||||||
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
from unilabos.resources.container import RegularContainer
|
from unilabos.resources.container import RegularContainer
|
||||||
from unilabos.resources.graphio import initialize_resource
|
from unilabos.resources.graphio import initialize_resource
|
||||||
from unilabos.resources.registry import add_schema
|
from unilabos.resources.registry import add_schema
|
||||||
|
from unilabos.resources.resource_tracker import (
|
||||||
|
ResourceDict,
|
||||||
|
ResourceDictInstance,
|
||||||
|
ResourceTreeSet,
|
||||||
|
ResourceTreeInstance,
|
||||||
|
RETURN_UNILABOS_SAMPLES,
|
||||||
|
JSON_UNILABOS_PARAM,
|
||||||
|
PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample,
|
||||||
|
)
|
||||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||||
from unilabos.ros.msgs.message_converter import (
|
from unilabos.ros.msgs.message_converter import (
|
||||||
get_msg_type,
|
get_msg_type,
|
||||||
@@ -36,17 +47,11 @@ from unilabos.ros.msgs.message_converter import (
|
|||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||||
from unilabos.resources.resource_tracker import (
|
|
||||||
ResourceDict,
|
|
||||||
ResourceDictInstance,
|
|
||||||
ResourceTreeSet,
|
|
||||||
ResourceTreeInstance,
|
|
||||||
)
|
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
from unilabos.utils.exception import DeviceClassInvalid
|
from unilabos.utils.exception import DeviceClassInvalid
|
||||||
from unilabos.utils.log import warning
|
from unilabos.utils.log import warning
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
from unilabos.config.config import BasicConfig
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from unilabos.app.ws_client import QueueItem
|
from unilabos.app.ws_client import QueueItem
|
||||||
@@ -59,7 +64,20 @@ class DeviceActionStatus:
|
|||||||
|
|
||||||
class TestResourceReturn(TypedDict):
|
class TestResourceReturn(TypedDict):
|
||||||
resources: List[List[ResourceDict]]
|
resources: List[List[ResourceDict]]
|
||||||
devices: List[DeviceSlot]
|
devices: List[Dict[str, Any]]
|
||||||
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatencyReturn(TypedDict):
|
||||||
|
"""test_latency方法的返回值类型"""
|
||||||
|
|
||||||
|
avg_rtt_ms: float
|
||||||
|
avg_time_diff_ms: float
|
||||||
|
max_time_error_ms: float
|
||||||
|
task_delay_ms: float
|
||||||
|
raw_delay_ms: float
|
||||||
|
test_count: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
class HostNode(BaseROS2DeviceNode):
|
class HostNode(BaseROS2DeviceNode):
|
||||||
@@ -232,6 +250,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self,
|
self,
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name="host_node",
|
||||||
device_uuid=host_node_dict["uuid"],
|
device_uuid=host_node_dict["uuid"],
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
||||||
@@ -286,7 +305,8 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
} # 用来存储多个ActionClient实例
|
} # 用来存储多个ActionClient实例
|
||||||
self._action_value_mappings: Dict[str, Dict] = (
|
self._action_value_mappings: Dict[str, Dict] = (
|
||||||
{}
|
{}
|
||||||
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
|
) # device_id -> action_value_mappings(本地+远程设备统一存储)
|
||||||
|
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
|
||||||
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
||||||
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
||||||
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
||||||
@@ -620,6 +640,8 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self.device_machine_names[device_id] = "本地"
|
self.device_machine_names[device_id] = "本地"
|
||||||
self.devices_instances[device_id] = d
|
self.devices_instances[device_id] = d
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
|
self._action_value_mappings[device_id] = d._ros_node._action_value_mappings
|
||||||
|
# noinspection PyProtectedMember
|
||||||
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
|
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
|
||||||
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
|
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
|
||||||
"UniLabJsonCommand"
|
"UniLabJsonCommand"
|
||||||
@@ -735,13 +757,14 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
if bCreate:
|
if bCreate:
|
||||||
self.lab_logger().trace(f"Status created: {device_id}.{property_name} = {msg.data}")
|
self.lab_logger().trace(f"Status created: {device_id}.{property_name} = {msg.data}")
|
||||||
else:
|
else:
|
||||||
self.lab_logger().debug(f"Status updated: {device_id}.{property_name} = {msg.data}")
|
self.lab_logger().trace(f"Status updated: {device_id}.{property_name} = {msg.data}")
|
||||||
|
|
||||||
def send_goal(
|
def send_goal(
|
||||||
self,
|
self,
|
||||||
item: "QueueItem",
|
item: "QueueItem",
|
||||||
action_type: str,
|
action_type: str,
|
||||||
action_kwargs: Dict[str, Any],
|
action_kwargs: Dict[str, Any],
|
||||||
|
sample_material: Dict[str, str],
|
||||||
server_info: Optional[Dict[str, Any]] = None,
|
server_info: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -755,18 +778,29 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
u = uuid.UUID(item.job_id)
|
u = uuid.UUID(item.job_id)
|
||||||
device_id = item.device_id
|
device_id = item.device_id
|
||||||
action_name = item.action_name
|
action_name = item.action_name
|
||||||
|
|
||||||
|
if BasicConfig.test_mode:
|
||||||
|
action_id = f"/devices/{device_id}/{action_name}"
|
||||||
|
self.lab_logger().info(
|
||||||
|
f"[TEST MODE] 模拟执行: {action_id} (job={item.job_id[:8]}), 参数: {str(action_kwargs)[:500]}"
|
||||||
|
)
|
||||||
|
# 根据注册表 handles 构建模拟返回值
|
||||||
|
mock_return = self._build_test_mode_return(device_id, action_name, action_kwargs)
|
||||||
|
self._handle_test_mode_result(item, action_id, mock_return)
|
||||||
|
return
|
||||||
|
|
||||||
if action_type.startswith("UniLabJsonCommand"):
|
if action_type.startswith("UniLabJsonCommand"):
|
||||||
if action_name.startswith("auto-"):
|
if action_name.startswith("auto-"):
|
||||||
action_name = action_name[5:]
|
action_name = action_name[5:]
|
||||||
action_id = f"/devices/{device_id}/_execute_driver_command"
|
action_id = f"/devices/{device_id}/_execute_driver_command"
|
||||||
action_kwargs = {
|
json_command: Dict[str, Any] = {
|
||||||
"string": json.dumps(
|
"function_name": action_name,
|
||||||
{
|
"function_args": action_kwargs,
|
||||||
"function_name": action_name,
|
JSON_UNILABOS_PARAM: {
|
||||||
"function_args": action_kwargs,
|
PARAM_SAMPLE_UUIDS: sample_material,
|
||||||
}
|
},
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
action_kwargs = {"string": json.dumps(json_command)}
|
||||||
if action_type.startswith("UniLabJsonCommandAsync"):
|
if action_type.startswith("UniLabJsonCommandAsync"):
|
||||||
action_id = f"/devices/{device_id}/_execute_driver_command_async"
|
action_id = f"/devices/{device_id}/_execute_driver_command_async"
|
||||||
else:
|
else:
|
||||||
@@ -777,24 +811,9 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
raise ValueError(f"ActionClient {action_id} not found.")
|
raise ValueError(f"ActionClient {action_id} not found.")
|
||||||
|
|
||||||
action_client: ActionClient = self._action_clients[action_id]
|
action_client: ActionClient = self._action_clients[action_id]
|
||||||
|
|
||||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
|
||||||
def assign_sample_id(obj):
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
if "sample_uuid" in obj:
|
|
||||||
obj["sample_id"] = obj["sample_uuid"]
|
|
||||||
obj.pop("sample_uuid")
|
|
||||||
for k, v in obj.items():
|
|
||||||
if k != "unilabos_extra":
|
|
||||||
assign_sample_id(v)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for item in obj:
|
|
||||||
assign_sample_id(item)
|
|
||||||
|
|
||||||
assign_sample_id(action_kwargs)
|
|
||||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||||
|
|
||||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
# self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}")
|
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}")
|
||||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||||
action_client.wait_for_server()
|
action_client.wait_for_server()
|
||||||
@@ -807,6 +826,51 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
)
|
)
|
||||||
future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f))
|
future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f))
|
||||||
|
|
||||||
|
def _build_test_mode_return(
|
||||||
|
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
根据注册表 handles 的 output 定义构建测试模式的模拟返回值
|
||||||
|
|
||||||
|
根据 data_key 中 @flatten 的层数决定嵌套数组层数,叶子值为空字典。
|
||||||
|
例如: "vessel" → {}, "plate.@flatten" → [{}], "a.@flatten.@flatten" → [[{}]]
|
||||||
|
"""
|
||||||
|
mock_return: Dict[str, Any] = {"test_mode": True, "action_name": action_name}
|
||||||
|
action_mappings = self._action_value_mappings.get(device_id, {})
|
||||||
|
action_mapping = action_mappings.get(action_name, {})
|
||||||
|
handles = action_mapping.get("handles", {})
|
||||||
|
if isinstance(handles, dict):
|
||||||
|
for output_handle in handles.get("output", []):
|
||||||
|
data_key = output_handle.get("data_key", "")
|
||||||
|
handler_key = output_handle.get("handler_key", "")
|
||||||
|
# 根据 @flatten 层数构建嵌套数组,叶子为空字典
|
||||||
|
flatten_count = data_key.count("@flatten")
|
||||||
|
value: Any = {}
|
||||||
|
for _ in range(flatten_count):
|
||||||
|
value = [value]
|
||||||
|
mock_return[handler_key] = value
|
||||||
|
return mock_return
|
||||||
|
|
||||||
|
def _handle_test_mode_result(
|
||||||
|
self, item: "QueueItem", action_id: str, mock_return: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
测试模式下直接构建结果并走正常的结果回调流程(跳过 ROS)
|
||||||
|
"""
|
||||||
|
job_id = item.job_id
|
||||||
|
status = "success"
|
||||||
|
return_info = serialize_result_info("", True, mock_return)
|
||||||
|
|
||||||
|
self.lab_logger().info(f"[TEST MODE] Result for {action_id} ({job_id[:8]}): {status}")
|
||||||
|
|
||||||
|
from unilabos.app.web.controller import store_job_result
|
||||||
|
store_job_result(job_id, status, return_info, mock_return)
|
||||||
|
|
||||||
|
# 发布状态到桥接器
|
||||||
|
for bridge in self.bridges:
|
||||||
|
if hasattr(bridge, "publish_job_status"):
|
||||||
|
bridge.publish_job_status(mock_return, item, status, return_info)
|
||||||
|
|
||||||
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
|
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
|
||||||
"""目标响应回调"""
|
"""目标响应回调"""
|
||||||
goal_handle = future.result()
|
goal_handle = future.result()
|
||||||
@@ -854,9 +918,14 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
# 适配后端的一些额外处理
|
# 适配后端的一些额外处理
|
||||||
return_value = return_info.get("return_value")
|
return_value = return_info.get("return_value")
|
||||||
if isinstance(return_value, dict):
|
if isinstance(return_value, dict):
|
||||||
unilabos_samples = return_info.get("unilabos_samples")
|
unilabos_samples = return_value.pop(RETURN_UNILABOS_SAMPLES, None)
|
||||||
if isinstance(unilabos_samples, list):
|
if isinstance(unilabos_samples, list) and unilabos_samples:
|
||||||
return_info["unilabos_samples"] = unilabos_samples
|
self.lab_logger().info(
|
||||||
|
f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): "
|
||||||
|
f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}"
|
||||||
|
f"{'...' if len(unilabos_samples) > 5 else ''}"
|
||||||
|
)
|
||||||
|
return_info["samples"] = unilabos_samples
|
||||||
suc = return_info.get("suc", False)
|
suc = return_info.get("suc", False)
|
||||||
if not suc:
|
if not suc:
|
||||||
status = "failed"
|
status = "failed"
|
||||||
@@ -882,7 +951,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
# 清理 _goals 中的记录
|
# 清理 _goals 中的记录
|
||||||
if job_id in self._goals:
|
if job_id in self._goals:
|
||||||
del self._goals[job_id]
|
del self._goals[job_id]
|
||||||
self.lab_logger().debug(f"[Host Node] Removed goal {job_id[:8]} from _goals")
|
self.lab_logger().trace(f"[Host Node] Removed goal {job_id[:8]} from _goals")
|
||||||
|
|
||||||
# 存储结果供 HTTP API 查询
|
# 存储结果供 HTTP API 查询
|
||||||
try:
|
try:
|
||||||
@@ -1161,8 +1230,12 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
def _node_info_update_callback(self, request, response):
|
def _node_info_update_callback(self, request, response):
|
||||||
"""
|
"""
|
||||||
更新节点信息回调
|
更新节点信息回调
|
||||||
|
|
||||||
|
处理两种消息:
|
||||||
|
1. 首次上报(main_slave_run): 带 devices_config + registry_config,存储 action_value_mappings
|
||||||
|
2. 设备重注册(SYNC_SLAVE_NODE_INFO): 带 edge_device_id + registry_name,用 registry_name 索引已存储的 mappings
|
||||||
"""
|
"""
|
||||||
# self.lab_logger().info(f"[Host Node] Node info update request received: {request}")
|
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
|
||||||
try:
|
try:
|
||||||
from unilabos.app.communication import get_communication_client
|
from unilabos.app.communication import get_communication_client
|
||||||
from unilabos.app.web.client import HTTPClient, http_client
|
from unilabos.app.web.client import HTTPClient, http_client
|
||||||
@@ -1172,12 +1245,48 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
info = info["SYNC_SLAVE_NODE_INFO"]
|
info = info["SYNC_SLAVE_NODE_INFO"]
|
||||||
machine_name = info["machine_name"]
|
machine_name = info["machine_name"]
|
||||||
edge_device_id = info["edge_device_id"]
|
edge_device_id = info["edge_device_id"]
|
||||||
|
registry_name = info.get("registry_name", "")
|
||||||
self.device_machine_names[edge_device_id] = machine_name
|
self.device_machine_names[edge_device_id] = machine_name
|
||||||
|
|
||||||
|
# 用 registry_name 索引已存储的 registry_config,获取 action_value_mappings
|
||||||
|
if registry_name and registry_name in self._slave_registry_configs:
|
||||||
|
action_mappings = self._slave_registry_configs[registry_name].get(
|
||||||
|
"class", {}
|
||||||
|
).get("action_value_mappings", {})
|
||||||
|
if action_mappings:
|
||||||
|
self._action_value_mappings[edge_device_id] = action_mappings
|
||||||
|
self.lab_logger().info(
|
||||||
|
f"[Host Node] Loaded {len(action_mappings)} action mappings "
|
||||||
|
f"for remote device {edge_device_id} (registry: {registry_name})"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
devices_config = info.pop("devices_config")
|
devices_config = info.pop("devices_config")
|
||||||
registry_config = info.pop("registry_config")
|
registry_config = info.pop("registry_config")
|
||||||
if registry_config:
|
if registry_config:
|
||||||
http_client.resource_registry({"resources": registry_config})
|
http_client.resource_registry({"resources": registry_config})
|
||||||
|
|
||||||
|
# 存储 slave 的 registry_config,用于后续 SYNC_SLAVE_NODE_INFO 索引
|
||||||
|
for reg_name, reg_data in registry_config.items():
|
||||||
|
if isinstance(reg_data, dict) and "class" in reg_data:
|
||||||
|
self._slave_registry_configs[reg_name] = reg_data
|
||||||
|
|
||||||
|
# 解析 devices_config,建立 device_id -> action_value_mappings 映射
|
||||||
|
if devices_config:
|
||||||
|
for device_tree in devices_config:
|
||||||
|
for device_dict in device_tree:
|
||||||
|
device_id = device_dict.get("id", "")
|
||||||
|
class_name = device_dict.get("class", "")
|
||||||
|
if device_id and class_name and class_name in self._slave_registry_configs:
|
||||||
|
action_mappings = self._slave_registry_configs[class_name].get(
|
||||||
|
"class", {}
|
||||||
|
).get("action_value_mappings", {})
|
||||||
|
if action_mappings:
|
||||||
|
self._action_value_mappings[device_id] = action_mappings
|
||||||
|
self.lab_logger().info(
|
||||||
|
f"[Host Node] Stored {len(action_mappings)} action mappings "
|
||||||
|
f"for remote device {device_id} (class: {class_name})"
|
||||||
|
)
|
||||||
|
|
||||||
self.lab_logger().debug(f"[Host Node] Node info update: {info}")
|
self.lab_logger().debug(f"[Host Node] Node info update: {info}")
|
||||||
response.response = "OK"
|
response.response = "OK"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1327,10 +1436,20 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
|
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def test_latency(self):
|
def test_latency(self) -> TestLatencyReturn:
|
||||||
"""
|
"""
|
||||||
测试网络延迟的action实现
|
测试网络延迟的action实现
|
||||||
通过5次ping-pong机制校对时间误差并计算实际延迟
|
通过5次ping-pong机制校对时间误差并计算实际延迟
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TestLatencyReturn: 包含延迟测试结果的字典,包括:
|
||||||
|
- avg_rtt_ms: 平均往返时间(毫秒)
|
||||||
|
- avg_time_diff_ms: 平均时间差(毫秒)
|
||||||
|
- max_time_error_ms: 最大时间误差(毫秒)
|
||||||
|
- task_delay_ms: 实际任务延迟(毫秒),-1表示无法计算
|
||||||
|
- raw_delay_ms: 原始时间差(毫秒),-1表示无法计算
|
||||||
|
- test_count: 有效测试次数
|
||||||
|
- status: 测试状态,"success"表示成功,"all_timeout"表示全部超时
|
||||||
"""
|
"""
|
||||||
import uuid as uuid_module
|
import uuid as uuid_module
|
||||||
|
|
||||||
@@ -1393,7 +1512,15 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
if not ping_results:
|
if not ping_results:
|
||||||
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
|
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
|
||||||
return {"status": "all_timeout"}
|
return {
|
||||||
|
"avg_rtt_ms": -1.0,
|
||||||
|
"avg_time_diff_ms": -1.0,
|
||||||
|
"max_time_error_ms": -1.0,
|
||||||
|
"task_delay_ms": -1.0,
|
||||||
|
"raw_delay_ms": -1.0,
|
||||||
|
"test_count": 0,
|
||||||
|
"status": "all_timeout",
|
||||||
|
}
|
||||||
|
|
||||||
# 统计分析
|
# 统计分析
|
||||||
rtts = [r["rtt_ms"] for r in ping_results]
|
rtts = [r["rtt_ms"] for r in ping_results]
|
||||||
@@ -1401,7 +1528,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
avg_rtt_ms = sum(rtts) / len(rtts)
|
avg_rtt_ms = sum(rtts) / len(rtts)
|
||||||
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
|
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
|
||||||
max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs)))
|
max_time_diff_error_ms: float = max(abs(min(time_diffs)), abs(max(time_diffs)))
|
||||||
|
|
||||||
self.lab_logger().info("-" * 50)
|
self.lab_logger().info("-" * 50)
|
||||||
self.lab_logger().info("[测试统计]")
|
self.lab_logger().info("[测试统计]")
|
||||||
@@ -1441,7 +1568,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
self.lab_logger().info("=" * 60)
|
self.lab_logger().info("=" * 60)
|
||||||
|
|
||||||
return {
|
res: TestLatencyReturn = {
|
||||||
"avg_rtt_ms": avg_rtt_ms,
|
"avg_rtt_ms": avg_rtt_ms,
|
||||||
"avg_time_diff_ms": avg_time_diff_ms,
|
"avg_time_diff_ms": avg_time_diff_ms,
|
||||||
"max_time_error_ms": max_time_diff_error_ms,
|
"max_time_error_ms": max_time_diff_error_ms,
|
||||||
@@ -1452,9 +1579,15 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
"test_count": len(ping_results),
|
"test_count": len(ping_results),
|
||||||
"status": "success",
|
"status": "success",
|
||||||
}
|
}
|
||||||
|
return res
|
||||||
|
|
||||||
def test_resource(
|
def test_resource(
|
||||||
self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
|
self,
|
||||||
|
sample_uuids: SampleUUIDsType,
|
||||||
|
resource: ResourceSlot = None,
|
||||||
|
resources: List[ResourceSlot] = None,
|
||||||
|
device: DeviceSlot = None,
|
||||||
|
devices: List[DeviceSlot] = None,
|
||||||
) -> TestResourceReturn:
|
) -> TestResourceReturn:
|
||||||
if resources is None:
|
if resources is None:
|
||||||
resources = []
|
resources = []
|
||||||
@@ -1465,6 +1598,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
return {
|
return {
|
||||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||||
"devices": [device, *devices],
|
"devices": [device, *devices],
|
||||||
|
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||||
}
|
}
|
||||||
|
|
||||||
def handle_pong_response(self, pong_data: dict):
|
def handle_pong_response(self, pong_data: dict):
|
||||||
@@ -1515,7 +1649,9 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
# 构建服务地址
|
# 构建服务地址
|
||||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
|
self.lab_logger().trace(
|
||||||
|
f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------"
|
||||||
|
)
|
||||||
|
|
||||||
# 创建服务客户端
|
# 创建服务客户端
|
||||||
sclient = self.create_client(SerialCommand, srv_address)
|
sclient = self.create_client(SerialCommand, srv_address)
|
||||||
@@ -1550,7 +1686,9 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
response = future.result()
|
response = future.result()
|
||||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
|
self.lab_logger().trace(
|
||||||
|
f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
|||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
class JointRepublisher(BaseROS2DeviceNode):
|
class JointRepublisher(BaseROS2DeviceNode):
|
||||||
def __init__(self,device_id,resource_tracker, **kwargs):
|
def __init__(self,device_id, registry_name, resource_tracker, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from unilabos.resources.graphio import initialize_resources
|
|||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
|
|
||||||
class ResourceMeshManager(BaseROS2DeviceNode):
|
class ResourceMeshManager(BaseROS2DeviceNode):
|
||||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs):
|
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", registry_name: str = "", rate=50, **kwargs):
|
||||||
"""初始化资源网格管理器节点
|
"""初始化资源网格管理器节点
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -37,6 +37,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeRe
|
|||||||
|
|
||||||
|
|
||||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||||
def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
||||||
# 保存属性,以便在调用父类初始化前使用
|
# 保存属性,以便在调用父类初始化前使用
|
||||||
self.port = port
|
self.port = port
|
||||||
self.baudrate = baudrate
|
self.baudrate = baudrate
|
||||||
@@ -28,6 +28,7 @@ class ROS2SerialNode(BaseROS2DeviceNode):
|
|||||||
BaseROS2DeviceNode.__init__(
|
BaseROS2DeviceNode.__init__(
|
||||||
self,
|
self,
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
|
registry_name=registry_name,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
|||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from rosidl_runtime_py import message_to_ordereddict
|
from rosidl_runtime_py import message_to_ordereddict
|
||||||
from unilabos_msgs.msg import Resource
|
|
||||||
from unilabos_msgs.srv import ResourceUpdate
|
|
||||||
|
|
||||||
from unilabos.messages import * # type: ignore # protocol names
|
from unilabos.messages import * # type: ignore # protocol names
|
||||||
from rclpy.action import ActionServer, ActionClient
|
from rclpy.action import ActionServer, ActionClient
|
||||||
@@ -15,7 +13,6 @@ from rclpy.action.server import ServerGoalHandle
|
|||||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||||
|
|
||||||
from unilabos.compile import action_protocol_generators
|
from unilabos.compile import action_protocol_generators
|
||||||
from unilabos.resources.graphio import nested_dict_to_list
|
|
||||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||||
from unilabos.ros.msgs.message_converter import (
|
from unilabos.ros.msgs.message_converter import (
|
||||||
get_action_type,
|
get_action_type,
|
||||||
@@ -50,6 +47,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
*,
|
*,
|
||||||
driver_instance: "WorkstationBase",
|
driver_instance: "WorkstationBase",
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
registry_name: str,
|
||||||
device_uuid: str,
|
device_uuid: str,
|
||||||
status_types: Dict[str, Any],
|
status_types: Dict[str, Any],
|
||||||
action_value_mappings: Dict[str, Any],
|
action_value_mappings: Dict[str, Any],
|
||||||
@@ -65,6 +63,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=driver_instance,
|
driver_instance=driver_instance,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
device_uuid=device_uuid,
|
device_uuid=device_uuid,
|
||||||
status_types=status_types,
|
status_types=status_types,
|
||||||
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
|
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
|
||||||
@@ -231,15 +230,15 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
try:
|
try:
|
||||||
# 统一处理单个或多个资源
|
# 统一处理单个或多个资源
|
||||||
resource_id = (
|
resource_id = (
|
||||||
protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"]
|
protocol_kwargs[k]["id"]
|
||||||
|
if v == "unilabos_msgs/Resource"
|
||||||
|
else protocol_kwargs[k][0]["id"]
|
||||||
)
|
)
|
||||||
resource_uuid = protocol_kwargs[k].get("uuid", None)
|
resource_uuid = protocol_kwargs[k].get("uuid", None)
|
||||||
r = SerialCommand_Request()
|
r = SerialCommand_Request()
|
||||||
r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True})
|
r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True})
|
||||||
# 发送请求并等待响应
|
# 发送请求并等待响应
|
||||||
response: SerialCommand_Response = await self._resource_clients[
|
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(
|
||||||
"resource_get"
|
|
||||||
].call_async(
|
|
||||||
r
|
r
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
@@ -307,12 +306,54 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
# 向Host更新物料当前状态
|
# 向Host更新物料当前状态
|
||||||
for k, v in goal.get_fields_and_field_types().items():
|
for k, v in goal.get_fields_and_field_types().items():
|
||||||
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||||
r = ResourceUpdate.Request()
|
continue
|
||||||
r.resources = [
|
self.lab_logger().info(f"更新资源状态: {k}")
|
||||||
convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k])
|
try:
|
||||||
]
|
# 去重:使用 seen 集合获取唯一的资源对象
|
||||||
response = await self._resource_clients["resource_update"].call_async(r)
|
seen = set()
|
||||||
|
unique_resources = []
|
||||||
|
|
||||||
|
# 获取资源数据,统一转换为列表
|
||||||
|
resource_data = protocol_kwargs[k]
|
||||||
|
is_sequence = v != "unilabos_msgs/Resource"
|
||||||
|
if not is_sequence:
|
||||||
|
resource_list = [resource_data] if isinstance(resource_data, dict) else resource_data
|
||||||
|
else:
|
||||||
|
# 处理序列类型,可能是嵌套列表
|
||||||
|
resource_list = []
|
||||||
|
if isinstance(resource_data, list):
|
||||||
|
for item in resource_data:
|
||||||
|
if isinstance(item, list):
|
||||||
|
resource_list.extend(item)
|
||||||
|
else:
|
||||||
|
resource_list.append(item)
|
||||||
|
else:
|
||||||
|
resource_list = [resource_data]
|
||||||
|
|
||||||
|
for res_data in resource_list:
|
||||||
|
if not isinstance(res_data, dict):
|
||||||
|
continue
|
||||||
|
res_name = res_data.get("id") or res_data.get("name")
|
||||||
|
if not res_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 使用 resource_tracker 获取本地 PLR 实例
|
||||||
|
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
||||||
|
# 获取父资源
|
||||||
|
res = self.resource_tracker.parent_resource(plr)
|
||||||
|
if res is None:
|
||||||
|
res = plr
|
||||||
|
if id(res) not in seen:
|
||||||
|
seen.add(id(res))
|
||||||
|
unique_resources.append(res)
|
||||||
|
|
||||||
|
# 使用新的资源树接口更新
|
||||||
|
if unique_resources:
|
||||||
|
await self.update_resource(unique_resources)
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().error(f"资源更新失败: {e}")
|
||||||
|
self.lab_logger().error(traceback.format_exc())
|
||||||
|
|
||||||
# 设置成功状态和返回值
|
# 设置成功状态和返回值
|
||||||
execution_success = True
|
execution_success = True
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class DeviceClassCreator(Generic[T]):
|
|||||||
if self.device_instance is not None:
|
if self.device_instance is not None:
|
||||||
for c in self.children:
|
for c in self.children:
|
||||||
if c.res_content.type != "device":
|
if c.res_content.type != "device":
|
||||||
self.resource_tracker.add_resource(c.get_plr_nested_dict())
|
res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0]
|
||||||
|
self.resource_tracker.add_resource(res)
|
||||||
|
|
||||||
def create_instance(self, data: Dict[str, Any]) -> T:
|
def create_instance(self, data: Dict[str, Any]) -> T:
|
||||||
"""
|
"""
|
||||||
@@ -119,7 +120,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
# return resource, source_type
|
# return resource, source_type
|
||||||
|
|
||||||
def _process_resource_references(
|
def _process_resource_references(
|
||||||
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
递归处理资源引用,替换_resource_child_name对应的资源
|
递归处理资源引用,替换_resource_child_name对应的资源
|
||||||
@@ -164,6 +165,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
states[prefix_path] = resource_instance.serialize_all_state()
|
states[prefix_path] = resource_instance.serialize_all_state()
|
||||||
return serialized
|
return serialized
|
||||||
else:
|
else:
|
||||||
|
processed_child_names[child_name] = resource_instance
|
||||||
self.resource_tracker.add_resource(resource_instance)
|
self.resource_tracker.add_resource(resource_instance)
|
||||||
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
||||||
if name_to_uuid:
|
if name_to_uuid:
|
||||||
@@ -182,12 +184,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
result = {}
|
result = {}
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
|
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
|
||||||
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid)
|
result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
return [
|
return [
|
||||||
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||||
for i, item in enumerate(data)
|
for i, item in enumerate(data)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
# 首先处理资源引用
|
# 首先处理资源引用
|
||||||
states = {}
|
states = {}
|
||||||
processed_data = self._process_resource_references(
|
processed_data = self._process_resource_references(
|
||||||
data, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -270,7 +272,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
|||||||
arg_value = spec_args[param_name].annotation
|
arg_value = spec_args[param_name].annotation
|
||||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||||
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
||||||
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid)
|
processed_child_names = {}
|
||||||
|
processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid)
|
||||||
|
for child_name, resource_instance in processed_data.items():
|
||||||
|
for ind, name in enumerate([child.res_content.name for child in self.children]):
|
||||||
|
if name == child_name:
|
||||||
|
self.children.pop(ind)
|
||||||
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource
|
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"PyLabRobot创建实例失败: {e}")
|
logger.error(f"PyLabRobot创建实例失败: {e}")
|
||||||
@@ -342,9 +349,10 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
|
|||||||
try:
|
try:
|
||||||
# 创建实例,额外补充一个给protocol node的字段,后面考虑取消
|
# 创建实例,额外补充一个给protocol node的字段,后面考虑取消
|
||||||
data["children"] = self.children
|
data["children"] = self.children
|
||||||
for child in self.children:
|
# super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
|
||||||
if child.res_content.type != "device":
|
# for child in self.children:
|
||||||
self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
# if child.res_content.type != "device":
|
||||||
|
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||||
deck_dict = data.get("deck")
|
deck_dict = data.get("deck")
|
||||||
if deck_dict:
|
if deck_dict:
|
||||||
from pylabrobot.resources import Deck, Resource
|
from pylabrobot.resources import Deck, Resource
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user