diff --git a/test/experiments/Simple_polyer.json b/test/experiments/Simple_polyer.json new file mode 100644 index 00000000..1aadf680 --- /dev/null +++ b/test/experiments/Simple_polyer.json @@ -0,0 +1,336 @@ +{ + "nodes": [ + { + "id": "ReactorX", + "name": "模拟常量合成工作站", + "children": [ + "reactor", + "IKA1", + "serial_pump", + "bottle_water_1", + "bottle_MAA_2", + "bottle_HEMA_3", + "bottle_acrylamide_4", + "bottle_TEA_5", + "bottle_ethanol_6", + "bottle_water_7", + "bottle_KPS_8", + "waste_bottle_1", + "waste_bottle_2", + "workbench_deck", + "lab_deck" + ], + "parent": null, + "type": "device", + "class": "workstation", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "protocol_type": ["EvacuateAndRefillProtocol"], + "deck": { + "data": { + "_resource_child_name": "lab_deck", + "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" + } + } + }, + "data": { + } + }, + { + "id": "reactor", + "name": "reactor", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { + "x": 698.1111111111111, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 5000.0, + "size_x": 200.0, + "size_y": 200.0, + "size_z": 200.0 + }, + "data": { "liquids": [] } + }, + { + "id": "workbench_deck", + "name": "workbench_deck", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 0, "y": 0, "z": 0 }, + "config": { + "max_volume": 0.0, + "size_x": 60000.0, + "size_y": 36000.0, + "size_z": 10.0 + }, + "data": { "liquids": [] } + }, + { + "id": "lab_deck", + "name": "lab_deck", + "children": [], + "parent": "ReactorX", + "type": "deck", + "class": "OTDeck", + "position": { "x": 0, "y": 0, "z": 0 }, + "config": { + "type": "OTDeck", + "with_trash": false, + "rotation": { "x": 0, "y": 0, "z": 0, "type": "Rotation" } + }, + "data": { "liquids": [] } + }, + { + "id": "IKA1", + "name": "IKA HeaterStirrer", + "children": [], + "parent": "ReactorX", + "type": "device", + "class": "heaterstirrer.ika", + "position": { + "x": 620, + "y": 428, + "z": 0 + }, + "config": { + "port": "COM7", + "baudrate": 9600 + }, + "data": { + "status": "Idle", + "stir_speed": 0, + "temp": 20, + "temp_target": 20 + } + }, + { + "id": "serial_pump", + "name": "serial_pump", + "children": [], + "parent": "ReactorX", + "type": "device", + "class": "serial", + "position": { "x": 780, "y": 80, "z": 0 }, + "config": { "port": "COM8", "baudrate": 9600 }, + "data": {} + }, + { + "id": "pump_T08_1", + "name": "T08-1", + "children": [], + "parent": "serial_pump", + "type": "device", + "class": "syringe_pump_with_valve.runze.SY03B-T08", + "position": { "x": 640, "y": 200, "z": 0 }, + "config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }, + "data": {} + }, + { + "id": "pump_T06_1", + "name": "T06-1", + "children": [], + "parent": "serial_pump", + "type": "device", + "class": "syringe_pump_with_valve.runze.SY03B-T06", + "position": { "x": 940, "y": 200, "z": 0 }, + "config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }, + "data": {} + }, + { + "id": "bottle_water_1", + "name": "bottle_water_1", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 360, "y": 360, "z": 0 }, + "config": { "max_volume": 1000.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["Water", 500.0]] } + }, + { + "id": "bottle_MAA_2", + "name": "bottle_MAA_2", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 360, "y": 440, "z": 0 }, + "config": { "max_volume": 500.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["MAA", 300.0]] } + }, + { + "id": "bottle_HEMA_3", + "name": "bottle_HEMA_3", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 360, "y": 520, "z": 0 }, + "config": { "max_volume": 500.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["HEMA", 300.0]] } + }, + { + "id": "bottle_acrylamide_4", + "name": "bottle_acrylamide_4", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 260, "y": 520, "z": 0 }, + "config": { "max_volume": 500.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["Acrylamide", 300.0]] } + }, + { + "id": "bottle_TEA_5", + "name": "bottle_TEA_5", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 260, "y": 440, "z": 0 }, + "config": { "max_volume": 500.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["Triethylamine", 300.0]] } + }, + { + "id": "bottle_ethanol_6", + "name": "bottle_ethanol_6", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 920, "y": 520, "z": 0 }, + "config": { "max_volume": 1000.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["Ethanol", 500.0]] } + }, + { + "id": "bottle_water_7", + "name": "bottle_water_7", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 920, "y": 440, "z": 0 }, + "config": { "max_volume": 1000.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["Water", 500.0]] } + }, + { + "id": "bottle_KPS_8", + "name": "bottle_KPS_8", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 920, "y": 360, "z": 0 }, + "config": { "max_volume": 500.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [["K2S2O8", 200.0]] } + }, + { + "id": "waste_bottle_1", + "name": "waste_bottle_1", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 540, "y": 600, "z": 0 }, + "config": { "max_volume": 2000.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [] } + }, + { + "id": "waste_bottle_2", + "name": "waste_bottle_2", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": "container", + "position": { "x": 600, "y": 600, "z": 0 }, + "config": { "max_volume": 2000.0, "size_x": 80.0, "size_y": 80.0, "size_z": 150.0 }, + "data": { "liquids": [] } + } + ], + "links": [ + { + "source": "pump_T08_1", + "target": "bottle_water_7", + "type": "fluid", + "port": { "pump_T08_1": "7", "bottle_water_7": "top" } + }, + { + "source": "pump_T08_1", + "target": "bottle_MAA_2", + "type": "fluid", + "port": { "pump_T08_1": "6", "bottle_MAA_2": "top" } + }, + { + "source": "pump_T08_1", + "target": "bottle_HEMA_3", + "type": "fluid", + "port": { "pump_T08_1": "5", "bottle_HEMA_3": "top" } + }, + { + "source": "pump_T08_1", + "target": "bottle_acrylamide_4", + "type": "fluid", + "port": { "pump_T08_1": "4", "bottle_acrylamide_4": "top" } + }, + { + "source": "pump_T08_1", + "target": "bottle_TEA_5", + "type": "fluid", + "port": { "pump_T08_1": "1", "bottle_TEA_5": "top" } + }, + { + "source": "pump_T08_1", + "target": "reactor", + "type": "fluid", + "port": { "pump_T08_1": "3", "reactor": "top" } + }, + { + "source": "pump_T08_1", + "target": "waste_bottle_1", + "type": "fluid", + "port": { "pump_T08_1": "8", "waste_bottle_1": "top" } + }, + { + "source": "pump_T06_1", + "target": "bottle_ethanol_6", + "type": "fluid", + "port": { "pump_T06_1": "4", "bottle_ethanol_6": "top" } + }, + { + "source": "pump_T06_1", + "target": "bottle_water_1", + "type": "fluid", + "port": { "pump_T06_1": "3", "bottle_water_1": "top" } + }, + { + "source": "pump_T06_1", + "target": "bottle_KPS_8", + "type": "fluid", + "port": { "pump_T06_1": "2", "bottle_KPS_8": "top" } + }, + { + "source": "pump_T08_1", + "target": "pump_T06_1", + "type": "fluid", + "port": {"pump_T08_1": "2" , "pump_T06_1": "5"} + }, + + { + "source": "pump_T06_1", + "target": "waste_bottle_2", + "type": "fluid", + "port": { "pump_T06_1": "6", "waste_bottle_2": "top" } + } + ] +} \ No newline at end of file diff --git a/unilabos/devices/heaterstirrer/__init__.py b/unilabos/devices/heaterstirrer/__init__.py index e69de29b..aa23a4ee 100644 --- a/unilabos/devices/heaterstirrer/__init__.py +++ b/unilabos/devices/heaterstirrer/__init__.py @@ -0,0 +1 @@ +from .ika import HeaterStirrer_IKA diff --git a/unilabos/devices/heaterstirrer/ika.py b/unilabos/devices/heaterstirrer/ika.py new file mode 100644 index 00000000..87256167 --- /dev/null +++ b/unilabos/devices/heaterstirrer/ika.py @@ -0,0 +1,190 @@ +import time +import asyncio +from contextlib import contextmanager + +import serial + + +class IkaNamurClient: + """IKA NAMUR 串口客户端(RS-232 9600 7E1,CRLF)。 + + 提供基础的 send() 以及常用指令的便捷方法。 + """ + + def __init__(self, port: str = "COM7", baud: int = 9600, timeout: float = 1.0): + self.port = port + self.baud = baud + self.timeout = timeout + self._ser: serial.Serial | None = None + + def open(self) -> None: + if self._ser and self._ser.is_open: + return + self._ser = serial.Serial( + self.port, + self.baud, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout, + write_timeout=2, + ) + time.sleep(0.1) + + def close(self) -> None: + if self._ser and self._ser.is_open: + self._ser.close() + + def send(self, *tokens: str) -> str: + if not self._ser or not self._ser.is_open: + self.open() + line = " ".join(tokens).upper() + payload = (line + "\r\n").encode("ascii") + self._ser.reset_input_buffer() + self._ser.write(payload) + time.sleep(0.25) + buf = bytearray() + end = time.time() + self.timeout + while time.time() < end or self._ser.in_waiting: + data = self._ser.read(self._ser.in_waiting or 1) + if data: + buf.extend(data) + else: + time.sleep(0.02) + return buf.decode("ascii", errors="ignore").strip() + + # 便捷方法 + def read_name(self) -> str: + return self.send("IN_NAME") + + def read_speed(self) -> str: + return self.send("IN_PV_4") + + def read_speed_setpoint(self) -> str: + return self.send("IN_SP_4") + + def set_speed(self, rpm: int) -> str: + return self.send("OUT_SP_4", str(rpm)) + + def start(self) -> str: + return self.send("START_4") + + def stop(self) -> str: + return self.send("STOP_4") + + +@contextmanager +def ika_client(port: str = "COM7", baud: int = 9600, timeout: float = 1.0): + cli = IkaNamurClient(port, baud, timeout) + try: + cli.open() + yield cli + finally: + cli.close() + + +class HeaterStirrer_IKA: + """IKA 加热搅拌器(NAMUR 协议)统一接口,供 unilabos 调用。""" + + def __init__(self, port: str = "COM7", baudrate: int = 9600, timeout: float = 1.0): + self._status = "Idle" + self._stir_speed = 0.0 + self._temp_target = 20.0 + self._cli = IkaNamurClient(port=port, baud=baudrate, timeout=timeout) + self._cli.open() + + @property + def status(self) -> str: + self._status = "Idle" if self._stir_speed == 0 else "Running" + return self._status + + @property + def stir_speed(self) -> float: + return self._stir_speed + + def set_stir_speed(self, speed: float): + speed_int = int(float(speed)) + self._cli.set_speed(speed_int) + if speed_int > 0: + self._cli.start() + else: + self._cli.stop() + self._stir_speed = float(speed_int) + + @property + def temp_target(self) -> float: + return self._temp_target + + def set_temp_target(self, temp: float): + self._temp_target = float(temp) + self._cli.send("OUT_SP_1", f"{int(self._temp_target)}") + self._cli.send("START_1") + + @property + def temp(self) -> float: + # 具体型号若支持查询实际温度,可在此扩展 NAMUR 读指令 + return self._temp_target + + # 兼容 stir_protocol.py 的动作接口 + def _extract_vessel_id(self, vessel) -> str: + if isinstance(vessel, dict): + return str(vessel.get("id", "")) + return str(vessel) + + async def start_stir(self, vessel, stir_speed: float, purpose: str = "") -> bool: + """开始持续搅拌(协议动作) + + - vessel: 可为字符串或形如 {"id": "..."} 的字典 + - stir_speed: 目标转速 RPM + - purpose: 可选用途描述(仅用于上层日志) + """ + _ = self._extract_vessel_id(vessel) # 当前实现不强依赖容器,仅做形参兼容 + try: + speed_int = int(float(stir_speed)) + except (ValueError, TypeError): + speed_int = 0 + # 同步串口调用放入线程,避免阻塞事件循环 + await asyncio.to_thread(self.set_stir_speed, speed_int) + return True + + async def stop_stir(self, vessel) -> bool: + """停止搅拌(协议动作)""" + _ = self._extract_vessel_id(vessel) + await asyncio.to_thread(self.set_stir_speed, 0) + return True + + async def stir(self, stir_time: float, stir_speed: float, settling_time: float, **kwargs) -> bool: + """定时搅拌 + 沉降(协议动作) + + - stir_time: 搅拌时间(秒) + - stir_speed: 搅拌速度(RPM) + - settling_time: 沉降时间(秒) + 其余 kwargs(如 vessel/time/time_spec/event)按协议形态传入,此处可忽略。 + """ + try: + total_stir_seconds = max(0.0, float(stir_time)) + except (ValueError, TypeError): + total_stir_seconds = 0.0 + try: + speed_int = int(float(stir_speed)) + except (ValueError, TypeError): + speed_int = 0 + try: + total_settle_seconds = max(0.0, float(settling_time)) + except (ValueError, TypeError): + total_settle_seconds = 0.0 + + # 开始搅拌 + await asyncio.to_thread(self.set_stir_speed, speed_int) + if total_stir_seconds > 0: + await asyncio.sleep(total_stir_seconds) + + # 停止搅拌进入沉降 + await asyncio.to_thread(self.set_stir_speed, 0) + if total_settle_seconds > 0: + await asyncio.sleep(total_settle_seconds) + + return True + + def close(self): + self._cli.close() \ No newline at end of file diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml index 40fd9d3e..002d44bd 100644 --- a/unilabos/registry/devices/pump_and_valve.yaml +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -847,6 +847,14 @@ syringe_pump_with_valve.runze.SY03B-T06: io_type: target label: 6-in side: WEST + - data_key: fluid_port_5 + data_source: executor + data_type: fluid + description: 六通阀门端口5-特殊输入 + handler_key: '5' + io_type: target + label: 5-in + side: WEST icon: '' init_param_schema: config: diff --git a/unilabos/registry/devices/temperature.yaml b/unilabos/registry/devices/temperature.yaml index 874fe517..38cb5027 100644 --- a/unilabos/registry/devices/temperature.yaml +++ b/unilabos/registry/devices/temperature.yaml @@ -573,6 +573,119 @@ heaterstirrer.dalong: - temp_target type: object version: 1.0.0 +heaterstirrer.ika: + category: + - temperature + class: + action_value_mappings: + auto-set_stir_speed: + feedback: {} + goal: {} + goal_default: + speed: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: set_stir_speed的参数schema + properties: + feedback: {} + goal: + properties: + speed: + type: number + required: + - speed + type: object + result: {} + required: + - goal + title: set_stir_speed参数 + type: object + type: UniLabJsonCommand + set_temp_target: + feedback: {} + goal: + command: temp + goal_default: + command: '' + handles: {} + result: + success: success + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: SendCmd_Feedback + type: object + goal: + properties: + command: + type: string + required: + - command + title: SendCmd_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: SendCmd_Result + type: object + required: + - goal + title: SendCmd + type: object + type: SendCmd + module: unilabos.devices.heaterstirrer.ika:HeaterStirrer_IKA + status_types: + status: str + stir_speed: float + temp: float + temp_target: float + type: python + config_info: [] + description: IKA 加热搅拌器(NAMUR 串口协议,9600 7E1),提供转速设置与加热控制。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 9600 + type: integer + port: + default: COM7 + type: string + required: [] + type: object + data: + properties: + status: + type: string + stir_speed: + type: number + temp: + type: number + temp_target: + type: number + required: + - status + - stir_speed + - temp + - temp_target + type: object + version: 1.0.0 tempsensor: category: - temperature diff --git a/unilabos/ros/nodes/presets/serial_node.py b/unilabos/ros/nodes/presets/serial_node.py index 545682bd..e222479c 100644 --- a/unilabos/ros/nodes/presets/serial_node.py +++ b/unilabos/ros/nodes/presets/serial_node.py @@ -27,6 +27,7 @@ def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: # 初始化BaseROS2DeviceNode,使用自身作为driver_instance BaseROS2DeviceNode.__init__( self, + device_uuid=kwargs.get("device_uuid", str(uuid.uuid4())), driver_instance=self, device_id=device_id, status_types={}, diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index 22919c5c..0481a872 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -385,8 +385,14 @@ def to_plr_resources(self) -> List["PLRResource"]: import inspect # 类型映射 - TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck"} - + # 统一将常见小写类型映射到 PLR 类名;同时兜底将 "resource" 视作通用容器 + TYPE_MAP = { + "plate": "Plate", + "well": "Well", + "deck": "Deck", + "container": "Container", + "resource": "Container", + } def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict): """一次遍历收集 name_to_uuid 和 all_states""" name_to_uuid[node.res_content.name] = node.res_content.uuid