From 549a50220bbd598ed6c7f6b31d3c57137458f4bb Mon Sep 17 00:00:00 2001 From: ZiWei <131428629+ziwei09@users.noreply.github.com> Date: Sun, 16 Nov 2025 14:35:53 +0800 Subject: [PATCH] fix camera & workstation & warehouse & reaction station driver --- .../experiments/reaction_station_bioyond.json | 63 ++- .../bioyond_studio/reaction_station.py | 435 +++++++++++++++++- .../workstation/bioyond_studio/station.py | 7 +- .../workstation/workstation_http_service.py | 89 +++- unilabos/registry/devices/camera.yaml | 2 +- .../devices/reaction_station_bioyond.yaml | 297 +++++++----- unilabos/resources/bioyond/warehouses.py | 6 +- unilabos/ros/nodes/presets/camera.py | 3 +- 8 files changed, 761 insertions(+), 141 deletions(-) diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json index 7f0ab208..5cbe5b43 100644 --- a/test/experiments/reaction_station_bioyond.json +++ b/test/experiments/reaction_station_bioyond.json @@ -5,10 +5,16 @@ "name": "reaction_station_bioyond", "parent": null, "children": [ - "Bioyond_Deck" + "Bioyond_Deck", + "reactor_1", + "reactor_2", + "reactor_3", + "reactor_4", + "reactor_5" ], "type": "device", "class": "reaction_station.bioyond", + "position": {"x": 0, "y": 3800, "z": 0}, "config": { "config": { "api_key": "DE9BDDA0", @@ -64,6 +70,61 @@ }, "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", "name": "Bioyond_Deck", diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index 071d99b5..68440a3b 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -1,7 +1,11 @@ import json +import time import requests 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.ros.msgs.message_converter import convert_to_ros_msg, Float64, String from unilabos.devices.workstation.bioyond_studio.config import ( WORKFLOW_STEP_IDS, 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 +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): """Bioyond反应站类 @@ -37,6 +72,19 @@ class BioyondReactionStation(BioyondWorkstation): print(f"BioyondReactionStation初始化完成 - workflow_mappings: {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): @@ -291,22 +339,39 @@ class BioyondReactionStation(BioyondWorkstation): def liquid_feeding_titration( self, - volume_formula: 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", torque_variation: int = 2, temperature: float = 25.00 ): """液体进料(滴定) + 支持两种模式: + 1. 直接提供 volume_formula (传统方式) + 2. 自动计算公式: 提供 x_value, feeding_order_data, extracted_actuals (新方式) + Args: - volume_formula: 分液公式(μL) 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: 观察时间(分钟) torque_variation: 是否观察(int类型, 1=否, 2=是) 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)"}') material_id = self.hardware_interface._get_material_id_by_name(assign_material_name) @@ -316,6 +381,84 @@ class BioyondReactionStation(BioyondWorkstation): if isinstance(temperature, str): 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"] observe_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["observe"] @@ -343,6 +486,286 @@ class BioyondReactionStation(BioyondWorkstation): print(f"当前队列长度: {len(self.pending_task_params)}") 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( self, volume: str = "350", @@ -580,10 +1003,10 @@ class BioyondReactionStation(BioyondWorkstation): # print(f"\n✅ 任务创建成功: {result}") # print(f"\n✅ 任务创建成功") print(f"{'='*60}\n") - + # 返回结果,包含合并后的工作流数据和订单参数 return json.dumps({ - "success": True, + "success": True, "result": result, "merged_workflow": merged_workflow, "order_params": order_params diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index cb080f02..e349b083 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -9,6 +9,7 @@ import traceback from datetime import datetime from typing import Dict, Any, List, Optional, Union import json +from pathlib import Path from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer 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.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 unilabos.devices.workstation.bioyond_studio.config import ( @@ -676,13 +678,13 @@ class BioyondWorkstation(WorkstationBase): self._synced_resources = [] def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): - time.sleep(3) - ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ + future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": mount_device_id, "target_resources": mount_resource, "sites": sites, }) + return future def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: """创建Bioyond通信模块""" @@ -1327,6 +1329,7 @@ class BioyondWorkstation(WorkstationBase): logger.error(f"处理物料变更报送失败: {e}") return {"processed": False, "error": str(e)} + def handle_external_error(self, error_data: Dict[str, Any]) -> Dict[str, Any]: """处理错误处理报送 diff --git a/unilabos/devices/workstation/workstation_http_service.py b/unilabos/devices/workstation/workstation_http_service.py index 87f5332b..11d87693 100644 --- a/unilabos/devices/workstation/workstation_http_service.py +++ b/unilabos/devices/workstation/workstation_http_service.py @@ -22,6 +22,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse from dataclasses import dataclass, asdict from datetime import datetime +from pathlib import Path from unilabos.utils.log import logger @@ -76,6 +77,14 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): 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协议规范) if endpoint == '/report/step_finish': response = self._handle_step_finish_report(request_data) @@ -90,6 +99,8 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): response = self._handle_material_change_report(request_data) elif endpoint == '/report/error_handling': response = self._handle_error_handling_report(request_data) + elif endpoint == '/report/temperature-cutoff': + response = self._handle_temperature_cutoff_report(request_data) # 保留LIMS协议端点以兼容现有系统 elif endpoint == '/LIMS/step_finish': response = self._handle_step_finish_report(request_data) @@ -107,7 +118,8 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): "/report/order_finish", "/report/batch_update", "/report/material_change", - "/report/error_handling" + "/report/error_handling", + "/report/temperature-cutoff" ]} ) @@ -128,6 +140,11 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): parsed_path = urlparse(self.path) endpoint = parsed_path.path + try: + self._save_raw_request(endpoint, {"method": "GET"}) + except Exception: + pass + if endpoint == '/status': response = self._handle_status_check() elif endpoint == '/health': @@ -318,6 +335,50 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): 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: """处理批量报送""" try: @@ -510,6 +571,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): "POST /report/batch_update", "POST /report/material_change", "POST /report/error_handling", + "POST /report/temperature-cutoff", "GET /status", "GET /health" ] @@ -547,6 +609,22 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): """重写日志方法""" 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: """工作站HTTP服务""" @@ -572,6 +650,10 @@ class WorkstationHTTPService: # 创建HTTP服务器 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 = "unknown" @@ -599,6 +681,7 @@ class WorkstationHTTPService: logger.info("扩展报送端点:") logger.info(" - POST /report/material_change # 物料变更报送") logger.info(" - POST /report/error_handling # 错误处理报送") + logger.info(" - POST /report/temperature-cutoff # 温度/粘度报送") logger.info("兼容端点:") logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成") logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成") @@ -700,6 +783,9 @@ if __name__ == "__main__": def handle_external_error(self, error_data): return {"handled": True} + def process_temperature_cutoff_report(self, report_request): + return {"processed": True, "metrics": report_request.data} + workstation = BioyondWorkstation() http_service = WorkstationHTTPService(workstation) @@ -723,4 +809,3 @@ if __name__ == "__main__": except Exception as e: print(f"服务器运行错误: {e}") http_service.stop() - diff --git a/unilabos/registry/devices/camera.yaml b/unilabos/registry/devices/camera.yaml index 08c809cf..b64b342c 100644 --- a/unilabos/registry/devices/camera.yaml +++ b/unilabos/registry/devices/camera.yaml @@ -1,4 +1,4 @@ -camera.USB: +camera: category: - camera class: diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 84918b3d..84bcf4a9 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -4,106 +4,6 @@ reaction_station.bioyond: - reaction_station_bioyond class: 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: feedback: {} goal: @@ -160,6 +60,56 @@ reaction_station.bioyond: title: drip_back参数 type: object 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: feedback: {} goal: @@ -287,22 +237,41 @@ reaction_station.bioyond: feedback: {} goal: assign_material_name: assign_material_name + extracted_actuals: extracted_actuals + feeding_order_data: feeding_order_data temperature: temperature time: time titration_type: titration_type torque_variation: torque_variation volume_formula: volume_formula + x_value: x_value goal_default: assign_material_name: '' - temperature: '' - time: '' - titration_type: '' - torque_variation: '' + extracted_actuals: '' + feeding_order_data: '' + temperature: '25.00' + time: '90' + titration_type: '2' + torque_variation: '2' 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: {} schema: - description: 液体进料(滴定) + description: 液体进料(滴定)。支持两种模式:1)直接提供volume_formula;2)自动计算-提供x_value+feeding_order_data+extracted_actuals,系统自动生成公式"1000*(m二酐-x)*V二酐滴定/m二酐滴定" properties: feedback: {} goal: @@ -310,28 +279,37 @@ reaction_station.bioyond: assign_material_name: description: 物料名称 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: - description: 温度设定(°C) + default: '25.00' + description: 温度设定(°C),默认25.00 type: string time: - description: 观察时间(分钟) + default: '90' + description: 观察时间(分钟),默认90 type: string titration_type: - description: 是否滴定(1=否, 2=是) + default: '2' + description: 是否滴定(1=否, 2=是),默认2 type: string torque_variation: - description: 是否观察 (1=否, 2=是) + default: '2' + description: 是否观察 (1=否, 2=是),默认2 type: string volume_formula: - description: 分液公式(μL) + description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + type: string + x_value: + description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 type: string required: - - volume_formula - assign_material_name - - time - - torque_variation - - titration_type - - temperature type: object result: {} required: @@ -546,7 +524,19 @@ reaction_station.bioyond: module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation protocol_type: [] 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 config_info: [] description: Bioyond反应站 @@ -558,18 +548,75 @@ reaction_station.bioyond: config: type: object deck: - type: string - protocol_type: - type: string + type: object required: [] type: object data: properties: - workflow_sequence: - items: - type: string - type: array + all_workflows: + type: object + bioyond_status: + type: object + workstation_status: + type: object 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 version: 1.0.0 diff --git a/unilabos/resources/bioyond/warehouses.py b/unilabos/resources/bioyond/warehouses.py index 2ca68d97..31862512 100644 --- a/unilabos/resources/bioyond/warehouses.py +++ b/unilabos/resources/bioyond/warehouses.py @@ -18,9 +18,9 @@ def bioyond_warehouse_1x4x4(name: str) -> WareHouse: dx=10.0, dy=10.0, dz=10.0, - item_dx=137.0, - item_dy=96.0, - item_dz=120.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, category="warehouse", col_offset=0, # 从01开始: A01, A02, A03, A04 layout="row-major", # ⭐ 改为行优先排序 diff --git a/unilabos/ros/nodes/presets/camera.py b/unilabos/ros/nodes/presets/camera.py index e161671f..25ae921a 100644 --- a/unilabos/ros/nodes/presets/camera.py +++ b/unilabos/ros/nodes/presets/camera.py @@ -6,12 +6,13 @@ from cv_bridge import CvBridge from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker 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.__init__( self, driver_instance=self, device_id=device_id, + device_uuid=device_uuid, status_types={}, action_value_mappings={}, hardware_interface="camera",