fix camera & workstation & warehouse & reaction station driver

This commit is contained in:
ZiWei
2025-11-16 14:35:53 +08:00
committed by Xuwznln
parent 4189a2cfbe
commit 549a50220b
8 changed files with 761 additions and 141 deletions

View File

@@ -5,10 +5,16 @@
"name": "reaction_station_bioyond", "name": "reaction_station_bioyond",
"parent": null, "parent": null,
"children": [ "children": [
"Bioyond_Deck" "Bioyond_Deck",
"reactor_1",
"reactor_2",
"reactor_3",
"reactor_4",
"reactor_5"
], ],
"type": "device", "type": "device",
"class": "reaction_station.bioyond", "class": "reaction_station.bioyond",
"position": {"x": 0, "y": 3800, "z": 0},
"config": { "config": {
"config": { "config": {
"api_key": "DE9BDDA0", "api_key": "DE9BDDA0",
@@ -64,6 +70,61 @@
}, },
"data": {} "data": {}
}, },
{
"id": "reactor_1",
"name": "reactor_1",
"children": [],
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1150, "y": 380, "z": 0},
"config": {},
"data": {}
},
{
"id": "reactor_2",
"name": "reactor_2",
"children": [],
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1365, "y": 380, "z": 0},
"config": {},
"data": {}
},
{
"id": "reactor_3",
"name": "reactor_3",
"children": [],
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1580, "y": 380, "z": 0},
"config": {},
"data": {}
},
{
"id": "reactor_4",
"name": "reactor_4",
"children": [],
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1790, "y": 380, "z": 0},
"config": {},
"data": {}
},
{
"id": "reactor_5",
"name": "reactor_5",
"children": [],
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 2010, "y": 380, "z": 0},
"config": {},
"data": {}
},
{ {
"id": "Bioyond_Deck", "id": "Bioyond_Deck",
"name": "Bioyond_Deck", "name": "Bioyond_Deck",

View File

@@ -1,7 +1,11 @@
import json import json
import time
import requests import requests
from typing import List, Dict, Any from typing import List, Dict, Any
from pathlib import Path
from datetime import datetime
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
from unilabos.devices.workstation.bioyond_studio.config import ( from unilabos.devices.workstation.bioyond_studio.config import (
WORKFLOW_STEP_IDS, WORKFLOW_STEP_IDS,
WORKFLOW_TO_SECTION_MAP, WORKFLOW_TO_SECTION_MAP,
@@ -10,6 +14,37 @@ from unilabos.devices.workstation.bioyond_studio.config import (
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
class BioyondReactor:
def __init__(self, config: dict = None, deck=None, protocol_type=None, **kwargs):
self.in_temperature = 0.0
self.out_temperature = 0.0
self.pt100_temperature = 0.0
self.sensor_average_temperature = 0.0
self.target_temperature = 0.0
self.setting_temperature = 0.0
self.viscosity = 0.0
self.average_viscosity = 0.0
self.speed = 0.0
self.force = 0.0
def update_metrics(self, payload: Dict[str, Any]):
def _f(v):
try:
return float(v)
except Exception:
return 0.0
self.target_temperature = _f(payload.get("targetTemperature"))
self.setting_temperature = _f(payload.get("settingTemperature"))
self.in_temperature = _f(payload.get("inTemperature"))
self.out_temperature = _f(payload.get("outTemperature"))
self.pt100_temperature = _f(payload.get("pt100Temperature"))
self.sensor_average_temperature = _f(payload.get("sensorAverageTemperature"))
self.speed = _f(payload.get("speed"))
self.force = _f(payload.get("force"))
self.viscosity = _f(payload.get("viscosity"))
self.average_viscosity = _f(payload.get("averageViscosity"))
class BioyondReactionStation(BioyondWorkstation): class BioyondReactionStation(BioyondWorkstation):
"""Bioyond反应站类 """Bioyond反应站类
@@ -37,6 +72,19 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"BioyondReactionStation初始化完成 - workflow_mappings: {self.workflow_mappings}") print(f"BioyondReactionStation初始化完成 - workflow_mappings: {self.workflow_mappings}")
print(f"workflow_mappings长度: {len(self.workflow_mappings)}") print(f"workflow_mappings长度: {len(self.workflow_mappings)}")
self.in_temperature = 0.0
self.out_temperature = 0.0
self.pt100_temperature = 0.0
self.sensor_average_temperature = 0.0
self.target_temperature = 0.0
self.setting_temperature = 0.0
self.viscosity = 0.0
self.average_viscosity = 0.0
self.speed = 0.0
self.force = 0.0
self._frame_to_reactor_id = {1: "reactor_1", 2: "reactor_2", 3: "reactor_3", 4: "reactor_4", 5: "reactor_5"}
# ==================== 工作流方法 ==================== # ==================== 工作流方法 ====================
def reactor_taken_out(self): def reactor_taken_out(self):
@@ -291,22 +339,39 @@ class BioyondReactionStation(BioyondWorkstation):
def liquid_feeding_titration( def liquid_feeding_titration(
self, self,
volume_formula: str,
assign_material_name: str, assign_material_name: str,
titration_type: str = "1", volume_formula: str = None,
x_value: str = None,
feeding_order_data: str = None,
extracted_actuals: str = None,
titration_type: str = "2",
time: str = "90", time: str = "90",
torque_variation: int = 2, torque_variation: int = 2,
temperature: float = 25.00 temperature: float = 25.00
): ):
"""液体进料(滴定) """液体进料(滴定)
支持两种模式:
1. 直接提供 volume_formula (传统方式)
2. 自动计算公式: 提供 x_value, feeding_order_data, extracted_actuals (新方式)
Args: Args:
volume_formula: 分液公式(μL)
assign_material_name: 物料名称 assign_material_name: 物料名称
titration_type: 是否滴定(1=否, 2=是) volume_formula: 分液公式(μL),如果提供则直接使用,否则自动计算
x_value: 手工输入的x值,格式如 "1-2-3"
feeding_order_data: feeding_order JSON字符串或对象,用于获取m二酐值
extracted_actuals: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume
titration_type: 是否滴定(1=否, 2=是),默认2
time: 观察时间(分钟) time: 观察时间(分钟)
torque_variation: 是否观察(int类型, 1=否, 2=是) torque_variation: 是否观察(int类型, 1=否, 2=是)
temperature: 温度(°C) temperature: 温度(°C)
自动公式模板: 1000*(m二酐-x)*V二酐滴定/m二酐滴定
其中:
- m二酐滴定 = actualTargetWeigh (从extracted_actuals获取)
- V二酐滴定 = actualVolume (从extracted_actuals获取)
- x = x_value (手工输入)
- m二酐 = feeding_order中type为"main_anhydride"的amount值
""" """
self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding(titration)"}') self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding(titration)"}')
material_id = self.hardware_interface._get_material_id_by_name(assign_material_name) material_id = self.hardware_interface._get_material_id_by_name(assign_material_name)
@@ -316,6 +381,84 @@ class BioyondReactionStation(BioyondWorkstation):
if isinstance(temperature, str): if isinstance(temperature, str):
temperature = float(temperature) temperature = float(temperature)
# 如果没有直接提供volume_formula,则自动计算
if not volume_formula and x_value and feeding_order_data and extracted_actuals:
# 1. 解析 feeding_order_data 获取 m二酐
if isinstance(feeding_order_data, str):
try:
feeding_order_data = json.loads(feeding_order_data)
except json.JSONDecodeError as e:
raise ValueError(f"feeding_order_data JSON解析失败: {str(e)}")
# 支持两种格式:
# 格式1: 直接是数组 [{...}, {...}]
# 格式2: 对象包裹 {"feeding_order": [{...}, {...}]}
if isinstance(feeding_order_data, list):
feeding_order_list = feeding_order_data
elif isinstance(feeding_order_data, dict):
feeding_order_list = feeding_order_data.get("feeding_order", [])
else:
raise ValueError("feeding_order_data 必须是数组或包含feeding_order的字典")
# 从feeding_order中找到main_anhydride的amount
m_anhydride = None
for item in feeding_order_list:
if item.get("type") == "main_anhydride":
m_anhydride = item.get("amount")
break
if m_anhydride is None:
raise ValueError("在feeding_order中未找到type为'main_anhydride'的条目")
# 2. 解析 extracted_actuals 获取 actualTargetWeigh 和 actualVolume
if isinstance(extracted_actuals, str):
try:
extracted_actuals_obj = json.loads(extracted_actuals)
except json.JSONDecodeError as e:
raise ValueError(f"extracted_actuals JSON解析失败: {str(e)}")
else:
extracted_actuals_obj = extracted_actuals
# 获取actuals数组
actuals_list = extracted_actuals_obj.get("actuals", [])
if not actuals_list:
# actuals为空,无法自动生成公式,回退到手动模式
print(f"警告: extracted_actuals中actuals数组为空,无法自动生成公式,请手动提供volume_formula")
volume_formula = None # 清空,触发后续的错误检查
else:
# 根据assign_material_name匹配对应的actual数据
# 假设order_code中包含物料名称
matched_actual = None
for actual in actuals_list:
order_code = actual.get("order_code", "")
# 简单匹配:如果order_code包含物料名称
if assign_material_name in order_code:
matched_actual = actual
break
# 如果没有匹配到,使用第一个
if not matched_actual and actuals_list:
matched_actual = actuals_list[0]
if not matched_actual:
raise ValueError("无法从extracted_actuals中获取实际加料量数据")
m_anhydride_titration = matched_actual.get("actualTargetWeigh") # m二酐滴定
v_anhydride_titration = matched_actual.get("actualVolume") # V二酐滴定
if m_anhydride_titration is None or v_anhydride_titration is None:
raise ValueError(f"实际加料量数据不完整: actualTargetWeigh={m_anhydride_titration}, actualVolume={v_anhydride_titration}")
# 3. 构建公式: 1000*(m二酐-x)*V二酐滴定/m二酐滴定
# x_value 格式如 "{{1-2-3}}",保留完整格式(包括花括号)直接替换到公式中
volume_formula = f"1000*({m_anhydride}-{x_value})*{v_anhydride_titration}/{m_anhydride_titration}"
print(f"自动生成滴定公式: {volume_formula}")
print(f" m二酐={m_anhydride}, x={x_value}, V二酐滴定={v_anhydride_titration}, m二酐滴定={m_anhydride_titration}")
elif not volume_formula:
raise ValueError("必须提供 volume_formula 或 (x_value + feeding_order_data + extracted_actuals)")
liquid_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["liquid"] liquid_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["liquid"]
observe_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["observe"] observe_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["observe"]
@@ -343,6 +486,286 @@ class BioyondReactionStation(BioyondWorkstation):
print(f"当前队列长度: {len(self.pending_task_params)}") print(f"当前队列长度: {len(self.pending_task_params)}")
return json.dumps({"suc": True}) return json.dumps({"suc": True})
def _extract_actuals_from_report(self, report) -> Dict[str, Any]:
data = report.get('data') if isinstance(report, dict) else None
actual_target_weigh = None
actual_volume = None
if data:
extra = data.get('extraProperties') or {}
if isinstance(extra, dict):
for v in extra.values():
obj = None
try:
obj = json.loads(v) if isinstance(v, str) else v
except Exception:
obj = None
if isinstance(obj, dict):
tw = obj.get('targetWeigh')
vol = obj.get('volume')
if tw is not None:
try:
actual_target_weigh = float(tw)
except Exception:
pass
if vol is not None:
try:
actual_volume = float(vol)
except Exception:
pass
return {
'actualTargetWeigh': actual_target_weigh,
'actualVolume': actual_volume
}
def extract_actuals_from_batch_reports(self, batch_reports_result: str) -> dict:
print(f"[DEBUG] extract_actuals 收到原始数据: {batch_reports_result[:500]}...") # 打印前500字符
try:
obj = json.loads(batch_reports_result) if isinstance(batch_reports_result, str) else batch_reports_result
if isinstance(obj, dict) and "return_info" in obj:
inner = obj["return_info"]
obj = json.loads(inner) if isinstance(inner, str) else inner
reports = obj.get("reports", []) if isinstance(obj, dict) else []
print(f"[DEBUG] 解析后的 reports 数组长度: {len(reports)}")
except Exception as e:
print(f"[DEBUG] 解析异常: {e}")
reports = []
actuals = []
for i, r in enumerate(reports):
print(f"[DEBUG] 处理 report[{i}]: order_code={r.get('order_code')}, has_extracted={r.get('extracted') is not None}, has_report={r.get('report') is not None}")
order_code = r.get("order_code")
order_id = r.get("order_id")
ex = r.get("extracted")
if isinstance(ex, dict) and (ex.get("actualTargetWeigh") is not None or ex.get("actualVolume") is not None):
print(f"[DEBUG] 从 extracted 字段提取: actualTargetWeigh={ex.get('actualTargetWeigh')}, actualVolume={ex.get('actualVolume')}")
actuals.append({
"order_code": order_code,
"order_id": order_id,
"actualTargetWeigh": ex.get("actualTargetWeigh"),
"actualVolume": ex.get("actualVolume")
})
continue
report = r.get("report")
vals = self._extract_actuals_from_report(report) if report else {"actualTargetWeigh": None, "actualVolume": None}
print(f"[DEBUG] 从 report 字段提取: {vals}")
actuals.append({
"order_code": order_code,
"order_id": order_id,
**vals
})
print(f"[DEBUG] 最终提取的 actuals 数组长度: {len(actuals)}")
result = {
"return_info": json.dumps({"actuals": actuals}, ensure_ascii=False)
}
print(f"[DEBUG] 返回结果: {result}")
return result
def process_temperature_cutoff_report(self, report_request) -> Dict[str, Any]:
try:
data = report_request.data
def _f(v):
try:
return float(v)
except Exception:
return 0.0
self.target_temperature = _f(data.get("targetTemperature"))
self.setting_temperature = _f(data.get("settingTemperature"))
self.in_temperature = _f(data.get("inTemperature"))
self.out_temperature = _f(data.get("outTemperature"))
self.pt100_temperature = _f(data.get("pt100Temperature"))
self.sensor_average_temperature = _f(data.get("sensorAverageTemperature"))
self.speed = _f(data.get("speed"))
self.force = _f(data.get("force"))
self.viscosity = _f(data.get("viscosity"))
self.average_viscosity = _f(data.get("averageViscosity"))
try:
if hasattr(self, "_ros_node") and self._ros_node is not None:
props = [
"in_temperature","out_temperature","pt100_temperature","sensor_average_temperature",
"target_temperature","setting_temperature","viscosity","average_viscosity",
"speed","force"
]
for name in props:
pub = self._ros_node._property_publishers.get(name)
if pub:
pub.publish_property()
frame = data.get("frameCode")
reactor_id = None
try:
reactor_id = self._frame_to_reactor_id.get(int(frame))
except Exception:
reactor_id = None
if reactor_id and hasattr(self._ros_node, "sub_devices"):
child = self._ros_node.sub_devices.get(reactor_id)
if child and hasattr(child, "driver_instance"):
child.driver_instance.update_metrics(data)
pubs = getattr(child.ros_node_instance, "_property_publishers", {})
for name in props:
p = pubs.get(name)
if p:
p.publish_property()
except Exception:
pass
event = {
"frameCode": data.get("frameCode"),
"generateTime": data.get("generateTime"),
"targetTemperature": data.get("targetTemperature"),
"settingTemperature": data.get("settingTemperature"),
"inTemperature": data.get("inTemperature"),
"outTemperature": data.get("outTemperature"),
"pt100Temperature": data.get("pt100Temperature"),
"sensorAverageTemperature": data.get("sensorAverageTemperature"),
"speed": data.get("speed"),
"force": data.get("force"),
"viscosity": data.get("viscosity"),
"averageViscosity": data.get("averageViscosity"),
"request_time": report_request.request_time,
"timestamp": datetime.now().isoformat(),
"reactor_id": self._frame_to_reactor_id.get(int(data.get("frameCode", 0))) if str(data.get("frameCode", "")).isdigit() else None,
}
base_dir = Path(__file__).resolve().parents[3] / "unilabos_data"
base_dir.mkdir(parents=True, exist_ok=True)
out_file = base_dir / "temperature_cutoff_events.json"
try:
existing = json.loads(out_file.read_text(encoding="utf-8")) if out_file.exists() else []
if not isinstance(existing, list):
existing = []
except Exception:
existing = []
existing.append(event)
out_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
if hasattr(self, "_ros_node") and self._ros_node is not None:
ns = self._ros_node.namespace
topics = {
"targetTemperature": f"{ns}/metrics/temperature_cutoff/target_temperature",
"settingTemperature": f"{ns}/metrics/temperature_cutoff/setting_temperature",
"inTemperature": f"{ns}/metrics/temperature_cutoff/in_temperature",
"outTemperature": f"{ns}/metrics/temperature_cutoff/out_temperature",
"pt100Temperature": f"{ns}/metrics/temperature_cutoff/pt100_temperature",
"sensorAverageTemperature": f"{ns}/metrics/temperature_cutoff/sensor_average_temperature",
"speed": f"{ns}/metrics/temperature_cutoff/speed",
"force": f"{ns}/metrics/temperature_cutoff/force",
"viscosity": f"{ns}/metrics/temperature_cutoff/viscosity",
"averageViscosity": f"{ns}/metrics/temperature_cutoff/average_viscosity",
}
for k, t in topics.items():
v = data.get(k)
if v is not None:
pub = self._ros_node.create_publisher(Float64, t, 10)
pub.publish(convert_to_ros_msg(Float64, float(v)))
evt_pub = self._ros_node.create_publisher(String, f"{ns}/events/temperature_cutoff", 10)
evt_pub.publish(convert_to_ros_msg(String, json.dumps(event, ensure_ascii=False)))
return {"processed": True, "frame": data.get("frameCode")}
except Exception as e:
return {"processed": False, "error": str(e)}
def wait_for_multiple_orders_and_get_reports(self, batch_create_result: str = None, timeout: int = 7200, check_interval: int = 10) -> Dict[str, Any]:
try:
timeout = int(timeout) if timeout else 7200
check_interval = int(check_interval) if check_interval else 10
if not batch_create_result or batch_create_result == "":
raise ValueError("batch_create_result为空")
try:
if isinstance(batch_create_result, str) and '[...]' in batch_create_result:
batch_create_result = batch_create_result.replace('[...]', '[]')
result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result
if isinstance(result_obj, dict) and "return_value" in result_obj:
inner = result_obj.get("return_value")
if isinstance(inner, str):
result_obj = json.loads(inner)
elif isinstance(inner, dict):
result_obj = inner
order_codes = result_obj.get("order_codes", [])
order_ids = result_obj.get("order_ids", [])
except Exception as e:
raise ValueError(f"解析batch_create_result失败: {e}")
if not order_codes or not order_ids:
raise ValueError("缺少order_codes或order_ids")
if not isinstance(order_codes, list):
order_codes = [order_codes]
if not isinstance(order_ids, list):
order_ids = [order_ids]
if len(order_codes) != len(order_ids):
raise ValueError("order_codes与order_ids数量不匹配")
total = len(order_codes)
pending = {c: {"order_id": order_ids[i], "completed": False} for i, c in enumerate(order_codes)}
reports = []
start_time = time.time()
while pending:
elapsed_time = time.time() - start_time
if elapsed_time > timeout:
for oc in list(pending.keys()):
reports.append({
"order_code": oc,
"order_id": pending[oc]["order_id"],
"status": "timeout",
"completion_status": None,
"report": None,
"extracted": None,
"elapsed_time": elapsed_time
})
break
completed_round = []
for oc in list(pending.keys()):
oid = pending[oc]["order_id"]
if oc in self.order_completion_status:
info = self.order_completion_status[oc]
try:
rq = json.dumps({"order_id": oid})
rep = self.hardware_interface.order_report(rq)
if not rep:
rep = {"error": "无法获取报告"}
reports.append({
"order_code": oc,
"order_id": oid,
"status": "completed",
"completion_status": info.get('status'),
"report": rep,
"extracted": self._extract_actuals_from_report(rep),
"elapsed_time": elapsed_time
})
completed_round.append(oc)
del self.order_completion_status[oc]
except Exception as e:
reports.append({
"order_code": oc,
"order_id": oid,
"status": "error",
"completion_status": info.get('status') if 'info' in locals() else None,
"report": None,
"extracted": None,
"error": str(e),
"elapsed_time": elapsed_time
})
completed_round.append(oc)
for oc in completed_round:
del pending[oc]
if pending:
time.sleep(check_interval)
completed_count = sum(1 for r in reports if r['status'] == 'completed')
timeout_count = sum(1 for r in reports if r['status'] == 'timeout')
error_count = sum(1 for r in reports if r['status'] == 'error')
final_elapsed_time = time.time() - start_time
summary = {
"total": total,
"completed": completed_count,
"timeout": timeout_count,
"error": error_count,
"elapsed_time": round(final_elapsed_time, 2),
"reports": reports
}
return {
"return_info": json.dumps(summary, ensure_ascii=False)
}
except Exception as e:
raise
def liquid_feeding_beaker( def liquid_feeding_beaker(
self, self,
volume: str = "350", volume: str = "350",
@@ -580,10 +1003,10 @@ class BioyondReactionStation(BioyondWorkstation):
# print(f"\n✅ 任务创建成功: {result}") # print(f"\n✅ 任务创建成功: {result}")
# print(f"\n✅ 任务创建成功") # print(f"\n✅ 任务创建成功")
print(f"{'='*60}\n") print(f"{'='*60}\n")
# 返回结果,包含合并后的工作流数据和订单参数 # 返回结果,包含合并后的工作流数据和订单参数
return json.dumps({ return json.dumps({
"success": True, "success": True,
"result": result, "result": result,
"merged_workflow": merged_workflow, "merged_workflow": merged_workflow,
"order_params": order_params "order_params": order_params

View File

@@ -9,6 +9,7 @@ import traceback
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
import json import json
from pathlib import Path
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
@@ -19,6 +20,7 @@ from unilabos.resources.graphio import resource_bioyond_to_plr, resource_plr_to_
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.resource import Resource as ResourcePLR
from unilabos.devices.workstation.bioyond_studio.config import ( from unilabos.devices.workstation.bioyond_studio.config import (
@@ -676,13 +678,13 @@ class BioyondWorkstation(WorkstationBase):
self._synced_resources = [] self._synced_resources = []
def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot):
time.sleep(3) future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{
ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{
"plr_resources": resource, "plr_resources": resource,
"target_device_id": mount_device_id, "target_device_id": mount_device_id,
"target_resources": mount_resource, "target_resources": mount_resource,
"sites": sites, "sites": sites,
}) })
return future
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
"""创建Bioyond通信模块""" """创建Bioyond通信模块"""
@@ -1327,6 +1329,7 @@ class BioyondWorkstation(WorkstationBase):
logger.error(f"处理物料变更报送失败: {e}") logger.error(f"处理物料变更报送失败: {e}")
return {"processed": False, "error": str(e)} return {"processed": False, "error": str(e)}
def handle_external_error(self, error_data: Dict[str, Any]) -> Dict[str, Any]: def handle_external_error(self, error_data: Dict[str, Any]) -> Dict[str, Any]:
"""处理错误处理报送 """处理错误处理报送

View File

@@ -22,6 +22,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime from datetime import datetime
from pathlib import Path
from unilabos.utils.log import logger from unilabos.utils.log import logger
@@ -76,6 +77,14 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}") logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}")
try:
payload_for_log = {"method": "POST", **request_data}
self._save_raw_request(endpoint, payload_for_log)
if hasattr(self.workstation, '_reports_received_count'):
self.workstation._reports_received_count += 1
except Exception:
pass
# 统一的报送端点路由基于LIMS协议规范 # 统一的报送端点路由基于LIMS协议规范
if endpoint == '/report/step_finish': if endpoint == '/report/step_finish':
response = self._handle_step_finish_report(request_data) response = self._handle_step_finish_report(request_data)
@@ -90,6 +99,8 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
response = self._handle_material_change_report(request_data) response = self._handle_material_change_report(request_data)
elif endpoint == '/report/error_handling': elif endpoint == '/report/error_handling':
response = self._handle_error_handling_report(request_data) response = self._handle_error_handling_report(request_data)
elif endpoint == '/report/temperature-cutoff':
response = self._handle_temperature_cutoff_report(request_data)
# 保留LIMS协议端点以兼容现有系统 # 保留LIMS协议端点以兼容现有系统
elif endpoint == '/LIMS/step_finish': elif endpoint == '/LIMS/step_finish':
response = self._handle_step_finish_report(request_data) response = self._handle_step_finish_report(request_data)
@@ -107,7 +118,8 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"/report/order_finish", "/report/order_finish",
"/report/batch_update", "/report/batch_update",
"/report/material_change", "/report/material_change",
"/report/error_handling" "/report/error_handling",
"/report/temperature-cutoff"
]} ]}
) )
@@ -128,6 +140,11 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
parsed_path = urlparse(self.path) parsed_path = urlparse(self.path)
endpoint = parsed_path.path endpoint = parsed_path.path
try:
self._save_raw_request(endpoint, {"method": "GET"})
except Exception:
pass
if endpoint == '/status': if endpoint == '/status':
response = self._handle_status_check() response = self._handle_status_check()
elif endpoint == '/health': elif endpoint == '/health':
@@ -318,6 +335,50 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
message=f"任务完成报送处理失败: {str(e)}" message=f"任务完成报送处理失败: {str(e)}"
) )
def _handle_temperature_cutoff_report(self, request_data: Dict[str, Any]) -> HttpResponse:
try:
required_fields = ['token', 'request_time', 'data']
if missing := [f for f in required_fields if f not in request_data]:
return HttpResponse(success=False, message=f"缺少必要字段: {', '.join(missing)}")
data = request_data['data']
metrics = [
'frameCode',
'generateTime',
'targetTemperature',
'settingTemperature',
'inTemperature',
'outTemperature',
'pt100Temperature',
'sensorAverageTemperature',
'speed',
'force',
'viscosity',
'averageViscosity'
]
if miss := [f for f in metrics if f not in data]:
return HttpResponse(success=False, message=f"data字段缺少必要内容: {', '.join(miss)}")
report_request = WorkstationReportRequest(
token=request_data['token'],
request_time=request_data['request_time'],
data=data
)
result = {}
if hasattr(self.workstation, 'process_temperature_cutoff_report'):
result = self.workstation.process_temperature_cutoff_report(report_request)
return HttpResponse(
success=True,
message=f"温度/粘度报送已处理: 帧{data['frameCode']}",
acknowledgment_id=f"TEMP_CUTOFF_{int(time.time()*1000)}_{data['frameCode']}",
data=result
)
except Exception as e:
logger.error(f"处理温度/粘度报送失败: {e}\n{traceback.format_exc()}")
return HttpResponse(success=False, message=f"温度/粘度报送处理失败: {str(e)}")
def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse: def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理批量报送""" """处理批量报送"""
try: try:
@@ -510,6 +571,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"POST /report/batch_update", "POST /report/batch_update",
"POST /report/material_change", "POST /report/material_change",
"POST /report/error_handling", "POST /report/error_handling",
"POST /report/temperature-cutoff",
"GET /status", "GET /status",
"GET /health" "GET /health"
] ]
@@ -547,6 +609,22 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"""重写日志方法""" """重写日志方法"""
logger.debug(f"HTTP请求: {format % args}") logger.debug(f"HTTP请求: {format % args}")
def _save_raw_request(self, endpoint: str, request_data: Dict[str, Any]) -> None:
try:
base_dir = Path(__file__).resolve().parents[3] / "unilabos_data" / "http_reports"
base_dir.mkdir(parents=True, exist_ok=True)
log_path = getattr(self.workstation, "_http_log_path", None)
log_file = Path(log_path) if log_path else (base_dir / f"http_{int(time.time()*1000)}.log")
payload = {
"endpoint": endpoint,
"received_at": datetime.now().isoformat(),
"body": request_data
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
class WorkstationHTTPService: class WorkstationHTTPService:
"""工作站HTTP服务""" """工作站HTTP服务"""
@@ -572,6 +650,10 @@ class WorkstationHTTPService:
# 创建HTTP服务器 # 创建HTTP服务器
self.server = HTTPServer((self.host, self.port), handler_factory) self.server = HTTPServer((self.host, self.port), handler_factory)
base_dir = Path(__file__).resolve().parents[3] / "unilabos_data" / "http_reports"
base_dir.mkdir(parents=True, exist_ok=True)
session_log = base_dir / f"http_{int(time.time()*1000)}.log"
setattr(self.workstation, "_http_log_path", str(session_log))
# 安全地获取 device_id 用于线程命名 # 安全地获取 device_id 用于线程命名
device_id = "unknown" device_id = "unknown"
@@ -599,6 +681,7 @@ class WorkstationHTTPService:
logger.info("扩展报送端点:") logger.info("扩展报送端点:")
logger.info(" - POST /report/material_change # 物料变更报送") logger.info(" - POST /report/material_change # 物料变更报送")
logger.info(" - POST /report/error_handling # 错误处理报送") logger.info(" - POST /report/error_handling # 错误处理报送")
logger.info(" - POST /report/temperature-cutoff # 温度/粘度报送")
logger.info("兼容端点:") logger.info("兼容端点:")
logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成") logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成")
logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成") logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成")
@@ -700,6 +783,9 @@ if __name__ == "__main__":
def handle_external_error(self, error_data): def handle_external_error(self, error_data):
return {"handled": True} return {"handled": True}
def process_temperature_cutoff_report(self, report_request):
return {"processed": True, "metrics": report_request.data}
workstation = BioyondWorkstation() workstation = BioyondWorkstation()
http_service = WorkstationHTTPService(workstation) http_service = WorkstationHTTPService(workstation)
@@ -723,4 +809,3 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
print(f"服务器运行错误: {e}") print(f"服务器运行错误: {e}")
http_service.stop() http_service.stop()

View File

@@ -1,4 +1,4 @@
camera.USB: camera:
category: category:
- camera - camera
class: class:

View File

@@ -4,106 +4,6 @@ reaction_station.bioyond:
- reaction_station_bioyond - reaction_station_bioyond
class: class:
action_value_mappings: action_value_mappings:
auto-create_order:
feedback: {}
goal: {}
goal_default:
json_str: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
json_str:
type: string
required:
- json_str
type: object
result: {}
required:
- goal
title: create_order参数
type: object
type: UniLabJsonCommand
auto-merge_workflow_with_parameters:
feedback: {}
goal: {}
goal_default:
json_str: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
json_str:
type: string
required:
- json_str
type: object
result: {}
required:
- goal
title: merge_workflow_with_parameters参数
type: object
type: UniLabJsonCommand
auto-process_web_workflows:
feedback: {}
goal: {}
goal_default:
web_workflow_json: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
web_workflow_json:
type: string
required:
- web_workflow_json
type: object
result: {}
required:
- goal
title: process_web_workflows参数
type: object
type: UniLabJsonCommand
auto-workflow_step_query:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result: {}
required:
- goal
title: workflow_step_query参数
type: object
type: UniLabJsonCommand
drip_back: drip_back:
feedback: {} feedback: {}
goal: goal:
@@ -160,6 +60,56 @@ reaction_station.bioyond:
title: drip_back参数 title: drip_back参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
extract_actuals_from_batch_reports:
feedback: {}
goal:
batch_reports_result: batch_reports_result
goal_default:
batch_reports_result: ''
handles:
input:
- data_key: batch_reports_result
data_source: handle
data_type: string
handler_key: BATCH_REPORTS_RESULT
io_type: source
label: Batch Order Completion Reports
output:
- data_key: return_info
data_source: executor
data_type: string
handler_key: ACTUALS_EXTRACTED
io_type: sink
label: Extracted Actuals
result:
return_info: return_info
schema:
description: 从批量任务完成报告中提取每个订单的实际加料量输出extracted列表。
properties:
feedback: {}
goal:
properties:
batch_reports_result:
description: 批量任务完成信息JSON字符串或对象包含reports数组
type: string
required:
- batch_reports_result
type: object
result:
properties:
return_info:
description: JSON字符串包含actuals数组每项含order_code, order_id, actualTargetWeigh,
actualVolume
type: string
required:
- return_info
title: extract_actuals_from_batch_reports结果
type: object
required:
- goal
title: extract_actuals_from_batch_reports参数
type: object
type: UniLabJsonCommand
liquid_feeding_beaker: liquid_feeding_beaker:
feedback: {} feedback: {}
goal: goal:
@@ -287,22 +237,41 @@ reaction_station.bioyond:
feedback: {} feedback: {}
goal: goal:
assign_material_name: assign_material_name assign_material_name: assign_material_name
extracted_actuals: extracted_actuals
feeding_order_data: feeding_order_data
temperature: temperature temperature: temperature
time: time time: time
titration_type: titration_type titration_type: titration_type
torque_variation: torque_variation torque_variation: torque_variation
volume_formula: volume_formula volume_formula: volume_formula
x_value: x_value
goal_default: goal_default:
assign_material_name: '' assign_material_name: ''
temperature: '' extracted_actuals: ''
time: '' feeding_order_data: ''
titration_type: '' temperature: '25.00'
torque_variation: '' time: '90'
titration_type: '2'
torque_variation: '2'
volume_formula: '' volume_formula: ''
handles: {} x_value: ''
handles:
input:
- data_key: extracted_actuals
data_source: handle
data_type: string
handler_key: ACTUALS_EXTRACTED
io_type: source
label: Extracted Actuals From Reports
- data_key: feeding_order_data
data_source: handle
data_type: object
handler_key: feeding_order
io_type: source
label: Feeding Order Data From Calculation Node
result: {} result: {}
schema: schema:
description: 液体进料(滴定) description: 液体进料(滴定)。支持两种模式:1)直接提供volume_formula;2)自动计算-提供x_value+feeding_order_data+extracted_actuals,系统自动生成公式"1000*(m二酐-x)*V二酐滴定/m二酐滴定"
properties: properties:
feedback: {} feedback: {}
goal: goal:
@@ -310,28 +279,37 @@ reaction_station.bioyond:
assign_material_name: assign_material_name:
description: 物料名称 description: 物料名称
type: string type: string
extracted_actuals:
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定)
type: string
feeding_order_data:
description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例:
{"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}'
type: string
temperature: temperature:
description: 温度设定(°C) default: '25.00'
description: 温度设定(°C),默认25.00
type: string type: string
time: time:
description: 观察时间(分钟) default: '90'
description: 观察时间(分钟),默认90
type: string type: string
titration_type: titration_type:
description: 是否滴定(1=否, 2=是) default: '2'
description: 是否滴定(1=否, 2=是),默认2
type: string type: string
torque_variation: torque_variation:
description: 是否观察 (1=否, 2=是) default: '2'
description: 是否观察 (1=否, 2=是),默认2
type: string type: string
volume_formula: volume_formula:
description: 分液公式(μL) description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
type: string
x_value:
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
type: string type: string
required: required:
- volume_formula
- assign_material_name - assign_material_name
- time
- torque_variation
- titration_type
- temperature
type: object type: object
result: {} result: {}
required: required:
@@ -546,7 +524,19 @@ reaction_station.bioyond:
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
protocol_type: [] protocol_type: []
status_types: status_types:
workflow_sequence: String all_workflows: dict
average_viscosity: float
bioyond_status: dict
force: float
in_temperature: float
out_temperature: float
pt100_temperature: float
sensor_average_temperature: float
setting_temperature: float
speed: float
target_temperature: float
viscosity: float
workstation_status: dict
type: python type: python
config_info: [] config_info: []
description: Bioyond反应站 description: Bioyond反应站
@@ -558,18 +548,75 @@ reaction_station.bioyond:
config: config:
type: object type: object
deck: deck:
type: string type: object
protocol_type:
type: string
required: [] required: []
type: object type: object
data: data:
properties: properties:
workflow_sequence: all_workflows:
items: type: object
type: string bioyond_status:
type: array type: object
workstation_status:
type: object
required: required:
- workflow_sequence - bioyond_status
- all_workflows
- workstation_status
type: object
version: 1.0.0
reaction_station.reactor:
category:
- reactor
- reaction_station_bioyond
class:
action_value_mappings: {}
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
status_types:
average_viscosity: float
force: float
in_temperature: float
out_temperature: float
pt100_temperature: float
sensor_average_temperature: float
setting_temperature: float
speed: float
target_temperature: float
viscosity: float
type: python
config_info: []
description: 反应站子设备-反应器
handles: []
icon: reaction_station.webp
init_param_schema:
config:
properties:
config:
type: object
required: []
type: object
data:
properties:
average_viscosity:
type: number
force:
type: number
in_temperature:
type: number
out_temperature:
type: number
pt100_temperature:
type: number
sensor_average_temperature:
type: number
setting_temperature:
type: number
speed:
type: number
target_temperature:
type: number
viscosity:
type: number
required: []
type: object type: object
version: 1.0.0 version: 1.0.0

View File

@@ -18,9 +18,9 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
dx=10.0, dx=10.0,
dy=10.0, dy=10.0,
dz=10.0, dz=10.0,
item_dx=137.0, item_dx=147.0,
item_dy=96.0, item_dy=106.0,
item_dz=120.0, item_dz=130.0,
category="warehouse", category="warehouse",
col_offset=0, # 从01开始: A01, A02, A03, A04 col_offset=0, # 从01开始: A01, A02, A03, A04
layout="row-major", # ⭐ 改为行优先排序 layout="row-major", # ⭐ 改为行优先排序

View File

@@ -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', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None): def __init__(self, device_id='video_publisher', 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,
device_uuid=device_uuid,
status_types={}, status_types={},
action_value_mappings={}, action_value_mappings={},
hardware_interface="camera", hardware_interface="camera",