mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 16:19:17 +00:00
Compare commits
16 Commits
e4d915c59c
...
prcix9320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41be9e4e19 | ||
|
|
f38f3dfc89 | ||
|
|
699a0b3ce7 | ||
|
|
cf3a20ae79 | ||
|
|
cdf0652020 | ||
|
|
60073ff139 | ||
|
|
d3f59913b0 | ||
|
|
a9053b822f | ||
|
|
d238c2ab8b | ||
|
|
9a7d5c7c82 | ||
|
|
f6d46e669d | ||
|
|
abf5555e37 | ||
|
|
4f7d431c0b | ||
|
|
341a1b537c | ||
|
|
957fb41a6f | ||
|
|
26271bcab8 |
@@ -452,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. 在画布上连接它们(建立父子关系)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -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,6 +356,7 @@ 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)
|
||||||
@@ -367,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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# 保存请求到文件
|
# 保存请求到文件
|
||||||
@@ -388,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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
# 有正在执行或准备执行的任务,加入队列
|
# 有正在执行或准备执行的任务,加入队列
|
||||||
@@ -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]
|
||||||
@@ -233,9 +254,14 @@ class DeviceActionManager:
|
|||||||
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 +286,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状态
|
||||||
@@ -333,13 +367,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(
|
||||||
@@ -540,7 +579,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())
|
||||||
@@ -603,6 +642,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 +674,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 +686,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加到设备管理器
|
# 添加到设备管理器
|
||||||
@@ -652,6 +713,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 +746,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1113,6 +1177,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": {
|
||||||
@@ -1294,7 +1363,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ from pylabrobot.resources import (
|
|||||||
ResourceHolder,
|
ResourceHolder,
|
||||||
Lid,
|
Lid,
|
||||||
Trash,
|
Trash,
|
||||||
Tip,
|
Tip, TubeRack,
|
||||||
)
|
)
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
from unilabos.resources.resource_tracker import (
|
||||||
|
ResourceTreeSet,
|
||||||
|
ResourceDict,
|
||||||
|
EXTRA_SAMPLE_UUID,
|
||||||
|
EXTRA_UNILABOS_SAMPLE_UUID,
|
||||||
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
@@ -203,7 +208,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
|
if spread == "":
|
||||||
|
spread = "wide"
|
||||||
if self._simulator:
|
if self._simulator:
|
||||||
return await self._simulate_handler.aspirate(
|
return await self._simulate_handler.aspirate(
|
||||||
resources,
|
resources,
|
||||||
@@ -216,17 +222,33 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
spread,
|
spread,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
)
|
)
|
||||||
await super().aspirate(
|
try:
|
||||||
resources,
|
await super().aspirate(
|
||||||
vols,
|
resources,
|
||||||
use_channels,
|
vols,
|
||||||
flow_rates,
|
use_channels,
|
||||||
offsets,
|
flow_rates,
|
||||||
liquid_height,
|
offsets,
|
||||||
blow_out_air_volume,
|
liquid_height,
|
||||||
spread,
|
blow_out_air_volume,
|
||||||
**backend_kwargs,
|
spread,
|
||||||
)
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||||
|
await super().aspirate(
|
||||||
|
resources,
|
||||||
|
vols,
|
||||||
|
use_channels,
|
||||||
|
flow_rates,
|
||||||
|
offsets,
|
||||||
|
liquid_height,
|
||||||
|
blow_out_air_volume,
|
||||||
|
spread="custom",
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
res_samples = []
|
res_samples = []
|
||||||
res_volumes = []
|
res_volumes = []
|
||||||
@@ -238,10 +260,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
channels_to_use = use_channels
|
channels_to_use = use_channels
|
||||||
|
|
||||||
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
||||||
res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)})
|
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
|
||||||
|
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
|
||||||
res_volumes.append(volume)
|
res_volumes.append(volume)
|
||||||
self.pending_liquids_dict[channel] = {
|
self.pending_liquids_dict[channel] = {
|
||||||
"sample_uuid": resource.unilabos_extra.get("sample_uuid", None),
|
EXTRA_SAMPLE_UUID: sample_uuid_value,
|
||||||
"volume": volume,
|
"volume": volume,
|
||||||
}
|
}
|
||||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||||
@@ -258,6 +281,8 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
) -> SimpleReturn:
|
) -> SimpleReturn:
|
||||||
|
if spread == "":
|
||||||
|
spread = "wide"
|
||||||
if self._simulator:
|
if self._simulator:
|
||||||
return await self._simulate_handler.dispense(
|
return await self._simulate_handler.dispense(
|
||||||
resources,
|
resources,
|
||||||
@@ -270,23 +295,40 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
spread,
|
spread,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
)
|
)
|
||||||
await super().dispense(
|
try:
|
||||||
resources,
|
await super().dispense(
|
||||||
vols,
|
resources,
|
||||||
use_channels,
|
vols,
|
||||||
flow_rates,
|
use_channels,
|
||||||
offsets,
|
flow_rates,
|
||||||
liquid_height,
|
offsets,
|
||||||
blow_out_air_volume,
|
liquid_height,
|
||||||
**backend_kwargs,
|
blow_out_air_volume,
|
||||||
)
|
spread,
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||||
|
await super().dispense(
|
||||||
|
resources,
|
||||||
|
vols,
|
||||||
|
use_channels,
|
||||||
|
flow_rates,
|
||||||
|
offsets,
|
||||||
|
liquid_height,
|
||||||
|
blow_out_air_volume,
|
||||||
|
"custom",
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
res_samples = []
|
res_samples = []
|
||||||
res_volumes = []
|
res_volumes = []
|
||||||
for resource, volume, channel in zip(resources, vols, use_channels):
|
for resource, volume, channel in zip(resources, vols, use_channels):
|
||||||
res_uuid = self.pending_liquids_dict[channel]["sample_uuid"]
|
res_uuid = self.pending_liquids_dict[channel][EXTRA_SAMPLE_UUID]
|
||||||
self.pending_liquids_dict[channel]["volume"] -= volume
|
self.pending_liquids_dict[channel]["volume"] -= volume
|
||||||
resource.unilabos_extra["sample_uuid"] = res_uuid
|
resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid
|
||||||
res_samples.append({"name": resource.name, "sample_uuid": res_uuid})
|
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
|
||||||
res_volumes.append(volume)
|
res_volumes.append(volume)
|
||||||
|
|
||||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||||
@@ -691,18 +733,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def set_liquid_from_plate(
|
def set_liquid_from_plate(
|
||||||
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float]
|
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
|
||||||
) -> SetLiquidFromPlateReturn:
|
) -> SetLiquidFromPlateReturn:
|
||||||
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
|
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
|
||||||
|
|
||||||
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
|
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
|
||||||
"""
|
"""
|
||||||
if isinstance(plate, list): # 未来移除
|
assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack) , f"plate must be a Plate, now: {type(plate)}"
|
||||||
plate = plate[0]
|
plate: Union[Plate, TubeRack]
|
||||||
assert issubclass(plate.__class__, Plate), "plate must be a Plate"
|
|
||||||
plate: Plate = cast(Plate, plate)
|
|
||||||
# 根据 well_names 获取对应的 Well 对象
|
# 根据 well_names 获取对应的 Well 对象
|
||||||
wells = [plate.get_well(name) for name in well_names]
|
if issubclass(plate.__class__, Plate):
|
||||||
|
wells = [plate.get_well(name) for name in well_names]
|
||||||
|
elif issubclass(plate.__class__, TubeRack):
|
||||||
|
wells = [plate.get_tube(name) for name in well_names]
|
||||||
res_volumes = []
|
res_volumes = []
|
||||||
|
|
||||||
# 如果 liquid_names 和 volumes 都为空,直接返回
|
# 如果 liquid_names 和 volumes 都为空,直接返回
|
||||||
@@ -848,7 +891,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for _ in range(len(sources)):
|
for _ in range(len(sources)):
|
||||||
tip = []
|
tip = []
|
||||||
for __ in range(len(use_channels)):
|
for __ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
resources=[sources[_]],
|
resources=[sources[_]],
|
||||||
@@ -888,7 +931,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for i in range(0, len(sources), 8):
|
for i in range(0, len(sources), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
current_targets = waste_liquid[i : i + 8]
|
current_targets = waste_liquid[i : i + 8]
|
||||||
current_reagent_sources = sources[i : i + 8]
|
current_reagent_sources = sources[i : i + 8]
|
||||||
@@ -982,7 +1025,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for _ in range(len(targets)):
|
for _ in range(len(targets)):
|
||||||
tip = []
|
tip = []
|
||||||
for x in range(len(use_channels)):
|
for x in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
@@ -1034,7 +1077,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for i in range(0, len(targets), 8):
|
for i in range(0, len(targets), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
current_targets = targets[i : i + 8]
|
current_targets = targets[i : i + 8]
|
||||||
current_reagent_sources = reagent_sources[i : i + 8]
|
current_reagent_sources = reagent_sources[i : i + 8]
|
||||||
@@ -1159,11 +1202,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
Number of mix cycles. If *None* (default) no mixing occurs regardless of
|
Number of mix cycles. If *None* (default) no mixing occurs regardless of
|
||||||
mix_stage.
|
mix_stage.
|
||||||
"""
|
"""
|
||||||
|
num_sources = len(sources)
|
||||||
|
num_targets = len(targets)
|
||||||
|
len_asp_vols = len(asp_vols)
|
||||||
|
len_dis_vols = len(dis_vols)
|
||||||
# 确保 use_channels 有默认值
|
# 确保 use_channels 有默认值
|
||||||
if use_channels is None:
|
if use_channels is None:
|
||||||
# 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7)
|
# 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7)
|
||||||
use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0]
|
use_channels = list(range(self.channel_num)) if self.channel_num == 8 else [0]
|
||||||
|
elif len(use_channels) == 8:
|
||||||
|
if self.channel_num != 8:
|
||||||
|
raise ValueError(f"if channel_num is 8, use_channels length must be 8, but got {len(use_channels)}")
|
||||||
|
if num_sources%8 != 0 or num_targets%8 != 0 or len_asp_vols%8 != 0 or len_dis_vols%8 != 0:
|
||||||
|
raise ValueError(f"if channel_num is 8, sources, targets, asp_vols, and dis_vols length must be divisible by 8, but got {num_sources}, {num_targets}, {len_asp_vols}, and {len_dis_vols}")
|
||||||
|
|
||||||
if is_96_well:
|
if is_96_well:
|
||||||
pass # This mode is not verified.
|
pass # This mode is not verified.
|
||||||
@@ -1197,86 +1248,227 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
# 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix)
|
# 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix)
|
||||||
num_sources = len(sources)
|
num_sources = len(sources)
|
||||||
num_targets = len(targets)
|
num_targets = len(targets)
|
||||||
|
len_asp_vols = len(asp_vols)
|
||||||
|
len_dis_vols = len(dis_vols)
|
||||||
|
|
||||||
if num_sources == 1 and num_targets > 1:
|
# if num_targets != 1 and num_sources != 1:
|
||||||
# 模式1: 一对多 (1 source -> N targets)
|
# if len_asp_vols != num_sources and len_asp_vols != num_targets:
|
||||||
await self._transfer_one_to_many(
|
# raise ValueError(f"asp_vols length must be equal to sources or targets length, but got {len_asp_vols} and {num_sources} and {num_targets}")
|
||||||
sources[0],
|
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
|
||||||
targets,
|
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
|
||||||
tip_racks,
|
|
||||||
use_channels,
|
if len(use_channels) != 8:
|
||||||
asp_vols,
|
max_len = max(num_sources, num_targets)
|
||||||
dis_vols,
|
for i in range(max_len):
|
||||||
asp_flow_rates,
|
|
||||||
dis_flow_rates,
|
# 辅助函数:安全地从列表中获取元素,如果列表为空则返回None
|
||||||
offsets,
|
def safe_get(lst, idx, default=None):
|
||||||
touch_tip,
|
return [lst[idx]] if lst else default
|
||||||
liquid_height,
|
|
||||||
blow_out_air_volume,
|
# 动态构建参数字典,只传递实际提供的参数
|
||||||
spread,
|
kwargs = {
|
||||||
mix_stage,
|
'sources': [sources[i%num_sources]],
|
||||||
mix_times,
|
'targets': [targets[i%num_targets]],
|
||||||
mix_vol,
|
'tip_racks': tip_racks,
|
||||||
mix_rate,
|
'use_channels': use_channels,
|
||||||
mix_liquid_height,
|
'asp_vols': [asp_vols[i%len_asp_vols]],
|
||||||
delays,
|
'dis_vols': [dis_vols[i%len_dis_vols]],
|
||||||
)
|
}
|
||||||
elif num_sources > 1 and num_targets == 1:
|
|
||||||
# 模式2: 多对一 (N sources -> 1 target)
|
# 条件性添加可选参数
|
||||||
await self._transfer_many_to_one(
|
if asp_flow_rates is not None:
|
||||||
sources,
|
kwargs['asp_flow_rates'] = [asp_flow_rates[i%len_asp_vols]]
|
||||||
targets[0],
|
if dis_flow_rates is not None:
|
||||||
tip_racks,
|
kwargs['dis_flow_rates'] = [dis_flow_rates[i%len_dis_vols]]
|
||||||
use_channels,
|
if offsets is not None:
|
||||||
asp_vols,
|
kwargs['offsets'] = safe_get(offsets, i)
|
||||||
dis_vols,
|
if touch_tip is not None:
|
||||||
asp_flow_rates,
|
kwargs['touch_tip'] = touch_tip if touch_tip else False
|
||||||
dis_flow_rates,
|
if liquid_height is not None:
|
||||||
offsets,
|
kwargs['liquid_height'] = safe_get(liquid_height, i)
|
||||||
touch_tip,
|
if blow_out_air_volume is not None:
|
||||||
liquid_height,
|
kwargs['blow_out_air_volume'] = safe_get(blow_out_air_volume, i)
|
||||||
blow_out_air_volume,
|
if spread is not None:
|
||||||
spread,
|
kwargs['spread'] = spread
|
||||||
mix_stage,
|
if mix_stage is not None:
|
||||||
mix_times,
|
kwargs['mix_stage'] = safe_get(mix_stage, i)
|
||||||
mix_vol,
|
if mix_times is not None:
|
||||||
mix_rate,
|
kwargs['mix_times'] = safe_get(mix_times, i)
|
||||||
mix_liquid_height,
|
if mix_vol is not None:
|
||||||
delays,
|
kwargs['mix_vol'] = safe_get(mix_vol, i)
|
||||||
)
|
if mix_rate is not None:
|
||||||
elif num_sources == num_targets:
|
kwargs['mix_rate'] = safe_get(mix_rate, i)
|
||||||
# 模式3: 一对一 (N sources -> N targets)
|
if mix_liquid_height is not None:
|
||||||
await self._transfer_one_to_one(
|
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i)
|
||||||
sources,
|
if delays is not None:
|
||||||
targets,
|
kwargs['delays'] = safe_get(delays, i)
|
||||||
tip_racks,
|
|
||||||
use_channels,
|
await self._transfer_base_method(**kwargs)
|
||||||
asp_vols,
|
|
||||||
dis_vols,
|
|
||||||
asp_flow_rates,
|
|
||||||
dis_flow_rates,
|
# if num_sources == 1 and num_targets > 1:
|
||||||
offsets,
|
# # 模式1: 一对多 (1 source -> N targets)
|
||||||
touch_tip,
|
# await self._transfer_one_to_many(
|
||||||
liquid_height,
|
# sources,
|
||||||
blow_out_air_volume,
|
# targets,
|
||||||
spread,
|
# tip_racks,
|
||||||
mix_stage,
|
# use_channels,
|
||||||
mix_times,
|
# asp_vols,
|
||||||
mix_vol,
|
# dis_vols,
|
||||||
mix_rate,
|
# asp_flow_rates,
|
||||||
mix_liquid_height,
|
# dis_flow_rates,
|
||||||
delays,
|
# offsets,
|
||||||
)
|
# touch_tip,
|
||||||
else:
|
# liquid_height,
|
||||||
raise ValueError(
|
# blow_out_air_volume,
|
||||||
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
# spread,
|
||||||
"Supported modes: 1->N, N->1, or N->N."
|
# mix_stage,
|
||||||
)
|
# mix_times,
|
||||||
|
# mix_vol,
|
||||||
|
# mix_rate,
|
||||||
|
# mix_liquid_height,
|
||||||
|
# delays,
|
||||||
|
# )
|
||||||
|
# elif num_sources > 1 and num_targets == 1:
|
||||||
|
# # 模式2: 多对一 (N sources -> 1 target)
|
||||||
|
# await self._transfer_many_to_one(
|
||||||
|
# sources,
|
||||||
|
# targets[0],
|
||||||
|
# tip_racks,
|
||||||
|
# use_channels,
|
||||||
|
# asp_vols,
|
||||||
|
# dis_vols,
|
||||||
|
# asp_flow_rates,
|
||||||
|
# dis_flow_rates,
|
||||||
|
# offsets,
|
||||||
|
# touch_tip,
|
||||||
|
# liquid_height,
|
||||||
|
# blow_out_air_volume,
|
||||||
|
# spread,
|
||||||
|
# mix_stage,
|
||||||
|
# mix_times,
|
||||||
|
# mix_vol,
|
||||||
|
# mix_rate,
|
||||||
|
# mix_liquid_height,
|
||||||
|
# delays,
|
||||||
|
# )
|
||||||
|
# elif num_sources == num_targets:
|
||||||
|
# # 模式3: 一对一 (N sources -> N targets)
|
||||||
|
# await self._transfer_one_to_one(
|
||||||
|
# sources,
|
||||||
|
# targets,
|
||||||
|
# tip_racks,
|
||||||
|
# use_channels,
|
||||||
|
# asp_vols,
|
||||||
|
# dis_vols,
|
||||||
|
# asp_flow_rates,
|
||||||
|
# dis_flow_rates,
|
||||||
|
# offsets,
|
||||||
|
# touch_tip,
|
||||||
|
# liquid_height,
|
||||||
|
# blow_out_air_volume,
|
||||||
|
# spread,
|
||||||
|
# mix_stage,
|
||||||
|
# mix_times,
|
||||||
|
# mix_vol,
|
||||||
|
# mix_rate,
|
||||||
|
# mix_liquid_height,
|
||||||
|
# delays,
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# raise ValueError(
|
||||||
|
# f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
||||||
|
# "Supported modes: 1->N, N->1, or N->N."
|
||||||
|
# )
|
||||||
|
|
||||||
return TransferLiquidReturn(
|
return TransferLiquidReturn(
|
||||||
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
||||||
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
|
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
|
||||||
)
|
)
|
||||||
|
async def _transfer_base_method(
|
||||||
|
self,
|
||||||
|
sources: Sequence[Container],
|
||||||
|
targets: Sequence[Container],
|
||||||
|
tip_racks: Sequence[TipRack],
|
||||||
|
use_channels: List[int],
|
||||||
|
asp_vols: List[float],
|
||||||
|
dis_vols: List[float],
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
|
||||||
|
# 从kwargs中提取参数,提供默认值
|
||||||
|
asp_flow_rates = kwargs.get('asp_flow_rates')
|
||||||
|
dis_flow_rates = kwargs.get('dis_flow_rates')
|
||||||
|
offsets = kwargs.get('offsets')
|
||||||
|
touch_tip = kwargs.get('touch_tip', False)
|
||||||
|
liquid_height = kwargs.get('liquid_height')
|
||||||
|
blow_out_air_volume = kwargs.get('blow_out_air_volume')
|
||||||
|
spread = kwargs.get('spread', 'wide')
|
||||||
|
mix_stage = kwargs.get('mix_stage')
|
||||||
|
mix_times = kwargs.get('mix_times')
|
||||||
|
mix_vol = kwargs.get('mix_vol')
|
||||||
|
mix_rate = kwargs.get('mix_rate')
|
||||||
|
mix_liquid_height = kwargs.get('mix_liquid_height')
|
||||||
|
delays = kwargs.get('delays')
|
||||||
|
|
||||||
|
tip = []
|
||||||
|
tip.extend(self._get_next_tip())
|
||||||
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
|
await self.mix(
|
||||||
|
targets=[targets[0]],
|
||||||
|
mix_time=mix_times,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
offsets=offsets if offsets else None,
|
||||||
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
|
use_channels=use_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.aspirate(
|
||||||
|
resources=[sources[0]],
|
||||||
|
vols=[asp_vols[0]],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
|
||||||
|
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
||||||
|
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
||||||
|
blow_out_air_volume=(
|
||||||
|
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||||
|
),
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
if delays is not None:
|
||||||
|
await self.custom_delay(seconds=delays[0])
|
||||||
|
await self.dispense(
|
||||||
|
resources=[targets[0]],
|
||||||
|
vols=[dis_vols[0]],
|
||||||
|
use_channels=use_channels,
|
||||||
|
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
|
||||||
|
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
||||||
|
blow_out_air_volume=(
|
||||||
|
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
|
||||||
|
),
|
||||||
|
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
||||||
|
spread=spread,
|
||||||
|
)
|
||||||
|
if delays is not None and len(delays) > 1:
|
||||||
|
await self.custom_delay(seconds=delays[1])
|
||||||
|
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||||
|
await self.mix(
|
||||||
|
targets=[targets[0]],
|
||||||
|
mix_time=mix_times,
|
||||||
|
mix_vol=mix_vol,
|
||||||
|
offsets=offsets if offsets else None,
|
||||||
|
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
|
||||||
|
mix_rate=mix_rate if mix_rate else None,
|
||||||
|
use_channels=use_channels,
|
||||||
|
)
|
||||||
|
if delays is not None and len(delays) > 1:
|
||||||
|
await self.custom_delay(seconds=delays[0])
|
||||||
|
await self.touch_tip(targets[0])
|
||||||
|
await self.discard_tips(use_channels=use_channels)
|
||||||
|
|
||||||
async def _transfer_one_to_one(
|
async def _transfer_one_to_one(
|
||||||
self,
|
self,
|
||||||
@@ -1315,11 +1507,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
if len(sources) != len(targets):
|
if len(sources) != len(targets):
|
||||||
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
|
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
|
||||||
|
|
||||||
if len(use_channels) == 1:
|
if len(use_channels) != 1:
|
||||||
for _ in range(len(targets)):
|
for _ in range(len(targets)):
|
||||||
tip = []
|
tip = []
|
||||||
for ___ in range(len(use_channels)):
|
for ___ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
@@ -1383,7 +1575,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for i in range(0, len(targets), 8):
|
for i in range(0, len(targets), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
current_targets = targets[i : i + 8]
|
current_targets = targets[i : i + 8]
|
||||||
current_reagent_sources = sources[i : i + 8]
|
current_reagent_sources = sources[i : i + 8]
|
||||||
@@ -1488,7 +1680,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
# 单通道模式:一次吸液,多次分液
|
# 单通道模式:一次吸液,多次分液
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
@@ -1560,7 +1752,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for i in range(0, len(targets), 8):
|
for i in range(0, len(targets), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
current_targets = targets[i : i + 8]
|
current_targets = targets[i : i + 8]
|
||||||
@@ -1699,7 +1891,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
await self.mix(
|
await self.mix(
|
||||||
@@ -1718,7 +1910,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for idx, source in enumerate(sources):
|
for idx, source in enumerate(sources):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
@@ -1801,7 +1993,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
await self.mix(
|
await self.mix(
|
||||||
@@ -1819,7 +2011,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for i in range(0, len(sources), 8):
|
for i in range(0, len(sources), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
tip.extend(next(self.current_tip))
|
tip.extend(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip)
|
await self.pick_up_tips(tip)
|
||||||
|
|
||||||
current_sources = sources[i : i + 8]
|
current_sources = sources[i : i + 8]
|
||||||
@@ -2019,7 +2211,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for rack in tip_racks:
|
for rack in tip_racks:
|
||||||
for tip in rack:
|
for tip in rack:
|
||||||
yield tip
|
yield tip
|
||||||
raise RuntimeError("Out of tips!")
|
# raise RuntimeError("Out of tips!")
|
||||||
|
|
||||||
|
def _get_next_tip(self):
|
||||||
|
"""从 current_tip 迭代器获取下一个 tip,耗尽时抛出明确错误而非 StopIteration"""
|
||||||
|
try:
|
||||||
|
return next(self.current_tip)
|
||||||
|
except StopIteration as e:
|
||||||
|
raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks: Sequence[TipRack]):
|
def set_tiprack(self, tip_racks: Sequence[TipRack]):
|
||||||
"""Set the tip racks for the liquid handler."""
|
"""Set the tip racks for the liquid handler."""
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from pylabrobot.resources import (
|
|||||||
Trash,
|
Trash,
|
||||||
PlateAdapter,
|
PlateAdapter,
|
||||||
TubeRack,
|
TubeRack,
|
||||||
|
create_homogeneous_resources,
|
||||||
)
|
)
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||||
@@ -55,7 +56,8 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
|||||||
TransferLiquidReturn,
|
TransferLiquidReturn,
|
||||||
)
|
)
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.resources.itemized_carrier import ItemizedCarrier
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
class PRCXIError(RuntimeError):
|
class PRCXIError(RuntimeError):
|
||||||
@@ -801,7 +803,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
return super().set_liquid(wells, liquid_names, volumes)
|
return super().set_liquid(wells, liquid_names, volumes)
|
||||||
|
|
||||||
def set_liquid_from_plate(
|
def set_liquid_from_plate(
|
||||||
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float]
|
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
|
||||||
) -> SetLiquidFromPlateReturn:
|
) -> SetLiquidFromPlateReturn:
|
||||||
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
|
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
|
||||||
|
|
||||||
@@ -925,29 +927,36 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
delays: Optional[List[int]] = None,
|
delays: Optional[List[int]] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
) -> TransferLiquidReturn:
|
) -> TransferLiquidReturn:
|
||||||
return await super().transfer_liquid(
|
if self.step_mode:
|
||||||
sources,
|
await self.create_protocol(f"step_mode_protocol_{time.time()}")
|
||||||
targets,
|
|
||||||
tip_racks,
|
res =await super().transfer_liquid(
|
||||||
use_channels=use_channels,
|
sources,
|
||||||
asp_vols=asp_vols,
|
targets,
|
||||||
dis_vols=dis_vols,
|
tip_racks,
|
||||||
asp_flow_rates=asp_flow_rates,
|
use_channels=use_channels,
|
||||||
dis_flow_rates=dis_flow_rates,
|
asp_vols=asp_vols,
|
||||||
offsets=offsets,
|
dis_vols=dis_vols,
|
||||||
touch_tip=touch_tip,
|
asp_flow_rates=asp_flow_rates,
|
||||||
liquid_height=liquid_height,
|
dis_flow_rates=dis_flow_rates,
|
||||||
blow_out_air_volume=blow_out_air_volume,
|
offsets=offsets,
|
||||||
spread=spread,
|
touch_tip=touch_tip,
|
||||||
is_96_well=is_96_well,
|
liquid_height=liquid_height,
|
||||||
mix_stage=mix_stage,
|
blow_out_air_volume=blow_out_air_volume,
|
||||||
mix_times=mix_times,
|
spread=spread,
|
||||||
mix_vol=mix_vol,
|
is_96_well=is_96_well,
|
||||||
mix_rate=mix_rate,
|
mix_stage=mix_stage,
|
||||||
mix_liquid_height=mix_liquid_height,
|
mix_times=mix_times,
|
||||||
delays=delays,
|
mix_vol=mix_vol,
|
||||||
none_keys=none_keys,
|
mix_rate=mix_rate,
|
||||||
|
mix_liquid_height=mix_liquid_height,
|
||||||
|
delays=delays,
|
||||||
|
none_keys=none_keys,
|
||||||
)
|
)
|
||||||
|
if self.step_mode:
|
||||||
|
await self.run_protocol()
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
async def custom_delay(self, seconds=0, msg=None):
|
async def custom_delay(self, seconds=0, msg=None):
|
||||||
return await super().custom_delay(seconds, msg)
|
return await super().custom_delay(seconds, msg)
|
||||||
@@ -997,18 +1006,32 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
return await super().aspirate(
|
return await super().aspirate(
|
||||||
resources,
|
resources,
|
||||||
vols,
|
vols,
|
||||||
use_channels,
|
use_channels,
|
||||||
flow_rates,
|
flow_rates,
|
||||||
offsets,
|
offsets,
|
||||||
liquid_height,
|
liquid_height,
|
||||||
blow_out_air_volume,
|
blow_out_air_volume,
|
||||||
spread,
|
spread,
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||||
|
return await super().aspirate(
|
||||||
|
resources,
|
||||||
|
vols,
|
||||||
|
use_channels,
|
||||||
|
flow_rates,
|
||||||
|
offsets,
|
||||||
|
liquid_height,
|
||||||
|
blow_out_air_volume,
|
||||||
|
spread="custom",
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def drop_tips(
|
async def drop_tips(
|
||||||
self,
|
self,
|
||||||
@@ -1032,17 +1055,33 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||||
**backend_kwargs,
|
**backend_kwargs,
|
||||||
):
|
):
|
||||||
return await super().dispense(
|
try:
|
||||||
resources,
|
return await super().dispense(
|
||||||
vols,
|
resources,
|
||||||
use_channels,
|
vols,
|
||||||
flow_rates,
|
use_channels,
|
||||||
offsets,
|
flow_rates,
|
||||||
liquid_height,
|
offsets,
|
||||||
blow_out_air_volume,
|
liquid_height,
|
||||||
spread,
|
blow_out_air_volume,
|
||||||
**backend_kwargs,
|
spread,
|
||||||
)
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
if "Resource is too small to space channels" in str(e) and spread != "custom":
|
||||||
|
# 目标资源过小无法分布多通道时,退化为 custom(所有通道对准中心)
|
||||||
|
return await super().dispense(
|
||||||
|
resources,
|
||||||
|
vols,
|
||||||
|
use_channels,
|
||||||
|
flow_rates,
|
||||||
|
offsets,
|
||||||
|
liquid_height,
|
||||||
|
blow_out_air_volume,
|
||||||
|
"custom",
|
||||||
|
**backend_kwargs,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def discard_tips(
|
async def discard_tips(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ class VirtualTransferPump:
|
|||||||
|
|
||||||
# 从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"
|
||||||
@@ -54,7 +54,9 @@ class VirtualTransferPump:
|
|||||||
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):
|
||||||
@@ -189,7 +191,9 @@ class VirtualTransferPump:
|
|||||||
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")
|
||||||
|
|
||||||
@@ -207,7 +211,11 @@ class VirtualTransferPump:
|
|||||||
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:
|
||||||
@@ -244,7 +252,9 @@ class VirtualTransferPump:
|
|||||||
|
|
||||||
# 📊 最终状态日志
|
# 📊 最终状态日志
|
||||||
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 {
|
||||||
@@ -252,7 +262,7 @@ class VirtualTransferPump:
|
|||||||
"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:
|
||||||
@@ -262,7 +272,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 其他泵操作方法
|
# 其他泵操作方法
|
||||||
@@ -388,7 +398,9 @@ class VirtualTransferPump:
|
|||||||
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__()
|
||||||
|
|||||||
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", "")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
|||||||
@@ -4134,19 +4134,8 @@ liquid_handler:
|
|||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources
|
||||||
label: sources
|
label: 待移动液体
|
||||||
- data_key: targets
|
- data_key: targets
|
||||||
data_source: handle
|
|
||||||
data_type: resource
|
|
||||||
handler_key: targets
|
|
||||||
label: targets
|
|
||||||
- data_key: tip_racks
|
|
||||||
data_source: handle
|
|
||||||
data_type: resource
|
|
||||||
handler_key: tip_racks
|
|
||||||
label: tip_racks
|
|
||||||
output:
|
|
||||||
- data_key: sources
|
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets
|
handler_key: targets
|
||||||
@@ -4161,9 +4150,9 @@ liquid_handler:
|
|||||||
data_source: executor
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources_out
|
handler_key: sources_out
|
||||||
label: sources
|
label: 移液后源孔
|
||||||
- data_key: targets
|
- data_key: targets.@flatten
|
||||||
data_source: handle
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets_out
|
handler_key: targets_out
|
||||||
label: 移液后目标孔
|
label: 移液后目标孔
|
||||||
@@ -4812,13 +4801,13 @@ liquid_handler.biomek:
|
|||||||
targets: ''
|
targets: ''
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: sources
|
- data_key: liquid
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources
|
||||||
label: sources
|
label: sources
|
||||||
output:
|
output:
|
||||||
- data_key: targets
|
- data_key: liquid
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets
|
handler_key: targets
|
||||||
@@ -4971,21 +4960,21 @@ liquid_handler.biomek:
|
|||||||
volume: 0.0
|
volume: 0.0
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: sources
|
- data_key: liquid
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources
|
||||||
label: sources
|
label: sources
|
||||||
- data_key: targets
|
- data_key: liquid
|
||||||
data_source: handle
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets
|
handler_key: targets
|
||||||
label: targets
|
label: targets
|
||||||
- data_key: tip_racks
|
- data_key: liquid
|
||||||
data_source: handle
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: tip_racks
|
handler_key: tip_rack
|
||||||
label: tip_racks
|
label: tip_rack
|
||||||
output:
|
output:
|
||||||
- data_key: sources
|
- data_key: sources
|
||||||
data_source: handle
|
data_source: handle
|
||||||
@@ -5166,28 +5155,30 @@ liquid_handler.biomek:
|
|||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources
|
||||||
label: sources
|
io_type: target
|
||||||
|
label: 待移动液体
|
||||||
- data_key: targets
|
- data_key: targets
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets
|
handler_key: targets
|
||||||
label: targets
|
label: 转移目标
|
||||||
- data_key: tip_racks
|
- data_key: tip_racks
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: tip_racks
|
handler_key: tip_rack
|
||||||
label: tip_racks
|
label: 枪头盒
|
||||||
output:
|
output:
|
||||||
- data_key: sources
|
- 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
|
io_type: source
|
||||||
- data_key: targets
|
label: 移液后源孔
|
||||||
data_source: handle
|
- data_key: targets.@flatten
|
||||||
|
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
|
||||||
@@ -7735,31 +7726,6 @@ liquid_handler.prcxi:
|
|||||||
title: move_to参数
|
title: move_to参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommandAsync
|
type: UniLabJsonCommandAsync
|
||||||
auto-plr_pos_to_prcxi:
|
|
||||||
feedback: {}
|
|
||||||
goal: {}
|
|
||||||
goal_default:
|
|
||||||
resource: null
|
|
||||||
handles: {}
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: ''
|
|
||||||
properties:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
resource:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- resource
|
|
||||||
type: object
|
|
||||||
result: {}
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: plr_pos_to_prcxi参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommand
|
|
||||||
auto-post_init:
|
auto-post_init:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -8655,19 +8621,7 @@ liquid_handler.prcxi:
|
|||||||
z: 0.0
|
z: 0.0
|
||||||
sample_id: ''
|
sample_id: ''
|
||||||
type: ''
|
type: ''
|
||||||
handles:
|
handles: {}
|
||||||
input:
|
|
||||||
- data_key: plate
|
|
||||||
data_source: handle
|
|
||||||
data_type: resource
|
|
||||||
handler_key: plate
|
|
||||||
label: plate
|
|
||||||
output:
|
|
||||||
- data_key: plate
|
|
||||||
data_source: handle
|
|
||||||
data_type: resource
|
|
||||||
handler_key: plate
|
|
||||||
label: plate
|
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
plate: unilabos_resources
|
plate: unilabos_resources
|
||||||
to: unilabos_resources
|
to: unilabos_resources
|
||||||
@@ -9592,7 +9546,7 @@ liquid_handler.prcxi:
|
|||||||
well_names: null
|
well_names: null
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
- data_key: plate
|
- data_key: '@this.0@@@plate'
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: input_plate
|
handler_key: input_plate
|
||||||
@@ -9627,81 +9581,78 @@ liquid_handler.prcxi:
|
|||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
plate:
|
plate:
|
||||||
items:
|
properties:
|
||||||
properties:
|
category:
|
||||||
category:
|
type: string
|
||||||
|
children:
|
||||||
|
items:
|
||||||
type: string
|
type: string
|
||||||
children:
|
type: array
|
||||||
items:
|
config:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
data:
|
||||||
config:
|
type: string
|
||||||
type: string
|
id:
|
||||||
data:
|
type: string
|
||||||
type: string
|
name:
|
||||||
id:
|
type: string
|
||||||
type: string
|
parent:
|
||||||
name:
|
type: string
|
||||||
type: string
|
pose:
|
||||||
parent:
|
properties:
|
||||||
type: string
|
orientation:
|
||||||
pose:
|
properties:
|
||||||
properties:
|
w:
|
||||||
orientation:
|
type: number
|
||||||
properties:
|
x:
|
||||||
w:
|
type: number
|
||||||
type: number
|
y:
|
||||||
x:
|
type: number
|
||||||
type: number
|
z:
|
||||||
y:
|
type: number
|
||||||
type: number
|
required:
|
||||||
z:
|
- x
|
||||||
type: number
|
- y
|
||||||
required:
|
- z
|
||||||
- x
|
- w
|
||||||
- y
|
title: orientation
|
||||||
- z
|
type: object
|
||||||
- w
|
position:
|
||||||
title: orientation
|
properties:
|
||||||
type: object
|
x:
|
||||||
position:
|
type: number
|
||||||
properties:
|
y:
|
||||||
x:
|
type: number
|
||||||
type: number
|
z:
|
||||||
y:
|
type: number
|
||||||
type: number
|
required:
|
||||||
z:
|
- x
|
||||||
type: number
|
- y
|
||||||
required:
|
- z
|
||||||
- x
|
title: position
|
||||||
- y
|
type: object
|
||||||
- z
|
required:
|
||||||
title: position
|
- position
|
||||||
type: object
|
- orientation
|
||||||
required:
|
title: pose
|
||||||
- position
|
type: object
|
||||||
- orientation
|
sample_id:
|
||||||
title: pose
|
type: string
|
||||||
type: object
|
type:
|
||||||
sample_id:
|
type: string
|
||||||
type: string
|
required:
|
||||||
type:
|
- id
|
||||||
type: string
|
- name
|
||||||
required:
|
- sample_id
|
||||||
- id
|
- children
|
||||||
- name
|
- parent
|
||||||
- sample_id
|
- type
|
||||||
- children
|
- category
|
||||||
- parent
|
- pose
|
||||||
- type
|
- config
|
||||||
- category
|
- data
|
||||||
- pose
|
|
||||||
- config
|
|
||||||
- data
|
|
||||||
title: plate
|
|
||||||
type: object
|
|
||||||
title: plate
|
title: plate
|
||||||
type: array
|
type: object
|
||||||
volumes:
|
volumes:
|
||||||
items:
|
items:
|
||||||
type: number
|
type: number
|
||||||
@@ -9717,17 +9668,207 @@ liquid_handler.prcxi:
|
|||||||
- volumes
|
- volumes
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
|
$defs:
|
||||||
|
ResourceDict:
|
||||||
|
properties:
|
||||||
|
class:
|
||||||
|
description: Resource class name
|
||||||
|
title: Class
|
||||||
|
type: string
|
||||||
|
config:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource configuration
|
||||||
|
title: Config
|
||||||
|
type: object
|
||||||
|
data:
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Resource data, eg: container liquid data'
|
||||||
|
title: Data
|
||||||
|
type: object
|
||||||
|
description:
|
||||||
|
default: ''
|
||||||
|
description: Resource description
|
||||||
|
title: Description
|
||||||
|
type: string
|
||||||
|
extra:
|
||||||
|
additionalProperties: true
|
||||||
|
description: 'Extra data, eg: slot index'
|
||||||
|
title: Extra
|
||||||
|
type: object
|
||||||
|
icon:
|
||||||
|
default: ''
|
||||||
|
description: Resource icon
|
||||||
|
title: Icon
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
description: Resource ID
|
||||||
|
title: Id
|
||||||
|
type: string
|
||||||
|
model:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource model
|
||||||
|
title: Model
|
||||||
|
type: object
|
||||||
|
name:
|
||||||
|
description: Resource name
|
||||||
|
title: Name
|
||||||
|
type: string
|
||||||
|
parent:
|
||||||
|
anyOf:
|
||||||
|
- $ref: '#/$defs/ResourceDict'
|
||||||
|
- type: 'null'
|
||||||
|
default: null
|
||||||
|
description: Parent resource object
|
||||||
|
parent_uuid:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
default: null
|
||||||
|
description: Parent resource uuid
|
||||||
|
title: Parent Uuid
|
||||||
|
pose:
|
||||||
|
$ref: '#/$defs/ResourceDictPosition'
|
||||||
|
description: Resource position
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
description: Resource schema
|
||||||
|
title: Schema
|
||||||
|
type: object
|
||||||
|
type:
|
||||||
|
anyOf:
|
||||||
|
- const: device
|
||||||
|
type: string
|
||||||
|
- type: string
|
||||||
|
description: Resource type
|
||||||
|
title: Type
|
||||||
|
uuid:
|
||||||
|
description: Resource UUID
|
||||||
|
title: Uuid
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- uuid
|
||||||
|
- name
|
||||||
|
- type
|
||||||
|
- class
|
||||||
|
- config
|
||||||
|
- data
|
||||||
|
- extra
|
||||||
|
title: ResourceDict
|
||||||
|
type: object
|
||||||
|
ResourceDictPosition:
|
||||||
|
properties:
|
||||||
|
cross_section_type:
|
||||||
|
default: rectangle
|
||||||
|
description: Cross section type
|
||||||
|
enum:
|
||||||
|
- rectangle
|
||||||
|
- circle
|
||||||
|
- rounded_rectangle
|
||||||
|
title: Cross Section Type
|
||||||
|
type: string
|
||||||
|
layout:
|
||||||
|
default: x-y
|
||||||
|
description: Resource layout
|
||||||
|
enum:
|
||||||
|
- 2d
|
||||||
|
- x-y
|
||||||
|
- z-y
|
||||||
|
- x-z
|
||||||
|
title: Layout
|
||||||
|
type: string
|
||||||
|
position:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource position
|
||||||
|
position3d:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource position in 3D space
|
||||||
|
rotation:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionObject'
|
||||||
|
description: Resource rotation
|
||||||
|
scale:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionScale'
|
||||||
|
description: Resource scale
|
||||||
|
size:
|
||||||
|
$ref: '#/$defs/ResourceDictPositionSize'
|
||||||
|
description: Resource size
|
||||||
|
title: ResourceDictPosition
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionObject:
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
default: 0.0
|
||||||
|
description: X coordinate
|
||||||
|
title: X
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
default: 0.0
|
||||||
|
description: Y coordinate
|
||||||
|
title: Y
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
default: 0.0
|
||||||
|
description: Z coordinate
|
||||||
|
title: Z
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionObject
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionScale:
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
default: 0.0
|
||||||
|
description: x scale
|
||||||
|
title: X
|
||||||
|
type: number
|
||||||
|
y:
|
||||||
|
default: 0.0
|
||||||
|
description: y scale
|
||||||
|
title: Y
|
||||||
|
type: number
|
||||||
|
z:
|
||||||
|
default: 0.0
|
||||||
|
description: z scale
|
||||||
|
title: Z
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionScale
|
||||||
|
type: object
|
||||||
|
ResourceDictPositionSize:
|
||||||
|
properties:
|
||||||
|
depth:
|
||||||
|
default: 0.0
|
||||||
|
description: Depth
|
||||||
|
title: Depth
|
||||||
|
type: number
|
||||||
|
height:
|
||||||
|
default: 0.0
|
||||||
|
description: Height
|
||||||
|
title: Height
|
||||||
|
type: number
|
||||||
|
width:
|
||||||
|
default: 0.0
|
||||||
|
description: Width
|
||||||
|
title: Width
|
||||||
|
type: number
|
||||||
|
title: ResourceDictPositionSize
|
||||||
|
type: object
|
||||||
properties:
|
properties:
|
||||||
plate:
|
plate:
|
||||||
items: {}
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/ResourceDict'
|
||||||
|
type: array
|
||||||
title: Plate
|
title: Plate
|
||||||
type: array
|
type: array
|
||||||
volumes:
|
volumes:
|
||||||
items: {}
|
items:
|
||||||
|
type: number
|
||||||
title: Volumes
|
title: Volumes
|
||||||
type: array
|
type: array
|
||||||
wells:
|
wells:
|
||||||
items: {}
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/$defs/ResourceDict'
|
||||||
|
type: array
|
||||||
title: Wells
|
title: Wells
|
||||||
type: array
|
type: array
|
||||||
required:
|
required:
|
||||||
@@ -10089,26 +10230,26 @@ liquid_handler.prcxi:
|
|||||||
- data_key: sources
|
- data_key: sources
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: sources
|
handler_key: sources_identifier
|
||||||
label: sources
|
label: 待移动液体
|
||||||
- data_key: targets
|
- data_key: targets
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets
|
handler_key: targets_identifier
|
||||||
label: targets
|
label: 转移目标
|
||||||
- data_key: tip_racks
|
- data_key: tip_racks
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: tip_racks
|
handler_key: tip_rack_identifier
|
||||||
label: tip_racks
|
label: 枪头盒
|
||||||
output:
|
output:
|
||||||
- data_key: sources
|
- 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: targets
|
- data_key: targets.@flatten
|
||||||
data_source: handle
|
data_source: executor
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: targets_out
|
handler_key: targets_out
|
||||||
label: 移液后目标孔
|
label: 移液后目标孔
|
||||||
@@ -10492,12 +10633,6 @@ liquid_handler.prcxi:
|
|||||||
type: string
|
type: string
|
||||||
deck:
|
deck:
|
||||||
type: object
|
type: object
|
||||||
deck_y:
|
|
||||||
default: 400
|
|
||||||
type: string
|
|
||||||
deck_z:
|
|
||||||
default: 300
|
|
||||||
type: string
|
|
||||||
host:
|
host:
|
||||||
type: string
|
type: string
|
||||||
is_9320:
|
is_9320:
|
||||||
@@ -10508,44 +10643,17 @@ liquid_handler.prcxi:
|
|||||||
type: string
|
type: string
|
||||||
port:
|
port:
|
||||||
type: integer
|
type: integer
|
||||||
rail_interval:
|
|
||||||
default: 0
|
|
||||||
type: string
|
|
||||||
rail_nums:
|
|
||||||
default: 4
|
|
||||||
type: string
|
|
||||||
rail_width:
|
|
||||||
default: 27.5
|
|
||||||
type: string
|
|
||||||
setup:
|
setup:
|
||||||
default: true
|
default: true
|
||||||
type: string
|
type: string
|
||||||
simulator:
|
simulator:
|
||||||
default: false
|
default: false
|
||||||
type: string
|
type: string
|
||||||
start_rail:
|
|
||||||
default: 2
|
|
||||||
type: string
|
|
||||||
step_mode:
|
step_mode:
|
||||||
default: false
|
default: false
|
||||||
type: string
|
type: string
|
||||||
timeout:
|
timeout:
|
||||||
type: number
|
type: number
|
||||||
x_increase:
|
|
||||||
default: -0.003636
|
|
||||||
type: string
|
|
||||||
x_offset:
|
|
||||||
default: -0.8
|
|
||||||
type: string
|
|
||||||
xy_coupling:
|
|
||||||
default: -0.0045
|
|
||||||
type: string
|
|
||||||
y_increase:
|
|
||||||
default: -0.003636
|
|
||||||
type: string
|
|
||||||
y_offset:
|
|
||||||
default: -37.98
|
|
||||||
type: string
|
|
||||||
required:
|
required:
|
||||||
- deck
|
- deck
|
||||||
- host
|
- host
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import sys
|
|||||||
import inspect
|
import inspect
|
||||||
import importlib
|
import importlib
|
||||||
import threading
|
import threading
|
||||||
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
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
|
||||||
@@ -88,6 +89,14 @@ class Registry:
|
|||||||
)
|
)
|
||||||
test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。"
|
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": {
|
||||||
@@ -189,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",
|
||||||
@@ -838,6 +822,7 @@ class Registry:
|
|||||||
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
**({"always_free": True} if v.get("always_free") else {}),
|
||||||
}
|
}
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
if k not in device_config["class"]["action_value_mappings"]
|
if k not in device_config["class"]["action_value_mappings"]
|
||||||
@@ -943,6 +928,7 @@ class Registry:
|
|||||||
if is_valid:
|
if is_valid:
|
||||||
results.append((file, data, device_ids))
|
results.append((file, data, device_ids))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
|
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
|
||||||
|
|
||||||
# 线程安全地更新注册表
|
# 线程安全地更新注册表
|
||||||
|
|||||||
6884
unilabos/registry/resources/opentrons/lab.yaml
Normal file
6884
unilabos/registry/resources/opentrons/lab.yaml
Normal file
File diff suppressed because it is too large
Load Diff
3681
unilabos/resources/lab_resources.py
Normal file
3681
unilabos/resources/lab_resources.py
Normal file
File diff suppressed because it is too large
Load Diff
1
unilabos/resources/opentrons_custom_labware_defs.json
Normal file
1
unilabos/resources/opentrons_custom_labware_defs.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|
||||||
@@ -14,6 +16,32 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
EXTRA_CLASS = "unilabos_resource_class"
|
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):
|
||||||
@@ -22,18 +50,40 @@ class ResourceDictPositionSize(BaseModel):
|
|||||||
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)
|
||||||
@@ -52,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")
|
||||||
@@ -446,10 +514,17 @@ class ResourceTreeSet(object):
|
|||||||
trees.append(tree_instance)
|
trees.append(tree_instance)
|
||||||
return cls(trees)
|
return cls(trees)
|
||||||
|
|
||||||
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
|
def to_plr_resources(
|
||||||
|
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
|
||||||
|
) -> List["PLRResource"]:
|
||||||
"""
|
"""
|
||||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip_devices: 是否跳过 device 类型节点
|
||||||
|
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
|
||||||
|
否则返回各树的根节点列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[PLRResource]: PLR 资源实例列表
|
List[PLRResource]: PLR 资源实例列表
|
||||||
"""
|
"""
|
||||||
@@ -504,6 +579,71 @@ class ResourceTreeSet(object):
|
|||||||
d["model"] = res.config.get("model", None)
|
d["model"] = res.config.get("model", None)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
# deserialize 会单独处理的元数据 key,不传给构造函数
|
||||||
|
_META_KEYS = {"type", "parent_name", "location", "children", "rotation", "barcode"}
|
||||||
|
# deserialize 自定义逻辑使用的 key(如 TipSpot 用 prototype_tip 构建 make_tip),需保留
|
||||||
|
_DESERIALIZE_PRESERVED_KEYS = {"prototype_tip"}
|
||||||
|
|
||||||
|
def remove_incompatible_params(plr_d: dict) -> None:
|
||||||
|
"""递归移除 PLR 类不接受的参数,避免 deserialize 报错。
|
||||||
|
- 移除构造函数不接受的参数(如 compute_height_from_volume、ordering、category)
|
||||||
|
- 对 TubeRack:将 ordering 转为 ordered_items
|
||||||
|
- 保留 deserialize 自定义逻辑需要的 key(如 prototype_tip)
|
||||||
|
"""
|
||||||
|
if "type" in plr_d:
|
||||||
|
sub_cls = find_subclass(plr_d["type"], PLRResource)
|
||||||
|
if sub_cls is not None:
|
||||||
|
spec = inspect.signature(sub_cls)
|
||||||
|
valid_params = set(spec.parameters.keys())
|
||||||
|
# TubeRack 特殊处理:先转换 ordering,再参与后续过滤
|
||||||
|
if "ordering" not in valid_params and "ordering" in plr_d:
|
||||||
|
ordering = plr_d.pop("ordering", None)
|
||||||
|
if sub_cls.__name__ == "TubeRack":
|
||||||
|
plr_d["ordered_items"] = (
|
||||||
|
_ordering_to_ordered_items(plr_d, ordering)
|
||||||
|
if ordering
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
# 移除构造函数不接受的参数(保留 META 和 deserialize 自定义逻辑需要的 key)
|
||||||
|
for key in list(plr_d.keys()):
|
||||||
|
if (
|
||||||
|
key not in _META_KEYS
|
||||||
|
and key not in _DESERIALIZE_PRESERVED_KEYS
|
||||||
|
and key not in valid_params
|
||||||
|
):
|
||||||
|
plr_d.pop(key, None)
|
||||||
|
for child in plr_d.get("children", []):
|
||||||
|
remove_incompatible_params(child)
|
||||||
|
|
||||||
|
def _ordering_to_ordered_items(plr_d: dict, ordering: dict) -> dict:
|
||||||
|
"""将 ordering 转为 ordered_items,从 children 构建 Tube 对象"""
|
||||||
|
from pylabrobot.resources import Tube, Coordinate
|
||||||
|
from pylabrobot.serializer import deserialize as plr_deserialize
|
||||||
|
|
||||||
|
children = plr_d.get("children", [])
|
||||||
|
ordered_items = {}
|
||||||
|
for idx, (ident, child_name) in enumerate(ordering.items()):
|
||||||
|
child_data = children[idx] if idx < len(children) else None
|
||||||
|
if child_data is None:
|
||||||
|
continue
|
||||||
|
loc_data = child_data.get("location")
|
||||||
|
loc = (
|
||||||
|
plr_deserialize(loc_data)
|
||||||
|
if loc_data
|
||||||
|
else Coordinate(0, 0, 0)
|
||||||
|
)
|
||||||
|
tube = Tube(
|
||||||
|
name=child_data.get("name", child_name or ident),
|
||||||
|
size_x=child_data.get("size_x", 10),
|
||||||
|
size_y=child_data.get("size_y", 10),
|
||||||
|
size_z=child_data.get("size_z", 50),
|
||||||
|
max_volume=child_data.get("max_volume", 1000),
|
||||||
|
)
|
||||||
|
tube.location = loc
|
||||||
|
ordered_items[ident] = tube
|
||||||
|
plr_d["children"] = [] # 已并入 ordered_items,避免重复反序列化
|
||||||
|
return ordered_items
|
||||||
|
|
||||||
plr_resources = []
|
plr_resources = []
|
||||||
tracker = DeviceNodeResourceTracker()
|
tracker = DeviceNodeResourceTracker()
|
||||||
|
|
||||||
@@ -523,12 +663,11 @@ class ResourceTreeSet(object):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
||||||
)
|
)
|
||||||
spec = inspect.signature(sub_cls)
|
remove_incompatible_params(plr_dict)
|
||||||
if "category" not in spec.parameters:
|
|
||||||
plr_dict.pop("category", None)
|
|
||||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||||
from pylabrobot.resources import Coordinate
|
from pylabrobot.resources import Coordinate
|
||||||
from pylabrobot.serializer import deserialize
|
from pylabrobot.serializer import deserialize
|
||||||
|
|
||||||
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||||
plr_resource.location = location
|
plr_resource.location = location
|
||||||
plr_resource.load_all_state(all_states)
|
plr_resource.load_all_state(all_states)
|
||||||
@@ -544,6 +683,18 @@ class ResourceTreeSet(object):
|
|||||||
logger.error(f"堆栈: {traceback.format_exc()}")
|
logger.error(f"堆栈: {traceback.format_exc()}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
if requested_uuids:
|
||||||
|
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
|
||||||
|
result = []
|
||||||
|
for uid in requested_uuids:
|
||||||
|
if uid in tracker.uuid_to_resources:
|
||||||
|
result.append(tracker.uuid_to_resources[uid])
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"请求的 UUID {uid} 在资源树中未找到。"
|
||||||
|
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
return plr_resources
|
return plr_resources
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
from ast import Try
|
|
||||||
import inspect
|
import inspect
|
||||||
import io
|
import io
|
||||||
import json
|
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
|
||||||
@@ -49,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
|
||||||
@@ -133,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],
|
||||||
@@ -186,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}")
|
||||||
|
|
||||||
@@ -218,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()}"
|
||||||
@@ -265,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],
|
||||||
@@ -286,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()
|
||||||
@@ -363,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()
|
||||||
@@ -390,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:
|
||||||
@@ -424,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()
|
||||||
@@ -445,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(
|
||||||
@@ -473,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
|
||||||
):
|
):
|
||||||
@@ -492,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:
|
||||||
@@ -509,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)
|
||||||
@@ -626,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":
|
||||||
@@ -813,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]]:
|
||||||
"""
|
"""
|
||||||
处理资源更新操作的内部函数
|
处理资源更新操作的内部函数
|
||||||
@@ -838,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
|
||||||
@@ -874,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]
|
||||||
@@ -912,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(
|
||||||
@@ -941,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":
|
||||||
@@ -963,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":
|
||||||
@@ -1112,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,
|
||||||
@@ -1335,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,35 +1389,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if uuid_indices:
|
if uuid_indices:
|
||||||
uuids = [item[1] for item in uuid_indices]
|
uuids = [item[1] for item in uuid_indices]
|
||||||
resource_tree = await self.get_resource(uuids)
|
resource_tree = await self.get_resource(uuids)
|
||||||
plr_resources = resource_tree.to_plr_resources()
|
plr_resources = resource_tree.to_plr_resources(requested_uuids=uuids)
|
||||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||||
plr_resource = plr_resources[i]
|
try:
|
||||||
|
plr_resource = plr_resources[i]
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在")
|
||||||
|
raise e
|
||||||
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
|
||||||
|
|
||||||
# 第二遍:批量查询有uuid的资源
|
|
||||||
if uuid_indices:
|
|
||||||
uuids = [item[1] for item in uuid_indices]
|
|
||||||
resource_tree = await self.get_resource(uuids)
|
|
||||||
plr_resources = resource_tree.to_plr_resources()
|
|
||||||
# 通过uuid查找对应的plr_resource
|
|
||||||
tracker = self.resource_tracker
|
|
||||||
for idx, uuid, resource_data in uuid_indices:
|
|
||||||
try:
|
|
||||||
plr_resource = tracker.loop_find_with_uuid(plr_resources, uuid)
|
|
||||||
if "sample_id" in resource_data:
|
|
||||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
|
||||||
queried_resources[idx] = plr_resource
|
|
||||||
except Exception as e:
|
|
||||||
self.lab_logger().error(f"资源查询失败: {e}\n{traceback.format_exc()}")
|
|
||||||
continue
|
|
||||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||||
|
|
||||||
# 通过资源跟踪器获取本地实例
|
# 通过资源跟踪器获取本地实例
|
||||||
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()
|
||||||
@@ -1411,8 +1446,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)
|
||||||
@@ -1432,9 +1471,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)
|
||||||
|
|
||||||
@@ -1501,13 +1542,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:
|
||||||
@@ -1559,20 +1605,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
|
||||||
@@ -1601,6 +1664,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||||
|
|
||||||
# todo: 默认反报送
|
# todo: 默认反报送
|
||||||
return function(**function_args)
|
return function(**function_args)
|
||||||
except KeyError as ex:
|
except KeyError as ex:
|
||||||
@@ -1621,14 +1685,16 @@ 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
|
||||||
@@ -1686,6 +1752,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(
|
||||||
@@ -1695,14 +1764,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
|
||||||
@@ -1792,6 +1877,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:
|
||||||
@@ -1868,6 +1962,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__")
|
||||||
@@ -1913,6 +2012,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:
|
||||||
@@ -1930,6 +2030,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,
|
||||||
@@ -1941,6 +2042,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,
|
||||||
@@ -1949,6 +2051,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
|
||||||
@@ -1959,6 +2062,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,21 +811,6 @@ 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().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}: {str(goal_msg)[:1000]}")
|
||||||
@@ -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,6 +1230,10 @@ 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().trace(f"[Host Node] Node info update request received: {request}")
|
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
|
||||||
try:
|
try:
|
||||||
@@ -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={},
|
||||||
|
|||||||
@@ -47,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],
|
||||||
@@ -62,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},
|
||||||
@@ -340,6 +342,8 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
||||||
# 获取父资源
|
# 获取父资源
|
||||||
res = self.resource_tracker.parent_resource(plr)
|
res = self.resource_tracker.parent_resource(plr)
|
||||||
|
if res is None:
|
||||||
|
res = plr
|
||||||
if id(res) not in seen:
|
if id(res) not in seen:
|
||||||
seen.add(id(res))
|
seen.add(id(res))
|
||||||
unique_resources.append(res)
|
unique_resources.append(res)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -339,13 +339,8 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"max_volume": 500.0,
|
|
||||||
"type": "RegularContainer",
|
"type": "RegularContainer",
|
||||||
"category": "container",
|
"category": "container"
|
||||||
"max_temp": 200.0,
|
|
||||||
"min_temp": -20.0,
|
|
||||||
"has_stirrer": true,
|
|
||||||
"has_heater": true
|
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"liquids": [],
|
"liquids": [],
|
||||||
@@ -769,9 +764,7 @@
|
|||||||
"size_y": 250,
|
"size_y": 250,
|
||||||
"size_z": 0,
|
"size_z": 0,
|
||||||
"type": "RegularContainer",
|
"type": "RegularContainer",
|
||||||
"category": "container",
|
"category": "container"
|
||||||
"reagent": "sodium_chloride",
|
|
||||||
"physical_state": "solid"
|
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"current_mass": 500.0,
|
"current_mass": 500.0,
|
||||||
@@ -792,14 +785,11 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"volume": 500.0,
|
|
||||||
"size_x": 600,
|
"size_x": 600,
|
||||||
"size_y": 250,
|
"size_y": 250,
|
||||||
"size_z": 0,
|
"size_z": 0,
|
||||||
"type": "RegularContainer",
|
"type": "RegularContainer",
|
||||||
"category": "container",
|
"category": "container"
|
||||||
"reagent": "sodium_carbonate",
|
|
||||||
"physical_state": "solid"
|
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"current_mass": 500.0,
|
"current_mass": 500.0,
|
||||||
@@ -820,14 +810,11 @@
|
|||||||
"z": 0
|
"z": 0
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"volume": 500.0,
|
|
||||||
"size_x": 650,
|
"size_x": 650,
|
||||||
"size_y": 250,
|
"size_y": 250,
|
||||||
"size_z": 0,
|
"size_z": 0,
|
||||||
"type": "RegularContainer",
|
"type": "RegularContainer",
|
||||||
"category": "container",
|
"category": "container"
|
||||||
"reagent": "magnesium_chloride",
|
|
||||||
"physical_state": "solid"
|
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"current_mass": 500.0,
|
"current_mass": 500.0,
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
"host": "10.20.30.184",
|
"host": "10.20.30.184",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": false,
|
"debug": false,
|
||||||
"setup": false,
|
"setup": true,
|
||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
"simulator": false,
|
"simulator": false,
|
||||||
|
"step_mode": false,
|
||||||
"channel_num": 2
|
"channel_num": 2
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
},
|
},
|
||||||
"host": "10.20.30.184",
|
"host": "10.20.30.184",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": true,
|
"debug": false,
|
||||||
"setup": true,
|
"setup": false,
|
||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
"simulator": true,
|
"simulator": false,
|
||||||
"channel_num": 2
|
"channel_num": 2
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
@@ -789,6 +789,47 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"data": {}
|
"data": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trash",
|
||||||
|
"name": "trash",
|
||||||
|
|
||||||
|
"children": [],
|
||||||
|
"parent": "T16",
|
||||||
|
"type": "trash",
|
||||||
|
"class": "",
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "PRCXI9300Trash",
|
||||||
|
"size_x": 127.5,
|
||||||
|
"size_y": 86,
|
||||||
|
"size_z": 10,
|
||||||
|
"rotation": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"type": "Rotation"
|
||||||
|
},
|
||||||
|
"category": "trash",
|
||||||
|
"model": null,
|
||||||
|
"barcode": null,
|
||||||
|
"max_volume": "Infinity",
|
||||||
|
"material_z_thickness": 0,
|
||||||
|
"compute_volume_from_height": null,
|
||||||
|
"compute_height_from_volume": null
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"liquids": [],
|
||||||
|
"pending_liquids": [],
|
||||||
|
"liquid_history": [],
|
||||||
|
"Material": {
|
||||||
|
"uuid": "730067cf07ae43849ddf4034299030e9"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"edges": []
|
"edges": []
|
||||||
|
|||||||
@@ -182,3 +182,94 @@ def get_all_subscriptions(instance) -> list:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return subscriptions
|
return subscriptions
|
||||||
|
|
||||||
|
|
||||||
|
def always_free(func: F) -> F:
|
||||||
|
"""
|
||||||
|
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||||
|
|
||||||
|
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||||
|
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class MyDriver:
|
||||||
|
@always_free
|
||||||
|
def query_status(self, param: str):
|
||||||
|
# 这个动作可以随时执行,不需要排队
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
def transfer(self, volume: float):
|
||||||
|
# 这个动作会按正常排队逻辑执行
|
||||||
|
pass
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- 可以与其他装饰器组合使用,@always_free 应放在最外层
|
||||||
|
- 仅影响 WebSocket 调度层的 busy/free 判断,不影响 ROS2 层
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def is_always_free(func) -> bool:
|
||||||
|
"""
|
||||||
|
检查函数是否被标记为永久闲置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 被检查的函数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果函数被 @always_free 装饰则返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
return getattr(func, "_is_always_free", False)
|
||||||
|
|
||||||
|
|
||||||
|
def not_action(func: F) -> F:
|
||||||
|
"""
|
||||||
|
标记方法为非动作的装饰器
|
||||||
|
|
||||||
|
用于装饰 driver 类中的方法,使其在 complete_registry 时不被识别为动作。
|
||||||
|
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class MyDriver:
|
||||||
|
@not_action
|
||||||
|
def helper_method(self):
|
||||||
|
# 这个方法不会被注册为动作
|
||||||
|
pass
|
||||||
|
|
||||||
|
def actual_action(self, param: str):
|
||||||
|
# 这个方法会被注册为动作
|
||||||
|
self.helper_method()
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- 可以与其他装饰器组合使用,@not_action 应放在最外层
|
||||||
|
- 仅影响 complete_registry 的动作识别,不影响方法的正常调用
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
# 在函数上附加标记
|
||||||
|
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return wrapper # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def is_not_action(func) -> bool:
|
||||||
|
"""
|
||||||
|
检查函数是否被标记为非动作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 被检查的函数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
如果函数被 @not_action 装饰则返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
return getattr(func, "_is_not_action", False)
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ __all__ = [
|
|||||||
|
|
||||||
from ast import Constant
|
from ast import Constant
|
||||||
|
|
||||||
|
from unilabos.resources.resource_tracker import PARAM_SAMPLE_UUIDS
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
|
from unilabos.utils.decorator import is_not_action, is_always_free
|
||||||
|
|
||||||
|
|
||||||
class ImportManager:
|
class ImportManager:
|
||||||
@@ -277,6 +279,9 @@ class ImportManager:
|
|||||||
elif not name.startswith("_"):
|
elif not name.startswith("_"):
|
||||||
# 其他非_开头的方法归类为action
|
# 其他非_开头的方法归类为action
|
||||||
method_info = self._analyze_method_signature(method)
|
method_info = self._analyze_method_signature(method)
|
||||||
|
# 检查是否被 @always_free 装饰器标记
|
||||||
|
if is_always_free(method):
|
||||||
|
method_info["always_free"] = True
|
||||||
result["action_methods"][name] = method_info
|
result["action_methods"][name] = method_info
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -331,16 +336,24 @@ class ImportManager:
|
|||||||
result["status_methods"][actual_name] = method_info
|
result["status_methods"][actual_name] = method_info
|
||||||
else:
|
else:
|
||||||
# 其他非_开头的方法归类为action
|
# 其他非_开头的方法归类为action
|
||||||
|
# 检查是否被 @always_free 装饰器标记
|
||||||
|
if self._is_always_free_method(node):
|
||||||
|
method_info["always_free"] = True
|
||||||
result["action_methods"][method_name] = method_info
|
result["action_methods"][method_name] = method_info
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _analyze_method_signature(self, method) -> Dict[str, Any]:
|
def _analyze_method_signature(self, method, skip_unilabos_params: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
分析方法签名,提取具体的命名参数信息
|
分析方法签名,提取具体的命名参数信息
|
||||||
|
|
||||||
注意:此方法会跳过*args和**kwargs,只提取具体的命名参数
|
注意:此方法会跳过*args和**kwargs,只提取具体的命名参数
|
||||||
这样可以确保通过**dict方式传参时的准确性
|
这样可以确保通过**dict方式传参时的准确性
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: 要分析的方法
|
||||||
|
skip_unilabos_params: 是否跳过 unilabos 系统参数(如 sample_uuids),
|
||||||
|
registry 补全时为 True,JsonCommand 执行时为 False
|
||||||
|
|
||||||
示例用法:
|
示例用法:
|
||||||
method_info = self._analyze_method_signature(some_method)
|
method_info = self._analyze_method_signature(some_method)
|
||||||
params = {"param1": "value1", "param2": "value2"}
|
params = {"param1": "value1", "param2": "value2"}
|
||||||
@@ -361,6 +374,10 @@ class ImportManager:
|
|||||||
if param.kind == param.VAR_KEYWORD: # **kwargs
|
if param.kind == param.VAR_KEYWORD: # **kwargs
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 跳过 sample_uuids 参数(由系统自动注入,registry 补全时跳过)
|
||||||
|
if skip_unilabos_params and param_name == PARAM_SAMPLE_UUIDS:
|
||||||
|
continue
|
||||||
|
|
||||||
is_required = param.default == inspect.Parameter.empty
|
is_required = param.default == inspect.Parameter.empty
|
||||||
if is_required:
|
if is_required:
|
||||||
num_required += 1
|
num_required += 1
|
||||||
@@ -450,6 +467,20 @@ class ImportManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _is_not_action_method(self, node: ast.FunctionDef) -> bool:
|
||||||
|
"""检查是否是@not_action装饰的方法"""
|
||||||
|
for decorator in node.decorator_list:
|
||||||
|
if isinstance(decorator, ast.Name) and decorator.id == "not_action":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_always_free_method(self, node: ast.FunctionDef) -> bool:
|
||||||
|
"""检查是否是@always_free装饰的方法"""
|
||||||
|
for decorator in node.decorator_list:
|
||||||
|
if isinstance(decorator, ast.Name) and decorator.id == "always_free":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
|
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
|
||||||
"""从setter装饰器中获取属性名"""
|
"""从setter装饰器中获取属性名"""
|
||||||
for decorator in node.decorator_list:
|
for decorator in node.decorator_list:
|
||||||
@@ -549,6 +580,9 @@ class ImportManager:
|
|||||||
for i, arg in enumerate(node.args.args):
|
for i, arg in enumerate(node.args.args):
|
||||||
if arg.arg == "self":
|
if arg.arg == "self":
|
||||||
continue
|
continue
|
||||||
|
# 跳过 sample_uuids 参数(由系统自动注入)
|
||||||
|
if arg.arg == PARAM_SAMPLE_UUIDS:
|
||||||
|
continue
|
||||||
arg_info = {
|
arg_info = {
|
||||||
"name": arg.arg,
|
"name": arg.arg,
|
||||||
"type": None,
|
"type": None,
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ def configure_logger(loglevel=None, working_dir=None):
|
|||||||
root_logger.addHandler(console_handler)
|
root_logger.addHandler(console_handler)
|
||||||
|
|
||||||
# 如果指定了工作目录,添加文件处理器
|
# 如果指定了工作目录,添加文件处理器
|
||||||
|
log_filepath = None
|
||||||
if working_dir is not None:
|
if working_dir is not None:
|
||||||
logs_dir = os.path.join(working_dir, "logs")
|
logs_dir = os.path.join(working_dir, "logs")
|
||||||
os.makedirs(logs_dir, exist_ok=True)
|
os.makedirs(logs_dir, exist_ok=True)
|
||||||
@@ -213,6 +214,7 @@ def configure_logger(loglevel=None, working_dir=None):
|
|||||||
|
|
||||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||||
|
return log_filepath
|
||||||
|
|
||||||
|
|
||||||
# 配置日志系统
|
# 配置日志系统
|
||||||
|
|||||||
@@ -60,7 +60,11 @@
|
|||||||
==================== 连接关系图 ====================
|
==================== 连接关系图 ====================
|
||||||
|
|
||||||
控制流 (ready 端口串联):
|
控制流 (ready 端口串联):
|
||||||
create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ...
|
- create_resource 之间: 无 ready 连接
|
||||||
|
- set_liquid_from_plate 之间: 无 ready 连接
|
||||||
|
- create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
|
||||||
|
- transfer_liquid 之间: 通过 ready 端口串联
|
||||||
|
transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...
|
||||||
|
|
||||||
物料流:
|
物料流:
|
||||||
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
||||||
@@ -115,11 +119,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
|
|||||||
# 节点类型
|
# 节点类型
|
||||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||||
|
|
||||||
|
CLASS_NAMES_MAPPING = {
|
||||||
|
"plate": "PRCXI_BioER_96_wellplate",
|
||||||
|
"tip_rack": "PRCXI_300ul_Tips",
|
||||||
|
}
|
||||||
# create_resource 节点默认参数
|
# create_resource 节点默认参数
|
||||||
CREATE_RESOURCE_DEFAULTS = {
|
CREATE_RESOURCE_DEFAULTS = {
|
||||||
"device_id": "/PRCXI",
|
"device_id": "/PRCXI",
|
||||||
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
|
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
|
||||||
"class_name": "PRCXI_BioER_96_wellplate",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 默认液体体积 (uL)
|
# 默认液体体积 (uL)
|
||||||
@@ -358,6 +365,7 @@ def build_protocol_graph(
|
|||||||
protocol_steps: List[Dict[str, Any]],
|
protocol_steps: List[Dict[str, Any]],
|
||||||
workstation_name: str,
|
workstation_name: str,
|
||||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||||
|
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||||
|
|
||||||
@@ -383,6 +391,7 @@ def build_protocol_graph(
|
|||||||
slots_info[slot] = {
|
slots_info[slot] = {
|
||||||
"labware": item.get("labware", ""),
|
"labware": item.get("labware", ""),
|
||||||
"res_id": res_id,
|
"res_id": res_id,
|
||||||
|
"labware_id": labware_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 创建 Group 节点,包含所有 create_resource 节点
|
# 创建 Group 节点,包含所有 create_resource 节点
|
||||||
@@ -401,18 +410,17 @@ def build_protocol_graph(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 为每个唯一的 slot 创建 create_resource 节点
|
# 为每个唯一的 slot 创建 create_resource 节点
|
||||||
res_index = 0
|
|
||||||
last_create_resource_id = None
|
|
||||||
for slot, info in slots_info.items():
|
for slot, info in slots_info.items():
|
||||||
node_id = str(uuid.uuid4())
|
node_id = str(uuid.uuid4())
|
||||||
res_id = info["res_id"]
|
res_id = info["res_id"]
|
||||||
|
res_type_name = info["labware"].lower().replace(".", "point")
|
||||||
|
res_type_name = f"lab_{res_type_name}"
|
||||||
|
|
||||||
res_index += 1
|
|
||||||
G.add_node(
|
G.add_node(
|
||||||
node_id,
|
node_id,
|
||||||
template_name="create_resource",
|
template_name="create_resource",
|
||||||
resource_name="host_node",
|
resource_name="host_node",
|
||||||
name=f"Plate {res_index}",
|
name=f"{res_type_name}_slot{slot}",
|
||||||
description=f"Create plate on slot {slot}",
|
description=f"Create plate on slot {slot}",
|
||||||
lab_node_type="Labware",
|
lab_node_type="Labware",
|
||||||
footer="create_resource-host_node",
|
footer="create_resource-host_node",
|
||||||
@@ -423,18 +431,16 @@ def build_protocol_graph(
|
|||||||
param={
|
param={
|
||||||
"res_id": res_id,
|
"res_id": res_id,
|
||||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||||
"class_name": CREATE_RESOURCE_DEFAULTS["class_name"],
|
"class_name": res_type_name,
|
||||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
|
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
|
||||||
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
"slot_on_deck": slot,
|
"slot_on_deck": slot,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
slot_to_create_resource[slot] = node_id
|
slot_to_create_resource[slot] = node_id
|
||||||
|
if "tip" in res_type_name and "rack" in res_type_name:
|
||||||
# create_resource 之间通过 ready 串联
|
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
|
||||||
if last_create_resource_id is not None:
|
# create_resource 之间不需要 ready 连接
|
||||||
G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready")
|
|
||||||
last_create_resource_id = node_id
|
|
||||||
|
|
||||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||||
@@ -453,7 +459,6 @@ def build_protocol_graph(
|
|||||||
)
|
)
|
||||||
|
|
||||||
set_liquid_index = 0
|
set_liquid_index = 0
|
||||||
last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后
|
|
||||||
|
|
||||||
for labware_id, item in labware_info.items():
|
for labware_id, item in labware_info.items():
|
||||||
# 跳过 Tip/Rack 类型
|
# 跳过 Tip/Rack 类型
|
||||||
@@ -494,10 +499,7 @@ def build_protocol_graph(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# ready 连接:上一个节点 -> set_liquid_from_plate
|
# set_liquid_from_plate 之间不需要 ready 连接
|
||||||
if last_set_liquid_id is not None:
|
|
||||||
G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready")
|
|
||||||
last_set_liquid_id = node_id
|
|
||||||
|
|
||||||
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
||||||
create_res_node_id = slot_to_create_resource.get(slot)
|
create_res_node_id = slot_to_create_resource.get(slot)
|
||||||
@@ -507,7 +509,8 @@ def build_protocol_graph(
|
|||||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||||
|
|
||||||
last_control_node_id = last_set_liquid_id
|
# transfer_liquid 之间通过 ready 串联,从 None 开始
|
||||||
|
last_control_node_id = None
|
||||||
|
|
||||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||||
INPUT_PORT_MAPPING = {
|
INPUT_PORT_MAPPING = {
|
||||||
@@ -519,6 +522,7 @@ def build_protocol_graph(
|
|||||||
"reagent": "reagent",
|
"reagent": "reagent",
|
||||||
"solvent": "solvent",
|
"solvent": "solvent",
|
||||||
"compound": "compound",
|
"compound": "compound",
|
||||||
|
"tip_racks": "tip_rack_identifier",
|
||||||
}
|
}
|
||||||
|
|
||||||
OUTPUT_PORT_MAPPING = {
|
OUTPUT_PORT_MAPPING = {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
JSON 工作流转换模块
|
JSON 工作流转换模块
|
||||||
|
|
||||||
将 workflow/reagent 格式的 JSON 转换为统一工作流格式。
|
将 workflow/reagent/labware 格式的 JSON 转换为统一工作流格式。
|
||||||
|
|
||||||
输入格式:
|
输入格式:
|
||||||
{
|
{
|
||||||
|
"labware": [
|
||||||
|
{"name": "...", "slot": "1", "type": "lab_xxx"},
|
||||||
|
...
|
||||||
|
],
|
||||||
"workflow": [
|
"workflow": [
|
||||||
{"action": "...", "action_args": {...}},
|
{"action": "...", "action_args": {...}},
|
||||||
...
|
...
|
||||||
],
|
],
|
||||||
"reagent": {
|
"reagent": {
|
||||||
"reagent_name": {"slot": int, "well": [...], "labware": "..."},
|
"reagent_name": {"slot": int, "well": [...]},
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,18 +249,18 @@ def convert_from_json(
|
|||||||
if "workflow" not in json_data or "reagent" not in json_data:
|
if "workflow" not in json_data or "reagent" not in json_data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"不支持的 JSON 格式。请使用标准格式:\n"
|
"不支持的 JSON 格式。请使用标准格式:\n"
|
||||||
'{"workflow": [{"action": "...", "action_args": {...}}, ...], '
|
'{"labware": [...], "workflow": [...], "reagent": {...}}'
|
||||||
'"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 提取数据
|
# 提取数据
|
||||||
workflow = json_data["workflow"]
|
workflow = json_data["workflow"]
|
||||||
reagent = json_data["reagent"]
|
reagent = json_data["reagent"]
|
||||||
|
labware_defs = json_data.get("labware", []) # 新的 labware 定义列表
|
||||||
|
|
||||||
# 规范化步骤数据
|
# 规范化步骤数据
|
||||||
protocol_steps = normalize_workflow_steps(workflow)
|
protocol_steps = normalize_workflow_steps(workflow)
|
||||||
|
|
||||||
# reagent 已经是字典格式,直接使用
|
# reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
|
||||||
labware_info = reagent
|
labware_info = reagent
|
||||||
|
|
||||||
# 构建工作流图
|
# 构建工作流图
|
||||||
@@ -265,6 +269,7 @@ def convert_from_json(
|
|||||||
protocol_steps=protocol_steps,
|
protocol_steps=protocol_steps,
|
||||||
workstation_name=workstation_name,
|
workstation_name=workstation_name,
|
||||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||||
|
labware_defs=labware_defs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 校验句柄配置
|
# 校验句柄配置
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def upload_workflow(
|
|||||||
workflow_name: Optional[str] = None,
|
workflow_name: Optional[str] = None,
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
published: bool = False,
|
published: bool = False,
|
||||||
|
description: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
上传工作流到服务器
|
上传工作流到服务器
|
||||||
@@ -56,6 +57,7 @@ def upload_workflow(
|
|||||||
workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名
|
workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名
|
||||||
tags: 工作流标签列表,默认为空列表
|
tags: 工作流标签列表,默认为空列表
|
||||||
published: 是否发布工作流,默认为False
|
published: 是否发布工作流,默认为False
|
||||||
|
description: 工作流描述,发布时使用
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: API响应数据
|
Dict: API响应数据
|
||||||
@@ -75,6 +77,14 @@ def upload_workflow(
|
|||||||
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
||||||
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
||||||
|
|
||||||
|
# 从 JSON 文件中提取 description 和 tags(作为 fallback)
|
||||||
|
if not description and "description" in workflow_data:
|
||||||
|
description = workflow_data["description"]
|
||||||
|
print_status(f"从文件中读取 description", "info")
|
||||||
|
if not tags and "tags" in workflow_data:
|
||||||
|
tags = workflow_data["tags"]
|
||||||
|
print_status(f"从文件中读取 tags: {tags}", "info")
|
||||||
|
|
||||||
# 自动检测并转换格式
|
# 自动检测并转换格式
|
||||||
if not _is_node_link_format(workflow_data):
|
if not _is_node_link_format(workflow_data):
|
||||||
try:
|
try:
|
||||||
@@ -96,6 +106,7 @@ def upload_workflow(
|
|||||||
print_status(f" - 节点数量: {len(nodes)}", "info")
|
print_status(f" - 节点数量: {len(nodes)}", "info")
|
||||||
print_status(f" - 边数量: {len(edges)}", "info")
|
print_status(f" - 边数量: {len(edges)}", "info")
|
||||||
print_status(f" - 标签: {tags or []}", "info")
|
print_status(f" - 标签: {tags or []}", "info")
|
||||||
|
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
|
||||||
print_status(f" - 发布状态: {published}", "info")
|
print_status(f" - 发布状态: {published}", "info")
|
||||||
|
|
||||||
# 调用 http_client 上传
|
# 调用 http_client 上传
|
||||||
@@ -107,6 +118,7 @@ def upload_workflow(
|
|||||||
edges=edges,
|
edges=edges,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
published=published,
|
published=published,
|
||||||
|
description=description,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get("code") == 0:
|
if result.get("code") == 0:
|
||||||
@@ -131,8 +143,9 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
|
|||||||
workflow_name = args_dict.get("workflow_name")
|
workflow_name = args_dict.get("workflow_name")
|
||||||
tags = args_dict.get("tags", [])
|
tags = args_dict.get("tags", [])
|
||||||
published = args_dict.get("published", False)
|
published = args_dict.get("published", False)
|
||||||
|
description = args_dict.get("description", "")
|
||||||
|
|
||||||
if workflow_file:
|
if workflow_file:
|
||||||
upload_workflow(workflow_file, workflow_name, tags, published)
|
upload_workflow(workflow_file, workflow_name, tags, published, description)
|
||||||
else:
|
else:
|
||||||
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
||||||
|
|||||||
Reference in New Issue
Block a user