diff --git a/.conda/base/recipe.yaml b/.conda/base/recipe.yaml index a63dda77a..57318aae7 100644 --- a/.conda/base/recipe.yaml +++ b/.conda/base/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos - version: 0.10.19 + version: 0.11.3 source: path: ../../unilabos @@ -54,7 +54,7 @@ requirements: - pymodbus - matplotlib - pylibftdi - - uni-lab::unilabos-env ==0.10.19 + - uni-lab::unilabos-env ==0.11.3 about: repository: https://github.com/deepmodeling/Uni-Lab-OS diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index e9fd3e248..c41dac2bb 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.10.19 + version: 0.11.3 build: noarch: generic diff --git a/.conda/full/recipe.yaml b/.conda/full/recipe.yaml index ab0e0c9fa..b3a839908 100644 --- a/.conda/full/recipe.yaml +++ b/.conda/full/recipe.yaml @@ -3,7 +3,7 @@ package: name: unilabos-full - version: 0.10.19 + version: 0.11.3 build: noarch: generic @@ -11,7 +11,7 @@ build: requirements: run: # Base unilabos package (includes unilabos-env) - - uni-lab::unilabos ==0.10.19 + - uni-lab::unilabos ==0.11.3 # Documentation tools - sphinx - sphinx_rtd_theme diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md index 61b6252e6..b294dc18a 100644 --- a/.cursor/skills/add-device/SKILL.md +++ b/.cursor/skills/add-device/SKILL.md @@ -5,9 +5,98 @@ description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses # 添加新设备到 Uni-Lab-OS -**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。 +本 Skill 是自包含的设备接入指南,不依赖外部文档。迁移给别人时,只复制 `.cursor/skills/add-device/SKILL.md` 即可获得核心规则、模板、验证方式和常见错误清单。 -该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 +开始实现前,仍应搜索 `unilabos/devices/` 获取同类别已有设备的接口、参数名、状态字符串和返回值风格作为参考。 + +--- + +## 接入工作流 + +按下面顺序推进,并在工作中维护进度: + +```text +设备接入进度: +- [ ] 1. 确定设备类别(物模型)和对外单位 +- [ ] 2. 确定通信协议 +- [ ] 3. 收集指令协议(SDK、厂商文档、寄存器表、HTTP API、用户口述) +- [ ] 4. 对齐同类设备接口(搜索 unilabos/devices/) +- [ ] 5. 创建驱动 unilabos/devices//.py +- [ ] 6. 验证可导入、注册表扫描、启动测试 +- [ ] 7. 如需要,配置实验图文件 +``` + +## 设备类别(物模型) + +优先使用已有类别。只有确实无法归类时才使用 `custom`。 + +| 类别 ID | 说明 | 标准属性 | 标准动作 | +|---|---|---|---| +| `temperature` | 加热、冷却、温控 | `temp`, `temp_target`, `status` | `set_temperature`, `stop` | +| `pump_and_valve` | 泵、阀门、注射器 | 见子类型表 | 见子类型表 | +| `motor` | 电机、步进马达 | `position`, `status` | `enable`, `move_position`, `move_speed`, `stop` | +| `heaterstirrer` | 加热搅拌一体机 | `temp`, `stir_speed`, `status` | `set_temperature`, `stir`, `stop` | +| `balance` | 天平、称重 | `weight`, `unit`, `status` | `tare`, `read_weight` | +| `sensor` | 传感器(液位、温度等) | `value`, `level`, `status` | `read_value`, `set_threshold` | +| `liquid_handling` | 液体处理机器人 | `status`, `deck_state` | `transfer_liquid`, `aspirate`, `dispense` | +| `robot_arm` | 机械臂 | `arm_pose`, `arm_status` | `moveit_task`, `pick_and_place` | +| `workstation` | 工作站、组合设备 | `workflow_sequence`, `material_info` | `create_order`, `scheduler_start`, `scheduler_stop` | +| `virtual` | 虚拟、模拟设备 | 按模拟的真实设备定义 | 按模拟的真实设备定义 | +| `custom` | 不属于以上类别 | 用户自定义 | 用户自定义 | + +`pump_and_valve` 子类型: + +| 子类型 | 最小通用属性 | 最小通用动作 | 单位约定 | +|---|---|---|---| +| 注射泵(syringe pump) | `status`, `valve_position`, `position` | `initialize`, `set_valve_position`, `set_position`, `pull_plunger`, `push_plunger`, `stop_operation` | 体积=mL, 速度=mL/s | +| 电磁阀(solenoid valve) | `status`, `valve_position` | `open`, `close`, `set_valve_position` | 无 | +| 蠕动泵(peristaltic pump) | `status`, `speed` | `start`, `stop`, `set_speed` | 流速=mL/min | + +对外暴露的属性和动作参数必须使用用户友好的物理单位(mL、ul、degC、RPM 等),硬件原始值转换放在驱动内部。 + +## 通信协议和指令来源 + +先确认通信方式,再确认具体指令协议。物模型只定义设备“应该做什么”,不会告诉你硬件“具体发什么字节/请求”。 + +| 协议 | 常用 config 参数 | 常用依赖 | 现有抽象 | +|---|---|---|---| +| Serial (RS232/RS485) | `port`, `baudrate`, `timeout` | `pyserial` | 直接使用 `serial.Serial` | +| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` | +| TCP Socket | `host`, `port`, `timeout` | stdlib | 直接使用 `socket` | +| HTTP API | `url`, `token`, `timeout` | `requests` | `device_comms/rpc.py` | +| OPC UA | `url` | `opcua` | `device_comms/opcua_client/` | +| 无通信(虚拟) | 无 | 无 | 在动作中模拟行为 | + +必须从以下来源之一获得指令细节: + +| 来源 | 处理方式 | +|---|---| +| 现成 SDK/驱动代码 | 读取代码,提取指令逻辑,包装进 Uni-Lab-OS 类 | +| 协议文档/手册 | 解析命令、响应、校验、寄存器、错误码 | +| 用户口述 | 按描述实现指令编解码,标出不确定点 | +| 标准协议 | 使用标准实现,例如 Modbus 寄存器表、SCPI | +| 虚拟设备 | 跳过硬件通信,在动作方法中维护模拟状态 | + +## 对齐已有实现(强制) + +实现前必须搜索 `unilabos/devices/` 中同类别设备: + +- 参数名必须与已有设备保持一致;动作方法参数名是接口契约,不要随意改成 `volume_ml`、`target_temp_c` 这类新名字。 +- `status` 字符串值要和同类设备一致,优先使用英文稳定值,例如 `Idle`、`Running`、`Error`。 +- 状态属性用 `@property` + `@topic_config()` 明确声明。 +- 返回值使用结构化 dict,至少包含 `success`,需要给前端展示的信息放在 `message`、`data`、`error` 等字段。 + +## 架构选择 + +| 场景 | 推荐方式 | +|---|---| +| 简单设备 | 纯 Python 类 + `@device` | +| 工作站/组合设备 | `WorkstationBase` 或项目内已有工作站模式 | +| 液体处理 | `LiquidHandlerAbstract` / PyLabRobot 相关模式 | +| Modbus 设备 | 复用 `device_comms/modbus_plc/` 或项目内 Modbus 示例 | +| OPC UA 设备 | 复用 `device_comms/opcua_client/` | +| 外部独立包 | 使用 `create-device-package` skill | --- @@ -20,10 +109,10 @@ from unilabos.registry.decorators import device # 单设备 @device( - id="my_device.vendor", # 注册表唯一标识(必填) + id="my_device_vendor", # 注册表唯一标识(必填,只能包含英文、数字、下划线) category=["temperature"], # 分类标签列表(必填) description="设备描述", # 设备描述 - display_name="显示名称", # UI 显示名称(默认用 id) + displayname="显示名称", # UI 显示名称(默认用 id) icon="DeviceIcon.webp", # 图标文件名 version="1.0.0", # 版本号 device_type="python", # "python" 或 "ros2" @@ -34,15 +123,20 @@ from unilabos.registry.decorators import device # 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置) @device( - ids=["pump.vendor.model_A", "pump.vendor.model_B"], + ids=["pump_vendor_model_A", "pump_vendor_model_B"], id_meta={ - "pump.vendor.model_A": {"handles": [...], "description": "型号 A"}, - "pump.vendor.model_B": {"handles": [...], "description": "型号 B"}, + "pump_vendor_model_A": {"handles": [...], "description": "型号 A", "displayname": "泵型号 A"}, + "pump_vendor_model_B": {"handles": [...], "description": "型号 B", "displayname": "泵型号 B"}, }, category=["pump_and_valve"], ) ``` +**ID 与显示名规则:** +- `id` / `ids` 是注册表稳定标识,只能包含英文大小写字母、数字、下划线,推荐格式为 `vendor_model` 或 `category_vendor_model`。 +- `id` / `ids` 不能包含中文、空格、短横线、点号或其他符号;不要把中文设备名放进 id。 +- 中文名、品牌型号展示名、UI 友好名称使用 `displayname`,不要塞进 `id`。 + ### @action — 动作方法装饰器 ```python @@ -71,6 +165,45 @@ from unilabos.registry.decorators import action - `_` 开头的方法 → 不扫描 - `@not_action` 标记的方法 → 排除 +### 参数文档 → JSON Schema 元数据 + +在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息: + +```python +""" +Args: + param[显示名称]: 参数说明,会写入 JSON Schema 的 description。 +""" +``` + +- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。 +- `:` 后面的说明会写入 goal property 的 `description`。 +- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。 +- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。 + +### 特殊参数类型:ResourceSlot / DeviceSlot + +需要前端选择资源或设备时,用特殊类型注解,registry 会自动生成 `placeholder_keys`: + +```python +from typing import List +from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot + +@action(description="转移液体") +def transfer(self, source: ResourceSlot, target: ResourceSlot, volume_ul: float) -> dict: + """ + Args: + source[源资源]: 源容器或孔位。 + target[目标资源]: 目标容器或孔位。 + volume_ul[体积(ul)]: 转移体积。 + """ + return {"success": True} + +@action(description="同步设备") +def sync_devices(self, devices: List[DeviceSlot]) -> dict: + return {"success": True, "count": len(devices)} +``` + ### @topic_config — 状态属性配置 ```python @@ -105,15 +238,40 @@ import logging from typing import Any, Dict, Optional from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.registry.decorators import device, action, topic_config, not_action +from unilabos.registry.decorators import action, device, not_action, topic_config -@device(id="my_device", category=["my_category"], description="设备描述") +@device( + id="my_device", + category=["my_category"], + description="设备描述", + displayname="设备显示名", +) class MyDevice: + """设备类说明。""" + _ros_node: BaseROS2DeviceNode - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + port: str = "COM1", + baudrate: int = 9600, + timeout: float = 1.0, + **kwargs, + ): + """ + 初始化设备。 + + Args: + device_id[设备ID]: 设备实例 ID,默认使用 my_device。 + port[串口]: 设备串口号,例如 COM1 或 /dev/ttyUSB0。 + baudrate[波特率]: 串口波特率。 + timeout[超时时间(s)]: 通信超时时间,单位秒。 + """ self.device_id = device_id or "my_device" - self.config = config or {} + self.port = port + self.baudrate = baudrate + self.timeout = timeout self.logger = logging.getLogger(f"MyDevice.{self.device_id}") self.data: Dict[str, Any] = {"status": "Idle"} @@ -133,7 +291,13 @@ class MyDevice: @action(description="执行操作") def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: - """带 @action 装饰器 → 注册为 'my_action' 动作""" + """ + 带 @action 装饰器 → 注册为 'my_action' 动作。 + + Args: + param[操作数值]: 操作使用的数值参数。 + name[操作名称]: 操作名称或备注。 + """ return {"success": True} def get_info(self) -> Dict[str, Any]: @@ -154,7 +318,159 @@ class MyDevice: ### 要点 - `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部 -- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)` +- `__init__` 中需要现场配置的参数按基础类型显式展开,例如 `port: str`、`baudrate: int`、`timeout: float`、`enabled: bool`;不要把所有配置塞进单个 `config: dict` +- `__init__` 保留 `device_id` 和 `**kwargs` 兼容运行时注入,但不要把 `**kwargs` 当成主要配置入口 - `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` - 运行时状态存储在 `self.data` 字典中 - 设备文件放在 `unilabos/devices//` 目录下 + +--- + +## 通信实现片段 + +Serial 文本指令: + +```python +def _send_command(self, cmd: str) -> str: + self.ser.write(f"{cmd}\r\n".encode()) + return self.ser.readline().decode().strip() +``` + +RS-485 响应解析要先定位帧头,不要用硬编码索引直接解析原始响应: + +```python +def _normalize_response(self, raw: str, start_marker: str = "/") -> str: + pos = raw.find(start_marker) + return raw[pos:] if pos >= 0 else raw +``` + +自定义二进制帧: + +```python +def _build_frame(self, func_code: int, data: bytes) -> bytes: + frame = bytearray([0xFE, func_code]) + bytearray(data) + checksum = sum(frame[1:]) % 256 + frame.append(checksum) + return bytes(frame) +``` + +Modbus 寄存器映射: + +```python +REGISTER_MAP = { + "temp_target": {"addr": 0x000B, "scale": 10}, +} + +def set_temperature(self, temp: float, **kwargs) -> bool: + reg = REGISTER_MAP["temp_target"] + value = int(float(temp) * reg["scale"]) & 0xFFFF + self.client.write_register(reg["addr"], value, slave=self.slave_id) + self.data["temp_target"] = temp + return True +``` + +HTTP API 映射: + +```python +API_MAP = { + "set_temperature": { + "method": "POST", + "endpoint": "/api/temperature", + "body_key": "target", + }, +} +``` + +SDK 封装: + +```python +from my_device_sdk import DeviceController + +class MyDevice: + def __init__(self, device_id=None, port: str = "COM1", timeout: float = 1.0, **kwargs): + self.controller = DeviceController(port=port, timeout=timeout) +``` + +--- + +## 验证 + +无需手写注册表 YAML。`@device` 装饰器 + AST 扫描会在启动或检查时生成注册表条目。 + +```bash +# 1. 模块可导入 +python -c "from unilabos.devices.. import " + +# 2. 启动测试 +unilab -g .json + +# 3. 仅检查注册表 +unilab --check_mode --skip_env_check +``` + +仅在旧代码无 `@device`、需要覆盖特殊字段、或做 `--complete_registry` 旧设备补全时,才考虑 YAML。新设备默认不要手写 YAML。 + +## 图文件节点模板 + +实验图 JSON 中的 `class` 对应 `@device(id=...)`。`config` 中的字段应对应 `__init__` 的同名基础类型参数,不要只定义一个 `config: dict` 参数承载所有配置: + +```json +{ + "id": "my_device_1", + "name": "我的设备", + "children": [], + "parent": null, + "type": "device", + "class": "my_device", + "position": {"x": 0, "y": 0, "z": 0}, + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {} +} +``` + +工作站需要同时配置 `deck` 和 `children`: + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "children": ["my_deck"], + "config": {}, + "deck": { + "data": { + "_resource_child_name": "my_deck", + "_resource_type": "unilabos.resources.my_module:MyDeck" + } + } + }, + { + "id": "my_deck", + "type": "deck", + "class": "MyDeckClass", + "parent": "my_station", + "config": {"type": "MyDeckClass", "setup": true} + } + ] +} +``` + +--- + +## 常见错误清单 + +- 缺少 `@device`:设备不会被 AST 扫描发现。 +- `@device(id=...)` 使用中文、点号、短横线或空格:id 必须只包含英文、数字、下划线,显示名称用 `displayname`。 +- 只有 `@property` 没有 `@topic_config()`:属性不会稳定广播到 `status_types`。 +- `post_init` 没有 `@not_action`:会被误暴露为动作。 +- `self.data = {}`:空字典会导致属性读取和 schema 初始数据不稳定,必须预填充每个状态键。 +- 动作参数重命名:不要把同类设备已有的 `volume` 改成 `volume_ml`,参数名是接口契约。 +- `status` 使用中文或临时文本:前端和工作流依赖稳定英文状态值。 +- async 方法中使用 `time.sleep()`:应使用 `await self._ros_node.sleep(seconds)`。 +- 硬编码串口响应索引:RS-485 响应前可能有噪声字节,应先定位帧头。 +- 把硬件寄存器单位暴露给用户:对外使用物理单位,驱动内部做 scale 转换。 diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md index cd946cc31..3df13fd35 100644 --- a/.cursor/skills/batch-insert-reagent/SKILL.md +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{ ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" AUTH="Authorization: Lab " @@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ``` 返回成功时包含试剂 UUID: + ```json {"code": 0, "data": {"uuid": "xxx", ...}} ``` @@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ## 试剂字段说明 -| 字段 | 类型 | 必填 | 说明 | 示例 | -|------|------|------|------|------| -| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` | -| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` | -| `name` | string | 是 | 试剂中文/英文名称 | `"水"` | -| `molecular_formula` | string | 是 | 分子式 | `"H2O"` | -| `smiles` | string | 是 | SMILES 表示 | `"O"` | -| `stock_in_quantity` | number | 是 | 入库数量 | `10` | -| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` | -| `supplier` | string | 否 | 供应商名称 | `"国药集团"` | -| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` | -| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` | +| 字段 | 类型 | 必填 | 说明 | 示例 | +| ------------------- | ------ | ---- | ----------------------------- | ------------------------ | +| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` | +| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` | +| `name` | string | 是 | 试剂中文/英文名称 | `"水"` | +| `molecular_formula` | string | 是 | 分子式 | `"H2O"` | +| `smiles` | string | 是 | SMILES 表示 | `"O"` | +| `stock_in_quantity` | number | 是 | 入库数量 | `10` | +| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` | +| `supplier` | string | 否 | 供应商名称 | `"国药集团"` | +| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` | +| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` | ### unit 单位值 -| 值 | 单位 | -|------|------| +| 值 | 单位 | +| ------ | ---- | | `"mL"` | 毫升 | -| `"L"` | 升 | -| `"g"` | 克 | +| `"L"` | 升 | +| `"g"` | 克 | | `"kg"` | 千克 | -| `"瓶"` | 瓶 | +| `"瓶"` | 瓶 | > 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。 @@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \ ```json [ - {"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"}, - {"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"} + { + "cas": "7732-18-3", + "name": "水", + "molecular_formula": "H2O", + "smiles": "O", + "stock_in_quantity": 10, + "unit": "mL" + }, + { + "cas": "64-17-5", + "name": "乙醇", + "molecular_formula": "C2H6O", + "smiles": "CCO", + "stock_in_quantity": 5, + "unit": "L" + } ] ``` @@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat 7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z ``` +### 日期格式规则(重要) + +所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。 + +- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"` +- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"` +- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年 + +**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。 + ### 执行与汇报 每次 API 调用后: + 1. 检查返回 `code`(0 = 成功) 2. 记录成功/失败数量 3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」 @@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat ## 常见试剂速查表 -| 名称 | CAS | 分子式 | SMILES | -|------|-----|--------|--------| -| 水 | 7732-18-3 | H2O | O | -| 乙醇 | 64-17-5 | C2H6O | CCO | -| 甲醇 | 67-56-1 | CH4O | CO | -| 丙酮 | 67-64-1 | C3H6O | CC(C)=O | -| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | -| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O | -| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl | -| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 | -| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O | -| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl | -| 乙腈 | 75-05-8 | C2H3N | CC#N | -| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 | -| 正己烷 | 110-54-3 | C6H14 | CCCCCC | -| 异丙醇 | 67-63-0 | C3H8O | CC(C)O | -| 盐酸 | 7647-01-0 | HCl | Cl | -| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O | -| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O | -| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] | -| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl | -| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O | +| 名称 | CAS | 分子式 | SMILES | +| --------------------- | --------- | ---------- | ------------------------------------ | +| 水 | 7732-18-3 | H2O | O | +| 乙醇 | 64-17-5 | C2H6O | CCO | +| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O | +| 甲醇 | 67-56-1 | CH4O | CO | +| 丙酮 | 67-64-1 | C3H6O | CC(C)=O | +| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O | +| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O | +| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl | +| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 | +| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O | +| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl | +| 乙腈 | 75-05-8 | C2H3N | CC#N | +| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 | +| 正己烷 | 110-54-3 | C6H14 | CCCCCC | +| 异丙醇 | 67-63-0 | C3H8O | CC(C)O | +| 盐酸 | 7647-01-0 | HCl | Cl | +| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O | +| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O | +| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] | +| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl | +| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O | > 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。 diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md index de6fed5e1..0a368ba35 100644 --- a/.cursor/skills/batch-submit-experiment/SKILL.md +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -1,11 +1,13 @@ --- name: batch-submit-experiment -description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态. +description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态. --- -# 批量提交实验指南 +# Uni-Lab 批量提交实验指南 -通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 +通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w 生成 AUTH token(任选一种方式): ```bash -# 方式一:Python 一行生成 +# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic") python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" # 方式二:手动计算 # base64(ak:sk) → Authorization: Lab +# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代 ``` ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" AUTH="Authorization: Lab <上面命令输出的 token>" ``` @@ -44,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>" **批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。** -按优先级搜索: +**必须先用 Glob 工具搜索文件**,不要直接猜测路径: ``` -/unilabos_data/req_device_registry_upload.json -/req_device_registry_upload.json +Glob: **/req_device_registry_upload.json ``` -也可直接 Glob 搜索:`**/req_device_registry_upload.json` +常见位置(仅供参考,以 Glob 实际结果为准): +- `/unilabos_data/req_device_registry_upload.json` +- `/req_device_registry_upload.json` 找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。 -**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 +**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 ### 4. workflow_uuid(目标工作流) @@ -93,7 +99,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -104,9 +110,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" ``` -返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。 +返回: + +```json +{ + "code": 0, + "data": { + "items": [ + { + "uuid": "1b3f249a-...", + "name": "bt", + "description": null, + "status": "active", + "created_at": "2026-04-09T14:31:28+08:00" + }, + { + "uuid": "b6366243-...", + "name": "default", + "description": "默认项目", + "status": "active", + "created_at": "2026-03-26T11:13:36+08:00" + } + ] + } +} +``` -用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。 +展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。 ### 3. 列出可用 workflow @@ -123,6 +153,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A ``` 返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取: + - 每个 action 节点的 `node_uuid` - 每个节点对应的设备 ID(`resource_template_name`) - 每个节点的动作名(`node_template_name`) @@ -142,30 +173,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \ ```json { - "lab_uuid": "", - "project_uuid": "", - "workflow_uuid": "", - "name": "<实验名称>", - "node_params": [ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ { - "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], - "datas": [ - { - "node_uuid": "", - "param": {}, - "sample_params": [ - { - "container_uuid": "<容器UUID>", - "sample_value": { - "liquid_names": "<液体名称>", - "volumes": 1000 - } - } - ] - } - ] + "node_uuid": "", + "param": {}, + "sample_params": [ + { + "container_uuid": "<容器UUID>", + "sample_value": { + "liquid_names": "<液体名称>", + "volumes": 1000 + } + } + ] } - ] + ] + } + ] } ``` @@ -194,25 +225,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH" ### 每轮的字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| -------------- | ------------- | ----------------------------------------- | | `sample_uuids` | array\ | 该轮实验的样品 UUID 数组,无样品时传 `[]` | -| `datas` | array | 该轮中每个 workflow 节点的参数配置 | +| `datas` | array | 该轮中每个 workflow 节点的参数配置 | ### datas 中每个节点 -| 字段 | 类型 | 说明 | -|------|------|------| -| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | -| `param` | object | 动作参数(根据本地注册表 schema 填写) | -| `sample_params` | array | 样品相关参数(液体名、体积等) | +| 字段 | 类型 | 说明 | +| --------------- | ------ | -------------------------------------------- | +| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) | +| `param` | object | 动作参数(根据本地注册表 schema 填写) | +| `sample_params` | array | 样品相关参数(液体名、体积等) | ### sample_params 中每条 -| 字段 | 类型 | 说明 | -|------|------|------| -| `container_uuid` | string | 容器 UUID | -| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ---------------------------------------------------- | +| `container_uuid` | string | 容器 UUID | +| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` | --- @@ -233,6 +264,7 @@ python scripts/gen_notebook_params.py \ > 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。 脚本会: + 1. 调用 workflow detail API 获取所有 action 节点 2. 读取本地注册表,为每个节点查找对应的 action schema 3. 生成 `notebook_template.json`,包含: @@ -270,8 +302,11 @@ python scripts/gen_notebook_params.py \ "properties": { "goal": { "properties": { - "asp_vols": {"type": "array", "items": {"type": "number"}}, - "sources": {"type": "array"} + "asp_vols": { + "type": "array", + "items": { "type": "number" } + }, + "sources": { "type": "array" } }, "required": ["asp_vols", "sources"] } diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py index f22b37e88..a6cbea869 100644 --- a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -7,7 +7,7 @@ 选项: --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) - --base API 基础 URL(如 https://uni-lab.test.bohrium.com) + --base API 基础 URL(如 https://leap-lab.test.bohrium.com) --workflow-uuid 目标 workflow 的 UUID --registry 本地注册表文件路径(默认自动搜索) --rounds 实验轮次数(默认 1) @@ -17,7 +17,7 @@ 示例: python gen_notebook_params.py \\ --auth YTFmZDlkNGUtxxxx \\ - --base https://uni-lab.test.bohrium.com \\ + --base https://leap-lab.test.bohrium.com \\ --workflow-uuid abc-123-def \\ --rounds 2 """ diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md index 20cd2f335..c4fc7a100 100644 --- a/.cursor/skills/create-device-skill/SKILL.md +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config 决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取: -| `--addr` 值 | BASE URL | -|-------------|----------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | -| 其他自定义 URL | 直接使用该 URL | +| `--addr` 值 | BASE URL | +| -------------- | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | +| 其他自定义 URL | 直接使用该 URL | #### 必备项 ③:req_device_registry_upload.json(设备注册表) @@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config **推断 working_dir**(即 `unilabos_data` 所在目录): -| 条件 | working_dir 取值 | -|------|------------------| +| 条件 | working_dir 取值 | +| -------------------- | -------------------------------------------------------- | | 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | -| 仅传了 `--config` | `/unilabos_data/` | -| 都没传 | `<当前工作目录>/unilabos_data/` | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | **按优先级搜索文件**: @@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config python ./scripts/extract_device_actions.py --registry <找到的文件路径> ``` -#### 完整示例 - -用户提供: - -``` ---ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd ---sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b ---addr test ---port 8003 ---disable_browser -``` - -从中提取: -- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."` -- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com` -- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间 -- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi` - **四项全部就绪后才进入 Step 1。** ### Step 1 — 列出可用设备 @@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski 脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 每个 action 生成一个 JSON 文件,包含: + - `type` — 作为 API 调用的 `action_type` - `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义) - `goal` — goal 字段映射(含占位符 `$placeholder`) @@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ### Step 3 — 写 action-index.md -按模板为每个 action 写条目: +按模板为每个 action 写条目(**必须包含 `action_type`**): ```markdown ### `` <用途描述(一句话)> +- **action_type**: `<从 actions/.json 的 type 字段获取>` - **Schema**: [`actions/.json`](actions/.json) - **核心参数**: `param1`, `param2`(从 schema.required 获取) - **可选参数**: `param3`, `param4` @@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ``` 描述规则: + +- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住 - 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) - 从 `schema.required` 区分核心/可选参数 - 按功能分类(移液、枪头、外设等) @@ -165,6 +151,7 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski ### Step 4 — 写 SKILL.md 直接复用 `unilab-device-api` 的 API 模板,修改: + - 设备名称 - Action 数量 - 目录列表 @@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry ] ./ski - **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `Api` 类型的 key) - **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 - **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名) +- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件 API 模板结构: ```markdown ## 设备信息 + - device_id, Python 源码路径, 设备类名 ## 前置条件(缺一不可) + - ak/sk → AUTH, --addr → BASE URL ## 请求约定 + - Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名) ## Session State + - lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name ## API Endpoints -# - #1 GET /edge/lab/info → 直接拿到 lab_uuid -# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 -# - #3 创建节点 POST /edge/workflow/node -# body: {workflow_uuid, resource_template_name: "", node_template_name: ""} -# - #4 删除节点 DELETE /lab/workflow/nodes -# - #5 更新节点参数 PATCH /lab/workflow/node -# - #6 查询节点 handles POST /lab/workflow/node-handles -# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid -# - #7 批量创建边 POST /lab/workflow/edges -# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]} -# - #8 启动工作流 POST /lab/workflow/{uuid}/run -# - #9 运行设备单动作 POST /lab/mcp/run/action + +# - #1 GET /edge/lab/info → 直接拿到 lab_uuid + +# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户 + +# - #3 创建节点 POST /edge/workflow/node + +# body: {workflow_uuid, resource_template_name: "", node_template_name: ""} + +# - #4 删除节点 DELETE /lab/workflow/nodes + +# - #5 更新节点参数 PATCH /lab/workflow/node + +# - #6 查询节点 handles POST /lab/workflow/node-handles + +# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid + +# - #7 批量创建边 POST /lab/workflow/edges + +# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]} + +# - #8 启动工作流 POST /lab/workflow/{uuid}/run + +# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/.json 的 type 字段获取,传错会导致任务永远卡住) + # - #10 查询任务状态 GET /lab/mcp/task/{task_uuid} + # - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action + # - #12 获取资源树 GET /lab/material/download/{lab_uuid} + # - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid} -# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles + +# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles + +# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name= + +# 返回 res_template_uuid,用于 #15 创建物料时的必填字段 + +# - #15 创建物料节点 POST /edge/material/node + +# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...} + +# - #16 更新物料节点 PUT /edge/material/node + +# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...} ## Placeholder Slot 填写规则 + - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} - unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 - unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 @@ -217,13 +239,15 @@ API 模板结构: - 列出本设备所有 Slot 字段、类型及含义 ## 渐进加载策略 + ## 完整工作流 Checklist ``` ### Step 5 — 验证 检查文件完整性: -- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情) + +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理) - [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件 @@ -272,100 +296,196 @@ API 模板结构: `placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式: -| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | -|---------------|-----------|---------|---------| -| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | -| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | -| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | -| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | -| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 | +| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | +| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | +| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 | ### ResourceSlot(`unilabos_resources`) 最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): +- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}` +- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` +- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料 + +> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。 + +### DeviceSlot / NodeSlot / ClassSlot + +- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点 +- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选 +- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找 + +### FormulationSlot(`unilabos_formulation`) + +描述**液体配方**:向哪些容器中加入哪些液体及体积。 + ```json -{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} +[ + { + "sample_uuid": "", + "well_name": "bottle_A1", + "liquids": [{ "name": "LiPF6", "volume": 0.6 }] + } +] ``` -- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}` -- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` -- `id` 本身是从 parent 计算的路径格式 -- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置) +- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径) +- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL) +- `sample_uuid` — 样品 UUID,无样品传 `""` +- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息 -> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 +### 通过 API #12 获取资源树 -### DeviceSlot(`unilabos_devices`) +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` -填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径: +注意 `lab_uuid` 在路径中(不是查询参数)。返回结构: -``` -"/host_node" -"/bioyond_cell/reaction_station" +```json +{ + "code": 0, + "data": { + "nodes": [ + {"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""}, + {"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""}, + {"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"} + ], + "edges": [...] + } +} ``` -- 只填路径字符串,不需要 `{id, uuid}` 对象 -- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备) +- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent` +- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等) +- `parent` 为父节点名称(空字符串表示顶级) +- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点 +- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid` -### NodeSlot(`unilabos_nodes`) +## 物料管理 API -范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**: +设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。 -``` -"/PRCXI/PRCXI_Deck" +典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。 + +### API #14 — 按名称查询物料模板 + +创建物料前,需要先获取物料模板的 UUID。通过模板名称查询: + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" ``` -- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`) +| 参数 | 必填 | 说明 | +| ---------- | ------ | -------------------------------- | +| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) | +| `name` | **是** | 物料模板名称(如 `"container"`) | -### ClassSlot(`unilabos_class`) +返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。 -填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找: +模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。 -``` -"container" -``` +### API #15 — 创建物料节点 -### FormulationSlot(`unilabos_formulation`) +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` -描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**: +请求体: ```json -[ - { - "sample_uuid": "", - "well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1", - "liquids": [ - { "name": "LiPF6", "volume": 0.6 }, - { "name": "DMC", "volume": 1.2 } - ] - } -] +{ + "res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "my_custom_bottle", + "display_name": "自定义瓶子", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "", + "init_param_data": {}, + "schema": {}, + "data": { + "liquids": [["water", 1000, "uL"]], + "max_volume": 50000 + }, + "plate_well_datas": {}, + "plate_reagent_datas": {}, + "pose": {}, + "model": {} +} ``` -#### 字段说明 +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- | +| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID | +| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 | +| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) | +| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 | +| `type` | 否 | string | 从模板继承 | 节点类型 | +| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 | +| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 | +| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) | +| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 | +| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 | +| `pose` | 否 | object | 用户指定 | 位姿信息 | +| `model` | 否 | object | 用户指定 | 3D 模型信息 | + +#### container 的 `data` 格式 + +> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。 -| 字段 | 类型 | 说明 | -|------|------|------| -| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` | -| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) | -| `liquids` | array | 要加入的液体列表 | -| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) | -| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) | - -#### 填写规则 +```json +{ + "liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]], + "max_volume": 50000 +} +``` -- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选 -- 每个数组元素代表一个目标容器的配方 -- 一个容器可以加入多种液体(`liquids` 数组多条记录) -- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息 +- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]` +- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL -### 通过 API #12 获取资源树 +### API #16 — 更新物料节点 ```bash -curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体: + +```json +{ + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "display_name": "新显示名称", + "description": "新描述", + "init_param_data": {}, + "data": {}, + "pose": {}, + "schema": {}, + "extra": {} +} ``` -注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 +| 字段 | 必填 | 类型 | 数据来源 | 说明 | +| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- | +| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 | +| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 | +| `display_name` | 否 | string | 用户指定 | 更新显示名称 | +| `description` | 否 | string | 用户指定 | 更新描述 | +| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 | +| `data` | 否 | object | 用户指定 | 更新节点数据 | +| `pose` | 否 | object | 用户指定 | 更新位姿 | +| `schema` | 否 | object | 用户指定 | 更新 schema | +| `extra` | 否 | object | 用户指定 | 更新扩展数据 | + +> 只传需要更新的字段,未传的字段保持不变。 ## 最终目录结构 diff --git a/.cursor/skills/filter-workflow-by-tags/SKILL.md b/.cursor/skills/filter-workflow-by-tags/SKILL.md new file mode 100644 index 000000000..6cedd7c49 --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/SKILL.md @@ -0,0 +1,450 @@ +--- +name: filter-workflow-by-tags +description: Query backend workflow list, aggregate all tags, and filter workflows by domain/scenario requirements using tags. Use when the user wants to search workflows, find workflows by tags, list available workflow tags, filter workflows by category/domain/scenario, or mentions 工作流筛选/标签查询/workflow tags/按领域查找工作流. +--- +# Uni-Lab 工作流标签筛选指南 + +通过 Uni-Lab 云端 API 查询工作流列表,汇总所有可用标签(tags),并根据领域和场景要求筛选工作流。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 + +## 使用模式识别 + +**用户可能一开始就给出场景目标**(如"我要做有机合成实验"、"找柱层析相关的 protocol")。此时: + +1. **识别场景关键词** → 映射到可能的 tags(如 synthesis、organic、chromatography、purification) +2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选) +3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol** +4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接“与其他 Skill 的协作” + +**如果用户未给场景目标**,则按标准 checklist 询问筛选条件。 + +--- + +## 前置条件 + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token: + +```bash +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" +``` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------- | ------------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. lab_uuid(实验室 UUID) + +如果用户未提供 `lab_uuid`,通过以下 API 自动获取: + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 即为 `lab_uuid`。 + +**三项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态: + +- `lab_uuid` — 实验室 UUID +- `all_workflows` — 完整工作流列表(分页获取后缓存到内存或临时文件) +- `all_tags` — 所有工作流的标签汇总 + +--- + +## API 端点 + +### 查询工作流列表(支持分页) + +``` +GET $BASE/api/v1/lab/workflow/owner/list?page=&page_size=&lab_uuid=$lab_uuid +``` + +**参数:** + +- `page` — 页码,从 1 开始 +- `page_size` — 每页数量,建议 1000 +- `lab_uuid` — 实验室 UUID + +**返回结构:** + +```json +{ + "code": 0, + "data": { + "has_more": true, + "data": [ + { + "uuid": "9661bba2-1b9f-4687-a63d-910245df174b", + "name": "Untitled", + "description": "", + "user_id": "114211", + "published": false, + "tags": null + }, + { + "uuid": "e0436638-190b-46bc-b1a1-2711d9602f6a", + "name": "Synthesis v2", + "user_id": "114211", + "published": true, + "tags": ["synthesis", "organic"] + } + ] + } +} +``` + +**字段说明:** + +- `has_more` — 若为 `true`,需要继续请求 `page+1` +- `tags` — 可能为 `null`、空数组或字符串数组;聚合时必须容忍 `null` + +### 启动工作流(直接运行) + +``` +POST $BASE/api/v1/lab/workflow//run +``` + +**用途:** 直接启动一个 workflow 的默认执行(使用模板中预设的参数),无需创建 notebook。适用于快速测试或无参数变化的重复执行。 + +**请求体:** 空 JSON `{}` 或省略 + +**返回:** + +```json +{ + "code": 0, + "data": "" +} +``` + +- `run_uuid` — 本次执行的唯一标识(不是 notebook UUID) + +**注意:** + +- 该接口会使用 workflow 模板中保存的默认参数直接执行 +- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID),应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params` +- 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度 + +### 查询任务状态 + +``` +GET $BASE/api/v1/lab/mcp/task/ +``` + +**用途:** 查询由 `POST /lab/workflow//run` 返回的 `run_uuid`(即 task_uuid)的实时执行状态,包括整体状态和每个节点(JOS:Job On Station)的执行详情。 + +**路径参数:** + +- `task_uuid` — 等同于启动工作流接口返回的 `run_uuid` + +**返回:** + +```json +{ + "code": 0, + "data": { + "status": "running", + "jos_status": [ + { + "uuid": "d0e24bfe-8d99-450e-b19d-f25849dfbaad", + "node_name": "PRCXI_BioER_96_wellplate_slot_1", + "action_name": "create_resource", + "status": "success", + "return_info": { + "suc": true, + "error": "", + "return_value": { ... } + } + }, + { + "uuid": "...", + "node_name": "...", + "action_name": "transfer_liquid", + "status": "pending", + "return_info": null + } + ] + } +} +``` + +**字段说明:** + +- `data.status` — 整体任务状态 + - `running` — 正在执行(至少一个节点 pending 或 running) + - `success` — 全部节点成功 + - `failed` — 有节点失败 +- `data.jos_status[]` — 节点级执行列表(按执行顺序) + - `uuid` — 节点执行实例 UUID + - `node_name` — 节点名称(资源/设备名或工位名) + - `action_name` — 动作类型(`create_resource`、`transfer_liquid`、`centrifuge`、等) + - `status` — 节点状态:`success`、`pending`、`running`、`failed` + - `return_info` — 执行返回,失败时 `suc=false` 且 `error` 有错误信息 + +**注意:** + +- 此接口的 `task_uuid` **是** `POST /lab/workflow//run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼 +- **不要**把 notebook UUID(`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询 +- `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度 +- 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree),可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要 + +**状态轮询示例:** + +```bash +# 每 5 秒轮询一次直至完成 +TASK="b183d97e-d2b5-4b24-b14b-820df04d87c0" +while :; do + st=$(curl -s -X GET "$BASE/api/v1/lab/mcp/task/$TASK" -H "$AUTH" \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \ + print(d['status'], '|', sum(1 for j in d['jos_status'] if j['status']=='success'), '/', len(d['jos_status']))") + echo "$(date +%H:%M:%S) $st" + [[ "$st" == success* || "$st" == failed* ]] && break + sleep 5 +done +``` + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 0: 识别用户是否已给出场景目标(如"有机合成"、"柱层析") + - 若已给出 → 记录场景关键词,自动进入后续步骤 + - 若未给出 → 在 Step 6 询问用户 +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid(如用户未提供) +- [ ] Step 4: 分页获取所有工作流(从 page=1 开始直到 has_more=false) +- [ ] Step 5: 汇总所有非空 tags → 生成 all_tags(去重、排序、附出现次数) +- [ ] Step 6: 根据场景关键词(Step 0 或新询问)在 all_tags 中做语义映射 → 确定候选 tags + - 若语义映射不唯一,列出候选 tags 让用户确认 +- [ ] Step 7: 按候选 tags 筛选工作流(默认 any 模式,召回优先) +- [ ] Step 8: 展示筛选结果(uuid、name、description、tags、published) +- [ ] Step 9: 引导用户从结果中选择**明确的实验 protocol** + - 若结果只有 1 条 → 直接确认该 workflow_uuid + - 若结果 2–10 条 → 让用户按编号选择 + - 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published) + - 若结果为空 → 放宽条件(去掉最稀有 tag)或提示用户换关键词 +- [ ] Step 10: 记录用户选中的 workflow_uuid,并提示提交实验或查看详情 +``` + +--- + +## 推荐路径:使用脚本 + +同目录下提供 `scripts/filter_workflows.py`,一次完成分页抓取、标签聚合与筛选: + +```bash +# 1. 仅汇总标签(不筛选) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --summary-only + +# 2. 按标签筛选(ANY 模式:包含任一) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode any + +# 3. 按标签筛选(ALL 模式:必须同时包含) +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis organic \ + --mode all \ + --output filtered.json + +# 4. 仅筛选已发布 +python scripts/filter_workflows.py \ + --auth "" \ + --base "$BASE" \ + --lab-uuid "$lab_uuid" \ + --tags synthesis \ + --published-only +``` + +**`--auth` 参数说明**:传入 `Authorization` 头中 `Lab` 之后的 base64 token(不带 `Lab ` 前缀),脚本内部会自动补上 scheme。 + +**输出结构:** + +```json +{ + "total_workflows": 150, + "tag_counts": {"synthesis": 12, "organic": 8, "analysis": 5}, + "all_tags": ["analysis", "organic", "synthesis"], + "filter": {"tags": ["synthesis", "organic"], "mode": "any"}, + "filtered_workflows": [ + {"uuid": "...", "name": "...", "description": "...", "tags": [...], "published": true} + ] +} +``` + +--- + +## 手动路径:curl + jq + +如果脚本不可用或环境缺少 Python,可用 shell 实现。 + +### 1. 分页抓取(写入 `all_workflows.json`) + +```bash +page=1 +echo "[]" > all_workflows.json + +while :; do + resp=$(curl -s -X GET \ + "$BASE/api/v1/lab/workflow/owner/list?page=$page&page_size=1000&lab_uuid=$lab_uuid" \ + -H "$AUTH") + + page_data=$(echo "$resp" | jq -c '.data.data // []') + jq -c --argjson p "$page_data" '. + $p' all_workflows.json > _tmp.json && mv _tmp.json all_workflows.json + + has_more=$(echo "$resp" | jq -r '.data.has_more') + [ "$has_more" != "true" ] && break + page=$((page + 1)) +done + +echo "Total: $(jq 'length' all_workflows.json)" +``` + +### 2. 汇总所有标签(含出现次数) + +```bash +jq '[.[].tags // [] | .[]] | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)' \ + all_workflows.json +``` + +### 3. 按标签筛选 + +```bash +# ANY:包含任一指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select((.tags // []) | any(. as $t | $want | index($t)))]' \ + all_workflows.json + +# ALL:同时包含所有指定标签 +jq --argjson want '["synthesis","organic"]' \ + '[.[] | select(($want | all(. as $w | (.tags // []) | index($w))))]' \ + all_workflows.json +``` + +--- + +## 筛选策略 + +agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺序选择 tag: + +1. **优先用户显式指定的 tags**:若用户明确给出标签词,直接精确匹配。 +2. **从 all_tags 中做语义映射**:若用户描述是自然语言(如"有机合成、柱层析"),在 all_tags 中找语义相关项(如 `synthesis`、`organic`、`chromatography`)。必要时展示候选 tag 让用户确认。 +3. **模式选择**: + - 默认 `any`(更多召回),给出 tag 集合的并集匹配 + - 用户强调"必须同时满足"时用 `all` +4. **空结果兜底**:如果筛选为空,放宽条件(去掉最稀有 tag、切换 any 模式),并提醒用户。 + +--- + +## 引导到明确的 Protocol + +筛选完成后,**最终目标是让用户确认一个具体的 workflow_uuid**,而不是停留在"一堆候选"上。按结果数量采取不同策略: + +| 结果数量 | 策略 | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 0 条 | 放宽筛选(去掉最稀有 tag → 切换 any 模式 → 去掉 `--published-only`)。仍为空则提示换关键词,或列出 `all_tags` 让用户重新选。 | +| 1 条 | 直接确认:"找到唯一匹配:`` (uuid ``),是否用它?"用户确认后记录 `workflow_uuid`。 | +| 2–10 条 | 编号列表展示,让用户选编号。每项给出 name、tags、description 摘要、published 状态。 | +| 10–30 条 | 先展示 tag 分布帮助用户进一步收紧:列出匹配结果中最常见的子标签,提示"加一个 tag 可将结果缩小到 N 条"。 | +| >30 条 | 强制要求用户补充条件:仅 published、指定具体 tag 组合、或按名称关键词过滤。 | + +**确认 workflow 后**: + +1. 将 `workflow_uuid` 写入 session state +2. 提示用户下一步可用的 skill: + - 提交实验 → 引导到“与其他 Skill 的协作” + - 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/` +3. 若用户想换一个,回到筛选步骤。 + +--- + +## 展示结果 + +推荐格式(表格 + 汇总统计): + +``` +共 150 个工作流,其中 32 个匹配筛选条件 [tags: synthesis OR organic] + +| UUID (短) | 名称 | Tags | 已发布 | +|-----------|--------------------------|------------------------------|--------| +| e0436638 | Synthesis v2 | synthesis, organic | ✓ | +| 5b60dbb8 | Grignard Protocol | synthesis, organometallic | ✓ | +| ... | ... | ... | ... | + +所有可用标签(按频次): + synthesis (12), organic (8), analysis (5), purification (4), ... +``` + +如果用户下一步想执行某工作流 → 引导到“与其他 Skill 的协作”。 + +--- + +## 常见问题 + +### Q: tags 为 null 的工作流要不要展示? + +默认**不展示**在筛选结果中(因为无法按 tag 匹配)。但在 `--summary-only` 或无筛选条件时,这些工作流仍会计入总数,并在输出中单独列出"未打标签"计数。 + +### Q: 如何按名称/描述做模糊匹配? + +脚本未内置,但可在 jq 中组合: + +```bash +jq '[.[] | select((.name + " " + (.description // "")) | test("organic"; "i"))]' all_workflows.json +``` + +### Q: `page_size=1000` 是否会被服务端限制? + +接口通常允许最大 1000;如果返回量少于 1000 且 `has_more=false`,说明已到末页。极端情况下若服务端返回错误,可降到 200 或 500 再试。 + +### Q: 工作流数量极大(>10k)怎么办? + +1. 先跑 `--summary-only` 了解 tag 分布 +2. 提示用户先限定 `--published-only` 或指定 tag +3. 考虑将 `all_workflows.json` 缓存到本地,下次直接复用 + +--- + +## 与其他 Skill 的协作 + +- 正常情况下,找到 workflow 之后可以直接用它提交实验(启动工作流的 api 端点 POST $BASE/api/v1/lab/workflow//run,不用别的 skill) +- **仅当需要进行多次实验时,使用 batch-submit-experiment** — 筛选到目标工作流后,`workflow_uuid` 直接用于实验提交 + +## 脚本依赖 + +`scripts/filter_workflows.py` 仅使用 Python 标准库(`urllib`、`json`、`argparse`),无需额外安装。 diff --git a/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py new file mode 100755 index 000000000..87bb0b1cf --- /dev/null +++ b/.cursor/skills/filter-workflow-by-tags/scripts/filter_workflows.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""分页拉取 Uni-Lab 工作流列表,汇总 tags 并按 tag 筛选。 + +使用示例: + python filter_workflows.py \ + --auth \ + --base https://leap-lab.test.bohrium.com \ + --lab-uuid a9059772-... \ + --tags synthesis organic --mode any + +仅依赖 Python 标准库。 +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter + + +def fetch_all_workflows(base: str, auth_token: str, lab_uuid: str, page_size: int = 1000) -> list[dict]: + """分页拉取所有 owner 工作流,直到 has_more=false。""" + workflows: list[dict] = [] + page = 1 + while True: + query = urllib.parse.urlencode( + {"page": page, "page_size": page_size, "lab_uuid": lab_uuid} + ) + url = f"{base.rstrip('/')}/api/v1/lab/workflow/owner/list?{query}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Lab {auth_token}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + sys.exit(f"[ERROR] HTTP {e.code} on page {page}: {e.read().decode('utf-8', 'ignore')}") + except urllib.error.URLError as e: + sys.exit(f"[ERROR] URL error on page {page}: {e.reason}") + + if payload.get("code") != 0: + sys.exit(f"[ERROR] API returned non-zero code: {payload}") + + data = payload.get("data") or {} + page_items = data.get("data") or [] + workflows.extend(page_items) + + if not data.get("has_more"): + break + page += 1 + # 防御性兜底,避免接口异常导致无限循环 + if page > 1000: + print(f"[WARN] page count exceeded 1000, stopping early", file=sys.stderr) + break + + return workflows + + +def aggregate_tags(workflows: list[dict]) -> tuple[list[str], dict[str, int], int]: + """返回 (sorted_tags, tag_counts, untagged_count)。""" + counter: Counter[str] = Counter() + untagged = 0 + for wf in workflows: + tags = wf.get("tags") + if not tags: + untagged += 1 + continue + for t in tags: + if isinstance(t, str) and t.strip(): + counter[t.strip()] += 1 + return sorted(counter.keys()), dict(counter), untagged + + +def filter_workflows( + workflows: list[dict], + want_tags: list[str], + mode: str, + published_only: bool, +) -> list[dict]: + """按 tag 筛选。mode 取值 any / all。""" + want_set = {t.strip() for t in want_tags if t.strip()} + out: list[dict] = [] + for wf in workflows: + if published_only and not wf.get("published"): + continue + if not want_set: + out.append(wf) + continue + tags = wf.get("tags") or [] + tag_set = {t for t in tags if isinstance(t, str)} + if mode == "all": + if want_set.issubset(tag_set): + out.append(wf) + else: # any + if want_set & tag_set: + out.append(wf) + return out + + +def project_workflow(wf: dict) -> dict: + """精简输出字段。""" + return { + "uuid": wf.get("uuid"), + "name": wf.get("name"), + "description": wf.get("description", ""), + "tags": wf.get("tags") or [], + "published": bool(wf.get("published")), + "user_id": wf.get("user_id"), + } + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Fetch & filter Uni-Lab workflows by tags.") + p.add_argument("--auth", required=True, help="Base64 token (the part after `Lab `).") + p.add_argument("--base", required=True, help="Base URL, e.g. https://leap-lab.test.bohrium.com") + p.add_argument("--lab-uuid", required=True, help="Lab UUID.") + p.add_argument("--tags", nargs="*", default=[], help="Tags to filter by (space separated).") + p.add_argument( + "--mode", + choices=["any", "all"], + default="any", + help="any: workflow contains at least one tag; all: workflow contains every tag.", + ) + p.add_argument("--published-only", action="store_true", help="Only include published workflows.") + p.add_argument("--page-size", type=int, default=1000, help="Page size, default 1000.") + p.add_argument( + "--summary-only", + action="store_true", + help="Print tag summary without applying filter (still fetches everything).", + ) + p.add_argument("--output", help="Write JSON result to this path. If omitted, print to stdout.") + return p.parse_args() + + +def main() -> None: + args = parse_args() + workflows = fetch_all_workflows( + base=args.base, + auth_token=args.auth, + lab_uuid=args.lab_uuid, + page_size=args.page_size, + ) + sorted_tags, tag_counts, untagged = aggregate_tags(workflows) + + if args.summary_only: + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + } + else: + filtered = filter_workflows( + workflows, + want_tags=args.tags, + mode=args.mode, + published_only=args.published_only, + ) + result = { + "total_workflows": len(workflows), + "untagged_count": untagged, + "tag_counts": tag_counts, + "all_tags": sorted_tags, + "filter": { + "tags": args.tags, + "mode": args.mode, + "published_only": args.published_only, + }, + "matched_count": len(filtered), + "filtered_workflows": [project_workflow(wf) for wf in filtered], + } + + payload = json.dumps(result, ensure_ascii=False, indent=2) + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(payload) + print(f"Wrote {len(workflows)} workflows summary → {args.output}", file=sys.stderr) + else: + print(payload) + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/host-node/SKILL.md b/.cursor/skills/host-node/SKILL.md new file mode 100644 index 000000000..06025355d --- /dev/null +++ b/.cursor/skills/host-node/SKILL.md @@ -0,0 +1,251 @@ +--- +name: host-node +description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation. +--- + +# Host Node API Skill + +## 设备信息 + +- **device_id**: `host_node` +- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py` +- **设备类**: `HostNode` +- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`) + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `host_node` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"host_node","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `host_node` +- `node_template_name` — action 名称(如 `create_resource`, `test_latency`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"host_node","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `test_latency` | `UniLabJsonCommand` | +| `create_resource` | `ResourceCreateFromOuterEasy` | +| `auto-test_resource` | `UniLabJsonCommand` | +| `manual_confirm` | `UniLabJsonCommand` | + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### host_node 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ----------- | ------------ | ------------------------------ | +| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) | +| `create_resource` | `device_id` | DeviceSlot | 归属设备 | +| `create_resource` | `parent` | NodeSlot | 父节点路径 | +| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` | +| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 | +| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 | +| `auto-test_resource` | `device` | DeviceSlot | 测试设备 | +| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 | + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 +2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` diff --git a/.cursor/skills/host-node/action-index.md b/.cursor/skills/host-node/action-index.md new file mode 100644 index 000000000..c931bc53f --- /dev/null +++ b/.cursor/skills/host-node/action-index.md @@ -0,0 +1,58 @@ +# Action Index — host_node + +4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +--- + +## 资源管理 + +### `create_resource` + +在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体 + +- **action_type**: `ResourceCreateFromOuterEasy` +- **Schema**: [`actions/create_resource.json`](actions/create_resource.json) +- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck` +- **占位符字段**: + - `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径) + - `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"` + - `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"` + - `class_name` — **ClassSlot**,填类名如 `"container"` + +### `auto-test_resource` + +测试资源系统,返回当前资源树和设备列表 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/test_resource.json`](actions/test_resource.json) +- **可选参数**: `resource`, `resources`, `device`, `devices` +- **占位符字段**: + - `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}` + - `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]` + - `device` — **DeviceSlot**,设备路径字符串 + - `devices` — **DeviceSlot**,设备路径字符串 + +--- + +## 系统工具 + +### `test_latency` + +测试设备通信延迟,返回 RTT、时间差、任务延迟等指标 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/test_latency.json`](actions/test_latency.json) +- **参数**: 无(零参数调用) + +--- + +## 人工确认 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表) +- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型 diff --git a/.cursor/skills/host-node/actions/create_resource.json b/.cursor/skills/host-node/actions/create_resource.json new file mode 100644 index 000000000..c7f16d5b7 --- /dev/null +++ b/.cursor/skills/host-node/actions/create_resource.json @@ -0,0 +1,93 @@ +{ + "type": "ResourceCreateFromOuterEasy", + "goal": { + "res_id": "res_id", + "class_name": "class_name", + "parent": "parent", + "device_id": "device_id", + "bind_locations": "bind_locations", + "liquid_input_slot": "liquid_input_slot[]", + "liquid_type": "liquid_type[]", + "liquid_volume": "liquid_volume[]", + "slot_on_deck": "slot_on_deck" + }, + "schema": { + "type": "object", + "properties": { + "res_id": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "class_name": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "bind_locations": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "bind_locations", + "additionalProperties": false + }, + "liquid_input_slot": { + "type": "array", + "items": { + "type": "integer" + } + }, + "liquid_type": { + "type": "array", + "items": { + "type": "string" + } + }, + "liquid_volume": { + "type": "array", + "items": { + "type": "number" + } + }, + "slot_on_deck": { + "type": "string" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } + }, + "goal_default": {}, + "placeholder_keys": { + "res_id": "unilabos_resources", + "device_id": "unilabos_devices", + "parent": "unilabos_nodes", + "class_name": "unilabos_class" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/manual_confirm.json b/.cursor/skills/host-node/actions/manual_confirm.json new file mode 100644 index 000000000..ee0b220ee --- /dev/null +++ b/.cursor/skills/host-node/actions/manual_confirm.json @@ -0,0 +1,32 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_latency.json b/.cursor/skills/host-node/actions/test_latency.json new file mode 100644 index 000000000..0fbd448fc --- /dev/null +++ b/.cursor/skills/host-node/actions/test_latency.json @@ -0,0 +1,11 @@ +{ + "type": "UniLabJsonCommand", + "goal": {}, + "schema": { + "type": "object", + "properties": {}, + "required": [] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/host-node/actions/test_resource.json b/.cursor/skills/host-node/actions/test_resource.json new file mode 100644 index 000000000..e9459fc77 --- /dev/null +++ b/.cursor/skills/host-node/actions/test_resource.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "resources": "resources", + "device": "device", + "devices": "devices" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "resources": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resources" + }, + "type": "array" + }, + "device": { + "type": "string", + "description": "device reference" + }, + "devices": { + "type": "string", + "description": "device reference" + } + }, + "required": [], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "resources": "unilabos_resources", + "device": "unilabos_devices", + "devices": "unilabos_devices" + } +} \ No newline at end of file diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md index 189237110..b94a0aaf9 100644 --- a/.cursor/skills/submit-agent-result/SKILL.md +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -1,11 +1,13 @@ --- name: submit-agent-result -description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果. +description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果. --- -# 提交历史实验记录指南 +# Uni-Lab 提交历史实验记录指南 -通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 +通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 + +> **重要**:本指南中的 `Authorization: Lab ` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。 ## 前置条件(缺一不可) @@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note 生成 AUTH token: ```bash +# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic" python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" ``` -输出即为 token 值,拼接为 `Authorization: Lab `。 +输出即为 token 值,拼接为 `Authorization: Lab `(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。 ### 2. --addr → BASE URL -| `--addr` 值 | BASE | -|-------------|------| -| `test` | `https://uni-lab.test.bohrium.com` | -| `uat` | `https://uni-lab.uat.bohrium.com` | -| `local` | `http://127.0.0.1:48197` | -| 不传(默认) | `https://uni-lab.bohrium.com` | +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | 确认后设置: + ```bash BASE="<根据 addr 确定的 URL>" +# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic" AUTH="Authorization: Lab <上面命令输出的 token>" ``` @@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>" notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。 如果用户不记得,可提示: + - 查看之前的对话记录中创建 notebook 时返回的 UUID - 或通过平台页面查找对应的 notebook @@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次, 用户需要提供实验结果数据,支持以下方式: -| 方式 | 说明 | -|------|------| -| JSON 文件 | 直接作为 `agent_result` 的内容合并 | -| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | -| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | +| 方式 | 说明 | +| --------- | ----------------------------------------------- | +| JSON 文件 | 直接作为 `agent_result` 的内容合并 | +| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | +| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | **四项全部就绪后才可开始。** @@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" 返回: ```json -{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } } ``` 记住 `data.uuid` 为 `lab_uuid`。 @@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ #### 必要字段 -| 字段 | 类型 | 说明 | -|------|------|------| +| 字段 | 类型 | 说明 | +| --------------- | ------------- | ------------------------------------------- | | `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 | -| `agent_result` | object | 实验结果数据,任意 JSON 对象 | +| `agent_result` | object | 实验结果数据,任意 JSON 对象 | #### agent_result 内容格式 `agent_result` 接受**任意 JSON 对象**,常见格式: **简单键值对**: + ```json { - "avg_rtt_ms": 12.5, - "status": "success", - "test_count": 5 + "avg_rtt_ms": 12.5, + "status": "success", + "test_count": 5 } ``` **包含嵌套结构**: + ```json { - "summary": {"total": 100, "passed": 98, "failed": 2}, - "measurements": [ - {"sample_id": "S001", "value": 3.14, "unit": "mg/mL"}, - {"sample_id": "S002", "value": 2.71, "unit": "mg/mL"} - ] + "summary": { "total": 100, "passed": 98, "failed": 2 }, + "measurements": [ + { "sample_id": "S001", "value": 3.14, "unit": "mg/mL" }, + { "sample_id": "S002", "value": 2.71, "unit": "mg/mL" } + ] } ``` **从 CSV 文件导入**(脚本自动转换): + ```json { - "experiment_data": [ - {"温度": 25, "压力": 101.3, "产率": 0.85}, - {"温度": 30, "压力": 101.3, "产率": 0.91} - ] + "experiment_data": [ + { "温度": 25, "压力": 101.3, "产率": 0.85 }, + { "温度": 30, "压力": 101.3, "产率": 0.91 } + ] } ``` @@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \ [--output ] ``` -| 参数 | 必选 | 说明 | -|------|------|------| -| `--notebook-uuid` | 是 | 目标 notebook UUID | -| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | -| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | -| `--base` | 提交时必选 | API base URL | -| `--submit` | 否 | 加上此标志则直接提交到云端 | -| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | +| 参数 | 必选 | 说明 | +| ----------------- | ---------- | ----------------------------------------------- | +| `--notebook-uuid` | 是 | 目标 notebook UUID | +| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) | +| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) | +| `--base` | 提交时必选 | API base URL | +| `--submit` | 否 | 加上此标志则直接提交到云端 | +| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) | ### 文件合并规则 -| 文件类型 | 合并方式 | -|----------|----------| -| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | -| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | -| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | +| 文件类型 | 合并方式 | +| --------------------- | -------------------------------------------- | +| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | +| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | +| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | 多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。 @@ -210,7 +219,7 @@ python scripts/prepare_agent_result.py \ --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ --files results.json \ --auth YTFmZDlkNGUt... \ - --base https://uni-lab.test.bohrium.com \ + --base https://leap-lab.test.bohrium.com \ --submit ``` @@ -272,4 +281,4 @@ Task Progress: ### Q: 认证方式是 Lab 还是 Api? -本指南统一使用 `Authorization: Lab ` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 +本指南统一使用 `Authorization: Lab ` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 diff --git a/.cursor/skills/virtual-workbench/SKILL.md b/.cursor/skills/virtual-workbench/SKILL.md new file mode 100644 index 000000000..1c295ffd6 --- /dev/null +++ b/.cursor/skills/virtual-workbench/SKILL.md @@ -0,0 +1,282 @@ +--- +name: virtual-workbench +description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations. +--- + +# Virtual Workbench API Skill + +## 设备信息 + +- **device_id**: `virtual_workbench` +- **Python 源码**: `unilabos/devices/virtual/workbench.py` +- **设备类**: `VirtualWorkbench` +- **当前纳入动作**: 5 个(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`) +- **暂跳过动作**: `manual_confirm`、扣电测试 `test`(需要启用时先从最新注册表重新提取 schema) +- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行) + +### 典型工作流程 + +1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle) +2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台 +3. `start_heating` — 启动加热(3 个加热台可并行) +4. `move_to_output` — 加热完成后移到输出位置 Cn + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab ` + +### 2. --addr → BASE URL + +| `--addr` 值 | BASE | +| ------------ | ----------------------------------- | +| `test` | `https://leap-lab.test.bohrium.com` | +| `uat` | `https://leap-lab.uat.bohrium.com` | +| `local` | `http://127.0.0.1:48197` | +| 不传(默认) | `https://leap-lab.bohrium.com` | + +确认后设置: + +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `device_name` — `virtual_workbench` + +## 请求约定 + +所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。 + +### 2. 创建工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"<名称>","lab_uuid":"","description":"<描述>"}' +``` + +返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid` + +### 3. 创建节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","resource_template_name":"virtual_workbench","node_template_name":""}' +``` + +- `resource_template_name` 固定为 `virtual_workbench` +- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`) + +### 4. 删除节点 + +```bash +curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":[""],"workflow_uuid":""}' +``` + +### 5. 更新节点参数 + +```bash +curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"workflow_uuid":"","uuid":"","param":{...}}' +``` + +参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。 + +### 6. 查询节点 handles + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuids":["",""]}' +``` + +### 7. 批量创建边 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"edges":[{"source_node_uuid":"","target_node_uuid":"","source_handle_uuid":"","target_handle_uuid":""}]}' +``` + +### 8. 启动工作流 + +```bash +curl -s -X POST "$BASE/api/v1/lab/workflow//run" -H "$AUTH" +``` + +### 9. 运行设备单动作 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"lab_uuid":"","device_id":"virtual_workbench","action":"","action_type":"","param":{...}}' +``` + +`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。 + +> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/.json` 的 `type` 字段获取。 + +#### action_type 速查表 + +| action | action_type | +|--------|-------------| +| `auto-prepare_materials` | `UniLabJsonCommand` | +| `auto-move_to_heating_station` | `UniLabJsonCommand` | +| `auto-start_heating` | `UniLabJsonCommand` | +| `auto-move_to_output` | `UniLabJsonCommand` | +| `transfer` | `UniLabJsonCommandAsync` | + +> `manual_confirm` 和扣电测试 `test` 当前不纳入本 skill 的推荐操作范围;不要基于历史 JSON 直接调用,需先重新生成并校验 schema。 + +### 10. 查询任务状态 + +```bash +curl -s -X GET "$BASE/api/v1/lab/mcp/task/" -H "$AUTH" +``` + +### 11. 运行工作流单节点 + +```bash +curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"node_uuid":""}' +``` + +### 12. 获取资源树(物料信息) + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。 + +### 13. 获取工作流模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。 + +### 14. 按名称查询物料模板 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=" -H "$AUTH" +``` + +返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。 + +### 15. 创建物料节点 + +```bash +curl -s -X POST "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"res_template_uuid":"","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}' +``` + +### 16. 更新物料节点 + +```bash +curl -s -X PUT "$BASE/api/v1/edge/material/node" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}' +``` + +--- + +## Placeholder Slot 填写规则 + +| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 | +| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- | +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) | +| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 | + +### virtual_workbench 设备的 Slot 字段表 + +| Action | 字段 | Slot 类型 | 说明 | +| ----------------- | ---------------- | ------------ | -------------------- | +| `transfer` | `resource` | ResourceSlot | 待转移物料数组 | +| `transfer` | `target_device` | DeviceSlot | 目标设备路径 | +| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 | + +> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。 +> `manual_confirm` 先跳过,不维护其 Slot 字段表。 + +--- + +## 渐进加载策略 + +1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览 +2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数 +3. **[actions/\.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid +- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料 +- [ ] Step 3: 读 action-index.md 确定要用的 action 名 +- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接 +- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param +- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段 +- [ ] Step 7: 更新节点参数 (PATCH #5) +- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid +- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点 +- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11) +- [ ] Step 11: 查询任务状态 (GET #10) 确认完成 +``` + +### 典型 5 物料并发加热工作流示例 + +``` +prepare_materials (count=5) + ├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output + ├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output + ├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output + ├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output + └─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output +``` + +创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。 + +`start_heating` 完成后还需要继续连接到 `move_to_output`,否则加热完成的物料不会移出加热台: + +| source action | source handle | target action | target handle | 传递参数 | +| ------------- | ------------- | ------------- | ------------- | -------- | +| `auto-prepare_materials` | `channel_N` | `auto-move_to_heating_station` | `material_input` | `material_number` | +| `auto-move_to_heating_station` | `heating_station_output` | `auto-start_heating` | `station_id_input` | `station_id` | +| `auto-move_to_heating_station` | `material_number_output` | `auto-start_heating` | `material_number_input` | `material_number` | +| `auto-start_heating` | `heating_done_station` | `auto-move_to_output` | `output_station_input` | `station_id` | +| `auto-start_heating` | `heating_done_material` | `auto-move_to_output` | `output_material_input` | `material_number` | diff --git a/.cursor/skills/virtual-workbench/action-index.md b/.cursor/skills/virtual-workbench/action-index.md new file mode 100644 index 000000000..7b3401fa3 --- /dev/null +++ b/.cursor/skills/virtual-workbench/action-index.md @@ -0,0 +1,79 @@ +# Action Index — virtual_workbench + +当前纳入 5 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/.json`。 + +暂跳过:`manual_confirm`、扣电测试 `test`。这两个动作需要启用时,先从最新 `req_device_registry_upload.json` 重新提取 schema 并校验参数。 + +--- + +## 物料准备 + +### `auto-prepare_materials` + +批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json) +- **可选参数**: `count`(物料数量,默认 5) + +--- + +## 机械臂 & 加热台操作 + +### `auto-move_to_heating_station` + +将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台) + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json) +- **核心参数**: `material_number`(物料编号,integer) + +### `auto-start_heating` + +启动指定加热台的加热程序(可并行,3 个加热台同时工作) + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/start_heating.json`](actions/start_heating.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +### `auto-move_to_output` + +将加热完成的物料从加热台移动到输出位置 Cn + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json) +- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号) + +--- + +## 物料转移 + +### `transfer` + +异步转移物料到目标设备(通过 ROS 资源转移) + +- **action_type**: `UniLabJsonCommandAsync` +- **Schema**: [`actions/transfer.json`](actions/transfer.json) +- **核心参数**: `resource`, `target_device`, `mount_resource` +- **占位符字段**: + - `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]` + - `target_device` — **DeviceSlot**,目标设备路径字符串 + - `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]` + +--- + +## 暂跳过动作 + +### `manual_confirm` + +创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)。当前先不纳入推荐操作范围。 + +- **action_type**: `UniLabJsonCommand` +- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json) +- **状态**: 暂跳过。源码参数已包含扣电测试相关字段,历史 JSON 可能过期;需要启用时重新提取 schema。 + +### `test` + +启动扣电测试。当前先不纳入本 skill。 + +- **状态**: 暂跳过。需要启用时从注册表生成 `actions/test.json` 后再补充索引。 diff --git a/.cursor/skills/virtual-workbench/actions/manual_confirm.json b/.cursor/skills/virtual-workbench/actions/manual_confirm.json new file mode 100644 index 000000000..84d06f5b9 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/manual_confirm.json @@ -0,0 +1,270 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource", + "timeout_seconds": "timeout_seconds", + "assignee_user_ids": "assignee_user_ids" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + }, + "timeout_seconds": { + "type": "integer" + }, + "assignee_user_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "resource", + "target_device", + "mount_resource", + "timeout_seconds", + "assignee_user_ids" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources", + "assignee_user_ids": "unilabos_manual_confirm" + } +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json new file mode 100644 index 000000000..b5e55adc2 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_heating_station.json @@ -0,0 +1,19 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "material_number": { + "type": "integer" + } + }, + "required": [ + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/move_to_output.json b/.cursor/skills/virtual-workbench/actions/move_to_output.json new file mode 100644 index 000000000..913e86796 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/move_to_output.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/prepare_materials.json b/.cursor/skills/virtual-workbench/actions/prepare_materials.json new file mode 100644 index 000000000..5fbd8a9cd --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/prepare_materials.json @@ -0,0 +1,20 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "count": "count" + }, + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "default": 5 + } + }, + "required": [] + }, + "goal_default": { + "count": 5 + }, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/start_heating.json b/.cursor/skills/virtual-workbench/actions/start_heating.json new file mode 100644 index 000000000..913e86796 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/start_heating.json @@ -0,0 +1,24 @@ +{ + "type": "UniLabJsonCommand", + "goal": { + "station_id": "station_id", + "material_number": "material_number" + }, + "schema": { + "type": "object", + "properties": { + "station_id": { + "type": "integer" + }, + "material_number": { + "type": "integer" + } + }, + "required": [ + "station_id", + "material_number" + ] + }, + "goal_default": {}, + "placeholder_keys": {} +} \ No newline at end of file diff --git a/.cursor/skills/virtual-workbench/actions/transfer.json b/.cursor/skills/virtual-workbench/actions/transfer.json new file mode 100644 index 000000000..c286c68f5 --- /dev/null +++ b/.cursor/skills/virtual-workbench/actions/transfer.json @@ -0,0 +1,255 @@ +{ + "type": "UniLabJsonCommandAsync", + "goal": { + "resource": "resource", + "target_device": "target_device", + "mount_resource": "mount_resource" + }, + "schema": { + "type": "object", + "properties": { + "resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "resource" + }, + "type": "array" + }, + "target_device": { + "type": "string", + "description": "device reference" + }, + "mount_resource": { + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "sample_id": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "parent": { + "type": "string" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string" + }, + "pose": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z" + ], + "title": "position", + "additionalProperties": false + }, + "orientation": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "y": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "z": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + }, + "w": { + "type": "number", + "minimum": -1.7976931348623157e+308, + "maximum": 1.7976931348623157e+308 + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "title": "orientation", + "additionalProperties": false + } + }, + "required": [ + "position", + "orientation" + ], + "title": "pose", + "additionalProperties": false + }, + "config": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "title": "mount_resource" + }, + "type": "array" + } + }, + "required": [ + "resource", + "target_device", + "mount_resource" + ], + "_unilabos_placeholder_info": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } + }, + "goal_default": {}, + "placeholder_keys": { + "resource": "unilabos_resources", + "target_device": "unilabos_devices", + "mount_resource": "unilabos_resources" + } +} \ No newline at end of file diff --git a/.cursor/skills/yibin-electrolyte-submit/SKILL.md b/.cursor/skills/yibin-electrolyte-submit/SKILL.md new file mode 100644 index 000000000..51cc8e4b4 --- /dev/null +++ b/.cursor/skills/yibin-electrolyte-submit/SKILL.md @@ -0,0 +1,487 @@ +--- +name: yibin-electrolyte-submit +description: >- + 通过 Uni-Lab Notebook API 向宜宾电解液工站提交实验,覆盖配液分液(Bioyond LIMS)、 + 扣电组装(CoinCellAssembly)、扣电测试全流程。 + 包含 Excel 解析、formulation 构建、工作流节点参数填写、notebook 提交与状态轮询。 + Use when the user wants to submit electrolyte experiments, assemble or test coin cells, + parse experiment Excel files, build notebook payloads, or mentions + 宜宾/配液/分液/扣电/电解液实验/notebook提交/CoinCell/BioyondLIMS. +--- + +# 宜宾电解液产线 API 操作指南 + +本 skill 覆盖两个设备的完整操作流程: +1. **配液分液工站** (`bioyond_cell_workstation`) — Bioyond LIMS 配液/分液/转运 +2. **扣电组装站** (`BatteryStation`) — Modbus PLC 扣电组装/数据采集 + +## 设备信息 + +| 属性 | 配液分液工站 | 扣电组装站 | +|------|------------|-----------| +| device_id | `bioyond_cell_workstation` | `BatteryStation` | +| 显示名 | 配液分液工站 | 扣电工作站 | +| 源码 | `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | +| 类名 | `BioyondCellWorkstation` | `CoinCellAssemblyWorkstation` | +| 通讯 | HTTP REST (Bioyond LIMS API) | Modbus TCP (PLC 寄存器) | + +## 前置条件 + +### 认证信息 + +``` +AUTH="Authorization: Lab OTdlY2FkNmUtZmZmMi00YjhiLThhOWEtNWM5ODAyOTJmOTUxOmU0OGM2YWJkLTA4ZmEtNDFjMy04NzhhLTc4M2FiODlhZjYxMw==" +BASE="https://uni-lab.test.bohrium.com" +``` + +来源:`--ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --addr test` + +### 启动 unilab(云端模式) + +> **重要**:提交实验前必须确保 unilab 正在运行且已连接云端 WebSocket。 + +```powershell +$env:PYTHONIOENCODING="utf-8" +conda activate newunilab2603 +cd D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation +unilab -g D:\UniLabdev\Uni-Lab-OS\yibin_electrolyte_config.json --ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --upload_registry --addr test --disable_browser --skip_env_check +``` + +**启动要点**: +1. 必须先激活虚拟环境 `newunilab2603` +2. 工作目录切到 `unilabos/devices/workstation`(设备驱动所在目录) +3. `--upload_registry` 将 64 个设备 + 142 个资源注册到云端 +4. `--skip_env_check` + `PYTHONIOENCODING=utf-8` 避免 Windows GBK 编码崩溃 +5. 启动后后台运行,等待日志出现 `Application startup complete` 和 `Host node ready signal published with 3 devices` + +**验证连接成功的标志**: +- 日志出现 `[MessageProcessor] ... wss://uni-lab.test.bohrium.com/api/v1/ws/schedule` +- 日志出现 `[WebSocketClient] Host node ready signal published with 3 devices` +- 日志出现 `Resource tree add completed`(资源树同步完成) + +### 云端物料上架与入库(启动后必做) + +> **在提交实验之前,必须提醒用户完成以下云端操作,否则实验会因物料缺失而失败。** + +1. **拖拽上料**:在云端 UI(`$BASE/laboratory/`)的资源树视图中,将物料拖拽到对应的仓库/库位上。unilab 启动后资源树会自动同步到云端,但物料的**上架位置**需要用户在 UI 上手动确认或调整。 + +2. **确认配液物料入库**:确保所有配液实验需要的试剂(如 LiPF6、EC、DMC、EMC 等)已在 LIMS 系统中完成入库。可通过以下方式验证: + - 云端 UI 资源树中对应仓库(如"粉末加样头堆栈"、"配液站内试剂仓库")下有物料节点 + - 或通过 API #8 获取资源树后检查物料节点是否存在 + +3. **告知 AI 可以提交**:用户完成上述操作后,告知 AI "物料已上架,可以提交实验",AI 再执行 notebook 提交流程。 + +**提醒话术模板**(AI 应在启动成功后发送给用户): +``` +unilab 已成功启动并连接云端。提交实验前请完成以下操作: +1. 在云端 UI 上确认资源树中的物料位置,必要时拖拽调整上料位 +2. 确保配液所需的试剂(粉末、液体)已在 LIMS 中完成入库 +3. 完成后告诉我,我将为您提交实验 +``` + +### 生成 Action Schema(首次使用) + +启动 unilab 后,在 `unilabos_data/` 目录下会生成 `req_device_registry_upload.json`。运行以下命令提取两个设备的 action JSON: + +```bash +python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json bioyond_cell_workstation .cursor/skills/yibin-electrolyte-submit/actions/ +python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json BatteryStation .cursor/skills/yibin-electrolyte-submit/actions/ +``` + +## 请求约定 + +- Windows 平台**必须用 `curl.exe`**(非 PowerShell 的 curl 别名) +- 所有请求带 `$AUTH` 头 +- URL 格式:`$BASE/api/v1/` +- POST/PATCH 请求体写入临时 JSON 文件后用 `-d '@tmp.json'` 传参(避免 PowerShell 转义问题) +- 本地 API 基址:`http://127.0.0.1:8002/api/v1/` + +## Session State + +每次会话开始时,依次获取以下信息: + +```bash +# 1. lab_uuid +curl.exe -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +# → data.uuid → $lab_uuid + +# 2. project_uuid +curl.exe -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" +# → data.items[].uuid/name → 让用户选择或取唯一项 → $project_uuid +``` + +## 工作流模板(重要) + +> **必须向用户索要已有的工作流模板 UUID 或 URL,不要自行创建。** +> +> 原因:通过 `edge/workflow/node` API 创建节点会报 `resource_node_template not found`—— +> 云端的工作流节点模板系统和设备注册表是独立的,需要用户在云端 UI 上预先配置好工作流模板。 + +**获取方式**: +- 用户提供工作流页面 URL,如 `$BASE/laboratory//workflow/` +- 从 URL 中提取 `workflow_uuid` +- 用 API 获取模板详情: + +``` +GET /api/v1/lab/workflow/template/detail/ +``` + +返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles、disabled。 + +**示例**: +``` +工作流 URL: https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/b49f80d9-58d6-4456-a521-56f4dd39cda0 +→ workflow_uuid = b49f80d9-58d6-4456-a521-56f4dd39cda0 +``` + +从模板详情中提取**未 disabled** 的节点的 `uuid` 和 `name`,后续提交 notebook 时使用。 + +## API Endpoints + +### #1 获取 lab_uuid + +``` +GET /api/v1/edge/lab/info +``` + +### #2 列出项目 + +``` +GET /api/v1/lab/project/list?lab_uuid=$lab_uuid +``` + +返回 `data.items[]`,取 `uuid` 和 `name`。 + +### #3 获取工作流模板详情 + +``` +GET /api/v1/lab/workflow/template/detail/ +``` + +返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles。 +提取活跃节点(`disabled != true`)的 `uuid` 用于构建 notebook 请求。 + +### #4 提交实验(创建 notebook)— 核心 API + +``` +POST /api/v1/lab/notebook +Body: { + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": [], + "datas": [ + { + "node_uuid": "<模板中的节点UUID>", + "param": { <参数键值对> }, + "sample_params": [] + } + ] + } + ] +} +``` + +**关键注意事项**: +- `node_params` 是数组,每个元素代表一轮实验 +- `datas` 中每个节点对应模板中的一个活跃节点 +- `param` 中的字段名**必须使用 Python 函数参数名**,不能用模板中存储的 LIMS 字段名(见下方映射表) + +### #5 查询 notebook 状态 + +``` +GET /api/v1/lab/notebook/status?uuid= +``` + +| status | 含义 | +|--------|------| +| `running` | 执行中 | +| `success` | 成功 | +| `fail` | 失败 | + +### #6 运行设备单动作(本地 API) + +``` +POST http://127.0.0.1:8002/api/v1/job/add +Body: { + "device_id": "", + "action": "", + "action_args": { <参数键值对> }, + "sample_material": {} +} +``` + +本地 API 可自动解析 `action_type`,无需手动指定。适用于快速调试或云端未连接时。 + +### #7 查询本地任务状态 + +``` +GET http://127.0.0.1:8002/api/v1/job//status +``` + +| status | 含义 | +|--------|------| +| 0 | UNKNOWN | +| 1 | ACCEPTED | +| 2 | EXECUTING | +| 4 | SUCCEEDED | +| 5 | CANCELED | +| 6 | ABORTED | + +### #8 获取资源树 + +``` +GET /api/v1/lab/material/download/ +``` + +返回所有节点(`id`, `name`, `uuid`, `type`, `parent`)。填写 Slot 字段时用此接口筛选节点。 + +## Placeholder Slot 填写规则 + +action JSON 中 `placeholder_keys` 标记了哪些字段需要填 Slot: + +| placeholder 值 | Slot 类型 | 填写格式 | +|---------------|-----------|---------| +| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | +| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` 路径字符串 | +| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` 路径字符串 | +| `unilabos_class` | ClassSlot | `"class_name"` 字符串 | +| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | + +### ResourceSlot 填写 + +从 API #8 资源树中筛选**物料**节点: + +```json +{"id": "/bioyond_cell_workstation/YB_Bioyond_Deck/自动堆栈-左", "name": "自动堆栈-左", "uuid": "3a19debc-..."} +``` + +数组字段:`[{id, name, uuid}, ...]` +特例:`create_resource` 的 `res_id` 允许填不存在的路径。 + +### DeviceSlot 填写 + +从资源树筛选 `type=device` 的节点,填路径字符串: + +``` +"/BatteryStation" +"/bioyond_cell_workstation" +``` + +### FormulationSlot 填写 + +```json +[ + { + "sample_uuid": "", + "well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1", + "liquids": [ + { "name": "LiPF6", "mass": 12.5 }, + { "name": "EC", "mass": 50.0 } + ] + } +] +``` + +`well_name` 从资源树中取物料节点的 `name`。 + +## 参数名映射(重要的坑) + +> 工作流模板中存储的参数名和 Python 函数实际接受的参数名**不一定相同**。 +> 提交 notebook 时必须使用 **Python 函数参数名**。 + +### `create_orders_formulation` 参数映射 + +| 模板中的 param 键 | 实际 Python 参数名 | 说明 | +|-------------------|-------------------|------| +| `pouch_cell_info` | `pouch_cell_volume` | 软包组装分液体积 (mL) | +| `conductivity_info` | `conductivity_volume` | 电导测试分液体积 (mL) | +| `load_shedding_info` | `coin_cell_volume` | 扣电组装分液体积 (mL) | +| `formulation` | `formulation` | 配方数组(名称一致) | +| `batch_id` | `batch_id` | 批次号(名称一致) | +| `bottle_type` | `bottle_type` | 配液瓶类型(名称一致) | +| `mix_time` | `mix_time` | 混匀时间(秒)(名称一致) | +| `conductivity_bottle_count` | `conductivity_bottle_count` | 电导瓶数(名称一致) | + +当从模板中读到 `param` 包含 `pouch_cell_info` 等 LIMS 字段名时,提交 notebook 时要用右列的 Python 函数参数名。否则会报 `TypeError: got an unexpected keyword argument`。 + +## 典型工作流 + +### 方式一:通过 Notebook API 批量提交(推荐) + +**适用场景**:多组配方的批量实验,云端管理实验记录 + +``` +1. 向用户索要工作流模板 URL(不要自行创建) +2. 获取 lab_uuid(API #1)和 project_uuid(API #2) +3. 获取工作流模板详情(API #3),提取活跃节点 UUID +4. 解析用户提供的 Excel 文件,构建 formulation 数组 +5. 提交 notebook(API #4) +6. 轮询 notebook 状态(API #5)直到完成 +``` + +**Excel 解析规则**: +- 全局参数在第一个数据行:`batch_id`、`bottle_type`、`mix_time`、`coin_cell_volume`、`pouch_cell_volume`、`conductivity_volume`、`conductivity_bottle_count` +- 配方列从"试剂名1"开始,交替排列:试剂名列 + 质量列(以 `(g)` 结尾) +- 每行一个配方,`order_name` = 配方ID列 +- formulation 中每个配方的 materials 数组只包含 `mass > 0` 的试剂 + +**node_params 构建**:所有配方放入同一个 round 的同一个 datas 条目中,因为只有一个节点(`create_orders_formulation`)。 + +### 方式二:设备单步操作(本地 API) + +**适用场景**:调试、快速测试 + +``` +1. 确保 unilab 已在本地启动 +2. 通过 POST http://127.0.0.1:8002/api/v1/job/add 提交任务 +3. 通过 GET /api/v1/job//status 查询状态 +``` + +### 设备操作流程:配液 → 转运 → 扣电 + +``` +1. [配液站] scheduler_start_and_auto_feeding → 启动调度 + 上料 +2. [配液站] create_orders_formulation → 创建配液实验(配方输入) +3. [配液站] transfer_3_to_2_to_1_auto → 分液瓶板转运到扣电站 +4. [扣电站] coin_cell_init → 初始化+自动+启动 +5. [扣电站] coin_cell_start → 发送瓶数+批量组装 +``` + +## 云端使用心得 + +### 环境准备 +- Windows 必须设置 `$env:PYTHONIOENCODING="utf-8"` 防止编码崩溃 +- 使用 `--skip_env_check` 跳过依赖检查,加快启动 +- 工作目录建议在 `unilabos/devices/workstation` 下启动 + +### 连接与注册 +- `--upload_registry` 会自动将设备和资源注册到云端 +- WebSocket 连接建立后,本地和云端的资源树会自动同步 +- 注册成功后用户需在云端 UI 完成**物料拖放上架**操作 +- 如果 unilab 断开重连,资源树会重新同步 + +### 工作流模板 +- **不要自行调用 API 创建工作流或节点**——云端工作流节点模板需要预配置 +- 始终向用户索要已有的工作流模板 URL +- 从 URL 中提取 `workflow_uuid`,通过 API #3 获取详情 +- 模板中 `disabled: true` 的节点跳过,只处理活跃节点 + +### Notebook 实验提交 +- Notebook 是云端管理实验的标准方式 +- 一个 notebook 可包含多轮(`node_params` 数组),每轮可包含多组参数 +- 提交后通过 API #5 轮询状态,LIMS 配液流程通常需要较长时间(8 个配方约 30-60 分钟) +- 实验进度可在云端 UI 和本地 unilab 日志中同步查看 + +### 常见错误 +| 错误 | 原因 | 解决 | +|------|------|------| +| `edge not started error` | unilab 未连接云端 WebSocket | 检查 unilab 是否在运行、重启 | +| `resource_node_template not found` | 云端没有该设备的工作流模板 | 向用户索要已有模板,不要自行创建 | +| `got an unexpected keyword argument` | 参数名用了模板字段名而非 Python 函数参数名 | 参照上方映射表转换 | +| `UnicodeEncodeError: 'gbk'` | Windows 默认编码不支持特殊字符 | 设置 `PYTHONIOENCODING=utf-8` | +| `parse parameter error` | 云端 API 字段名错误 | `device_id` (非 `device_name`)、`action` (非 `action_name`)、必须带 `action_type` | + +## 渐进加载策略 + +1. 先读本文件了解 API 端点、参数映射和云端注意事项 +2. 需要具体 action 参数时,读 [action-index.md](action-index.md) 查找 action 名称和核心参数 +3. 需要完整 schema 时,读 `actions/.json`(需先运行提取命令生成) +4. 需要理解参数含义时,读设备源码 + +## 完整 Notebook 提交 Checklist + +``` +- [ ] 确认 unilab 已在本地启动并连接云端 WebSocket +- [ ] 提醒用户在云端 UI 拖拽上料、确认物料位置 +- [ ] 提醒用户确认配液所需试剂已在 LIMS 完成入库 +- [ ] 等待用户确认物料就绪后再继续 +- [ ] 向用户索要工作流模板 URL → 提取 workflow_uuid +- [ ] 获取 lab_uuid(API #1) +- [ ] 获取 project_uuid(API #2) +- [ ] 获取工作流模板详情(API #3),提取活跃节点 UUID +- [ ] 解析用户 Excel 文件 → 构建 formulation + 全局参数 +- [ ] 注意参数名映射(模板字段名 → Python 函数参数名) +- [ ] 提交 notebook(API #4) +- [ ] 轮询 notebook 状态(API #5)直到完成 +``` + +--- + +## 真实场景:宜宾产线 Excel 提交提示词模板 + +> 以下为已验证可用的标准提示词,适用于配液-分液-扣电全流程。 + +### 场景说明 + +- unilab 运行在本地 Windows 机器(miniforge 环境),连接云端 WebSocket +- AI(Cursor / OpenClaw)在任意设备上,通过云端 API 操作,**不需要本地 127.0.0.1** +- 工作流为 5 节点串联:`create_orders_formulation` → `transfer_3_to_2_to_1_auto` → `coin_cell_init` → `coin_cell_start` → `transfer_1_to_2` + +### 已知固定参数(宜宾产线) + +``` +BASE = https://uni-lab.test.bohrium.com +lab_uuid = e9ed9102-d709-4741-b7a0-d1e8578e2065 +project = YiBinElectrolyte (bc5224b4-8120-4765-9961-9dfc1802a1f6) +workflow = 配液分液formulation全流程 (2bc59938-db79-4415-ac2d-9897ef125f2f) +``` + +#### 工作流节点 UUID(固定,无需重新查询) + +| 顺序 | action | node_uuid | +|------|--------|-----------| +| Step1 | auto-create_orders_formulation | `ece6744a-81ac-4ae4-8cd1-1c8eeda1dab6` | +| Step2 | auto-transfer_3_to_2_to_1_auto | `1c37a8dd-5ba0-413d-81db-94b9c936a171` | +| Step3 | auto-coin_cell_init | `97a676a2-d257-4479-9096-073b40300970` | +| Step4 | auto-coin_cell_start | `cf69017a-d29c-4aad-a63b-309d63dac2e9` | +| Step5 | auto-transfer_1_to_2 | `80d1c1aa-dbc3-4601-86b7-5c22a992dd9e` | + +> ⚠️ 扣电站动作已由 `func_pack_device_init_auto_start_combined` / `func_sendbottle_allpack_multi` +> 重命名为 `coin_cell_init` / `coin_cell_start`。上表 node_uuid 对应的云端工作流需用新动作名**重建**后才能正常派发; +> 若不确定云端是否已重建,请通过 API #3 重新获取工作流详情确认各节点的 action 名与最新 node_uuid。 + +### 标准提示词 + +``` +请使用 yibin-electrolyte-submit skill,提交以下实验: + +工作流模板 URL:https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/2bc59938-db79-4415-ac2d-9897ef125f2f +Excel 文件路径:<粘贴或上传 xlsx 路径> + +注意事项: +- lab_uuid、project_uuid、workflow节点UUID均已固定,无需重新查询 +- 直接解析 Excel → 构建 payload → 提交 +- mix_time 传标量整数即可(已兼容) +- 试剂名以 Excel 为准,注意区分 LiDFOB / LiDOFB 等拼写 +- csv_export_path 取 Excel 中 csv_export_path 列的值 +- 提交后告知 notebook UUID,无需自动轮询(实验耗时较长) +``` + +### Excel 列结构说明(experment_template_0415sim-*.xlsx) + +| 列范围 | 内容 | +|--------|------| +| C | batch_id | +| D | bottle_type | +| E-H | coin_cell_volume / conductivity_bottle_count / conductivity_volume / csv_export_path | +| I-T | 试剂名+质量 交替排列(最多6对)| +| U | mix_time | +| V | order_name(每行配方的订单号)| +| W | pouch_cell_volume | +| X-Y | target_device / target_location(Step2参数)| +| AA | material_search_enable(Step3参数)| +| AB-AS | 扣电站参数(Step4)| + +### CSV 导出说明 + +每次 `create_orders_formulation` 完成后,在 `csv_export_path` 目录下生成: +``` +electrolyte_orders_.csv +``` +列:`orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间` + +> **注意**:barCode 为 `null` 或 `"nullBarCode123456"` 是正常现象,表示 LIMS 中该物料尚未扫码。配液瓶缺失通常是因为物料未放在手动传递窗(`locationId` 前缀 `3a19deae-2c7a-`)。 diff --git a/.cursor/skills/yibin-electrolyte-submit/action-index.md b/.cursor/skills/yibin-electrolyte-submit/action-index.md new file mode 100644 index 000000000..c3cffcc2a --- /dev/null +++ b/.cursor/skills/yibin-electrolyte-submit/action-index.md @@ -0,0 +1,279 @@ +# Action 索引 + +> Action JSON 文件需运行提取命令生成,详见 [SKILL.md](SKILL.md) 中「生成 Action Schema」。 +> 以下描述和参数信息基于源码分析。 + +--- + +## 配液分液工站 (`bioyond_cell_workstation`) + +源码:`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` + +### 调度控制 + +#### `scheduler_start` + +启动 Bioyond LIMS 调度系统 + +- **核心参数**: 无(仅需 apiKey/requestTime,由设备内部处理) +- **返回**: LIMS 响应 `{code, message, data}` + +#### `scheduler_stop` + +停止调度 + +- **核心参数**: 无 + +#### `scheduler_continue` + +继续调度(从暂停状态恢复) + +- **核心参数**: 无 + +#### `scheduler_reset` + +复位调度 + +- **核心参数**: 无 + +#### `scheduler_start_and_auto_feeding` + +**组合操作**:启动调度 + 自动化上料(4号→3号手套箱) + +- **核心参数**: `xlsx_path`(Excel 物料模板路径,可选) +- **可选参数**: WH4 加样头面 12 个点位(materialName + quantity)、WH4 原液瓶面 9 个点位(materialName + quantity + materialType + targetWH)、WH3 人工堆栈 15 个点位(materialType + materialId + quantity) +- **流程**: 先 `scheduler_start()`,成功后执行 `auto_feeding4to3()` +- **备注**: 支持 Excel 模式和手动参数模式,Excel 路径存在时优先使用 Excel + +### 物料上料/下料 + +#### `auto_feeding4to3` + +自动化上料:从 4 号手套箱转运物料到 3 号手套箱 + +- **核心参数**: `xlsx_path`(Excel 物料模板路径) +- **可选参数**: 同 `scheduler_start_and_auto_feeding` 的 WH4/WH3 点位参数 +- **返回**: 等待上料任务完成后返回结果 + +#### `auto_batch_outbound_from_xlsx` + +自动化下料(从 Excel 读取下料信息) + +- **核心参数**: `xlsx_path`(Excel 下料模板) +- **Excel 列**: locationId, warehouseId, 数量, x, y, z + +### 物料管理 + +#### `create_and_inbound_materials` + +批量创建固体物料并入库 + +- **核心参数**: `material_names`(物料名称列表,默认 `["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"]`) +- **可选参数**: `type_id`(物料类型ID), `warehouse_name`(目标仓库,默认 "粉末加样头堆栈") +- **流程**: 创建物料 → 批量入库 → 同步 + +#### `create_material` + +创建单个物料并可选入库 + +- **核心参数**: `material_name`, `type_id`, `warehouse_name` +- **可选参数**: `location_name_or_id`(库位编号如 "A01" 或 UUID) + +#### `create_sample` + +创建配液板物料(含子瓶)并入库 + +- **核心参数**: `name`, `board_type`(如 "5ml分液瓶板"), `bottle_type`(如 "5ml分液瓶"), `location_code`(如 "A01") +- **可选参数**: `warehouse_name`(默认 "手动堆栈") +- **备注**: 自动创建 2x4=8 个子瓶 + +#### `storage_inbound` + +单个物料入库 + +- **核心参数**: `material_id`, `location_id` + +#### `storage_batch_inbound` + +批量物料入库 + +- **核心参数**: `items`(`[{materialId, locationId}, ...]`) + +### 配液实验 + +#### `create_orders` + +从 Excel 文件创建配液实验订单 + +- **核心参数**: `xlsx_path`(Excel 文件路径) +- **Excel 列**: 配方ID, 创建日期, 配液瓶类型, 混匀时间(s), 扣电组装分液体积, 软包组装分液体积, 电导测试分液体积, 电导测试分液瓶数, 以及所有以 `(g)` 结尾的物料列 +- **流程**: 解析 Excel → 提交订单 → 等待全部完成 → 计算质量比 → 提取分液瓶板 → 创建资源树对象 +- **返回**: `{status, total_orders, bottle_count, reports, mass_ratios, vial_plates}` + +#### `create_orders_formulation` + +从配方列表创建配液实验订单(前端/API 输入版本) + +- **核心参数**: `formulation`(配方数组) +- **可选参数**: `batch_id`, `bottle_type`(默认 "配液小瓶"), `mix_time`(秒,列表), `coin_cell_volume`, `pouch_cell_volume`, `conductivity_volume`, `conductivity_bottle_count` +- **formulation 格式**: + ```json + [ + { + "order_name": "配方A", + "materials": [ + {"name": "LiPF6", "mass": 12.5}, + {"name": "EC", "mass": 50.0}, + {"name": "DMC", "mass": 37.5} + ] + } + ] + ``` +- **返回**: 同 `create_orders` + +### 物料转运 + +#### `transfer_3_to_2_to_1_auto` + +**自动转运**:从 create_orders 结果中自动定位分液瓶板并转运到目标设备 + +- **核心参数**: `vial_plates`(分液瓶板列表,来自 create_orders 返回的 `vial_plates`) +- **可选参数**: `target_device`(默认 "BatteryStation"), `target_location`(默认 "bottle_rack_6x2"), `mass_ratios`(配方信息) +- **流程**: 遍历瓶板 → 解析 locationId → 调用 LIMS 转运 API → 更新资源树 +- **返回**: `{total, success, failed, results}` + +#### `transfer_3_to_2_to_1` + +3→2→1 物料转运(手动指定坐标) + +- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z` + +#### `transfer_3_to_2` + +3→2 物料转运 + +- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z` + +#### `transfer_1_to_2` + +1→2 物料转运 + +- **核心参数**: 无 + +### 查询 + +#### `order_list_v2` + +批量查询实验报告 + +- **可选参数**: `timeType`, `beginTime`, `endTime`, `status`(60=运行中, 80=完成, 90=失败), `filter`, `skipCount`, `pageCount`, `sorting` + +--- + +## 扣电组装站 (`BatteryStation`) + +源码:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` + +### 设备控制(组合操作) + +#### `coin_cell_init` + +**组合操作**:设备初始化 → 物料搜寻确认 → 切换自动模式 → 启动 + +- **核心参数**: `material_search_enable`(是否启用物料搜寻,默认 `False`) +- **前置检查**: REG_UNILAB_INTERACT=False, COIL_GB_L_IGNORE_CMD=False, 所有握手寄存器无残留 +- **流程**: 手动模式 → 初始化命令 → 监测物料搜寻弹窗并自动处理 → 自动模式 → 启动 +- **返回**: `True`/`False` +- **备注**: 第一次运行必须调用此函数;后续批次调用 `coin_cell_start` + +### 批量组装 + +#### `coin_cell_start` + +**发送瓶数 + 批量组装**(适用于第二批次及后续批次) + +- **核心参数**: `elec_num`(电解液瓶数), `elec_use_num`(每瓶组装电池数), `elec_vol`(电解液吸液量 μL,默认 50) +- **可选参数**: + - 双滴模式:`dual_drop_mode`(bool), `dual_drop_first_volume`(μL), `dual_drop_suction_timing`(bool), `dual_drop_start_timing`(bool) + - 组装参数:`assembly_type`(7=不用铝箔垫/8=用), `assembly_pressure`(N,默认 4200) + - 物料参数:`ne_plate_num`(负极片盘数), `ne_plate_matrix`(负极片矩阵点位), `sep_plate_num`(隔膜盘数), `sep_plate_matrix`(隔膜矩阵点位), `tip_box_matrix`(枪头盒矩阵点位) + - 开关:`aluminum_foil`(铝箔垫片), `battery_pressure_mode`(压力模式), `battery_clean_ignore`(忽略清洁) + - 其他:`file_path`(CSV保存路径), `formulations`(配方信息,用于CSV追溯) +- **流程**: 发送瓶数触发物料搬运 → 设置PLC参数 → 循环(等待PLC请求→下发参数→读取电池数据→写入CSV→更新资源树)→ 完成握手 +- **返回**: `{success, total_batteries, batteries, summary}` +- **备注**: 设备已初始化后直接调用;`formulations` 来自 create_orders 的 `mass_ratios` + +#### `func_allpack_cmd_simp` + +简化版组装(含双滴模式 + 负极片/隔膜/枪头参数,含断点续传) + +- **核心参数**: 同 `coin_cell_start` +- **备注**: 被 `coin_cell_start` 内部调用 + +### 设备控制(单步操作) + +#### `func_pack_device_init` + +设备初始化(手动模式 → 初始化 → 复位标志) + +#### `func_pack_device_auto` + +切换自动模式 + +#### `func_pack_device_start` + +启动设备 + +#### `func_pack_device_stop` + +设备停止 + +#### `func_pack_send_bottle_num` + +发送电解液瓶数(触发物料搬运) + +- **核心参数**: `bottle_num`(瓶数) + +### 数据采集 + +#### `func_read_data_and_output` + +持续数据采集并导出 CSV(后台循环运行) + +- **核心参数**: `file_path`(CSV 保存目录) +- **采集字段**: 开路电压, 极片质量, 组装时间, 压制力, 电解液加注量, 电池类型, 电解液二维码, 电池二维码 + +#### `func_stop_read_data` + +停止 CSV 数据采集 + +### 设备状态属性(只读) + +| 属性 | 类型 | 说明 | +|------|------|------| +| `sys_status` | str | 设备状态(启动中/停止中/复位中/初始化中) | +| `sys_mode` | str | 设备模式(手动/自动) | +| `data_assembly_coin_cell_num` | int | 已完成电池数量 | +| `data_assembly_time` | float | 单颗电池组装时间(秒) | +| `data_open_circuit_voltage` | float | 开路电压(V) | +| `data_pole_weight` | float | 正极片称重(g) | +| `data_glove_box_pressure` | float | 手套箱压力(mbar) | +| `data_glove_box_o2_content` | float | 手套箱氧含量(ppm) | +| `data_glove_box_water_content` | float | 手套箱水含量(ppm) | +| `data_coin_cell_code` | str | 电池二维码 | +| `data_electrolyte_code` | str | 电解液二维码 | + +--- + +## 配置参考 + +设备图文件 `yibin_electrolyte_config.json` 中的仓库映射(`warehouse_mapping`): + +| 仓库名称 | 说明 | 典型操作 | +|---------|------|---------| +| 粉末加样头堆栈 | 20 个点位 (A01-T01) | `create_and_inbound_materials` 入库目标 | +| 配液站内试剂仓库 | 9 个点位 (A01-C03) | 试剂存储 | +| 自动堆栈-左 | 4 个点位 | 分液瓶板存放,`transfer_3_to_2_to_1_auto` 的源位置 | +| 自动堆栈-右 | 4 个点位 | 分液瓶板存放 | +| 手动传递窗左/右 | 各 15 个点位 | 人工上料/下料 | +| 4号手套箱内部堆栈 | 12 个点位 | `auto_feeding4to3` 的源位置 | diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 402edc26f..2b227db1e 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - name: Setup Miniforge - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true @@ -38,7 +38,7 @@ jobs: - name: Install ROS dependencies, uv and unilabos-msgs run: | echo Installing ROS dependencies... - mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y + mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y - name: Install pip dependencies and unilabos run: | diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index ed45db9d4..2730ff4d1 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -1,6 +1,10 @@ name: Build Conda-Pack Environment on: + # 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack + workflow_run: + workflows: ["UniLabOS Conda Build"] + types: [completed] workflow_dispatch: inputs: branch: @@ -21,6 +25,16 @@ on: jobs: build-conda-pack: + if: | + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_run' + ) + env: + BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }} + PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }} strategy: fail-fast: false matrix: @@ -29,7 +43,7 @@ jobs: platform: linux-64 env_file: unilabos-linux-64.yaml script_ext: sh - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml script_ext: sh @@ -54,7 +68,9 @@ jobs: id: should_build shell: bash run: | - if [[ -z "${{ github.event.inputs.platforms }}" ]]; then + if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then + echo "should_build=true" >> $GITHUB_OUTPUT + elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then echo "should_build=true" >> $GITHUB_OUTPUT elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then echo "should_build=true" >> $GITHUB_OUTPUT @@ -65,17 +81,17 @@ jobs: - uses: actions/checkout@v6 if: steps.should_build.outputs.should_build == 'true' with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Setup Miniforge (with mamba) if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -86,13 +102,13 @@ jobs: run: | echo Installing unilabos and dependencies to unilab environment... echo Using mamba for faster and more reliable dependency resolution... - echo Build full: ${{ github.event.inputs.build_full }} - if "${{ github.event.inputs.build_full }}"=="true" ( + echo Build full: ${{ env.BUILD_FULL }} + if "${{ env.BUILD_FULL }}"=="true" ( echo Installing unilabos-full ^(complete package^)... - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y ) else ( echo Installing unilabos ^(minimal package^)... - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y ) - name: Install conda-pack, unilabos and dependencies (Unix) @@ -101,13 +117,13 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - echo "Build full: ${{ github.event.inputs.build_full }}" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + echo "Build full: ${{ env.BUILD_FULL }}" + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Installing unilabos-full (complete package)..." - mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y else echo "Installing unilabos (minimal package)..." - mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y fi - name: Get latest ros-humble-unilabos-msgs version (Windows) @@ -134,27 +150,27 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Checking for available ros-humble-unilabos-msgs versions... - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed echo. echo Updating ros-humble-unilabos-msgs to latest version... - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version - name: Check for newer ros-humble-unilabos-msgs (Unix) if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64' shell: bash run: | echo "Checking for available ros-humble-unilabos-msgs versions..." - mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed" + mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed" echo "" echo "Updating ros-humble-unilabos-msgs to latest version..." - mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version" + mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version" - name: Install latest unilabos from source (Windows) if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Uninstalling existing unilabos... mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip - echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})... + echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})... mamba run -n unilab pip install . echo Verifying installation... mamba run -n unilab pip show unilabos @@ -165,7 +181,7 @@ jobs: run: | echo "Uninstalling existing unilabos..." mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip" - echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..." + echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..." mamba run -n unilab pip install . echo "Verifying installation..." mamba run -n unilab pip show unilabos @@ -226,7 +242,9 @@ jobs: if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64' run: | echo Packing unilab environment with conda-pack... - mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i" + echo Packing environment at: %UNILAB_PREFIX% + mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo Pack file created: dir unilab-env-${{ matrix.platform }}.tar.gz @@ -235,8 +253,9 @@ jobs: shell: bash run: | echo "Packing unilab environment with conda-pack..." - mamba install conda-pack -c conda-forge -y - conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files + UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')" + echo "Packing environment at: $UNILAB_PREFIX" + mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files echo "Pack file created:" ls -lh unilab-env-${{ matrix.platform }}.tar.gz @@ -267,7 +286,7 @@ jobs: rem Create README using Python script echo Creating: README.txt - python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt + python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt echo. echo Distribution package contents: @@ -303,7 +322,7 @@ jobs: # Create README using Python script echo "Creating: README.txt" - python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt + python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt echo "" echo "Distribution package contents:" @@ -314,7 +333,7 @@ jobs: if: steps.should_build.outputs.should_build == 'true' uses: actions/upload-artifact@v6 with: - name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} path: dist-package/ retention-days: 90 if-no-files-found: error @@ -326,9 +345,9 @@ jobs: echo Build Summary echo ========================================== echo Platform: ${{ matrix.platform }} - echo Branch: ${{ github.event.inputs.branch }} + echo Branch: ${{ env.PACKAGE_REF }} echo Python version: 3.11.14 - if "${{ github.event.inputs.build_full }}"=="true" ( + if "${{ env.BUILD_FULL }}"=="true" ( echo Package: unilabos-full ^(complete^) ) else ( echo Package: unilabos ^(minimal^) @@ -337,7 +356,7 @@ jobs: echo Distribution package contents: dir dist-package echo. - echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }} + echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }} echo. echo After download, extract the ZIP and run: echo install_unilab.bat @@ -351,9 +370,9 @@ jobs: echo "Build Summary" echo "==========================================" echo "Platform: ${{ matrix.platform }}" - echo "Branch: ${{ github.event.inputs.branch }}" + echo "Branch: ${{ env.PACKAGE_REF }}" echo "Python version: 3.11.14" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then echo "Package: unilabos-full (complete)" else echo "Package: unilabos (minimal)" @@ -362,7 +381,7 @@ jobs: echo "Distribution package contents:" ls -lh dist-package/ echo "" - echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}" + echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}" echo "" echo "After download:" echo " install_unilab.sh" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index f3ac4d11f..3e4b07ddf 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -51,12 +51,12 @@ jobs: fetch-depth: 0 - name: Setup Miniforge (with mamba) - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: miniforge-version: latest use-mamba: true python-version: '3.11.14' - channels: conda-forge,robostack-staging,uni-lab,defaults + channels: conda-forge,robostack-staging,uni-lab channel-priority: flexible activate-environment: unilab auto-update-conda: false @@ -66,7 +66,7 @@ jobs: run: | echo "Installing unilabos and dependencies to unilab environment..." echo "Using mamba for faster and more reliable dependency resolution..." - mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y + mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y - name: Install latest unilabos from source run: | @@ -84,7 +84,7 @@ jobs: - name: Setup Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') @@ -105,7 +105,7 @@ jobs: test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing" - name: Upload build artifacts - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 if: | github.event.workflow_run.head_branch == 'main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true') @@ -125,4 +125,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index 4e1cf4f7b..5d3496a08 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -10,6 +10,9 @@ on: # 支持 tag 推送(不依赖 CI Check) push: tags: ['v*'] + # GitHub Release 发布时自动构建并上传 + release: + types: [published] # 手动触发 workflow_dispatch: inputs: @@ -60,7 +63,7 @@ jobs: - os: ubuntu-latest platform: linux-64 env_file: unilabos-linux-64.yaml - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 env_file: unilabos-osx-64.yaml - os: macos-latest # ARM64 @@ -80,7 +83,7 @@ jobs: - uses: actions/checkout@v6 with: # 如果是 workflow_run 触发,使用触发 CI Check 的 commit - ref: ${{ github.event.workflow_run.head_sha || github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }} fetch-depth: 0 - name: Check if platform should be built @@ -96,12 +99,14 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,defaults + miniforge-version: latest + use-mamba: true + python-version: '3.11.14' + channels: conda-forge,robostack-staging channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,24 +115,29 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - name: Build conda package if: steps.should_build.outputs.should_build == 'true' run: | - if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - else - rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge - fi + conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml --target-platform ${{ matrix.platform }} -c robostack -c robostack-staging -c conda-forge - name: List built packages if: steps.should_build.outputs.should_build == 'true' @@ -157,9 +167,15 @@ jobs: retention-days: 30 - name: Upload to Anaconda.org (unilab organization) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'release' || + startsWith(github.ref, 'refs/tags/') || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | for package in $(find ./output -name "*.conda"); do echo "Uploading $package to unilab organization..." - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index d116a67ee..f269606c3 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -1,14 +1,10 @@ name: UniLabOS Conda Build on: - # 在 CI Check 成功后自动触发 + # 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发 workflow_run: - workflows: ["CI Check"] + workflows: ["Multi-Platform Conda Build"] types: [completed] - branches: [main, dev] - # 标签推送时直接触发(发布版本) - push: - tags: ['v*'] # 手动触发 workflow_dispatch: inputs: @@ -33,37 +29,37 @@ on: type: boolean jobs: - # 等待 CI Check 完成的 job (仅用于 workflow_run 触发) - wait-for-ci: + # 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发) + wait-for-upstream: runs-on: ubuntu-latest if: github.event_name == 'workflow_run' outputs: should_continue: ${{ steps.check.outputs.should_continue }} steps: - - name: Check CI status + - name: Check upstream workflow status id: check run: | - if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then + if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then echo "should_continue=true" >> $GITHUB_OUTPUT - echo "CI Check passed, proceeding with build" + echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build" else echo "should_continue=false" >> $GITHUB_OUTPUT - echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build" + echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build" fi build: - needs: [wait-for-ci] - # 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式 + needs: [wait-for-upstream] + # 运行条件:workflow_run 触发且上游成功,或者手动触发 if: | always() && - (needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true') + (needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true') strategy: fail-fast: false matrix: include: - os: ubuntu-latest platform: linux-64 - - os: macos-15 # Intel (via Rosetta) + - os: macos-15-intel # Intel x86_64 platform: osx-64 - os: macos-latest # ARM64 platform: osx-arm64 @@ -79,7 +75,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # 如果是 workflow_run 触发,使用触发 CI Check 的 commit + # 如果是 workflow_run 触发,使用上游 conda 包构建的 commit ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 @@ -96,12 +92,14 @@ jobs: echo "should_build=false" >> $GITHUB_OUTPUT fi - - name: Setup Miniconda + - name: Setup Miniforge if: steps.should_build.outputs.should_build == 'true' - uses: conda-incubator/setup-miniconda@v3 + uses: conda-incubator/setup-miniconda@v4 with: - miniconda-version: 'latest' - channels: conda-forge,robostack-staging,uni-lab,defaults + miniforge-version: latest + use-mamba: true + python-version: '3.11.14' + channels: conda-forge,robostack-staging,uni-lab channel-priority: strict activate-environment: build-env auto-update-conda: false @@ -110,20 +108,29 @@ jobs: - name: Install rattler-build and anaconda-client if: steps.should_build.outputs.should_build == 'true' run: | - conda install -c conda-forge rattler-build anaconda-client + if [[ "$RUNNER_OS" == "Windows" ]]; then + cmd //D //S //C '%CONDA%\condabin\mamba.bat install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y' + else + mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y + fi + echo "CONDA_NO_PLUGINS=true" >> "$GITHUB_ENV" + echo "PYTHONUTF8=1" >> "$GITHUB_ENV" + echo "PYTHONIOENCODING=utf-8" >> "$GITHUB_ENV" - name: Show environment info if: steps.should_build.outputs.should_build == 'true' run: | conda info - conda list | grep -E "(rattler-build|anaconda-client)" + conda list -n build-env | grep -E "(rattler-build|anaconda-client)" + conda run -n build-env rattler-build --version + conda run -n build-env anaconda --version echo "Platform: ${{ matrix.platform }}" echo "OS: ${{ matrix.os }}" - echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}" + echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Building packages:" echo " - unilabos-env (environment dependencies)" echo " - unilabos (with pip package)" - if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then + if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then echo " - unilabos-full (complete package)" fi @@ -131,14 +138,19 @@ jobs: if: steps.should_build.outputs.should_build == 'true' run: | echo "Building unilabos-env (conda environment dependencies)..." - rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge + conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge - name: Upload unilabos-env to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos-env to uni-lab organization..." for package in $(find ./output -name "unilabos-env*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos (with pip package) @@ -146,33 +158,40 @@ jobs: run: | echo "Building unilabos package..." # 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取 - rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/base/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos to Anaconda.org (if enabled) - if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true' + if: | + steps.should_build.outputs.should_build == 'true' && + ( + github.event_name == 'workflow_run' || + github.event.inputs.upload_to_anaconda == 'true' + ) run: | echo "Uploading unilabos to uni-lab organization..." for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: Build unilabos-full - Only when explicitly requested if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' run: | echo "Building unilabos-full package on ${{ matrix.platform }}..." - rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output + conda run -n build-env rattler-build build -r .conda/full/recipe.yaml --target-platform ${{ matrix.platform }} -c uni-lab -c robostack-staging -c conda-forge --channel ./output - name: Upload unilabos-full to Anaconda.org (if enabled) if: | steps.should_build.outputs.should_build == 'true' && + github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' && github.event.inputs.upload_to_anaconda == 'true' run: | echo "Uploading unilabos-full to uni-lab organization..." for package in $(find ./output -name "unilabos-full*.conda"); do - anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" + conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" done - name: List built packages diff --git a/.gitignore b/.gitignore index 12b344d63..66de6049a 100644 --- a/.gitignore +++ b/.gitignore @@ -251,4 +251,7 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2 *.bz2 test_config.py - +# Local config files with secrets +yibin_coin_cell_only_config.json +yibin_electrolyte_config.json +yibin_electrolyte_only_config.json diff --git a/260415csv_export_walkthrough.md b/260415csv_export_walkthrough.md new file mode 100644 index 000000000..b783c8523 --- /dev/null +++ b/260415csv_export_walkthrough.md @@ -0,0 +1,72 @@ +# CSV 导出功能变更概要 + +## 修改的文件 + +### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py) + +#### 新增导入 +- `import csv` 和 `import os`(L14-15) + +#### 新增方法 + +| 方法 | 功能 | +|------|------| +| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) | +| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) | +| `_export_order_csv` | 汇总所有信息写入 CSV 文件 | + +#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`) +- `typemode="1"`, `realQuantity=1`, `usedQuantity=1` +- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗) +- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)" + +#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`) +- `typemode="1"`, `realQuantity=1`, `usedQuantity=1` +- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右) +- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶" +- **返回数组**,支持 1×5ml + n×20ml 的组合 + +#### 修改的方法 + +| 方法 | 变更 | +|------|------| +| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` | +| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` | +| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` | + +#### CSV 输出格式 +``` +orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间 +``` +- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示 +- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开) +- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容) + +--- + +### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml) + +为两个 action 注册了 `csv_export_path` 参数: + +- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path` +- `auto-create_orders_formulation`: 同上 + +--- + +### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯 + +在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。 + +并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列: +- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。 +- 新增 `orderName` 导出 +- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码) +- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组) +- 新增 `target_mass_ratio` 理论目标质量比 +- 新增 `real_mass_ratio` 实际称量真实质量比 + +*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。* + +## 向后兼容性 +- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响 +- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。 diff --git a/CHANGES_2026_03_24.md b/CHANGES_2026_03_24.md new file mode 100644 index 000000000..a514d1654 --- /dev/null +++ b/CHANGES_2026_03_24.md @@ -0,0 +1,168 @@ +# 变更说明 2026-03-24 + +## 问题背景 + +`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。 + +--- + +## 修复内容 + +### 1. `unilabos/resources/bioyond/decks.py` + +- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致 +- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化 + +```python +# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化) +def __init__(self, name, size_x, size_y, size_z, category): + super().__init__(...) + +# 修复后 +def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False): + super().__init__(...) + if setup: + self.setup() +``` + +--- + +### 2. `unilabos/resources/graphio.py` + +- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃 +- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程 + +```python +# 修复前 +bottle.tracker.liquids = [...] + +# 修复后 +if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [...] +``` + +--- + +### 3. `unilabos/app/main.py` + +- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因 +- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并 +- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响 + +--- + +### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复 + +- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}` +- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库 +- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因 + +```python +# 新增兜底 +if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库") + self.deck.setup() +``` + +--- + +--- + +## 补充修复 2026-03-25:依华扣电组装工站子物料未上传 + +### 问题 + +`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致: +- 前端子物料(成品弹夹、料盘、瓶架等)不显示 +- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError` + +### 修复文件 + +**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`** +- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑 + +**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`** +- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化 + +```python +# post_init 中新增 +if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化") + self.deck.setup() +``` + +### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误 + +**现象**:`deck.setup()` 被调用后,启动时抛出: +``` +设备后初始化失败: Must specify either `ordered_items` or `ordering`. +``` + +**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位: +```python +# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错 +plate = cls(name=name, ...) # ← 这里就崩了 +holes = create_ordered_items_2d(...) # ← 根本没走到这里 +for hole_name, hole in holes.items(): + plate.assign_child_resource(...) +``` +pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。 + +**修复**:先建洞位,再作为 `ordered_items` 传给构造函数: +```python +# 新(正确):先建洞位,再一次性传入构造函数 +holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...) +return cls(name=name, ..., ordered_items=holes) +``` + +> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。 + +--- + +## 补充修复 2026-03-25:3→2→1 转运资源同步失败 + +### 问题 + +配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败: +``` +[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2' +``` + +### 根因 + +`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题: + +1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉 +2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2` + +### 修复文件 + +**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`** + +改用全局设备注册表 `registered_devices` 跨设备访问目标 deck: + +```python +# 修复前(失效) +from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在 +return get_device_plr_resource_by_name(device_id, resource_name) + +# 修复后 +from unilabos.ros.nodes.base_device_node import registered_devices +device_info = registered_devices.get(device_id) +if device_info is not None: + driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get() + if driver is not None: + deck = getattr(driver, "deck", None) + if deck is not None: + res = deck.get_resource(resource_name) +``` + +关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。 + +--- + +## 根本原因分析 + +旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。 + +新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。 diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md index 3440044c6..a885e06d2 100644 --- a/docs/advanced_usage/configuration.md +++ b/docs/advanced_usage/configuration.md @@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat **获取方式:** -进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: +进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: ![copy_aksk.gif](image/copy_aksk.gif) @@ -69,7 +69,7 @@ class WSConfig: # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址 + remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址 # ROS配置 class ROSConfig: @@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `--addr` 参数支持以下预设值,会自动转换为对应的完整 URL: -- `test` → `https://uni-lab.test.bohrium.com/api/v1` -- `uat` → `https://uni-lab.uat.bohrium.com/api/v1` +- `test` → `https://leap-lab.test.bohrium.com/api/v1` +- `uat` → `https://leap-lab.uat.bohrium.com/api/v1` - `local` → `http://127.0.0.1:48197/api/v1` - 其他值 → 直接使用作为完整 URL @@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap `ak` 和 `sk` 是必需的认证参数: -1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 +1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得 2. **配置方式**: - **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) - **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK` @@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式: HTTP 客户端配置用于与云端服务通信: -| 参数 | 类型 | 默认值 | 说明 | -| ------------- | ---- | -------------------------------------- | ------------ | -| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | +| 参数 | 类型 | 默认值 | 说明 | +| ------------- | ---- | --------------------------------------- | ------------ | +| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 | **预设环境地址**: -- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) -- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` -- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` +- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认) +- 测试环境:`https://leap-lab.test.bohrium.com/api/v1` +- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1` - 本地环境:`http://127.0.0.1:48197/api/v1` ### 4. ROSConfig - ROS 配置 @@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10" export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" # 设置HTTP配置 -export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" +export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1" ``` ## 配置文件使用方法 @@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100 ```python class HTTPConfig: - remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + remote_addr = "https://leap-lab.test.bohrium.com/api/v1" ``` **环境变量方式:** ```bash -export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 +export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1 ``` **命令行方式(推荐):** diff --git a/docs/developer_guide/add_PLC.md b/docs/developer_guide/add_PLC.md new file mode 100644 index 000000000..e4f6bb6d7 --- /dev/null +++ b/docs/developer_guide/add_PLC.md @@ -0,0 +1,611 @@ +# PLC 通信标准与设备驱动编写指南(基于 AI4M 工站) + +> 本文档以 `unilabos/devices/workstation/AI4M`(水凝胶检测工站)为参考实现, +> 介绍如何将 PLC 控制的实验设备接入 Uni-Lab-OS:包含通信协议选型、节点表标准、 +> 通信基类、设备驱动、Registry 配置以及调试方法。 +> +> 阅读对象:负责现场调试与设备接入的同学。 + +--- + +## 0. 总览:一台 PLC 设备从硬件到云端的链路 + +``` + PLC(西门子 / 倍福 / 三菱 / 汇川 / 国产 PLC ...) + ▲ + │ 各家 PLC 私有协议(S7 / Modbus / EtherCAT ...) + │ + ┌──────────┴──────────┐ + │ OPC UA Server │ ← 统一在 PLC 侧或独立网关上配置 + │ (内置或 KEPServer)│ + └──────────┬──────────┘ + │ OPC UA over TCP(标准协议) + │ + ┌──────────┴──────────┐ + │ Uni-Lab 设备驱动 │ ← 本教程主体 + │ AI4MDevice │ + │ ├─ base_opcua_client.py 通信基类 + │ ├─ opcua_nodes_*.csv 节点表(标准) + │ └─ AI4M.py 动作函数 + └──────────┬──────────┘ + │ ROS2 Action / 云端 HTTP + ▼ + 实验记录本 / 云端调度 +``` + +**统一约定**:所有 PLC 设备**只暴露 OPC UA 接口**给 Uni-Lab,PC 端不直接处理 S7 / Modbus 等底层协议。 +这是 Uni-Lab 在工站类设备上的 PLC 通信标准。 + +--- + +## 1. 为什么选 OPC UA 作为标准? + +| 维度 | 自研 TCP/串口协议 | Modbus | **OPC UA** | +|---|---|---|---| +| 厂家无关 | ✗ | 部分 | **✓** | +| 自带类型系统 | ✗ | ✗(裸寄存器) | **✓(Boolean/Int16/Float...)** | +| 命名空间 / 节点树 | ✗ | ✗(地址=魔数) | **✓(带名字、可分组)** | +| 订阅推送 | ✗ | ✗ | **✓(DataChange Notification)** | +| 鉴权 / 加密 | 自己造 | ✗ | **✓** | +| 与 PLC 工程师沟通成本 | 高 | 中 | **低(按变量名沟通)** | + +实际接入时,PLC 工程师只需要在 PLC 侧把约定的"上位通讯变量"暴露到 OPC UA Server, +我们在 PC 侧就能用 `节点名 + 数据类型` 直接读写,不用管底层是 S7 还是 Modbus。 + +--- + +## 2. 节点表标准:`opcua_nodes_xxx.csv` + +PLC 侧暴露的所有变量统一**用一张 CSV 表**描述,这是 PC 端和 PLC 端**唯一的接口契约**。 +位置示例:`unilabos/devices/workstation/AI4M/opcua_nodes_AI4M.csv`。 + +### 2.1 列定义 + +| 列名 | 是否必填 | 说明 | +|---|---|---| +| `Name` | ✅ | 节点名(PLC 工程师在 PLC 项目中真实使用的变量名,通常是中文/原始名) | +| `EnglishName` | 推荐 | 英文别名,**PC 端代码全部用这个名字**调用 | +| `NodeType` | ✅ | `VARIABLE`(变量)或 `METHOD`(方法),AI4M 全部用变量 | +| `DataType` | ✅ | `BOOLEAN` / `INT16` / `INT32` / `FLOAT` / `DOUBLE` / `STRING` ... | +| `NodeLanguage` | 推荐 | `Chinese` / `English`,配合 `EnglishName` 做映射 | +| `NodeId` | ✅ | OPC UA 标准 NodeId,格式 `ns=;s=` 或 `ns=;i=` | + +### 2.2 真实样例(节选自 `opcua_nodes_AI4M.csv`) + +| Name | EnglishName | NodeType | DataType | NodeLanguage | NodeId | +|---|---|---|---|---|---| +| 机器人空闲 | `robot_ready` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|机器人空闲` | +| 机器人取烧杯编号 | `robot_pick_beaker_id` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|机器人取烧杯编号` | +| 检测1请求参数 | `station_1_request_params` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1请求参数` | +| 检测1工艺完成 | `station_1_process_complete` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|检测1工艺完成` | +| 磁力搅拌参数设置_C[0].搅拌速度 | `mag_stirrer_c0_stir_speed` | VARIABLE | INT16 | Chinese | `ns=4;s=上位通讯变量\|磁力搅拌参数设置_C[0].搅拌速度` | +| 报警复位 | `alarm_reset` | VARIABLE | BOOLEAN | Chinese | `ns=4;s=上位通讯变量\|报警复位` | + +### 2.3 设计规范(必读) + +1. **命名按"角色-编号-属性"分层**,便于代码批量寻址: + - `mag_stirrer_c{0..4}_stir_speed`(搅拌仪 0~4 的搅拌速度) + - `station_{1..3}_process_complete`(检测站 1~3 的完成信号) + - `robot_rack_pick_beaker_{1..5}_complete`(取烧杯 1~5 的完成信号) + + 这样在驱动里可以直接 `f"mag_stirrer_c{idx}_stir_speed"` 拼出节点名。 + +2. **数据类型与 PLC 侧严格一致**: + - `BOOL` → `BOOLEAN`,`INT/WORD` → `INT16/UINT16`,`DINT` → `INT32`,`REAL` → `FLOAT`。 + - 类型不一致会触发 `BadTypeMismatch`,写入失败。 + +3. **NodeId 必须从 PLC 工程或 OPC UA Server 中导出**,不要自己拼。 + 常见格式: + - 西门子 1500:`ns=4;s=上位通讯变量|<变量名>` + - 倍福 TwinCAT:`ns=4;s=PLC1.MAIN.<变量名>` + - KEPServerEX:`ns=2;s=Channel1.Device1.` + +4. **每个工站一个独立 CSV**,不要共用。 + AI4M 中真机用 `opcua_nodes_AI4M.csv`,仿真用 `opcua_nodes_AI4M_sim.csv`。 + +--- + +## 3. 通信基类架构 + +文件:`unilabos/devices/workstation/AI4M/base_opcua_client.py` + +整个通信层分两层: + +``` +BaseOpcUaClient # 最小可用:连接 + 节点注册 + 读写 + 方法调用 + ▲ + │ 继承 + │ +OpcUaClientWithSubscription # 生产可用:+ 订阅推送 + 缓存 + 自动重连 + ▲ + │ 继承 + │ +AI4MDevice # 业务驱动:在它之上写设备动作函数 +``` + +### 3.1 `BaseOpcUaClient` 核心能力 + +```python +class BaseOpcUaClient(UniversalDriver): + client: Optional[Client] = None + _node_registry: Dict[str, OpcUaNodeBase] = {} # name -> Variable/Method + _name_mapping: Dict[str, str] = {} # 英文名 -> 中文名 + _reverse_mapping: Dict[str, str] = {} # 中文名 -> 英文名 + _found_node_objects: Dict[str, Any] = {} # 缓存 ua.Node 用于订阅 + + @classmethod + def load_csv(cls, file_path) -> Tuple[List[OpcUaNode], dict, dict]: ... + def register_node_list(self, node_list) -> "BaseOpcUaClient": ... + def use_node(self, name) -> OpcUaNodeBase: ... + def read_node(self, node_name: str) -> str: ... # 返回 JSON + def write_node(self, json_input: str) -> str: ... + def call_method(self, node_name, *args) -> Tuple[Any, bool]: ... +``` + +它做的事情可以归纳为四步: + +1. **`load_csv`**:读取节点表,建立 `Name ↔ EnglishName` 双向映射。 +2. **`register_node_list`**:把节点登记进 `_variables_to_find` 待查找列表。 +3. **`_connect` → `_find_nodes`**:连上 OPC UA 后,按 `NodeId` 把每个节点解析成 `Variable` / `Method` 对象,放进 `_node_registry`。 +4. **`use_node(name)`**:业务代码取节点的唯一入口,**支持中英文混用**,找不到会自动重试一次。 + +### 3.2 `OpcUaClientWithSubscription` 增强能力 + +在 `BaseOpcUaClient` 基础上提供三个生产环境必备的能力: + +#### a) 订阅缓存(高频读零开销) + +```python +def _setup_subscriptions(self): + self._subscription = self.client.create_subscription( + self._subscription_interval, # 默认 500ms + SubscriptionHandler(self), + ) + for node_name, node in self._node_registry.items(): + if node.type == NodeType.VARIABLE and node.node_id: + handle = self._subscription.subscribe_data_change(ua_node) + self._subscription_handles[node_name] = handle +``` + +当 PLC 侧变量变化时,`datachange_notification` 回调会把新值写进 `self._node_values[name]`, +后续 `get_node_value` 优先读缓存——**业务代码可以放心地写 `while not self.get_node_value(...): time.sleep(1)` 而不用担心 OPC UA 频繁请求**。 + +#### b) 智能缓存的 `get_node_value` + +```python +def get_node_value(self, name, use_cache=True, force_read=False): + # 1. 中英文名归一化 + chinese_name = self._name_mapping.get(name, name) + + # 2. force_read=True 强制透传到 OPC UA Server + if force_read: ... + + # 3. 命中订阅推送 → 直接返回缓存 + # 4. 命中按需读 + 未过期(cache_timeout=5s)→ 返回缓存 + # 5. 否则发起 read 并更新缓存 +``` + +#### c) 连接监控 + 自动重连 + +后台线程每 30s 调一次 `client.get_namespace_array()` 探活,断线则自动 `disconnect → connect → 重新订阅`,最多重试 5 次。 + +### 3.3 数据类型 / 节点类型 + +`unilabos/device_comms/opcua_client/node/uniopcua.py`: + +```python +class DataType(Enum): + BOOLEAN = VariantType.Boolean + INT16 = VariantType.Int16 + INT32 = VariantType.Int32 + FLOAT = VariantType.Float + STRING = VariantType.String + # ... + +class NodeType(Enum): + VARIABLE = NodeClass.Variable + METHOD = NodeClass.Method + OBJECT = NodeClass.Object +``` + +`Variable.write()` 内部会按 `DataType` 做强制类型转换, +所以 CSV 里的 `DataType` 列就是"PC 端转换写入值的类型说明书"。 + +--- + +## 4. 编写设备驱动:以 `AI4MDevice` 为例 + +文件:`unilabos/devices/workstation/AI4M/AI4M.py` + +### 4.1 继承通信基类,最小骨架 + +```python +from typing import Optional +from unilabos.devices.workstation.AI4M.base_opcua_client import OpcUaClientWithSubscription + +class AI4MDevice(OpcUaClientWithSubscription): + def __init__( + self, + url: str, # opc.tcp://192.168.1.10:4840 + deck: Optional[AI4M_deck] = None, # 物料台面(资源树) + csv_path: str = None, # 节点表 CSV + username: str = None, + password: str = None, + use_subscription: bool = True, + cache_timeout: float = 5.0, + subscription_interval: int = 500, + *args, **kwargs, + ): + super().__init__( + url=url, username=username, password=password, + use_subscription=use_subscription, + cache_timeout=cache_timeout, + subscription_interval=subscription_interval, + *args, **kwargs, + ) + + # 物料台面初始化(见教程 4. 物料系统) + self.deck = deck or AI4M_deck(setup=True) + self._robot_lock = threading.Lock() + + # 关键:加载节点表 + if csv_path: + self.load_nodes_from_csv(csv_path) +``` + +`load_nodes_from_csv` 会一次性完成:解析 CSV → 注册节点 → 解析 NodeId → 建立订阅, +**之后整个驱动都通过 `self.get_node_value(name)` / `self.set_node_value(name, value)` 操作 PLC**。 + +### 4.2 PLC 通信的核心模式:握手协议(Handshake) + +PLC 编程的本质是"扫描周期 + 状态机",PC 端**绝对不能用 fire-and-forget 的方式发指令**。 +和 PLC 配合的标准模式是 **"PC 写指令 → PC 等待 PLC 回执 → PC 复位指令"**。 + +AI4M 中所有 `trigger_*` 函数都遵循以下三种握手范式之一: + +#### 范式 A:脉冲触发 + 完成信号(最常用) + +```python +def trigger_init(self) -> dict: + # ① 复位上一轮残留 + self.set_node_value("alarm_reset", True); time.sleep(1.0) + self.set_node_value("alarm_reset", False) + self.set_node_value("manual_auto_switch", False) + + # ② 等待 PLC 退出自动模式 + while self.get_node_value("auto_mode"): + time.sleep(1.0) + + # ③ 发起初始化脉冲(True → False) + self.set_node_value("initialize", True); time.sleep(1.0) + self.set_node_value("initialize", False) + + # ④ 等待 PLC 给出完成信号 + while not self.get_node_value("init finished"): + time.sleep(1.0) + + return {"message": "设备初始化完成"} +``` + +要点: +- **"PC 写一个 BOOL 拉高再拉低"** 模拟脉冲,PLC 用上升沿触发动作。 +- **`get_node_value` 要在 while 循环里轮询**,配合订阅缓存基本无压力。 +- **每个动作必须有"开始"和"完成"两个独立的 BOOL 节点**,不能复用。 + +#### 范式 B:参数下发 + 请求/已执行/完成 三步握手(带数据的工艺) + +```python +def trigger_station_process(self, station_id: int, mag_stir_speed: int, ...): + request_node = f"station_{station_id}_request_params" + params_received_node = f"station_{station_id}_params_received" + start_node = f"station_{station_id}_start" + complete_node = f"station_{station_id}_process_complete" + + # ① PC 复位三个状态位(避免上一轮影响) + self._reset_station_process_flags(station_id) + + # ② 等 PLC 主动请求参数(PLC 准备好了才接收) + while not self.get_node_value(request_node): + time.sleep(1.0) + + # ③ PC 下发参数(注意:PLC 内部数组是 0-based,PC 暴露给用户是 1-based) + station_idx = station_id - 1 + self.set_node_value(f"mag_stirrer_c{station_idx}_stir_speed", mag_stir_speed) + self.set_node_value(f"mag_stirrer_c{station_idx}_heat_temp", mag_stir_heat_temp) + self.set_node_value(f"mag_stirrer_c{station_idx}_time_set", mag_stir_time_set) + self.set_node_value(f"syringe_pump_{station_idx}_abs_position_set", syringe_pump_abs_pos) + + # ④ PC 通知 PLC "参数已就绪",等 PLC 回复"已执行" + self.set_node_value(start_node, True) + while not self.get_node_value(params_received_node): + time.sleep(1.0) + + # ⑤ 等 PLC 完成整个工艺 + while not self.get_node_value(complete_node): + time.sleep(5.0) + + self.set_node_value(start_node, False) # 复位,方便下一轮 + return {"station_id": station_id, "message": "..."} +``` + +四个状态位的语义: + +| 信号 | 方向 | 含义 | +|---|---|---| +| `station_X_request_params` | **PLC → PC** | "我准备好了,把参数给我" | +| `station_X_start` | **PC → PLC** | "参数我已经写好了,开干" | +| `station_X_params_received` | **PLC → PC** | "参数我已经吃下了" | +| `station_X_process_complete` | **PLC → PC** | "工艺已经做完" | + +**这是 PLC 通信教科书级别的标准范式**,所有带数据下发的动作都建议照抄。 + +#### 范式 C:编号下发 + 编号对应的完成信号(多目标互锁) + +```python +def trigger_robot_pick_beaker(self, pick_beaker_id: int, place_station_id: int = None, ...): + # ① 等机器人空闲(互锁) + while not self.get_node_value("robot_ready"): + time.sleep(1.0) + + # ② 阶段一:下发"取哪一杯"编号 + 等"取这一杯完成" + pick_complete_node = f"robot_rack_pick_beaker_{pick_beaker_id}_complete" + self.set_node_value("robot_pick_beaker_id", pick_beaker_id) + while not self.get_node_value(pick_complete_node): + time.sleep(1.0) + + # ③ 阶段二:下发"放到哪个工站"编号 + 等"放完成" + place_complete_node = f"robot_place_station_{place_station_id}_complete" + self._reset_station_process_flags(place_station_id) + self.set_node_value("robot_place_station_id", place_station_id) + while not self.get_node_value(place_complete_node): + time.sleep(1.0) +``` + +要点: +- **同一个动作的多个目标用"编号变量 + 编号对应的完成信号"实现**,不要每个目标都开一个开始位。 +- **配合 Python 端 `threading.Lock()` 做软互锁**,避免多个线程争抢机器人。 +- **每个阶段有独立的完成信号**,串行等待,不能合并。 + +### 4.3 一些容易踩坑的细节 + +1. **节点名映射** + `set_node_value("alarm_reset", True)` 实际写入的是 CSV 中文名 `报警复位`, + `get_node_value` 同理。**业务代码全部用 EnglishName**,不要直接用中文。 + +2. **PLC 数组索引和 PC 不一致** + AI4M 里 PC 暴露 `station_id ∈ {1, 2, 3}`,但 PLC 内部数组是 `C[0..2]`, + 驱动里要做 `station_idx = station_id - 1`,**这种映射只在驱动层做一次**, + 不要让上层(registry / 实验记录本)感知。 + +3. **订阅模式下 BOOL 节点的边沿同步** + 订阅有 ~500ms 延迟。如果你刚 `set_node_value(x, True)` 就立刻 `get_node_value(x)`, + 读到的可能还是 `False`(订阅还没推回来)。 + 解决方案:**写完后用 `force_read=True` 透传一次** 或加一段 `time.sleep`。 + +4. **永远不要忘记复位** + `start` 拉 True 后必须有地方拉回 False,否则下一轮 PLC 上升沿不触发。 + AI4M 在 `_reset_station_process_flags` 中统一做: + + ```python + def _reset_station_process_flags(self, station_id: int) -> None: + self.set_node_value(f"station_{station_id}_process_complete", False) + self.set_node_value(f"station_{station_id}_start", False) + self.set_node_value(f"station_{station_id}_params_received", False) + ``` + +5. **耗时长的等待 sleep 加大** + 工艺等待用 `time.sleep(5.0)`,机器人等待用 `time.sleep(1.0)`,初始化等待 `time.sleep(1.0)`, + 不要全部用 0.1s 轮询,会把日志刷爆。 + +--- + +## 5. 把驱动接到 Uni-Lab:Registry + Graph + +### 5.1 Registry YAML(动作 schema) + +文件:`unilabos/registry/devices/AI4M_station.yaml` + +```yaml +AI4M_station: + category: [AI4M_station] + class: + module: unilabos.devices.workstation.AI4M.AI4M:AI4MDevice # ← 入口类 + type: python + action_value_mappings: + auto-trigger_init: + schema: + description: 设备初始化... + properties: + goal: { properties: {}, required: [], type: object } + result: + properties: { message: { type: string } } + required: [message] + type: object + type: object + type: UniLabJsonCommand + + auto-trigger_station_process: + always_free: true + schema: + description: 执行检测工艺流程 + properties: + goal: + properties: + station_id: { type: integer, description: 检测编号 1-3 } + mag_stir_stir_speed: { type: integer } + mag_stir_heat_temp: { type: integer } + mag_stir_time_set: { type: integer } + syringe_pump_abs_position_set:{ type: integer } + required: [station_id, mag_stir_stir_speed, mag_stir_heat_temp, + mag_stir_time_set, syringe_pump_abs_position_set] + type: object + result: { ... } + type: UniLabJsonCommand + + init_param_schema: + config: + type: object + required: [url] + properties: + url: { type: string, description: OPC UA 服务器地址 } + csv_path: { type: string, description: 节点配置 CSV 路径 } + deck: { type: string, description: 资源树配置 } + username: { type: string } + password: { type: string } + use_subscription: { type: boolean, default: true } + cache_timeout: { type: number, default: 5.0 } + subscription_interval: { type: integer, default: 500 } +``` + +规则总结: +- `class.module` 指向驱动类(`module:ClassName`)。 +- `action_value_mappings` 中的 key 形如 `auto-<方法名>`,对应驱动里的同名 Python 方法。 +- `schema.goal` 自动转成 ROS2 Action 的 goal 消息,`schema.result` 转 result。 +- `init_param_schema.config` 对应 `__init__` 的入参,**所有需要现场改的参数都要列出来**(最重要的就是 `url` 和 `csv_path`)。 +- `always_free: true` 表示该动作不占用工站独占锁(多检测站可并发执行)。 + +### 5.2 Graph JSON(实例化) + +文件:`unilabos/devices/workstation/AI4M/AI4M.json` + +```json +{ + "nodes": [ + { + "id": "AI4M_station", + "name": "AI4M_station", + "type": "device", + "class": "AI4M_station", + "children": ["AI4M_deck"], + "parent": null, + "config": { + "url": "opc.tcp://192.168.1.10:4840", + "csv_path": "opcua_nodes_AI4M.csv", + "deck": { + "data": { + "_resource_child_name": "AI4M_deck", + "_resource_type": "unilabos.devices.workstation.AI4M.decks:AI4M_deck" + } + } + } + }, + { + "id": "AI4M_deck", + "type": "deck", + "class": "AI4M_deck", + "parent": "AI4M_station", + "config": { "type": "AI4M_deck" } + } + ] +} +``` + +要点: +- `class` 必须和 Registry YAML 的顶层 key 完全一致(`AI4M_station`)。 +- `config` 字段**逐字传给驱动 `__init__`**,所以 Graph JSON = "现场参数表"。 +- 多套相同设备时拷贝一份,把 `id` / `url` 改掉即可(参考 `AI4M002_station`)。 + +### 5.3 启动命令(来自 `start.md`) + +```cmd +# 真机 +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4M.json ` + --ak --sk --upload_registry --addr --disable_browser + +# 仿真(KEPServerEX 跑在本机 49320 端口) +python unilabos/app/main.py -g unilabos/devices/workstation/AI4M/AI4Msim.json ` + --ak --sk --upload_registry --disable_browser +``` + +`--upload_registry` 会把 `AI4M_station.yaml` 的 schema 上传到云端, +之后实验记录本就能看到所有 `auto-*` 动作。 + +--- + +## 6. 调试方法 + +### 6.1 用 KEPServerEX 仿真 PLC + +不带 PLC 的开发机上,可以用 KEPServerEX(或 `python-opcua` 自建 server)模拟。 +AI4M 提供了一份仿真节点表 `opcua_nodes_AI4M_sim.csv`,**只改 NodeId 不改语义**, +所以驱动代码无需任何改动即可在本机调试。 + +### 6.2 单独跑驱动(不开 ROS) + +在驱动文件末尾的 `if __name__ == '__main__':` 段: + +```python +if __name__ == '__main__': + A4 = AI4MDevice( + url="opc.tcp://192.168.1.10:4840", + csv_path="opcua_nodes_AI4M.csv", + ) + A4.trigger_init() + print("初始化完成") + A4.trigger_robot_pick_beaker(1, 1) +``` + +**新动作上线前一定要在这里裸跑一遍**,确认握手时序正确,再往上接 ROS。 + +### 6.3 看日志判断卡在哪 + +`base_opcua_client.py` 的日志已经覆盖了所有关键节点: + +``` +✓ 客户端已连接! +✓ 找到变量节点: 'robot_ready', NodeId: ns=4;s=... +✓ 已订阅节点: robot_ready +✓ 节点查找完成:所有 142 个节点均已找到 +``` + +如果看到 `⚠ 以下 N 个节点未找到`,**99% 是 CSV 里的 NodeId 写错了**,回去对一下 PLC 工程导出的 NodeId。 + +### 6.4 检查节点是否能直接读写 + +```python +# 透传读,绕过订阅缓存 +A4.get_node_value("robot_ready", force_read=True) + +# 直接读 JSON 形式(适合从 HTTP/调试面板调) +A4.read_node("robot_ready") + +# 写 +A4.set_node_value("alarm_reset", True) +A4.write_node('{"node_name": "alarm_reset", "value": false}') +``` + +--- + +## 7. 接入新 PLC 设备的 Checklist + +接到一台新工站时,按下面顺序做就能保证不漏: + +- [ ] 1. 让 PLC 工程师把上位通讯变量整理到 OPC UA Server,导出 NodeId 清单。 +- [ ] 2. 在 `unilabos/devices/workstation/<设备名>/` 下新建目录,复制 `AI4M/base_opcua_client.py` 不动。 +- [ ] 3. 整理 `opcua_nodes_<设备名>.csv`,6 列填齐,并补上 `EnglishName`。 +- [ ] 4. 在该目录写设备驱动 `<设备名>.py`,继承 `OpcUaClientWithSubscription`: + - [ ] `__init__` 调用 `super().__init__` + `self.load_nodes_from_csv(csv_path)`。 + - [ ] 每个动作函数用范式 A/B/C 写握手协议。 + - [ ] 每个动作函数都返回 `dict`,至少含 `message` 字段。 +- [ ] 5. 在 `unilabos/registry/devices/` 下新建 `<设备名>_station.yaml`,配置 `init_param_schema` 和 `action_value_mappings`。 +- [ ] 6. 在该目录新建 `<设备名>.json`(Graph),填好 `url` 和 `csv_path`。 +- [ ] 7. 用 `if __name__ == '__main__':` 单独跑驱动确认握手 OK。 +- [ ] 8. 用 `python unilabos/app/main.py -g --upload_registry ...` 上线,到实验记录本下发动作回归。 + +--- + +## 8. 参考实现速查 + +| 关注点 | 在 AI4M 中看哪里 | +|---|---| +| OPC UA 通信基类 | `base_opcua_client.py` | +| 节点定义类型系统 | `unilabos/device_comms/opcua_client/node/uniopcua.py` | +| 节点表 CSV 标准 | `opcua_nodes_AI4M.csv` | +| 设备驱动入口类 | `AI4M.py: AI4MDevice` | +| 握手范式 A(脉冲+完成) | `AI4M.py: trigger_init` | +| 握手范式 B(请求/参数/完成) | `AI4M.py: trigger_station_process` | +| 握手范式 C(编号+完成) | `AI4M.py: trigger_robot_pick_beaker` | +| 自动模式批量参数下发 | `AI4M.py: download_auto_params` | +| Registry schema | `unilabos/registry/devices/AI4M_station.yaml` | +| Graph 实例化 | `AI4M.json` / `AI4Msim.json` | +| 启动命令 | `start.md` | diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md index 15ba4e087..c2c6d293a 100644 --- a/docs/developer_guide/add_device.md +++ b/docs/developer_guide/add_device.md @@ -17,7 +17,12 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用, ```python from unilabos.registry.decorators import device, topic_config -@device(id="mock_gripper", category=["gripper"], description="Mock Gripper") +@device( + id="mock_gripper", + category=["gripper"], + description="Mock Gripper", + displayname="模拟夹爪", +) class MockGripper: def __init__(self): self._position: float = 0.0 @@ -188,7 +193,12 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构: from typing import Dict, Any from unilabos.registry.decorators import device, topic_config -@device(id="my_device", category=["general"], description="My Device") +@device( + id="my_device", + category=["general"], + description="My Device", + displayname="我的设备", +) class MyDevice: """设备类文档字符串 @@ -929,14 +939,21 @@ class MyDevice: ```python from unilabos.registry.decorators import device -@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp") +@device( + id="my_heating_device", + category=["heating"], + description="My Heating Device", + displayname="加热设备", + icon="heater.webp", +) class MyDevice: ... ``` -- `id`:设备唯一标识符,用于注册表匹配 +- `id`:设备唯一标识符,用于注册表匹配;只能包含英文大小写字母、数字、下划线,不能包含中文、空格、短横线、点号或其他符号 - `category`:分类列表,前端用于分组显示 - `description`:设备描述 +- `displayname`:设备显示名称,用于 UI 展示中文名或更友好的名称;不要把显示名写进 `id` - `icon`:图标文件名(可选) ### 2. 使用 `@topic_config` 声明需要广播的状态 diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md index 19f163121..dc7422350 100644 --- a/docs/developer_guide/networking_overview.md +++ b/docs/developer_guide/networking_overview.md @@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式: ``` ┌──────────────────────────────────────────────┐ │ Cloud Platform/Self-hosted Platform │ -│ uni-lab.bohrium.com │ +│ leap-lab.bohrium.com │ │ (Resource Management, Task Scheduling, │ │ Monitoring) │ └────────────────────┬─────────────────────────┘ @@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start ```bash # 测试云端连接 -curl https://uni-lab.bohrium.com/api/v1/health +curl https://leap-lab.bohrium.com/api/v1/health # 测试WebSocket # 启动Uni-Lab后查看日志 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 499ee9eec..8e4fd357d 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -33,11 +33,11 @@ **选择合适的安装包:** -| 安装包 | 适用场景 | 包含组件 | -|--------|----------|----------| -| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | -| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | -| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | +| 安装包 | 适用场景 | 包含组件 | +| --------------- | ---------------------------- | --------------------------------------------- | +| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | +| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | +| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | **关键步骤:** @@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge ``` **选择建议:** + - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 - **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt @@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')" #### 2.1 注册实验室账号 -1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) 2. 注册账号并登录 3. 创建新实验室 @@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json #### 5.2 访问 Web 界面 -启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) #### 5.3 添加设备和物料 @@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json **示例场景:** 创建一个简单的液体转移实验 1. **添加工作站(必需):** - - 在"仪器设备"中找到 `work_station` - 添加 `workstation` x1 2. **添加虚拟转移泵:** - - 在"仪器设备"中找到 `virtual_device` - 添加 `virtual_transfer_pump` x1 @@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt ``` **为什么使用这种方式?** + - `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译) - `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 - `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 @@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \ **详细步骤:** 1. **需求分析**: - - 明确实验流程 - 列出所需设备和物料 - 设计工作流程图 2. **环境搭建**: - - 安装 Uni-Lab-OS - 创建实验室账号 - 准备开发工具(IDE、Git) 3. **原型验证**: - - 使用虚拟设备测试流程 - 验证工作流逻辑 - 调整参数 4. **迭代开发**: - - 实现自定义设备驱动(同时撰写单点函数测试) - 编写注册表 - 单元测试 - 集成测试 5. **测试部署**: - - 连接真实硬件 - 空跑测试 - 小规模试验 @@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \ #### 14.5 社区支持 - **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) -- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com) --- diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md index d6902829f..f4951dde6 100644 --- a/docs/user_guide/graph_files.md +++ b/docs/user_guide/graph_files.md @@ -626,7 +626,7 @@ unilab **云端图文件管理**: -1. 登录 https://uni-lab.bohrium.com +1. 登录 https://leap-lab.bohrium.com 2. 进入"设备配置" 3. 创建或编辑配置 4. 保存到云端 diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index 34caa5b90..4f8df40db 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段: 您可以直接跟随 unilabos 的提示进行,无需查阅本节 - **工作目录设置**: - - 如果当前目录以 `unilabos_data` 结尾,则使用当前目录 - 否则使用 `当前目录/unilabos_data` 作为工作目录 - 可通过 `--working_dir` 指定自定义工作目录 @@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段: 支持多种后端环境: -- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`) -- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`) +- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`) +- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`) - `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`) - 自定义地址:直接指定完整 URL @@ -176,7 +175,7 @@ unilab --config path/to/your/config.py 如果是首次使用,系统会: -1. 提示前往 https://uni-lab.bohrium.com 注册实验室 +1. 提示前往 https://leap-lab.bohrium.com 注册实验室 2. 引导创建配置文件 3. 设置工作目录 @@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser 如果提示 "后续运行必须拥有一个实验室",请确保: -- 已在 https://uni-lab.bohrium.com 注册实验室 +- 已在 https://leap-lab.bohrium.com 注册实验室 - 正确设置了 `--ak` 和 `--sk` 参数 - 配置文件中包含正确的认证信息 diff --git a/recipes/conda_build_config.yaml b/recipes/conda_build_config.yaml index 8e95491c8..c8915207a 100644 --- a/recipes/conda_build_config.yaml +++ b/recipes/conda_build_config.yaml @@ -1,5 +1,5 @@ channel_sources: - - robostack,robostack-staging,conda-forge,defaults + - robostack,robostack-staging,conda-forge gazebo: - '11' diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index fc8a5ccff..f821c118d 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.19 + version: 0.11.3 source: path: ../../unilabos_msgs target_directory: src diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 91e07b242..18c724f1b 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.19" + version: "0.11.3" source: path: ../.. diff --git a/setup.py b/setup.py index 7ca06f2e2..8ada8c207 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name=package_name, - version='0.10.19', + version='0.11.3', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/unilabos/__init__.py b/unilabos/__init__.py index eebdd7577..1bebb74e8 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.19" +__version__ = "0.11.3" diff --git a/unilabos/app/community_packages.py b/unilabos/app/community_packages.py new file mode 100644 index 000000000..b8139af1c --- /dev/null +++ b/unilabos/app/community_packages.py @@ -0,0 +1,370 @@ +import hashlib +import json +import shutil +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +from unilabos.utils import logger +from unilabos.utils.banner_print import print_status + + +COMMUNITY_PREFIX = "community." +COMMUNITY_CACHE_DIR = "community_devices" +MANIFEST_FILENAME = "manifest.json" + + +class CommunityPackageError(RuntimeError): + """Raised when a graph references community packages that cannot be loaded.""" + + +@dataclass +class CommunityPackagePrepareResult: + devices_dirs: List[str] = field(default_factory=list) + aliases: Dict[str, str] = field(default_factory=dict) + classes: List[str] = field(default_factory=list) + + +def extract_community_classes(graph_data: Optional[Dict[str, Any]]) -> List[str]: + if not graph_data: + return [] + + result: List[str] = [] + for node in graph_data.get("nodes", []): + if not isinstance(node, dict): + continue + class_name = node.get("class") + if isinstance(class_name, str) and class_name.startswith(COMMUNITY_PREFIX): + result.append(class_name) + return sorted(set(result)) + + +def community_namespace(class_name: str) -> str: + parts = class_name.split(".") + if len(parts) < 2 or parts[0] != "community": + raise ValueError(f"Invalid community class: {class_name}") + return ".".join(parts[:2]) + + +def infer_alias_target(class_name: str) -> str: + namespace = community_namespace(class_name) + prefix = namespace + "." + if class_name.startswith(prefix) and len(class_name) > len(prefix): + return class_name[len(prefix):] + return class_name.rsplit(".", 1)[-1] + + +def load_manifest(working_dir: str | Path) -> Dict[str, Any]: + manifest_path = _manifest_path(working_dir) + if not manifest_path.is_file(): + return {"packages": {}} + try: + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("packages", {}) + return data + except Exception as exc: + logger.warning(f"[CommunityPackage] manifest 读取失败: {exc}") + return {"packages": {}} + + +def save_manifest(working_dir: str | Path, manifest: Dict[str, Any]) -> None: + manifest_path = _manifest_path(working_dir) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + tmp = manifest_path.with_suffix(".tmp") + tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(manifest_path) + + +def prepare_community_packages( + graph_data: Optional[Dict[str, Any]], + working_dir: str | Path, + http_client: Any = None, +) -> CommunityPackagePrepareResult: + classes = extract_community_classes(graph_data) + if not classes: + return CommunityPackagePrepareResult() + + print_status(f"发现 community 设备引用: {', '.join(classes)}", "info") + manifest = load_manifest(working_dir) + packages = manifest.setdefault("packages", {}) + remote_items = _resolve_remote_packages(classes, manifest, http_client) + + devices_dirs: List[str] = [] + aliases: Dict[str, str] = {} + missing_namespaces = {community_namespace(class_name) for class_name in classes} + + for item in remote_items: + package_dir = _ensure_remote_item_cached(item, working_dir, manifest, http_client=http_client) + if package_dir: + devices_dirs.append(str(package_dir)) + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + missing_namespaces.discard(namespace) + aliases.update(_normalize_aliases(item, classes)) + + for namespace in list(missing_namespaces): + cached = packages.get(namespace) + if not cached: + continue + package_dir = Path(cached.get("package_dir", "")) + if package_dir.is_dir(): + devices_dirs.append(str(package_dir)) + missing_namespaces.discard(namespace) + cached_aliases = cached.get("aliases") or {} + aliases.update({str(k): str(v) for k, v in cached_aliases.items()}) + + for class_name in classes: + aliases.setdefault(class_name, infer_alias_target(class_name)) + + if missing_namespaces: + raise CommunityPackageError( + "无法加载 community 设备包: " + + ", ".join(sorted(missing_namespaces)) + + "。请检查网络、后端 resolve 接口或本地缓存。" + ) + + devices_dirs = _dedupe_existing_dirs(devices_dirs) + if devices_dirs: + print_status(f"community 设备包挂载目录: {', '.join(devices_dirs)}", "info") + + save_manifest(working_dir, manifest) + return CommunityPackagePrepareResult(devices_dirs=devices_dirs, aliases=aliases, classes=classes) + + +def apply_community_aliases(registry: Any, aliases: Dict[str, str]) -> None: + if not aliases: + return + + added: List[str] = [] + for alias, target in aliases.items(): + if alias in registry.device_type_registry or alias in registry.resource_type_registry: + continue + if target in registry.device_type_registry: + registry.device_type_registry[alias] = registry.device_type_registry[target] + added.append(alias) + elif target in registry.resource_type_registry: + registry.resource_type_registry[alias] = registry.resource_type_registry[target] + added.append(alias) + else: + logger.warning(f"[CommunityPackage] alias 目标不存在: {alias} -> {target}") + + if added: + print_status(f"已注册 community class alias: {', '.join(sorted(added))}", "info") + + +def _resolve_remote_packages(classes: List[str], manifest: Dict[str, Any], http_client: Any) -> List[Dict[str, Any]]: + if http_client is None: + return [] + try: + current_packages = [] + for namespace, info in (manifest.get("packages") or {}).items(): + current_packages.append( + { + "class_namespace": namespace, + "version": info.get("version"), + "sha256": info.get("sha256"), + } + ) + + response = http_client.resolve_community_packages(classes, current_packages=current_packages) + data = response.get("data", response) if isinstance(response, dict) else [] + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + except Exception as exc: + logger.warning(f"[CommunityPackage] 远端 resolve 失败,将尝试本地缓存: {exc}") + return [] + + +def _ensure_remote_item_cached( + item: Dict[str, Any], + working_dir: str | Path, + manifest: Dict[str, Any], + http_client: Any = None, +) -> Optional[Path]: + package_info = item.get("package_info") or item + namespace = item.get("class_namespace") or package_info.get("class_namespace") + if not namespace: + return None + + packages = manifest.setdefault("packages", {}) + cached = packages.get(namespace) or {} + version = str(package_info.get("version") or cached.get("version") or "unknown") + sha256 = str(package_info.get("sha256") or cached.get("sha256") or "") + cached_dir = Path(cached.get("package_dir", "")) + if cached_dir.is_dir() and cached.get("version") == version and cached.get("sha256", "") == sha256: + return cached_dir + + download_url = package_info.get("download_url") + if not download_url: + if cached_dir.is_dir() and package_info.get("allow_cached_fallback"): + logger.warning(f"[CommunityPackage] {namespace} 无下载地址,使用旧缓存") + return cached_dir + raise CommunityPackageError(f"community package {namespace} 缺少 download_url") + + package_dir = _download_and_extract_package(download_url, working_dir, namespace, version, sha256, http_client) + pyproject = _find_pyproject(package_dir) + pyproject_meta = read_pyproject_metadata(pyproject) + aliases = _normalize_aliases(item, []) + + packages[namespace] = { + "class_namespace": namespace, + "version": version, + "sha256": sha256, + "download_url": download_url, + "package_dir": str(package_dir), + "pyproject": pyproject_meta, + "aliases": aliases, + } + (package_dir / "package_info.json").write_text( + json.dumps(package_info, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return package_dir + + +def _download_and_extract_package( + download_url: str, + working_dir: str | Path, + namespace: str, + version: str, + expected_sha256: str = "", + http_client: Any = None, +) -> Path: + import requests + + normalized = _normalize_package_dir_name(namespace) + target_root = Path(working_dir) / COMMUNITY_CACHE_DIR / normalized / version + package_dir = target_root / "package" + tmp_root = Path(tempfile.mkdtemp(prefix=f"{normalized}-{version}-", dir=str(_cache_root(working_dir)))) + archive_path = tmp_root / "package.archive" + + try: + print_status(f"下载 community 设备包 {namespace}@{version}", "info") + requester = getattr(http_client, "_session", None) or requests + with requester.get(download_url, stream=True, timeout=(5, 120)) as response: + response.raise_for_status() + with archive_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + + if expected_sha256: + actual = "sha256:" + _sha256_file(archive_path) + if actual != expected_sha256: + raise CommunityPackageError(f"{namespace}@{version} sha256 不匹配: {actual} != {expected_sha256}") + + extract_root = tmp_root / "extract" + extract_root.mkdir(parents=True, exist_ok=True) + _extract_archive(archive_path, extract_root) + pyproject = _find_pyproject(extract_root) + source_root = pyproject.parent + + if target_root.exists(): + shutil.rmtree(target_root) + target_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_root, package_dir) + return package_dir + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + +def _normalize_aliases(item: Dict[str, Any], classes: Iterable[str]) -> Dict[str, str]: + raw_aliases = item.get("aliases") or {} + aliases = {str(k): str(v) for k, v in raw_aliases.items()} if isinstance(raw_aliases, dict) else {} + + namespace = item.get("class_namespace") or (item.get("package_info") or {}).get("class_namespace") + if namespace: + for class_name in classes: + if class_name.startswith(namespace + "."): + aliases.setdefault(class_name, infer_alias_target(class_name)) + return aliases + + +def read_pyproject_metadata(pyproject_path: Path) -> Dict[str, str]: + text = pyproject_path.read_text(encoding="utf-8") + result: Dict[str, str] = {} + in_project = False + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if not in_project or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key in {"name", "version"}: + result[key] = value + return result + + +def _manifest_path(working_dir: str | Path) -> Path: + return _cache_root(working_dir) / MANIFEST_FILENAME + + +def _cache_root(working_dir: str | Path) -> Path: + root = Path(working_dir) / COMMUNITY_CACHE_DIR + root.mkdir(parents=True, exist_ok=True) + return root + + +def _normalize_package_dir_name(namespace: str) -> str: + return namespace.replace(COMMUNITY_PREFIX, "", 1).replace(".", "-").replace("_", "-") + + +def _dedupe_existing_dirs(paths: Iterable[str]) -> List[str]: + result: List[str] = [] + seen = set() + for path in paths: + resolved = str(Path(path).resolve()) + if resolved in seen or not Path(resolved).is_dir(): + continue + seen.add(resolved) + result.append(resolved) + return result + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _extract_archive(archive_path: Path, target_dir: Path) -> None: + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path) as zf: + for member in zf.namelist(): + _assert_safe_archive_member(target_dir, member) + zf.extractall(target_dir) + return + if tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path) as tf: + for member in tf.getmembers(): + _assert_safe_archive_member(target_dir, member.name) + tf.extractall(target_dir) + return + raise CommunityPackageError("community package 只支持 zip/tar/tar.gz 格式") + + +def _assert_safe_archive_member(target_dir: Path, member_name: str) -> None: + target_root = target_dir.resolve() + target_path = (target_dir / member_name).resolve() + if target_root != target_path and target_root not in target_path.parents: + raise CommunityPackageError(f"community package 包含非法路径: {member_name}") + + +def _find_pyproject(root: Path) -> Path: + candidates = sorted(root.rglob("pyproject.toml")) + if not candidates: + raise CommunityPackageError(f"community package 解压后未找到 pyproject.toml: {root}") + return candidates[0] diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6c0976825..9639515c8 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -1,5 +1,6 @@ import argparse import asyncio +import json import os import platform import shutil @@ -12,6 +13,15 @@ import networkx as nx import yaml +# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符 +# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃 +if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + # 首先添加项目根目录到路径 current_dir = os.path.dirname(os.path.abspath(__file__)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) @@ -233,8 +243,17 @@ def parse_args(): parser.add_argument( "--addr", type=str, - default="https://uni-lab.bohrium.com/api/v1", - help="Laboratory backend address", + default="https://leap-lab.bohrium.com/api/v1", + help="Laboratory backend address (API)", + ) + parser.add_argument( + "--schedule_addr", + type=str, + default="", + help=( + "Schedule WebSocket address. If empty, derived from --addr: " + "port +1 when --addr has a port, otherwise the same host is used." + ), ) parser.add_argument( "--skip_env_check", @@ -330,6 +349,29 @@ def parse_args(): return parser +def _resolve_graph_file_path(file_path: str | None) -> str | None: + if file_path is None: + return None + if os.path.isfile(file_path): + return file_path + temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) + if os.path.isfile(temp_file_path): + print_status(f"使用相对路径{temp_file_path}", "info") + return temp_file_path + return file_path + + +def _load_graph_json_preview(file_path: str | None) -> Dict[str, Any] | None: + if not file_path or not file_path.endswith(".json") or not os.path.isfile(file_path): + return None + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except Exception as exc: + print_status(f"预读取 graph JSON 失败,跳过 community 包解析: {exc}", "warning") + return None + + def main(): """主函数""" # 解析命令行参数 @@ -427,7 +469,7 @@ def main(): load_config_from_file(config_path) # 根据配置重新设置日志级别 - from unilabos.utils.log import configure_logger, logger + from unilabos.utils.log import configure_logger, configure_comm_logger, logger if hasattr(BasicConfig, "log_level"): logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") @@ -435,19 +477,29 @@ def main(): if file_path is not None: logger.info(f"[LOG_FILE] {file_path}") + # 为服务端通信(WebSocket)配置独立日志,避免与主日志混在一起,便于排查通信机制 + comm_log_path = configure_comm_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) + if comm_log_path is not None: + logger.info(f"[COMM_LOG_FILE] {comm_log_path}") + if args.addr != parser.get_default("addr"): if args.addr == "test": print_status("使用测试环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1" elif args.addr == "uat": print_status("使用uat环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" + HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1" elif args.addr == "local": print_status("使用本地环境地址", "info") HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" else: HTTPConfig.remote_addr = args.addr + # schedule 通道地址:显式指定则直接使用,否则在连接时从 remote_addr 派生 + if args_dict.get("schedule_addr", ""): + HTTPConfig.schedule_addr = args_dict["schedule_addr"] + print_status(f"使用独立 schedule 地址: {HTTPConfig.schedule_addr}", "info") + # 设置BasicConfig参数 if args_dict.get("ak", ""): BasicConfig.ak = args_dict.get("ak", "") @@ -495,6 +547,52 @@ def main(): # 显示启动横幅 print_unilab_banner(args_dict) + # Step -1: 预读取 graph 中的 community.* class,并在 build_registry 前挂载社区设备包 + if not check_mode and not workflow_upload: + startup_json_preview = None + graph_file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) + args_dict["_graph_file_path"] = graph_file_path + graph_preview = _load_graph_json_preview(graph_file_path) + + http_client_for_community = None + if BasicConfig.ak and BasicConfig.sk: + from unilabos.app.web import http_client as _http_client_for_community + + http_client_for_community = _http_client_for_community + if graph_preview is None and graph_file_path is None: + startup_json_preview = http_client_for_community.request_startup_json() + args_dict["_startup_json"] = startup_json_preview + graph_preview = startup_json_preview + + if graph_preview: + from unilabos.app.community_packages import ( + CommunityPackageError, + apply_community_aliases, + prepare_community_packages, + ) + + try: + community_result = prepare_community_packages( + graph_preview, + working_dir=BasicConfig.working_dir, + http_client=http_client_for_community, + ) + except CommunityPackageError as exc: + print_status(str(exc), "error") + os._exit(1) + + if community_result.devices_dirs: + existing_devices_dirs = args_dict.get("devices") or [] + args_dict["devices"] = existing_devices_dirs + community_result.devices_dirs + if not skip_env_check: + from unilabos.utils.environment_check import check_device_package_requirements + + if not check_device_package_requirements(args_dict["devices"]): + print_status("community 设备包依赖检查失败,程序退出", "error") + os._exit(1) + args_dict["_community_aliases"] = community_result.aliases + args_dict["_apply_community_aliases"] = apply_community_aliases + # Step 0: AST 分析优先 + YAML 注册表加载 # check_mode 和 upload_registry 都会执行实际 import 验证 devices_dirs = args_dict.get("devices", None) @@ -508,6 +606,9 @@ def main(): complete_registry=complete_registry, external_only=external_only, ) + apply_community_aliases = args_dict.get("_apply_community_aliases") + if apply_community_aliases: + apply_community_aliases(lab_registry, args_dict.get("_community_aliases") or {}) # Check mode: 注册表验证完成后直接退出 if check_mode: @@ -553,14 +654,18 @@ def main(): os._exit(0) if not BasicConfig.ak or not BasicConfig.sk: - print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning") + print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning") os._exit(1) graph: nx.Graph resource_tree_set: ResourceTreeSet resource_links: List[Dict[str, Any]] - request_startup_json = http_client.request_startup_json() + request_startup_json = args_dict.get("_startup_json") + if request_startup_json is None: + request_startup_json = http_client.request_startup_json() - file_path = args_dict.get("graph", BasicConfig.startup_json_path) + file_path = args_dict.get("_graph_file_path") + if file_path is None: + file_path = _resolve_graph_file_path(args_dict.get("graph") or BasicConfig.startup_json_path) if file_path is None: if not request_startup_json: print_status( @@ -571,11 +676,6 @@ def main(): print_status("联网获取设备加载文件成功", "info") graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json) else: - if not os.path.isfile(file_path): - temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) - if os.path.isfile(temp_file_path): - print_status(f"使用相对路径{temp_file_path}", "info") - file_path = temp_file_path if file_path.endswith(".json"): graph, resource_tree_set, resource_links = read_node_link_json(file_path) else: @@ -621,6 +721,8 @@ def main(): continue # 如果从远端获取了物料信息,则与本地物料进行同步 + # 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge + # 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作 if file_path is not None and request_startup_json and "nodes" in request_startup_json: print_status("开始同步远端物料到本地...", "info") remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"]) diff --git a/unilabos/app/model.py b/unilabos/app/model.py index f80ce35a0..3a031aaaf 100644 --- a/unilabos/app/model.py +++ b/unilabos/app/model.py @@ -59,6 +59,7 @@ class JobAddReq(BaseModel): task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") node_id: str = Field(examples=["node_id"], description="node uuid", default="") + notebook_id: str = Field(examples=["notebook_id"], description="notebook uuid", default="") server_info: dict = Field( examples=[{"send_timestamp": 1717000000.0}], description="server info (auto-generated if empty)", diff --git a/unilabos/app/utils.py b/unilabos/app/utils.py index f6114a13c..a225e3ae3 100644 --- a/unilabos/app/utils.py +++ b/unilabos/app/utils.py @@ -10,29 +10,170 @@ import sys +_PATCH_MARKER = "# UniLabOS DLL Patch" +_PATCH_END_MARKER = "# End UniLabOS DLL Patch" + +# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突 +_RESTART_EXIT_CODE = 75 + + +def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str: + """生成一段加在目标文件顶部的 DLL 加载补丁源码。 + + - 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上, + 防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时 + 目录会被移除)。 + - 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd,把它的依赖 DLL 提前装入 + 进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。 + """ + # 用 repr() 序列化路径:Python 解析 repr 的结果会还原成原始字符串, + # 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。 + lines = [ + _PATCH_MARKER, + "import os as _ulab_os", + f"_ulab_p = {lib_bin!r}", + 'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):', + " try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)", + " except Exception: _UNILAB_DLL_HANDLE = None", + ] + if preload_pyd: + lines.extend( + [ + "import ctypes as _ulab_ctypes", + f"try: _ulab_ctypes.CDLL({preload_pyd!r})", + "except Exception: pass", + ] + ) + lines.append(_PATCH_END_MARKER) + return "\n".join(lines) + "\n" + + +def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool: + """把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。""" + if not os.path.isfile(file_path): + return False + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + if _PATCH_MARKER in content: + return False + shutil.copy2(file_path, file_path + ".bak") + with open(file_path, "w", encoding="utf-8") as f: + f.write(_build_dll_patch(lib_bin, preload_pyd) + content) + return True + + +def _print_restart_banner(patched_files): + """打印重启提示并以 EX_TEMPFAIL 退出。 + + - 不使用 ANSI 颜色码:Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理, + 会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。 + - 同时写入 stderr 与 stdout:某些上层 launcher / supervisor 只重定向 + 其中一路,写两遍能保证用户至少看到一份。 + - 写入前防御性把流切到 UTF-8 with replace:``main.py`` 里已经做过一次, + 但本模块也可能被绕过 ``main.py`` 的代码路径直接 import;reconfigure + 失败也只是退回 errors=replace,不影响整体流程。 + """ + if sys.platform == "win32": + for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + + bar = "#" * 78 + files_lines = [f"[UniLabOS] - {p}" for p in patched_files] + body = "\n".join( + [ + "", + bar, + bar, + "##", + "## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。", + "## [UniLabOS] DLL load failure detected on Windows + conda;", + "## [UniLabOS] the following files have been auto-patched:", + "##", + *[f"## {line}" for line in files_lines], + "##", + "## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。", + "## [UniLabOS] The current process is unusable; the patch only takes", + "## [UniLabOS] effect on a fresh process.", + "##", + "## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<", + "##", + bar, + bar, + "", + ] + ) + + for stream in (sys.stderr, sys.stdout): + try: + stream.write(body) + stream.flush() + except Exception: + try: + print(body, file=stream) + except Exception: + pass + + sys.exit(_RESTART_EXIT_CODE) + + def patch_rclpy_dll_windows(): - """在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁""" + """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。 + + 背景:conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin`` + 下的 DLL;只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时, + ``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 / + 没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。 + + 本函数会: + 1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口; + 2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd`` + (``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。 + + 打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过 + ``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在 + stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。 + """ if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): return + try: - import rclpy + import rclpy # noqa: F401 return except ImportError as e: if not str(e).startswith("DLL load failed"): return + cp = os.environ["CONDA_PREFIX"] - impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") - pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) - if not os.path.exists(impl) or not pyd: + lib_bin = os.path.join(cp, "Library", "bin") + site_packages = os.path.join(cp, "Lib", "site-packages") + if not os.path.isdir(lib_bin): return - with open(impl, "r", encoding="utf-8") as f: - content = f.read() - lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/") - patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' - shutil.copy2(impl, impl + ".bak") - with open(impl, "w", encoding="utf-8") as f: - f.write(patch + content) + + patched = [] + + # 1) rclpy 自身的入口 + rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py") + rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd")) + rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else "" + if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd): + patched.append(rclpy_impl) + + # 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后 + # 例:geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd + rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py") + if _apply_dll_patch(rpyutils_dll, lib_bin): + patched.append(rpyutils_dll) + + if not patched: + # 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径, + # 不要再次打补丁污染文件,让上层看到真实的 ImportError。 + return + + _print_restart_banner(patched) patch_rclpy_dll_windows() diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 1dd056aeb..e9846bb3c 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -36,6 +36,9 @@ def __init__(self, remote_addr: Optional[str] = None, auth: Optional[str] = None auth_secret = BasicConfig.auth_secret() self.auth = auth_secret info(f"正在使用ak sk作为授权信息:[{auth_secret}]") + # 复用 TCP/TLS 连接,避免每次请求重新握手 + self._session = requests.Session() + self._session.headers.update({"Authorization": f"Lab {self.auth}"}) info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response: @@ -48,7 +51,7 @@ def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Respons Returns: Response: API响应对象 """ - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/edge", json={ "edges": resources, @@ -75,26 +78,28 @@ def resource_tree_add(self, resources: ResourceTreeSet, mount_uuid: str, first_a Returns: Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} """ - with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: - payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} - f.write(json.dumps(payload, indent=4)) - # 从序列化数据中提取所有节点的UUID(保存旧UUID) - old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + # dump() 只调用一次,复用给文件保存和 HTTP 请求 nodes_info = [x for xs in resources.dump() for x in xs] + old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + payload = {"nodes": nodes_info, "mount_uuid": mount_uuid} + body_bytes = _fast_dumps(payload) + with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f: + f.write(_fast_dumps_pretty(payload)) + http_headers = {"Content-Type": "application/json"} if not self.initialized or first_add: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material", - json={"nodes": nodes_info, "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=60, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/edge/material", - json={"nodes": nodes_info, "mount_uuid": mount_uuid}, - headers={"Authorization": f"Lab {self.auth}"}, + data=body_bytes, + headers=http_headers, timeout=10, ) @@ -133,7 +138,7 @@ def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[D """ with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/edge/material/query", json={"uuids": uuid_list, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -147,6 +152,7 @@ def resource_tree_get(self, uuid_list: List[str], with_children: bool) -> List[D logger.error(f"查询物料失败: {response.text}") else: data = res["data"]["nodes"] + logger.trace(f"resource_tree_get查询到物料: {data}") return data else: logger.error(f"查询物料失败: {response.text}") @@ -164,14 +170,14 @@ def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -198,7 +204,7 @@ def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]: """ with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f: f.write(json.dumps({"id": id, "with_children": with_children}, indent=4)) - response = requests.get( + response = self._session.get( f"{self.remote_addr}/lab/material", params={"id": id, "with_children": with_children}, headers={"Authorization": f"Lab {self.auth}"}, @@ -239,14 +245,14 @@ def resource_update(self, resources: List[Dict[str, Any]]) -> requests.Response: if not self.initialized: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, timeout=100, ) else: - response = requests.put( + response = self._session.put( f"{self.remote_addr}/lab/material", json={"nodes": resources}, headers={"Authorization": f"Lab {self.auth}"}, @@ -276,7 +282,7 @@ def upload_file(self, file_path: str, scene: str = "models") -> requests.Respons with open(file_path, "rb") as file: files = {"files": file} logger.info(f"上传文件: {file_path} 到 {scene}") - response = requests.post( + response = self._session.post( f"{self.remote_addr}/api/account/file_upload/{scene}", files=files, headers={"Authorization": f"Lab {self.auth}"}, @@ -316,7 +322,7 @@ def resource_registry( "Content-Type": "application/json", "Content-Encoding": "gzip", } - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/resource", data=compressed_body, headers=headers, @@ -350,7 +356,7 @@ def request_startup_json(self) -> Optional[Dict[str, Any]]: Returns: Response: API响应对象 """ - response = requests.get( + response = self._session.get( f"{self.remote_addr}/edge/material/download", headers={"Authorization": f"Lab {self.auth}"}, timeout=(3, 30), @@ -370,6 +376,34 @@ def request_startup_json(self) -> Optional[Dict[str, Any]]: logger.error(f"响应内容: {response.text}") return None + def resolve_community_packages( + self, + classes: List[str], + current_packages: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """ + 根据 graph 中的 community.* class 解析需要加载的社区设备包。 + """ + payload = { + "classes": classes, + "machine_name": BasicConfig.machine_name, + "current_packages": current_packages or [], + } + req_path = os.path.join(BasicConfig.working_dir, "req_community_package_resolve.json") + with open(req_path, "w", encoding="utf-8") as f: + f.write(json.dumps(payload, ensure_ascii=False, indent=4)) + response = self._session.post( + f"{self.remote_addr}/lab/square/community-packages/resolve", + json=payload, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=(5, 30), + ) + res_path = os.path.join(BasicConfig.working_dir, "res_community_package_resolve.json") + with open(res_path, "w", encoding="utf-8") as f: + f.write(f"{response.status_code}" + "\n" + response.text) + response.raise_for_status() + return response.json() + def workflow_import( self, name: str, @@ -411,7 +445,7 @@ def workflow_import( with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: f.write(json.dumps(payload, indent=4, ensure_ascii=False)) - response = requests.post( + response = self._session.post( f"{self.remote_addr}/lab/workflow/owner/import", json=payload, headers={"Authorization": f"Lab {self.auth}"}, diff --git a/unilabos/app/web/controller.py b/unilabos/app/web/controller.py index 6a01645cd..147b4d207 100644 --- a/unilabos/app/web/controller.py +++ b/unilabos/app/web/controller.py @@ -320,6 +320,7 @@ def job_add(req: JobAddReq) -> JobData: action_name=action_name, task_id=task_id, job_id=job_id, + notebook_id=req.notebook_id, device_action_key=device_action_key, ) diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 851ae3203..da39f5ce4 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -17,9 +17,10 @@ import traceback import websockets import ssl as ssl_module +import copy from queue import Queue, Empty from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Tuple from urllib.parse import urlparse from enum import Enum @@ -31,7 +32,12 @@ from unilabos.utils.type_check import serialize_result_info from unilabos.app.communication import BaseCommunicationClient from unilabos.config.config import WSConfig, HTTPConfig, BasicConfig -from unilabos.utils import logger +from unilabos.utils.log import get_comm_logger + +# 服务端通信专用 logger:独立成文件(unilabos_data/logs/ws_comm_*.log), +# 全量 TRACE 落本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题。 +# 未调用 configure_comm_logger 时安全回退到根 logger。 +logger = get_comm_logger() def format_job_log(job_id: str, task_id: str = "", device_id: str = "", action_name: str = "") -> str: @@ -59,6 +65,7 @@ class QueueItem: action_name: str task_id: str job_id: str + notebook_id: str device_action_key: str next_run_time: float = 0 # 下次执行时间戳 retry_count: int = 0 # 重试次数 @@ -71,6 +78,7 @@ class JobInfo: job_id: str task_id: str device_id: str + notebook_id: str action_name: str device_action_key: str status: JobStatus @@ -101,6 +109,17 @@ class WebSocketMessage: timestamp: float = field(default_factory=time.time) +@dataclass +class JobStartCacheEntry: + """job_start幂等缓存项""" + + request_data: Dict[str, Any] + response_message: Optional[Dict[str, Any]] = None + response_status: str = "" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + class WSResourceChatData(TypedDict): uuid: str device_uuid: str @@ -125,6 +144,28 @@ def add_queue_request(self, job_info: JobInfo) -> bool: """ with self.lock: device_key = job_info.device_action_key + existing_job = self.all_jobs.get(job_info.job_id) + if existing_job is not None: + if job_info.task_id != existing_job.task_id: + logger.warning( + "[DeviceActionManager] Duplicate job_id has different task_id: " + f"{job_info.job_id[:8]} old={existing_job.task_id[:8]} new={job_info.task_id[:8]}" + ) + return False + if job_info.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = job_info.notebook_id + existing_job.update_timestamp() + job_log = format_job_log( + existing_job.job_id, + existing_job.task_id, + existing_job.device_id, + existing_job.action_name, + ) + logger.info( + f"[DeviceActionManager] Duplicate queue request ignored for job {job_log}, " + f"status={existing_job.status}" + ) + return existing_job.status == JobStatus.READY # 总是将job添加到all_jobs中 self.all_jobs[job_info.job_id] = job_info @@ -364,7 +405,56 @@ def cancel_jobs_by_task_id(self, task_id: str) -> List[str]: return cancelled_job_ids - def check_ready_timeouts(self) -> List[JobInfo]: + def refresh_ready_timeouts(self, timeout_seconds: float = 10, reason: str = "") -> int: + """将 READY 任务的超时时间刷新到至少 now + timeout_seconds。""" + if timeout_seconds <= 0: + return 0 + + refreshed_count = 0 + now = time.time() + min_timeout = now + timeout_seconds + + with self.lock: + ready_candidates = list(self.active_jobs.values()) + for job in self.all_jobs.values(): + if job.always_free and job.status == JobStatus.READY and job not in ready_candidates: + ready_candidates.append(job) + + for job_info in ready_candidates: + if job_info.status != JobStatus.READY or job_info.ready_timeout is None: + continue + if job_info.ready_timeout >= min_timeout: + continue + + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + refreshed_count += 1 + + job_log = format_job_log( + job_info.job_id, + job_info.task_id, + job_info.device_id, + job_info.action_name, + ) + logger.info( + "[DeviceActionManager] Refreshed READY timeout for job %s from %.3f to %.3f%s", + job_log, + old_timeout, + job_info.ready_timeout, + f" ({reason})" if reason else "", + ) + + logger.info( + "[DeviceActionManager] READY timeout refresh window %.1fs; refreshed %s READY job(s)%s", + timeout_seconds, + refreshed_count, + f" ({reason})" if reason else "", + ) + + return refreshed_count + + def check_ready_timeouts(self, is_connected: bool = True) -> List[JobInfo]: """检查READY状态超时的任务,仅检测不处理""" timeout_jobs = [] @@ -381,6 +471,24 @@ def check_ready_timeouts(self) -> List[JobInfo]: # 找到所有超时的READY任务(只检测,不处理) for job_info in ready_candidates: + if job_info.status != JobStatus.READY: + continue + if not is_connected: + min_timeout = time.time() + 10 + if job_info.ready_timeout is not None and job_info.ready_timeout < min_timeout: + old_timeout = job_info.ready_timeout + job_info.ready_timeout = min_timeout + job_info.update_timestamp() + job_log = format_job_log( + job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name + ) + logger.info( + "[DeviceActionManager] WebSocket disconnected, keep READY job %s alive: %.3f -> %.3f", + job_log, + old_timeout, + job_info.ready_timeout, + ) + continue if job_info.is_ready_timeout(): timeout_jobs.append(job_info) job_log = format_job_log( @@ -478,8 +586,8 @@ async def _connection_handler(self): self.websocket_url, ssl=ssl_context, open_timeout=20, - ping_interval=WSConfig.ping_interval, - ping_timeout=10, + ping_interval=WSConfig.ws_ping_interval, + ping_timeout=WSConfig.ws_ping_timeout, close_timeout=5, additional_headers={ "Authorization": f"Lab {BasicConfig.auth_secret()}", @@ -492,12 +600,15 @@ async def _connection_handler(self): self.reconnect_count = 0 logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}") + self.device_manager.refresh_ready_timeouts(10, reason="websocket connected") # 启动发送协程 send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task") - # 每次连接(含重连)后重新向服务端注册, + # 每次连接(含重连)后尝试向服务端注册, # 否则服务端不知道客户端已上线,不会推送消息。 + # 注意:publish_host_ready 内部带就绪门禁——HostNode 未初始化完成时会自动延后, + # 首连若设备尚未就绪则不会在此发送,待 HostNode 初始化完成后由其回调补发。 if self.websocket_client: self.websocket_client.publish_host_ready() @@ -539,7 +650,10 @@ async def _connection_handler(self): self.reconnect_count += 1 backoff = WSConfig.reconnect_interval logger.info( - f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})" + "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)", + backoff, + self.reconnect_count, + WSConfig.max_reconnect_attempts, ) await asyncio.sleep(backoff) else: @@ -559,6 +673,7 @@ async def _message_handler(self): async for message in self.websocket: try: + logger.trace(f"[WS_RECV] {message}") data = json.loads(message) message_type = data.get("action", "") message_data = data.get("data") @@ -610,9 +725,10 @@ async def _send_handler(self): try: message_str = json.dumps(msg, ensure_ascii=False) await self.websocket.send(message_str) - # logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501 + logger.trace(f"[WS_SEND] {message_str}") except Exception as e: logger.error(f"[MessageProcessor] Failed to send message: {str(e)}") + logger.error(f"[WS_SEND_FAILED] {msg}") logger.error(traceback.format_exc()) break @@ -703,11 +819,61 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): action_name = data.get("action_name", "") task_id = data.get("task_id", "") job_id = data.get("job_id", "") + notebook_id = data.get("notebook_id", "") if not all([device_id, action_name, task_id, job_id]): logger.error("[MessageProcessor] Missing required fields in query_action_state") return + job_log = format_job_log(job_id, task_id, device_id, action_name) + + # 1) 该 job 仍在设备管理器中(READY/QUEUE/STARTED):不重复入队。 + # READY/STARTED 表示同一个 job 已被本地接收/执行,断线重连后仍回复 free, + # 让服务端继续发送 job_start;重复 job_start 会被幂等缓存拦截或回放结果。 + # QUEUE 表示该 job 尚未轮到执行,仍回复 busy。 + # 完成后 end_job 会把 job 从管理器移除,故运行中的 job 一定能在此命中。 + existing_job = self.device_manager.get_job_info(job_id) + if existing_job and existing_job.job_id == job_id and existing_job.task_id == task_id: + if existing_job.status in (JobStatus.READY, JobStatus.STARTED): + response_type, free, need_more = "query_action_status", True, 0 + elif existing_job.status == JobStatus.QUEUE: + response_type, free, need_more = "query_action_status", False, 10 + else: + response_type, free, need_more = "job_call_back_status", False, 10 + await self._send_action_state_response( + existing_job.device_id, + existing_job.action_name, + existing_job.task_id, + existing_job.job_id, + response_type, + free, + need_more, + notebook_id=existing_job.notebook_id or notebook_id, + ) + logger.trace( + f"[MessageProcessor] query_action_state {job_log} 返回当前状态 {existing_job.status}" + ) + return + + # 2) 不在管理器、但已 job_start 过(已完成被移除,多为断线重连后服务端重查): + # 回复 free,让服务端继续走 job_start,真正结果由 _handle_job_start 命中缓存回放。 + if self.websocket_client and self.websocket_client.is_job_cached(job_id, task_id): + self.websocket_client.log_cached_job(job_id, task_id, source="query_action_state") + await self._send_action_state_response( + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, + ) + logger.info( + f"[MessageProcessor] [缓存复用] query_action_state {job_log} 命中缓存(已完成),回复 free" + ) + return + device_action_key = f"/devices/{device_id}/{action_name}" # 检查action是否为always_free @@ -718,6 +884,7 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): job_id=job_id, task_id=task_id, device_id=device_id, + notebook_id=notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -728,17 +895,30 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): # 添加到设备管理器 can_start_immediately = self.device_manager.add_queue_request(job_info) - job_log = format_job_log(job_id, task_id, device_id, action_name) if can_start_immediately: # 可以立即开始 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", True, 0 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + True, + 0, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") else: # 需要排队 await self._send_action_state_response( - device_id, action_name, task_id, job_id, "query_action_status", False, 10 + device_id, + action_name, + task_id, + job_id, + "query_action_status", + False, + 10, + notebook_id=notebook_id, ) logger.trace(f"[MessageProcessor] Job {job_log} queued") @@ -749,15 +929,43 @@ async def _handle_query_action_state(self, data: Dict[str, Any]): async def _handle_job_start(self, data: Dict[str, Any]): """处理job_start消息""" try: + data = dict(data or {}) if not data.get("sample_material"): data["sample_material"] = {} req = JobAddReq(**data) job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action) + if self.websocket_client: + # 幂等缓存:首次 job_start 登记缓存并真正执行; + # 重复的 (task_id, job_id) 则假装执行——直接回放之前缓存的结果,不再下发设备动作。 + is_new_request = self.websocket_client.register_job_start_request(data) + if not is_new_request: + self.websocket_client.log_cached_job(req.job_id, req.task_id, source="job_start") + replayed = self.websocket_client.replay_cached_job_start_response(req.job_id, req.task_id) + if replayed: + logger.info( + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存,假装执行并回放缓存结果" + ) + else: + logger.info( + f"[MessageProcessor] [缓存复用] job_start {job_log} 命中缓存但暂无结果" + f"(原任务仍在执行),跳过重复执行" + ) + return + # 服务端对always_free动作可能跳过query_action_state直接发job_start, # 此时job尚未注册,需要自动补注册 existing_job = self.device_manager.get_job_info(req.job_id) + if existing_job and existing_job.task_id != req.task_id: + logger.warning( + "[MessageProcessor] job_start job_id matched but task_id mismatched, skip start: " + "job=%s old_task=%s new_task=%s", + req.job_id[:8], + existing_job.task_id[:8], + req.task_id[:8], + ) + return if not existing_job: action_name = req.action device_action_key = f"/devices/{req.device_id}/{action_name}" @@ -768,6 +976,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): job_id=req.job_id, task_id=req.task_id, device_id=req.device_id, + notebook_id=req.notebook_id, action_name=action_name, device_action_key=device_action_key, status=JobStatus.QUEUE, @@ -775,11 +984,16 @@ async def _handle_job_start(self, data: Dict[str, Any]): always_free=True, ) self.device_manager.add_queue_request(job_info) + existing_job = job_info logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") else: logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") return + if existing_job and req.notebook_id and not existing_job.notebook_id: + existing_job.notebook_id = req.notebook_id + notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "") + success = self.device_manager.start_job(req.job_id) if not success: logger.error(f"[MessageProcessor] Failed to start job {job_log}") @@ -795,6 +1009,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): action_name=req.action, task_id=req.task_id, job_id=req.job_id, + notebook_id=notebook_id, device_action_key=device_action_key, ) @@ -834,6 +1049,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "job_id": req.job_id, "task_id": req.task_id, "device_id": req.device_id, + "notebook_id": queue_item.notebook_id, "action_name": req.action, "status": "failed", "feedback_data": {}, @@ -855,6 +1071,7 @@ async def _handle_job_start(self, data: Dict[str, Any]): "query_action_status", True, 0, + notebook_id=next_job.notebook_id, ) next_job_log = format_job_log( next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name @@ -1004,11 +1221,16 @@ def _notify_resource_tree(dev_id, act, item_list): success = host_node.notify_resource_tree_update(dev_id, act, item_list) - if success: + if success is True: logger.info( f"[MessageProcessor] Resource tree {act} completed for device {dev_id}, " f"items: {len(item_list)}" ) + elif success is None: + logger.info( + f"[MessageProcessor] Resource tree {act} skipped for device {dev_id}: " + "在线增加设备暂不支持" + ) else: logger.warning(f"[MessageProcessor] Resource tree {act} failed for device {dev_id}") @@ -1032,6 +1254,11 @@ async def _handle_device_manage(self, device_list: list[ResourceDictType], actio for item in device_list: target_node_id = item.get("target_node_id", "host_node") + if action == "add": + logger.info( + f"[DeviceManage] 在线增加设备暂不支持,跳过 add_device: {item.get('id', '')}" + ) + continue def _notify(target_id: str, act: str, cfg: ResourceDictType): try: @@ -1101,7 +1328,15 @@ def do_cleanup(): logger.info(f"[MessageProcessor] Restart cleanup scheduled") async def _send_action_state_response( - self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int + self, + device_id: str, + action_name: str, + task_id: str, + job_id: str, + typ: str, + free: bool, + need_more: int, + notebook_id: str = "", ): """发送动作状态响应""" message = { @@ -1112,6 +1347,7 @@ async def _send_action_state_response( "action_name": action_name, "task_id": task_id, "job_id": job_id, + "notebook_id": notebook_id, "free": free, "need_more": need_more + 1, }, @@ -1183,7 +1419,9 @@ def _run(self): while self.is_running: try: # 检查READY状态超时的任务 - timeout_jobs = self.device_manager.check_ready_timeouts() + timeout_jobs = self.device_manager.check_ready_timeouts( + is_connected=self.message_processor.is_connected() + ) if timeout_jobs: logger.info(f"[QueueProcessor] Found {len(timeout_jobs)} READY jobs that timed out") # 为超时的job发布失败状态,通过正常job完成流程处理 @@ -1194,6 +1432,7 @@ def _run(self): action_name=timeout_job.action_name, task_id=timeout_job.task_id, job_id=timeout_job.job_id, + notebook_id=timeout_job.notebook_id, device_action_key=timeout_job.device_action_key, ) # 发布超时失败状态,这会触发正常的job完成流程 @@ -1252,6 +1491,7 @@ def _send_running_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1269,7 +1509,13 @@ def _send_busy_status(self): if not queued_jobs: return - logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") + queue_summary = {} + for j in queued_jobs: + key = f"{j.device_id}/{j.action_name}" + queue_summary[key] = queue_summary.get(key, 0) + 1 + logger.debug( + f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}" + ) for job_info in queued_jobs: # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY, @@ -1285,6 +1531,7 @@ def _send_busy_status(self): "action_name": job_info.action_name, "task_id": job_info.task_id, "job_id": job_info.job_id, + "notebook_id": job_info.notebook_id, "free": False, "need_more": 10 + 1, }, @@ -1330,12 +1577,15 @@ def handle_job_completed(self, job_id: str, status: str) -> None: "action_name": next_job.action_name, "task_id": next_job.task_id, "job_id": next_job.job_id, + "notebook_id": next_job.notebook_id, "free": True, "need_more": 0, }, } self.message_processor.send_message(message) - # next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) + # next_job_log = format_job_log( + # next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name + # ) # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # 立即触发下一轮状态检查 @@ -1373,6 +1623,12 @@ def __init__(self): self._job_running_last_sent: Dict[str, tuple] = {} self._job_running_debounce_interval: float = 10.0 # 秒 + # job_start幂等缓存: {(task_id, job_id): JobStartCacheEntry} + self._job_start_cache: Dict[Tuple[str, str], JobStartCacheEntry] = {} + self._job_start_cache_lock = threading.RLock() + self._job_start_cache_ttl_seconds: float = 24 * 60 * 60 + self._job_start_cache_max_entries: int = 1024 + # 设置相互引用 self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_websocket_client(self) @@ -1381,23 +1637,169 @@ def __init__(self): logger.info(f"[WebSocketClient] Client_id: {self.client_id}") def _build_websocket_url(self) -> Optional[str]: - """构建WebSocket连接URL""" + """构建 schedule 通道的 WebSocket 连接 URL + + 地址来源优先级: + 1. HTTPConfig.schedule_addr(--schedule_addr 显式指定)→ 直接使用,不做端口偏移 + 2. HTTPConfig.remote_addr(--addr)派生:带端口则 +1,否则沿用原 netloc + """ + # 1. 显式 schedule 地址 + if HTTPConfig.schedule_addr: + parsed = urlparse(HTTPConfig.schedule_addr) + scheme = "wss" if parsed.scheme in ("https", "wss") else "ws" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" + + # 2. 从 api 地址派生 if not HTTPConfig.remote_addr: return None parsed = urlparse(HTTPConfig.remote_addr) - - if parsed.scheme == "https": - scheme = "wss" - else: - scheme = "ws" + scheme = "wss" if parsed.scheme == "https" else "ws" if ":" in parsed.netloc and parsed.port is not None: - url = f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" - else: - url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" + return f"{scheme}://{parsed.hostname}:{parsed.port + 1}/api/v1/ws/schedule" + return f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" - return url + @staticmethod + def _job_start_cache_key(job_id: str, task_id: str) -> Optional[Tuple[str, str]]: + if not job_id or not task_id: + return None + return task_id, job_id + + def _prune_job_start_cache_locked(self) -> None: + now = time.time() + expired_keys = [ + key + for key, entry in self._job_start_cache.items() + if now - entry.updated_at > self._job_start_cache_ttl_seconds + ] + for key in expired_keys: + self._job_start_cache.pop(key, None) + + overflow = len(self._job_start_cache) - self._job_start_cache_max_entries + if overflow <= 0: + return + + oldest_keys = sorted(self._job_start_cache, key=lambda key: self._job_start_cache[key].updated_at)[:overflow] + for key in oldest_keys: + self._job_start_cache.pop(key, None) + + def register_job_start_request(self, request_data: Dict[str, Any]) -> bool: + """登记job_start请求;返回False表示同一(task_id, job_id)已处理过。""" + key = self._job_start_cache_key(request_data.get("job_id", ""), request_data.get("task_id", "")) + if key is None: + return True + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is not None: + cached.updated_at = time.time() + if cached.request_data != request_data: + logger.warning( + "[WebSocketClient] Duplicate job_start has different payload for " + f"job={key[1][:8]}, task={key[0][:8]}" + ) + return False + + self._job_start_cache[key] = JobStartCacheEntry(request_data=copy.deepcopy(request_data)) + self._prune_job_start_cache_locked() + return True + + def is_job_cached(self, job_id: str, task_id: str) -> bool: + """判断同一 (task_id, job_id) 是否已 job_start 过(已登记进幂等缓存)。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return False + cached.updated_at = time.time() + return True + + def log_cached_job(self, job_id: str, task_id: str, source: str = "") -> None: + """打印命中缓存的 job 内容(请求 + 已缓存结果),便于核对复用的数据。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + return + request_data = copy.deepcopy(cached.request_data) + response_message = copy.deepcopy(cached.response_message) + response_status = cached.response_status + + result_repr = json.dumps(response_message, ensure_ascii=False) if response_message else "none" + logger.info( + f"[WebSocketClient] [缓存复用] 命中缓存 source={source} job={job_id[:8]} task={task_id[:8]} " + f"status={response_status or 'none'} " + f"request={json.dumps(request_data, ensure_ascii=False)} " + f"result={result_repr}" + ) + + def get_cached_job_start_response_status(self, job_id: str, task_id: str) -> str: + """获取同一job_start已缓存的回复状态。""" + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return "" + + with self._job_start_cache_lock: + self._prune_job_start_cache_locked() + cached = self._job_start_cache.get(key) + if cached is None: + return "" + cached.updated_at = time.time() + return cached.response_status + + def cache_job_start_response(self, item: QueueItem, message: Dict[str, Any], status: str) -> None: + """缓存同一 (task_id, job_id) 的 job 结果(最新 job_status),供重复请求复用回放。""" + key = self._job_start_cache_key(item.job_id, item.task_id) + if key is None: + return + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None: + cached = JobStartCacheEntry(request_data={}) + self._job_start_cache[key] = cached + + cached.response_message = copy.deepcopy(message) + cached.response_status = status + cached.updated_at = time.time() + self._prune_job_start_cache_locked() + + def replay_cached_job_start_response(self, job_id: str, task_id: str) -> bool: + """回放同一 (task_id, job_id) 已缓存的最终结果。 + + 仅当已缓存到 success/failed 的终态结果时才回放;若原任务仍在执行 + (只缓存了 running 中间态),返回 False,由调用方决定如何处理。 + """ + key = self._job_start_cache_key(job_id, task_id) + if key is None: + return False + + with self._job_start_cache_lock: + cached = self._job_start_cache.get(key) + if cached is None or cached.response_message is None: + return False + if cached.response_status not in ("success", "failed"): + return False + message = copy.deepcopy(cached.response_message) + status = cached.response_status + cached.updated_at = time.time() + + sent = self.message_processor.send_message(message) + if sent: + logger.info( + f"[WebSocketClient] [缓存复用] 回放缓存结果 job={job_id[:8]} task={task_id[:8]} " + f"status={status} payload={json.dumps(message, ensure_ascii=False)}" + ) + return sent def start(self) -> None: """启动WebSocket客户端""" @@ -1468,10 +1870,6 @@ def publish_job_status( self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None ) -> None: """发布作业状态,拦截最终结果(给HostNode调用的接口)""" - if not self.is_connected(): - logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") - return - job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) # 拦截最终结果状态,与原版本逻辑一致 @@ -1487,6 +1885,17 @@ def publish_job_status( self.queue_processor.handle_job_completed(item.job_id, status) + cached_status = self.get_cached_job_start_response_status(item.job_id, item.task_id) + if cached_status in ["success", "failed"]: + # 断线重连时,旧 READY 占位可能在结果已回放后触发 timeout failed。 + # 已有终态时不允许重复终态覆盖缓存或再次发送,success 也不允许被 failed 降级。 + if cached_status == "success" or cached_status == status: + logger.warning( + f"[WebSocketClient] Skipped duplicate terminal job status for {job_log}: " + f"cached={cached_status}, incoming={status}" + ) + return + # running状态按job_id做debounce,内容变化时仍然上报 if status == "running": now = time.time() @@ -1504,6 +1913,7 @@ def publish_job_status( "job_id": item.job_id, "task_id": item.task_id, "device_id": item.device_id, + "notebook_id": item.notebook_id, "action_name": item.action_name, "status": status, "feedback_data": feedback_data, @@ -1511,6 +1921,12 @@ def publish_job_status( "timestamp": time.time(), }, } + self.cache_job_start_response(item, message, status) + + if not self.is_connected(): + logger.debug(f"[WebSocketClient] Not connected, cached job status for job {job_log} - {status}") + return + self.message_processor.send_message(message) logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") @@ -1549,43 +1965,50 @@ def publish_host_ready(self) -> None: logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") return + # 仅在 HostNode 初始化完成(设备已就绪)后才向服务端注册。 + # get_instance(0) 在未就绪时立即返回 None;此时必须延后发送, + # 否则会发出 devices=[] 的空 host_ready,令服务端误判节点已就绪而过早调度, + # 进而触发 READY 超时与启动期频繁断链重连。 + host_node = HostNode.get_instance(0) + if host_node is None: + logger.info("[WebSocketClient] Host node 尚未就绪,延后发送 host_ready(待初始化完成后再注册)") + return + # 收集设备信息 devices = [] machine_name = BasicConfig.machine_name try: - host_node = HostNode.get_instance(0) - if host_node: - # 获取设备信息 - for device_id, namespace in host_node.devices_names.items(): - device_key = ( - f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" - ) - is_online = device_key in host_node._online_devices - - # 获取设备的动作信息 - actions = {} - for action_id, client in host_node._action_clients.items(): - # action_id 格式: /namespace/device_id/action_name - if device_id in action_id: - action_name = action_id.split("/")[-1] - actions[action_name] = { - "action_path": action_id, - "action_type": str(type(client).__name__), - } - - devices.append( - { - "device_id": device_id, - "namespace": namespace, - "device_key": device_key, - "is_online": is_online, - "machine_name": host_node.device_machine_names.get(device_id, machine_name), - "actions": actions, + # 获取设备信息 + for device_id, namespace in host_node.devices_names.items(): + device_key = ( + f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + ) + is_online = device_key in host_node._online_devices + + # 获取设备的动作信息 + actions = {} + for action_id, client in host_node._action_clients.items(): + # action_id 格式: /namespace/device_id/action_name + if device_id in action_id: + action_name = action_id.split("/")[-1] + actions[action_name] = { + "action_path": action_id, + "action_type": str(type(client).__name__), } - ) - logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") + devices.append( + { + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + } + ) + + logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") except Exception as e: logger.warning(f"[WebSocketClient] Error collecting device info: {e}") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index b80d3b60d..be1f54cea 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -41,12 +41,17 @@ def auth_secret(cls): class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 20 # ping间隔(秒) + # 注意:字段名带 ws_ 前缀,是为了让旧客户端遗留的 local_config 中旧字段(ping_interval/ping_timeout)失效, + # 从而强制采用下面的新默认值。请勿改回旧名。 + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 8 # pong等待超时(秒),对齐服务端 PongWait # HTTP配置 class HTTPConfig: - remote_addr = "https://uni-lab.bohrium.com/api/v1" + remote_addr = "https://leap-lab.bohrium.com/api/v1" + # schedule 通道(WebSocket)地址;为空时从 remote_addr 派生:带端口则 +1,否则沿用原 netloc + schedule_addr = "" # ROS配置 @@ -77,7 +82,7 @@ def _update_config_from_env(): if not env_key.startswith(prefix): continue try: - key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix + key_path = env_key[len(prefix):] # Remove UNILAB_ prefix class_field = key_path.upper().split("_", 1) if len(class_field) != 2: logger.warning(f"[ENV] 环境变量格式不正确:{env_key}") diff --git a/unilabos/config/example_config.py b/unilabos/config/example_config.py index b096e410c..d59d8b525 100644 --- a/unilabos/config/example_config.py +++ b/unilabos/config/example_config.py @@ -9,4 +9,5 @@ class BasicConfig: class WSConfig: reconnect_interval = 5 # 重连间隔(秒) max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 30 # ping间隔(秒) \ No newline at end of file + ws_ping_interval = 5 # ping间隔(秒),对齐服务端 PingPeriod + ws_ping_timeout = 7 # pong等待超时(秒),对齐服务端 PongWait diff --git a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md index 21826dffc..a64acb1e4 100644 --- a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md +++ b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_CN.md @@ -219,10 +219,10 @@ device = NewareBatteryTestSystem( #### 步骤 2:提交测试任务 -使用 `submit_from_csv` 提交测试任务: +使用 `submit_from_csv_export_ndax` 提交测试任务: ```python -result = device.submit_from_csv( +result = device.submit_from_csv_export_ndax( csv_path="test_data.csv", output_dir="D:/neware_output" ) @@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。 **Q: 可以自定义上传路径吗?** A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。 -**Q: 为什么不在 `submit_from_csv` 中自动上传?** +**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?** A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。 **Q: 上传后如何访问文件?** diff --git a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md index e989c64a9..60cb2dc30 100644 --- a/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md +++ b/unilabos/devices/neware_battery_test_system/OSS_UPLOAD_README_EN.md @@ -230,10 +230,10 @@ device = NewareBatteryTestSystem( #### Step 2: Submit Test Tasks -Use `submit_from_csv` to submit test tasks: +Use `submit_from_csv_export_ndax` to submit test tasks: ```python -result = device.submit_from_csv( +result = device.submit_from_csv_export_ndax( csv_path="test_data.csv", output_dir="D:/neware_output" ) @@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable **Q: Can I customize upload paths?** A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility). -**Q: Why not auto-upload in `submit_from_csv`?** +**Q: Why not auto-upload in `submit_from_csv_export_ndax`?** A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility. **Q: How to access files after upload?** diff --git a/unilabos/devices/neware_battery_test_system/device.json b/unilabos/devices/neware_battery_test_system/device.json index 696112de8..8d5892550 100644 --- a/unilabos/devices/neware_battery_test_system/device.json +++ b/unilabos/devices/neware_battery_test_system/device.json @@ -14,7 +14,7 @@ "config": { "ip": "127.0.0.1", "port": 502, - "machine_id": 1, + "machine_ids": [1, 2, 3, 4, 5, 6, 86], "devtype": "27", "timeout": 20, "size_x": 500.0, @@ -26,10 +26,10 @@ "data": { "功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能", "监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等", - "提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号" + "提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务(NDA备份),或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号" }, "children": [] } ], "links": [] -} \ No newline at end of file +} diff --git a/unilabos/devices/neware_battery_test_system/generate_xml_content.py b/unilabos/devices/neware_battery_test_system/generate_xml_content.py new file mode 100644 index 000000000..6aef4fe99 --- /dev/null +++ b/unilabos/devices/neware_battery_test_system/generate_xml_content.py @@ -0,0 +1,2173 @@ +def xml_811_Li_002(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_Li_005(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_C(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_LFP_Gr(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_Gr_Li(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_LB6(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data + + +def xml_SiGr_Li_Step(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_SiGr(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_Cu_aging(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data= f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data +def xml_ZQXNLRMO(act_mass, Cap_mAh): + """ + 生成XML内容 + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + devid: 设备号 + subdevid: 排号 + chlid: 通道号 + """ + xml_data = f""" + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data + +def xml_811_Li_JY(act_mass=None, Cap_mAh=None): + """ + 生成XML内容 + + 参数: + act_mass: 可选,未使用 + Cap_mAh: 可选,未使用 + """ + xml_data = f""" + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + + + + + + + + + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+
+ +
+ + + + + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_811_datacoretest(act_mass, Cap_mAh): + """ + 生成XML内容(基于 811_datacoretest 工步模板) + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+ +
+ + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + + + + +
+
+ +
+ + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + +
+
+
+ + + + + + + + + + +
+ + + +
+
+ """ + return xml_data + +def xml_HC_LFP(act_mass, Cap_mAh): + """ + 生成XML内容(HC_LFP 工步模板) + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + """ + xml_data = f""" + + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+
+ +
+ + + + +
+
+
+ + +
+ + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + + +
+
+
+ + +
+ + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + + +
+
+
+ + +
+ + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+ +
+ + + + +
+
+
+ + +
+ + +
+
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+ """ + return xml_data + +def xml_HC_Gr(act_mass, Cap_mAh): + """ + 生成XML内容(HC_Gr 工步模板) + + 参数: + act_mass: 正极质量(mg) + Cap_mAh: 正极载量(mAh) + """ + xml_data = f""" + + + + + + + + + + + + + +
+ + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ + +
+ + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+ + +
+ + + +
+
+ """ + return xml_data diff --git a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py index 0a811458b..cb9eaa24a 100644 --- a/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py +++ b/unilabos/devices/neware_battery_test_system/neware_battery_test_system.py @@ -16,15 +16,21 @@ import os import sys import socket +import csv import xml.etree.ElementTree as ET import json import time +import inspect +from datetime import datetime from dataclasses import dataclass from typing import Any, Dict, List, Optional, TypedDict from pylabrobot.resources import ResourceHolder, Coordinate, create_ordered_items_2d, Deck, Plate +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.ros.nodes.base_device_node import ROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +from unilabos.utils.datacore import push_csv_by_config # ======================== # OSS 上传工具函数 @@ -256,12 +262,27 @@ def load_state(self, state: Dict[str, Any]) -> None: super().load_state(state) self._unilabos_state = state + def serialize(self) -> dict: + d = super().serialize() + channel_name = self._unilabos_state.get("Channel_Name") + if channel_name: + d["name"] = channel_name + return d + def serialize_state(self) -> Dict[str, Dict[str, Any]]: """格式不变""" data = super().serialize_state() data.update(self._unilabos_state) return data + def serialize_all_state(self) -> Dict[str, Dict[str, Any]]: + states = {} + channel_name = self._unilabos_state.get("Channel_Name", self.name) + states[channel_name] = self.serialize_state() + for child in self.children: + states.update(child.serialize_all_state()) + return states + class NewareBatteryTestSystem: """ @@ -292,24 +313,25 @@ class NewareBatteryTestSystem: # ======================== STATUS_SET = {"working", "stop", "finish", "protect", "pause", "false"} STATUS_COLOR = { - "working": "#22c55e", # 绿 - "stop": "#6b7280", # 灰 - "finish": "#3b82f6", # 蓝 - "protect": "#ef4444", # 红 - "pause": "#f59e0b", # 橙 - "false": "#9ca3af", # 不存在/无效 - "unknown": "#a855f7", # 未知 + "working": "#15803d", # 深绿 + "stop": "#4b5563", # 深灰 + "finish": "#1d4ed8", # 深蓝 + "protect": "#b91c1c", # 深红 + "pause": "#b45309", # 深橙 + "false": "#6b7280", # 灰 + "unknown": "#7c3aed", # 深紫 } # 字母常量 ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz' ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' LETTERS = ascii_uppercase + ascii_lowercase + DEFAULT_MACHINE_IDS = [1, 2, 3, 4, 5, 6, 86] def __init__(self, ip: str = None, port: int = None, - machine_id: int = 1, + machine_ids: Optional[List[int]] = None, devtype: str = None, timeout: int = None, @@ -319,6 +341,8 @@ def __init__(self, oss_upload_enabled: bool = False, oss_prefix: str = "neware_backup", + + datacore_config: Optional[Dict[str, Any]] = None, ): """ 初始化新威电池测试系统 @@ -326,16 +350,21 @@ def __init__(self, Args: ip: TCP服务器IP地址 port: TCP端口 + machine_ids: 设备ID列表 devtype: 设备类型标识 timeout: 通信超时时间(秒) - machine_id: 机器ID size_x, size_y, size_z: 设备物理尺寸 oss_upload_enabled: 是否启用OSS上传功能,默认False oss_prefix: OSS对象路径前缀,默认"neware_backup" + datacore_config: DataCore 大装置数据接入配置(来自图 JSON),支持字段 + enabled / ingest_url / device_id / device_key / timeout / + total_timeout / retry_interval;不配置则不推送 """ self.ip = ip or self.BTS_IP self.port = port or self.BTS_PORT - self.machine_id = machine_id + self.machine_ids = machine_ids + self.display_device_ids = self._resolve_display_device_ids() + self.primary_device_id = self.display_device_ids[0] self.devtype = devtype or self.DEVTYPE self.timeout = timeout or self.TIMEOUT @@ -347,11 +376,20 @@ def __init__(self, # OSS 上传配置 self.oss_upload_enabled = oss_upload_enabled self.oss_prefix = oss_prefix + + # DataCore 大装置数据接入配置 + self.datacore_config = datacore_config or {} self._last_status_update = None self._cached_status = {} self._last_backup_dir = None # 记录最近一次的 backup_dir,供上传使用 self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用,由框架设置 + self._channels = self._build_channel_map() + + def _resolve_display_device_ids(self) -> List[int]: + if self.machine_ids: + return [int(devid) for devid in self.machine_ids] + return self.DEFAULT_MACHINE_IDS.copy() def post_init(self, ros_node): @@ -376,27 +414,87 @@ def post_init(self, ros_node): ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}") # 不抛出异常,允许节点继续运行,后续可以重试连接 + def _plate_name(self, devid: int, plate_num: int) -> str: + return f"{devid}_P{plate_num}" + + def _plate_resource_key(self, devid: int, plate_num: int, row_idx: int, col_idx: int) -> str: + return f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}" + + def _get_plate_resource(self, devid: int, plate_num: int, row_idx: int, col_idx: int): + possible_names = [ + f"{self._plate_name(devid, plate_num)}_batterytestposition_{col_idx}_{row_idx}", + f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}", + f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx].lower()}{col_idx + 1}", + f"P{plate_num}_batterytestposition_{col_idx}_{row_idx}", + f"P{plate_num}_{self.LETTERS[row_idx]}{col_idx + 1}", + f"P{plate_num}_{self.LETTERS[row_idx].lower()}{col_idx + 1}", + ] + for name in possible_names: + if name in self.station_resources: + return self.station_resources[name], name, possible_names + return None, None, possible_names + def _setup_material_management(self): """设置物料管理系统""" - # 第1盘:5行8列网格 (A1-E8) - 5行对应subdevid 1-5,8列对应chlid 1-8 - # 先给物料设置一个最大的Deck,并设置其在空间中的位置 - - deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0)) - - plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d( - BatteryTestPosition, - num_items_x=8, # 8列(对应chlid 1-8) - num_items_y=5, # 5行(对应subdevid 1-5,即A-E) - dx=10, - dy=10, - dz=0, - item_dx=65, - item_dy=65 + deck_main = Deck( + name="ADeckName", + size_x=1200, + size_y=2800, + size_z=100, + origin=Coordinate(-5500, 0, 0) ) - plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources) - deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0)) - - # 只有在真实ROS环境下才调用update_resource + self.station_resources = {} + self.station_resources_by_plate = {} + + for row_idx, devid in enumerate(self.display_device_ids): + for plate_num in (1, 2): + plate_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d( + BatteryTestPosition, + num_items_x=8, + num_items_y=5, + dx=10, + dy=10, + dz=0, + item_dx=65, + item_dy=65 + ) + plate_name = self._plate_name(devid, plate_num) + plate = Plate( + name=plate_name, + size_x=540, + size_y=350, + size_z=50, + ordered_items=plate_resources + ) + location_x = 0 if plate_num == 1 else 590 + location_y = row_idx * 400 + deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0)) + + plate_key = (devid, plate_num) + subdev_start = 1 if plate_num == 1 else 6 + self.station_resources_by_plate[plate_key] = {} + for name, resource in plate_resources.items(): + new_name = f"{plate_name}_{name}" + # 从名称解析 col/row 索引,设置初始 Channel_Name + parts = name.rsplit("_", 2) + if len(parts) >= 3: + col_idx, row_idx = int(parts[-2]), int(parts[-1]) + chl_id = col_idx + 1 + subdev_id = subdev_start + row_idx + resource.load_state({ + "status": "unknown", + "color": self.STATUS_COLOR["unknown"], + "voltage": 0.0, + "current": 0.0, + "time": 0.0, + "Channel_Name": f"{devid}-{subdev_id}-{chl_id}", + }) + self.station_resources_by_plate[plate_key][new_name] = resource + self.station_resources[new_name] = resource + + self.station_resources_plate1 = self.station_resources_by_plate.get((self.primary_device_id, 1), {}) + self.station_resources_plate2 = self.station_resources_by_plate.get((self.primary_device_id, 2), {}) + if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')): try: ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ @@ -405,40 +503,6 @@ def _setup_material_management(self): except Exception as e: if hasattr(self._ros_node, 'lab_logger'): self._ros_node.lab_logger().warning(f"更新资源失败: {e}") - # 在非ROS环境下忽略此错误 - - # 为第1盘资源添加P1_前缀 - self.station_resources_plate1 = {} - for name, resource in plate1_resources.items(): - new_name = f"P1_{name}" - self.station_resources_plate1[new_name] = resource - - # 第2盘:5行8列网格 (A1-E8),在Z轴上偏移 - 5行对应subdevid 6-10,8列对应chlid 1-8 - plate2_resources = create_ordered_items_2d( - BatteryTestPosition, - num_items_x=8, # 8列(对应chlid 1-8) - num_items_y=5, # 5行(对应subdevid 6-10,即A-E) - dx=10, - dy=10, - dz=0, - item_dx=65, - item_dy=65 - ) - - plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources) - deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0)) - - - # 为第2盘资源添加P2_前缀 - self.station_resources_plate2 = {} - for name, resource in plate2_resources.items(): - new_name = f"P2_{name}" - self.station_resources_plate2[new_name] = resource - - # 合并两盘资源为统一的station_resources - self.station_resources = {} - self.station_resources.update(self.station_resources_plate1) - self.station_resources.update(self.station_resources_plate2) # ======================== # 核心属性(Uni-Lab标准) @@ -469,16 +533,16 @@ def channel_status(self) -> Dict[int, Dict]: status_map = self._query_all_channels() status_processed = {} if not status_map else self._group_by_devid(status_map) - # 修复数据过滤逻辑:如果machine_id对应的数据不存在,尝试使用第一个可用的设备数据 - status_current_machine = status_processed.get(self.machine_id, {}) + # 返回主设备数据,如果主设备没有匹配数据则回退到首个可用设备 + status_current_machine = status_processed.get(self.primary_device_id, {}) if not status_current_machine and status_processed: - # 如果machine_id没有匹配到数据,使用第一个可用的设备数据 + # 如果主设备没有匹配到数据,使用第一个可用的设备数据 first_devid = next(iter(status_processed.keys())) status_current_machine = status_processed[first_devid] if self._ros_node: self._ros_node.lab_logger().warning( - f"machine_id {self.machine_id} 没有匹配到数据,使用设备ID {first_devid} 的数据" + f"主设备ID {self.primary_device_id} 没有匹配到数据,使用设备ID {first_devid} 的数据" ) # 确保有默认的数据结构 @@ -488,139 +552,57 @@ def channel_status(self) -> Dict[int, Dict]: "subunits": {} } - # 确保subunits存在 - subunits = status_current_machine.get("subunits", {}) - - # 处理2盘电池的状态映射 - self._update_plate_resources(subunits) + self._update_plate_resources(status_processed) return status_current_machine - def _update_plate_resources(self, subunits: Dict): - """更新两盘电池资源的状态""" - # 第1盘:subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4) - for subdev_id in range(1, 6): # subdevid 1-5 - status_row = subunits.get(subdev_id, {}) - - for chl_id in range(1, 9): # chlid 1-8 - try: - # 根据用户描述:第一个是(0,0),最后一个是(7,4) - # 说明是8列5行,列从0开始,行从0开始 - col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7) - row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4) - - # 尝试多种可能的资源命名格式 - possible_names = [ - f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式 - f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式 - f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式 - ] - - r = None - resource_name = None - for name in possible_names: - if name in self.station_resources: - r = self.station_resources[name] - resource_name = name - break - - if r: - status_channel = status_row.get(chl_id, {}) - metrics = status_channel.get("metrics", {}) - # 构建BatteryTestPosition状态数据(移除capacity和energy) - channel_state = { - # 基本测量数据 - "voltage": metrics.get("voltage_V", 0.0), - "current": metrics.get("current_A", 0.0), - "time": metrics.get("totaltime_s", 0.0), - - # 状态信息 - "status": status_channel.get("state", "unknown"), - "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), - - # 通道名称标识 - "Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}", - - } - r.load_state(channel_state) - - # 调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} " - f"状态:{channel_state['status']}" + def _update_plate_resources(self, status_processed: Dict[int, Dict]): + """更新7台设备共14盘电池资源的状态""" + for devid in self.display_device_ids: + machine_data = status_processed.get(devid, {}) + subunits = machine_data.get("subunits", {}) + for plate_num, subdev_start, subdev_end in ((1, 1, 5), (2, 6, 10)): + for subdev_id in range(subdev_start, subdev_end + 1): + status_row = subunits.get(subdev_id, {}) + for chl_id in range(1, 9): + try: + col_idx = chl_id - 1 + row_idx = subdev_id - subdev_start + r, resource_name, possible_names = self._get_plate_resource( + devid=devid, + plate_num=plate_num, + row_idx=row_idx, + col_idx=col_idx ) - else: - # 如果找不到资源,记录调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}" - ) - except (KeyError, IndexError) as e: - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}") - continue - - # 第2盘:subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4) - for subdev_id in range(6, 11): # subdevid 6-10 - status_row = subunits.get(subdev_id, {}) - - for chl_id in range(1, 9): # chlid 1-8 - try: - col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7) - row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4) - - # 尝试多种可能的资源命名格式 - possible_names = [ - f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式 - f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式 - f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式 - ] - - r = None - resource_name = None - for name in possible_names: - if name in self.station_resources: - r = self.station_resources[name] - resource_name = name - break - - if r: - status_channel = status_row.get(chl_id, {}) - metrics = status_channel.get("metrics", {}) - # 构建BatteryTestPosition状态数据(移除capacity和energy) - channel_state = { - # 基本测量数据 - "voltage": metrics.get("voltage_V", 0.0), - "current": metrics.get("current_A", 0.0), - "time": metrics.get("totaltime_s", 0.0), - - # 状态信息 - "status": status_channel.get("state", "unknown"), - "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), - - # 通道名称标识 - "Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}", - - } - r.load_state(channel_state) - - # 调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} " - f"状态:{channel_state['status']}" - ) - else: - # 如果找不到资源,记录调试信息 - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug( - f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}" - ) - except (KeyError, IndexError) as e: - if self._ros_node and hasattr(self._ros_node, 'lab_logger'): - self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}") - continue + if r is None: + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"{devid}_P{plate_num}未找到资源: subdev{subdev_id}/chl{chl_id} -> " + f"尝试的名称: {possible_names}" + ) + continue + status_channel = status_row.get(chl_id, {}) + metrics = status_channel.get("metrics", {}) + channel_state = { + "voltage": metrics.get("voltage_V", 0.0), + "current": metrics.get("current_A", 0.0), + "time": metrics.get("totaltime_s", 0.0), + "status": status_channel.get("state", "unknown"), + "color": status_channel.get("color", self.STATUS_COLOR["unknown"]), + "Channel_Name": f"{devid}-{subdev_id}-{chl_id}", + } + r.load_state(channel_state) + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"更新{devid}_P{plate_num}资源状态: {resource_name} <- " + f"subdev{subdev_id}/chl{chl_id} 状态:{channel_state['status']}" + ) + except (KeyError, IndexError) as e: + if self._ros_node and hasattr(self._ros_node, 'lab_logger'): + self._ros_node.lab_logger().debug( + f"{devid}_P{plate_num}映射错误: subdev{subdev_id}/chl{chl_id} - {e}" + ) + continue ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": list(self.station_resources.values()) }) @@ -640,6 +622,22 @@ def total_channels(self) -> int: """获取总通道数""" return len(self._channels) + def _build_device_summary_dict(self) -> dict: + if not hasattr(self, '_channels') or not self._channels: + self._channels = self._build_channel_map() + channel_count_by_devid = {} + for channel in self._channels: + devid = channel.devid + channel_count_by_devid[devid] = channel_count_by_devid.get(devid, 0) + 1 + return { + "channel_count_by_devid": channel_count_by_devid, + "display_device_ids": self.display_device_ids, + "total_channels": len(self._channels) + } + + def device_summary(self) -> str: + return json.dumps(self._build_device_summary_dict(), ensure_ascii=False) + # ======================== # 设备动作方法(Uni-Lab标准) # ======================== @@ -919,6 +917,28 @@ def _ensure_local_import_path(self): def _canon(self, bs: str) -> str: """规范化电池体系名称""" return str(bs).strip().replace('-', '_').upper() + + def _get_builder_required_positional_count(self, builder) -> int: + """返回XML生成函数必填位置参数个数(仅统计无默认值的positional参数)""" + sig = inspect.signature(builder) + required = 0 + for p in sig.parameters.values(): + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): + if p.default is inspect.Parameter.empty: + required += 1 + return required + + def _is_csv_value_empty(self, value) -> bool: + """判断CSV单元格是否为空(兼容NaN/None/空串/null)""" + if value is None: + return True + if isinstance(value, str): + return value.strip().lower() in ("", "nan", "none", "null") + try: + # NaN 与自身不相等 + return value != value + except Exception: + return False def _compute_values(self, row): """ @@ -930,7 +950,7 @@ def _compute_values(self, row): Returns: tuple: (活性物质质量mg, 容量mAh) """ - pw = float(row['Pole_Weight']) + pw = float(row['pole_weight']) cm = float(row['集流体质量']) am = row['活性物质含量'] if isinstance(am, str) and am.endswith('%'): @@ -953,21 +973,33 @@ def _get_xml_builder(self, gen_mod, key: str): Returns: callable: XML生成函数 """ + # 体系标识 -> generate_xml_content 中的函数名(惰性解析:仅解析实际用到的体系, + # 避免某个函数未实现导致整个映射构建失败,从而拖累所有电池) fmap = { - 'LB6': gen_mod.xml_LB6, - 'GR_LI': gen_mod.xml_Gr_Li, - 'LFP_LI': gen_mod.xml_LFP_Li, - 'LFP_GR': gen_mod.xml_LFP_Gr, - '811_LI_002': gen_mod.xml_811_Li_002, - '811_LI_005': gen_mod.xml_811_Li_005, - 'SIGR_LI_STEP': gen_mod.xml_SiGr_Li_Step, - 'SIGR_LI': gen_mod.xml_SiGr_Li_Step, - '811_SIGR': gen_mod.xml_811_SiGr, - '811_CU_AGING': gen_mod.xml_811_Cu_aging, + 'LB6': 'xml_LB6', + 'GR_LI': 'xml_Gr_Li', + 'LFP_GR': 'xml_LFP_Gr', + '811_LI_002': 'xml_811_Li_002', + '811_LI_005': 'xml_811_Li_005', + 'SIGR_LI_STEP': 'xml_SiGr_Li_Step', + 'SIGR_LI': 'xml_SiGr_Li_Step', + '811_SIGR': 'xml_811_SiGr', + '811_CU_AGING': 'xml_811_Cu_aging', + '811_LI_JY': 'xml_811_Li_JY', + '811_DATACORETEST': 'xml_811_datacoretest', + 'ZQXNLRMO': 'xml_ZQXNLRMO', + 'HC_LFP': 'xml_HC_LFP', + 'HC_GR': 'xml_HC_Gr', } if key not in fmap: - raise ValueError(f"未定义电池体系映射: {key}") - return fmap[key] + raise ValueError(f"未定义电池体系映射: {key},可选体系: {sorted(fmap.keys())}") + func_name = fmap[key] + builder = getattr(gen_mod, func_name, None) + if builder is None: + raise ValueError( + f"电池体系 {key} 对应的XML生成函数 {func_name} 尚未在 generate_xml_content.py 中实现" + ) + return builder def _save_xml(self, xml: str, path: str): """ @@ -980,7 +1012,7 @@ def _save_xml(self, xml: str, path: str): with open(path, 'w', encoding='utf-8') as f: f.write(xml) - def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: + def submit_from_csv_export_ndax(self, csv_path: str, output_dir: str = ".") -> dict: """ 从CSV文件批量提交Neware测试任务(设备动作) @@ -1012,8 +1044,7 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: # 验证必需列 required = [ - 'Battery_Code', 'Electrolyte_Code', 'Pole_Weight', '集流体质量', '活性物质含量', - '克容量mah/g', '电池体系', '设备号', '排号', '通道号' + 'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号' ] missing = [c for c in required if c not in df.columns] if missing: @@ -1042,27 +1073,47 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: for idx, row in df.iterrows(): try: - coin_id = f"{row['Battery_Code']}-{row['Electrolyte_Code']}" - - # 计算活性物质质量和容量 - act_mass, cap_mAh = self._compute_values(row) - - if cap_mAh < 0: - error_msg = ( - f"容量为负数: Battery_Code={coin_id}, " - f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" - ) - if self._ros_node: - self._ros_node.lab_logger().warning(error_msg) - results.append(f"行{idx+1} 失败: {error_msg}") - continue - + coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}" + # 获取电池体系对应的XML生成函数 key = self._canon(row['电池体系']) builder = self._get_xml_builder(gen_mod, key) - - # 生成XML内容 - xml_content = builder(act_mass, cap_mAh) + builder_required_args = self._get_builder_required_positional_count(builder) + + # 生成XML内容:仅当工步模板需要时才校验并计算 act_mass/cap_mAh + if builder_required_args == 0: + xml_content = builder() + elif builder_required_args == 2: + calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g'] + missing_calc = [ + c for c in calc_cols + if c not in df.columns or self._is_csv_value_empty(row[c]) + ] + if missing_calc: + error_msg = ( + f"电池体系 {key} 需要 act_mass/Cap_mAh,以下列缺失或为空: {missing_calc}, " + f"CoinID={coin_id}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + + act_mass, cap_mAh = self._compute_values(row) + if cap_mAh < 0: + error_msg = ( + f"容量为负数: Battery_Code={coin_id}, " + f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + xml_content = builder(act_mass, cap_mAh) + else: + raise ValueError( + f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数" + ) # 获取设备信息 devid = int(row['设备号']) @@ -1085,7 +1136,8 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: chlid=chlid, CoinID=coin_id, recipe_path=recipe_path, - backup_dir=backup_dir + backup_dir=backup_dir, + filetype=0 ) submitted_count += 1 @@ -1093,7 +1145,7 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: if self._ros_node: self._ros_node.lab_logger().info( - f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}): {resp}" + f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, NDAX备份): {resp}" ) except Exception as e: @@ -1133,6 +1185,332 @@ def submit_from_csv(self, csv_path: str, output_dir: str = ".") -> dict: } + def submit_from_csv_export_excel(self, csv_path: str, output_dir: str = ".") -> dict: + """ + 从CSV文件批量提交Neware测试任务,备份格式为Excel(设备动作) + + 与 submit_from_csv_export_ndax 逻辑一致,唯一区别是 BTS 备份文件格式为 Excel 而非 NDA。 + + Args: + csv_path (str): 输入CSV文件路径 + output_dir (str): 输出目录,用于存储XML文件和备份,默认当前目录 + + Returns: + dict: 执行结果 {"return_info": str, "success": bool, "submitted_count": int} + """ + try: + self._ensure_local_import_path() + import pandas as pd + import generate_xml_content as gen_mod + from neware_driver import start_test + + if self._ros_node: + self._ros_node.lab_logger().info(f"开始从CSV文件提交任务(Excel备份): {csv_path}") + + if not os.path.exists(csv_path): + error_msg = f"CSV文件不存在: {csv_path}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + df = pd.read_csv(csv_path, encoding='gbk') + + required = [ + 'coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号' + ] + missing = [c for c in required if c not in df.columns] + if missing: + error_msg = f"CSV缺少必需列: {missing}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + xml_dir = os.path.join(output_dir, 'xml_dir') + backup_dir = os.path.join(output_dir, 'backup_dir') + os.makedirs(xml_dir, exist_ok=True) + os.makedirs(backup_dir, exist_ok=True) + + self._last_backup_dir = backup_dir + + if self._ros_node: + self._ros_node.lab_logger().info( + f"输出目录: XML={xml_dir}, 备份(Excel)={backup_dir}" + ) + + submitted_count = 0 + results = [] + + for idx, row in df.iterrows(): + try: + coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}" + + key = self._canon(row['电池体系']) + builder = self._get_xml_builder(gen_mod, key) + builder_required_args = self._get_builder_required_positional_count(builder) + + if builder_required_args == 0: + xml_content = builder() + elif builder_required_args == 2: + calc_cols = ['pole_weight', '集流体质量', '活性物质含量', '克容量mah/g'] + missing_calc = [ + c for c in calc_cols + if c not in df.columns or self._is_csv_value_empty(row[c]) + ] + if missing_calc: + error_msg = ( + f"电池体系 {key} 需要 act_mass/Cap_mAh,以下列缺失或为空: {missing_calc}, " + f"CoinID={coin_id}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + + act_mass, cap_mAh = self._compute_values(row) + if cap_mAh < 0: + error_msg = ( + f"容量为负数: Battery_Code={coin_id}, " + f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + xml_content = builder(act_mass, cap_mAh) + else: + raise ValueError( + f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数" + ) + + devid = int(row['设备号']) + subdevid = int(row['排号']) + chlid = int(row['通道号']) + + recipe_path = os.path.join( + xml_dir, + f"{coin_id}_{devid}_{subdevid}_{chlid}.xml" + ) + self._save_xml(xml_content, recipe_path) + + resp = start_test( + ip=self.ip, + port=self.port, + devid=devid, + subdevid=subdevid, + chlid=chlid, + CoinID=coin_id, + recipe_path=recipe_path, + backup_dir=backup_dir, + filetype=1 + ) + + submitted_count += 1 + results.append(f"行{idx+1} {coin_id}: {resp}") + + if self._ros_node: + self._ros_node.lab_logger().info( + f"已提交 {coin_id} (设备{devid}-{subdevid}-{chlid}, Excel备份): {resp}" + ) + + except Exception as e: + error_msg = f"行{idx+1} 处理失败: {str(e)}" + results.append(error_msg) + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + + success_msg = ( + f"批量提交完成(Excel备份): 成功{submitted_count}个,共{len(df)}行。" + f"\n详细结果:\n" + "\n".join(results) + ) + + if self._ros_node: + self._ros_node.lab_logger().info( + f"批量提交完成(Excel备份): 成功{submitted_count}/{len(df)}" + ) + + return { + "return_info": success_msg, + "success": True, + "submitted_count": submitted_count, + "total_count": len(df), + "results": results + } + + except Exception as e: + error_msg = f"批量提交失败(Excel备份): {str(e)}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return { + "return_info": error_msg, + "success": False, + "submitted_count": 0, + "total_count": 0 + } + + def submit_lp_csv_export_excel(self, csv_path: str, output_dir: str = ".") -> dict: + """ + 从CSV文件批量提交LP任务,备份格式为Excel(设备动作) + + 与 submit_from_csv_export_excel 逻辑一致,但当工步模板需要参数时, + 容量仅由“活性物质质量mg”和“克容量mah/g”计算。 + + Args: + csv_path (str): 输入CSV文件路径 + output_dir (str): 输出目录,用于存储XML文件和备份,默认当前目录 + + Returns: + dict: 执行结果 {"return_info": str, "success": bool, "submitted_count": int} + """ + try: + self._ensure_local_import_path() + import pandas as pd + import generate_xml_content as gen_mod + from neware_driver import start_test + + if self._ros_node: + self._ros_node.lab_logger().info(f"开始从CSV文件提交LP任务(Excel备份): {csv_path}") + + if not os.path.exists(csv_path): + error_msg = f"CSV文件不存在: {csv_path}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + df = pd.read_csv(csv_path, encoding='gbk') + + required = ['coin_cell_code', 'electrolyte_code', '电池体系', '设备号', '排号', '通道号'] + missing = [c for c in required if c not in df.columns] + if missing: + error_msg = f"CSV缺少必需列: {missing}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return {"return_info": error_msg, "success": False, "submitted_count": 0, "total_count": 0} + + xml_dir = os.path.join(output_dir, 'xml_dir') + backup_dir = os.path.join(output_dir, 'backup_dir') + os.makedirs(xml_dir, exist_ok=True) + os.makedirs(backup_dir, exist_ok=True) + + self._last_backup_dir = backup_dir + + if self._ros_node: + self._ros_node.lab_logger().info( + f"输出目录: XML={xml_dir}, 备份(Excel)={backup_dir}" + ) + + submitted_count = 0 + results = [] + + for idx, row in df.iterrows(): + try: + coin_id = f"{row['coin_cell_code']}-{row['electrolyte_code']}" + + key = self._canon(row['电池体系']) + builder = self._get_xml_builder(gen_mod, key) + builder_required_args = self._get_builder_required_positional_count(builder) + + if builder_required_args == 0: + xml_content = builder() + elif builder_required_args == 2: + calc_cols = ['活性物质质量mg', '克容量mah/g'] + missing_calc = [ + c for c in calc_cols + if c not in df.columns or self._is_csv_value_empty(row[c]) + ] + if missing_calc: + error_msg = ( + f"电池体系 {key} 需要 act_mass/Cap_mAh,以下列缺失或为空: {missing_calc}, " + f"CoinID={coin_id}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + + act_mass = float(row['活性物质质量mg']) + specific_capacity = float(row['克容量mah/g']) + cap_mAh = round(act_mass * specific_capacity / 1000.0, 3) + act_mass = round(act_mass, 2) + if cap_mAh < 0: + error_msg = ( + f"容量为负数: Battery_Code={coin_id}, " + f"活性物质质量mg={act_mass}, 容量mah={cap_mAh}" + ) + if self._ros_node: + self._ros_node.lab_logger().warning(error_msg) + results.append(f"行{idx+1} 失败: {error_msg}") + continue + xml_content = builder(act_mass, cap_mAh) + else: + raise ValueError( + f"XML生成函数参数不支持: {builder.__name__} 需要 {builder_required_args} 个必填位置参数" + ) + + devid = int(row['设备号']) + subdevid = int(row['排号']) + chlid = int(row['通道号']) + + recipe_path = os.path.join( + xml_dir, + f"{coin_id}_{devid}_{subdevid}_{chlid}.xml" + ) + self._save_xml(xml_content, recipe_path) + + resp = start_test( + ip=self.ip, + port=self.port, + devid=devid, + subdevid=subdevid, + chlid=chlid, + CoinID=coin_id, + recipe_path=recipe_path, + backup_dir=backup_dir, + filetype=1 + ) + + submitted_count += 1 + results.append(f"行{idx+1} {coin_id}: {resp}") + + if self._ros_node: + self._ros_node.lab_logger().info( + f"已提交LP {coin_id} (设备{devid}-{subdevid}-{chlid}, Excel备份): {resp}" + ) + + except Exception as e: + error_msg = f"行{idx+1} 处理失败: {str(e)}" + results.append(error_msg) + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + + success_msg = ( + f"LP批量提交完成(Excel备份): 成功{submitted_count}个,共{len(df)}行。" + f"\n详细结果:\n" + "\n".join(results) + ) + + if self._ros_node: + self._ros_node.lab_logger().info( + f"LP批量提交完成(Excel备份): 成功{submitted_count}/{len(df)}" + ) + + return { + "return_info": success_msg, + "success": True, + "submitted_count": submitted_count, + "total_count": len(df), + "results": results + } + + except Exception as e: + error_msg = f"LP批量提交失败(Excel备份): {str(e)}" + if self._ros_node: + self._ros_node.lab_logger().error(error_msg) + return { + "return_info": error_msg, + "success": False, + "submitted_count": 0, + "total_count": 0 + } + def get_device_summary(self) -> dict: """ 获取设备级别的摘要统计(设备动作) @@ -1141,16 +1519,7 @@ def get_device_summary(self) -> dict: dict: ROS2动作结果格式 {"return_info": str, "success": bool} """ try: - # 确保_channels已初始化 - if not hasattr(self, '_channels') or not self._channels: - self._channels = self._build_channel_map() - - summary = {} - for channel in self._channels: - devid = channel.devid - summary[devid] = summary.get(devid, 0) + 1 - - result_info = json.dumps(summary, ensure_ascii=False) + result_info = self.device_summary() success_msg = f"设备摘要统计: {result_info}" if self._ros_node: self._ros_node.lab_logger().info(success_msg) @@ -1218,7 +1587,7 @@ def upload_backup_to_oss( 上传备份目录中的文件到 OSS(ROS2 动作) Args: - backup_dir: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir + backup_dir: 备份目录路径,默认使用最近一次提交任务的 backup_dir file_pattern: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) oss_prefix: OSS 对象前缀,默认使用类初始化时的配置 @@ -1748,6 +2117,677 @@ def _group_by_devid(self, status_map: Dict['ChannelKey', dict]) -> Dict[int, Dic return result + def mock_assembly_data(self) -> dict: + """ + 模拟扣电组装站 auto-func_sendbottle_allpack_multi 的输出,返回固定的 16 颗电池 assembly_data。 + 用于在没有真实扣电组装站的情况下,测试 + mock_assembly_data → manual_confirm → battery_transfer_confirm → submit_auto_export_excel + 的完整参数传递与 TCP 下发链路。 + + Returns: + dict: { + "assembly_data": list[dict], # 9 字段 × 16 颗电池 + "success": bool, + "return_info": str, + } + """ + # 用确定性规律生成 16 颗电池,保证每次调用结果一致,便于回归测试 + assembly_data = [ + { + "Time": f"20260421_14{30 + i:02d}22", + "open_circuit_voltage": round(3.700 + (i % 5) * 0.006, 3), + "pole_weight": round(97.50 + i * 0.06, 2), + "assembly_time": 118 + (i % 4), + "assembly_pressure": round(5.0 + (i % 4) * 0.1, 1), + "target_assembly_pressure": 5.0, + "electrolyte_volume": round(79.0 + (i % 3) * 0.5, 1), + "data_coin_type": 2, + "electrolyte_code": f"EL-20260421{i:02d}", + "coin_cell_code": f"CC-20260421{i:02d}", + } + for i in range(1, 17) + ] + info = f"mock_assembly_data 返回 {len(assembly_data)} 颗电池的模拟组装数据" + if self._ros_node: + self._ros_node.lab_logger().info(f"[mock_assembly_data] {info}") + else: + print(f"[mock_assembly_data] {info}") + return { + "assembly_data": assembly_data, + "success": True, + "return_info": info, + } + + # ─── manual_confirm 辅助:CSV 表头别名 + 三模式展开 ─────────────────────────── + + @staticmethod + def _normalize_csv_headers(df): + """把 CSV 中文/英文表头统一映射为内部字段名。""" + alias = { + "coin_cell_code": "coin_cell_code", + "电池条码": "coin_cell_code", + "电池编号": "coin_cell_code", + "collector_mass": "collector_mass", + "集流体质量": "collector_mass", + "active_material": "active_material", + "活性物质含量": "active_material", + "活性物质的含量": "active_material", + "capacity": "capacity", + "克容量": "capacity", + "battery_system": "battery_system", + "电池体系": "battery_system", + "xml工步": "battery_system", + } + rename_map = {c: alias[c.strip()] for c in df.columns if c.strip() in alias} + return df.rename(columns=rename_map) + + def _expand_battery_params( + self, + mount_resource, + assembly_data, + collector_mass, + active_material, + capacity, + battery_system, + default_collector_mass, + default_active_material, + default_capacity, + default_battery_system, + param_csv_path, + ): + """按优先级 B(逐颗数组) > C(CSV) > A(标量默认) 展开为长度 N 的 4 个 list。""" + N = len(mount_resource) if mount_resource else 0 + FIELDS = ("collector_mass", "active_material", "capacity", "battery_system") + + # 1) 解析 CSV → coin_cell_code 索引表 + csv_map: Dict[str, Dict[str, Any]] = {} + if param_csv_path: + if not os.path.isfile(param_csv_path): + raise FileNotFoundError(f"param_csv_path 不存在: {param_csv_path}") + import pandas as pd + try: + df = pd.read_csv(param_csv_path, encoding="utf-8-sig") + except UnicodeDecodeError: + df = pd.read_csv(param_csv_path, encoding="gbk") + df = self._normalize_csv_headers(df) + if "coin_cell_code" not in df.columns: + raise ValueError( + f"param_csv_path 解析失败: 缺少必需列 coin_cell_code(或 电池条码 / 电池编号),实际列={list(df.columns)}" + ) + for _, row in df.iterrows(): + code = str(row["coin_cell_code"]).strip() + if not code or code.lower() == "nan": + continue + csv_map[code] = { + f: (row[f] if f in df.columns and not (isinstance(row[f], float) and row[f] != row[f]) else None) + for f in FIELDS + } + if self._ros_node: + self._ros_node.lab_logger().info( + f"[manual_confirm] CSV 已加载 {len(csv_map)} 行参数 from {param_csv_path}" + ) + + arr_map = { + "collector_mass": collector_mass or [], + "active_material": active_material or [], + "capacity": capacity or [], + "battery_system": battery_system or [], + } + default_map = { + "collector_mass": default_collector_mass, + "active_material": default_active_material, + "capacity": default_capacity, + "battery_system": default_battery_system, + } + + out: Dict[str, list] = {f: [None] * N for f in FIELDS} + for i in range(N): + code = "" + if assembly_data and i < len(assembly_data): + code = str(assembly_data[i].get("coin_cell_code", "")).strip() + + for f in FIELDS: + # B:逐颗数组 + arr = arr_map[f] + if arr and i < len(arr) and arr[i] not in (None, ""): + out[f][i] = arr[i] + continue + # C:CSV 按 coin_cell_code + if code and code in csv_map: + cv = csv_map[code].get(f) + if cv not in (None, ""): + out[f][i] = cv + continue + # A:标量默认 + d = default_map[f] + if d not in (None, ""): + out[f][i] = d + continue + + # 缺失校验 + missing = [(i, f) for f in FIELDS for i in range(N) if out[f][i] in (None, "")] + if missing: + raise ValueError( + f"battery_param 展开缺失: {missing};请检查 A(default_*) / B(逐颗数组) / C(param_csv_path) 三种填法是否覆盖到每颗电池" + ) + + # 数值字段类型规范化(CSV 读出来可能是 numpy 类型,转回 Python 原生) + out["collector_mass"] = [float(v) for v in out["collector_mass"]] + out["capacity"] = [float(v) for v in out["capacity"]] + out["active_material"] = [str(v) if not isinstance(v, str) else v for v in out["active_material"]] + out["battery_system"] = [str(v) for v in out["battery_system"]] + + return out["collector_mass"], out["active_material"], out["capacity"], out["battery_system"] + + def manual_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + formulations: List[Dict] = None, + assembly_data: List[Dict] = None, + csv_export_dir: str = "D:\\2604Agentic_test", + timeout_seconds: int = 86400, + assignee_user_ids: list[str] = None, + A: Dict = None, + B: Dict = None, + C: Dict = None, + **kwargs, + ) -> dict: + """ + 人工确认节点: + - 上游接收 bioyond 配方(formulations)+ 扣电组装数据(assembly_data 单数组) + - 人工在前端按 A/B/C 三种模式之一填写 4 个电池参数(collector_mass / active_material / + capacity / battery_system(xml工步)),并选择 target_device 与 mount_resource(通道) + - 后端按优先级 B > C > A 展开为长度 N 的 4 个 list;下游 submit_auto_export_excel 接口不变 + - 内部把 assembly_data 解包为 9 个并行数组,把 pole_weight / coin_cell_code 透传给下游 + - 把所有数据整合后写入 {csv_export_dir}/{YYYYMMDD}/battery_test_{YYYYMMDD_HHMMSS}.csv + + Args: + resource: 扣电组装物料系统(无需选择)—— 由系统自动管理的扣电资源列表 + target_device: 目标新威测试柜设备 + mount_resource: 新威测试通道 —— 选择目标新威测试柜上的测试通道(决定下游 N = len(mount_resource)) + formulations: 配方信息列表(来自 bioyond mass_ratios) + assembly_data: 扣电组装数据列表(每颗电池一个 dict) + csv_export_dir: 整合 CSV 导出根目录 + timeout_seconds: 超时时间(秒),默认 86400;由 node_type=manual_confirm 的外层调度/前端等待机制处理, + 设备函数体内不做本地计时中断 + assignee_user_ids: 通知人员 + A: 模式A 参数一致(dict, 4 个 default_*) + {default_collector_mass, default_active_material, default_capacity, default_battery_system} + B: 模式B 参数不一致 数量少(dict, 4 个 list) + {collector_mass, active_material, capacity, battery_system} + C: 模式C 参数不一致 数量多(dict) + {param_csv_path} + CSV 表头需含 coin_cell_code(或 电池条码) + 集流体质量(或 collector_mass) + + 活性物质含量(或 active_material) + 克容量(或 capacity) + + 电池体系(或 xml工步 / battery_system);行序不敏感,按 coin_cell_code 对齐 + """ + A = A or {} + B = B or {} + C = C or {} + + # 模式 A:标量默认(参数一致,整批共用) + default_collector_mass = A.get("default_collector_mass") + default_active_material = A.get("default_active_material") + default_capacity = A.get("default_capacity") + default_battery_system = A.get("default_battery_system") + + # 模式 B:逐颗数组(兼容老调用:若 kwargs 里平铺传入则也接受) + collector_mass = B.get("collector_mass") or kwargs.get("collector_mass") or [] + active_material = B.get("active_material") or kwargs.get("active_material") or [] + capacity = B.get("capacity") or kwargs.get("capacity") or [] + battery_system = B.get("battery_system") or kwargs.get("battery_system") or [] + + # 模式 C:CSV 路径 + param_csv_path = (C.get("param_csv_path") or "").strip() + + resource_dump = ResourceTreeSet.from_plr_resources(resource).dump() + mount_resource_dump = ResourceTreeSet.from_plr_resources(mount_resource).dump() + + assembly_data = assembly_data or [] + formulations = formulations or [] + + Time = [b.get("Time", "") for b in assembly_data] + open_circuit_voltage = [b.get("open_circuit_voltage", 0.0) for b in assembly_data] + pole_weight = [b.get("pole_weight", 0.0) for b in assembly_data] + assembly_time = [b.get("assembly_time", 0) for b in assembly_data] + assembly_pressure = [b.get("assembly_pressure", 0) for b in assembly_data] + target_assembly_pressure = [b.get("target_assembly_pressure", "") for b in assembly_data] + electrolyte_volume = [b.get("electrolyte_volume", 0) for b in assembly_data] + data_coin_type = [b.get("data_coin_type", 0) for b in assembly_data] + electrolyte_code = [b.get("electrolyte_code", "") for b in assembly_data] + coin_cell_code = [b.get("coin_cell_code", "") for b in assembly_data] + + # 按优先级 B > C > A 展开为长度 N 的 4 个 list + collector_mass, active_material, capacity, battery_system = self._expand_battery_params( + mount_resource=mount_resource, + assembly_data=assembly_data, + collector_mass=collector_mass, + active_material=active_material, + capacity=capacity, + battery_system=battery_system, + default_collector_mass=default_collector_mass, + default_active_material=default_active_material, + default_capacity=default_capacity, + default_battery_system=default_battery_system, + param_csv_path=param_csv_path, + ) + + csv_out_path = None + try: + csv_out_path = self._export_manual_confirm_csv( + csv_export_dir=csv_export_dir, + mount_resource=mount_resource, + formulations=formulations, + assembly_rows={ + "Time": Time, + "open_circuit_voltage": open_circuit_voltage, + "pole_weight": pole_weight, + "assembly_time": assembly_time, + "assembly_pressure": assembly_pressure, + "target_assembly_pressure": target_assembly_pressure, + "electrolyte_volume": electrolyte_volume, + "data_coin_type": data_coin_type, + "electrolyte_code": electrolyte_code, + "coin_cell_code": coin_cell_code, + }, + collector_mass=collector_mass, + active_material=active_material, + capacity=capacity, + battery_system=battery_system, + ) + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"[manual_confirm] 整合 CSV 导出失败: {e}") + else: + print(f"[manual_confirm] 整合 CSV 导出失败: {e}") + + # 推送整合 CSV 到 DataCore 大装置数据接入(尽力而为,失败不影响节点返回) + if csv_out_path: + lab_logger = self._ros_node.lab_logger() if self._ros_node else None + ok = push_csv_by_config( + csv_out_path, + self.datacore_config, + tag="manual_confirm", + log=lab_logger.info if lab_logger else print, + ) + if not ok and lab_logger: + lab_logger.warning(f"[manual_confirm] DataCore 推送未成功: {csv_out_path}") + + return { + "resource": resource_dump, + "coin_cell_code": coin_cell_code, + "electrolyte_code": electrolyte_code, + "target_device": target_device, + "mount_resource": mount_resource_dump, + "collector_mass": collector_mass, + "active_material": active_material, + "capacity": capacity, + "battery_system": battery_system, + "pole_weight": pole_weight, + } + + def _export_manual_confirm_csv( + self, + csv_export_dir: str, + mount_resource: List[ResourceSlot], + formulations: List[Dict], + assembly_rows: Dict[str, List[Any]], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + ) -> Optional[str]: + """把 manual_confirm 收集到的全部参数整合写入 CSV。路径:{csv_export_dir}/{YYYYMMDD}/battery_test_{YYYYMMDD_HHMMSS}.csv""" + n_assembly = len(assembly_rows.get("Time", [])) + n_channel = len(mount_resource) if mount_resource else 0 + n = max(n_assembly, n_channel, len(collector_mass or []), len(active_material or []), + len(capacity or []), len(battery_system or [])) + if n == 0: + return None + + date_str = datetime.now().strftime("%Y%m%d") + datetime_str = datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = os.path.join(csv_export_dir, date_str) + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"battery_test_{datetime_str}.csv") + + header = [ + "Time", "open_circuit_voltage", "pole_weight", + "assembly_time", "assembly_pressure", "target_assembly_pressure", "electrolyte_volume", + "data_coin_type", "electrolyte_code", "coin_cell_code", + "orderName", "prep_bottle_barcode", "vial_bottle_barcodes", + "target_mass_ratio", "real_mass_ratio", + "collector_mass", "active_material", "capacity", "battery_system", + "channel_name", + ] + + file_exists = os.path.exists(out_path) + with open(out_path, "a", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + if not file_exists: + writer.writerow(header) + + def safe_get(lst, i, default=""): + try: + return lst[i] if lst and i < len(lst) else default + except Exception: + return default + + # 按 electrolyte_code 分组匹配配方:相同电解液码的电池共用同一配方; + # 不同电解液码按「首次出现顺序」依次对应 formulations[0], [1], ... + # (一瓶电解液 = 一个配方/订单,可灌装多颗扣电,故不能按行号一一对应) + electrolyte_codes = assembly_rows.get("electrolyte_code", []) + distinct_codes = [] + for _code in electrolyte_codes: + _code = str(_code).strip() + if _code and _code not in distinct_codes: + distinct_codes.append(_code) + code_to_formulation: Dict[str, dict] = {} + for _idx, _code in enumerate(distinct_codes): + if _idx < len(formulations) and isinstance(formulations[_idx], dict): + code_to_formulation[_code] = formulations[_idx] + + for i in range(n): + # 优先按电解液码取配方;无可用电解液码时回退到原有的按行号匹配 + code_i = str(safe_get(electrolyte_codes, i, "")).strip() + if distinct_codes: + form = code_to_formulation.get(code_i, {}) + else: + form = formulations[i] if formulations and i < len(formulations) else {} + if not isinstance(form, dict): + form = {} + target_ratio = form.get("target_mass_ratio", {}) if isinstance(form, dict) else {} + real_ratio = form.get("real_mass_ratio", {}) if isinstance(form, dict) else {} + ch_name = self._extract_channel_name(mount_resource[i]) if mount_resource and i < len(mount_resource) else "" + + writer.writerow([ + safe_get(assembly_rows["Time"], i), + safe_get(assembly_rows["open_circuit_voltage"], i, 0.0), + safe_get(assembly_rows["pole_weight"], i, 0.0), + safe_get(assembly_rows["assembly_time"], i, 0), + safe_get(assembly_rows["assembly_pressure"], i, 0), + safe_get(assembly_rows["target_assembly_pressure"], i, ""), + safe_get(assembly_rows["electrolyte_volume"], i, 0), + safe_get(assembly_rows["data_coin_type"], i, 0), + safe_get(assembly_rows["electrolyte_code"], i), + safe_get(assembly_rows["coin_cell_code"], i), + form.get("orderName", "") if isinstance(form, dict) else "", + form.get("prep_bottle_barcode", "") if isinstance(form, dict) else "", + form.get("vial_bottle_barcodes", "") if isinstance(form, dict) else "", + json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "", + json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "", + safe_get(collector_mass, i, ""), + safe_get(active_material, i, ""), + safe_get(capacity, i, ""), + safe_get(battery_system, i, ""), + (f"'{ch_name}" if ch_name else ""), + ]) + f.flush() + + if self._ros_node: + self._ros_node.lab_logger().info(f"[manual_confirm] 整合 CSV 已写入 {out_path}({n} 行)") + return out_path + + + async def battery_transfer_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + timeout_seconds: int = 86400, + assignee_user_ids: list[str] = None, + **kwargs, + ): + """ + 电池装夹人工确认 + TCP 转运。 + - 该节点通过 yaml 的 node_type: manual_confirm 机制阻塞等待人工确认。 + - 人工在前端确认通道与电池对应关系(装夹就位)后,方法体才会被框架调用。 + - timeout_seconds 由外层调度/前端等待机制处理;该方法体内不做本地计时中断。 + - 方法体执行真正的 TCP 资源转运。 + + Args: + resource: 扣电组装物料系统(无需选择)—— 由系统自动管理的扣电资源列表 + target_device: 目标新威测试柜设备 + mount_resource: 新威测试通道 —— 选择目标新威测试柜上的测试通道 + timeout_seconds: 超时时间(秒),由外层调度/前端等待机制处理 + assignee_user_ids: 通知人员 + """ + future = ROS2DeviceNode.run_async_func( + self._ros_node.transfer_resource_to_another, True, + **{ + "plr_resources": resource, + "target_device_id": target_device, + "target_resources": mount_resource, + "sites": [None] * len(mount_resource), + }, + ) + result = await future + return result + # ────────────────────────────────────────────── + # test() 辅助方法 + # ────────────────────────────────────────────── + + @staticmethod + def _extract_channel_name(res) -> Optional[str]: + """从 BatteryTestPosition 或通用 Resource 中提取 Channel_Name (devid-subdevid-chlid)""" + # 情况1: ResourceSlot 对象 —— 直接读 _unilabos_state + state = getattr(res, "_unilabos_state", None) + if isinstance(state, dict): + ch = state.get("Channel_Name") + if ch: + return str(ch) + # 情况2: serialize_state() + if hasattr(res, "serialize_state"): + try: + ss = res.serialize_state() + if isinstance(ss, dict): + ch = ss.get("Channel_Name") + if ch: + return str(ch) + except Exception: + pass + # 情况3: 来自 ResourceTreeSet.dump() 的 dict + if isinstance(res, dict): + data = res.get("data", {}) + if isinstance(data, dict): + ch = data.get("Channel_Name") + if ch: + return str(ch) + ch = res.get("name") or res.get("id") + if ch and len(str(ch).split("-")) == 3: + return str(ch) + # 情况4: name 本身就是 "devid-subdevid-chlid" + name = getattr(res, "name", "") + if name and len(name.split("-")) == 3: + return name + return None + + @staticmethod + def _extract_pole_weight(res) -> float: + """从电池资源 state 中提取极片称重 (mg)""" + state = getattr(res, "_unilabos_state", None) + if isinstance(state, dict) and "pole_weight" in state: + return float(state["pole_weight"]) + if hasattr(res, "serialize_state"): + try: + ss = res.serialize_state() + if isinstance(ss, dict) and "pole_weight" in ss: + return float(ss["pole_weight"]) + except Exception: + pass + if isinstance(res, dict): + data = res.get("data", {}) + if isinstance(data, dict) and "pole_weight" in data: + return float(data["pole_weight"]) + return 0.0 + + @staticmethod + def _parse_active_material(val) -> float: + """解析活性物质含量,支持 0.97 或 '97%' 两种格式""" + if isinstance(val, str): + val = val.strip() + if val.endswith("%"): + return float(val[:-1]) / 100.0 + return float(val) + return float(val) + + # ────────────────────────────────────────────── + # test 动作:下发测试 + # ────────────────────────────────────────────── + + async def submit_auto_export_excel( + self, + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + pole_weight: List[float] = None, + coin_cell_code: List[str] = None, + electrolyte_code: List[str] = None, + resource: List[ResourceSlot] = None, + output_dir: str = "D:\\2604Agentic_test", + ) -> dict: + """ + 对每颗电池计算测试参数、生成 XML 工步文件并通过 TCP 下发给新威测试仪。 + + 循环长度由 mount_resource 驱动(真正要下发的通道数量)。 + + Args: + mount_resource: 新威测试通道 —— 目标通道资源列表(含 Channel_Name = devid-subdevid-chlid),循环长度来源 + collector_mass: 各电池集流体质量 (mg) + active_material: 各电池活性物质比例(0.97 或 "97%") + capacity: 各电池克容量 (mAh/g) + battery_system: xml 工步标识(如 "811_LI_002") + pole_weight: 各电池极片质量 (mg),来自上游 manual_confirm 的透传;为空时回退到从 resource 状态提取 + coin_cell_code: 各电池条码(来自上游 manual_confirm 从 assembly_data 解包);作为 Neware 备份文件的 CoinID/barcode + resource: 扣电组装物料系统(无需选择)—— 由系统自动管理的扣电资源列表;仅在 coin_cell_code 与 pole_weight 均未提供时作为回退 + """ + import importlib + gen_mod = importlib.import_module( + "unilabos.devices.neware_battery_test_system.generate_xml_content" + ) + from .neware_driver import start_test as _start_test + + resource = resource or [] + pole_weight = pole_weight or [] + coin_cell_code = coin_cell_code or [] + electrolyte_code = electrolyte_code or [] + + n = len(mount_resource) if mount_resource else 0 + results = [] + submitted = 0 + + if n == 0: + msg = "mount_resource 为空,没有通道可下发" + if self._ros_node: + self._ros_node.lab_logger().warning(f"[test] {msg}") + return { + "return_info": f"共 0 颗电池,成功下发 0 颗({msg})", + "success": False, + "submitted_count": 0, + "total_count": 0, + "results": [], + } + + xml_dir = os.path.join(output_dir, "xml_dir") + os.makedirs(xml_dir, exist_ok=True) + backup_dir = os.path.join(output_dir, "backup_dir") + os.makedirs(backup_dir, exist_ok=True) + + for i in range(n): + try: + # 1. 解析通道地址 + ch_name = self._extract_channel_name(mount_resource[i]) + if not ch_name: + raise ValueError(f"无法从 mount_resource[{i}] 提取 Channel_Name") + parts = ch_name.split("-") + if len(parts) != 3: + raise ValueError(f"Channel_Name 格式错误,期望 devid-subdevid-chlid,实际: {ch_name}") + devid, subdevid, chlid = int(parts[0]), int(parts[1]), int(parts[2]) + + # 2. 获取电池标识与极片重量(按优先级 coin_cell_code > resource > 兜底) + res = resource[i] if i < len(resource) else None + base_coin = ( + (coin_cell_code[i] if i < len(coin_cell_code) and coin_cell_code[i] else None) + or (getattr(res, "name", None) if res is not None else None) + or (res.get("name") if isinstance(res, dict) else None) + or f"battery_{i}" + ) + elec_code = electrolyte_code[i] if i < len(electrolyte_code) and electrolyte_code[i] else "" + coin_id = f"{base_coin}-{elec_code}-{devid}-{subdevid}-{chlid}" + if pole_weight and i < len(pole_weight): + pw = float(pole_weight[i]) + elif res is not None: + pw = self._extract_pole_weight(res) + else: + raise ValueError(f"无法获取 pole_weight:pole_weight 列表长度不足 且 resource 为空") + + # 3. 计算活性物质质量与容量 + cm = float(collector_mass[i]) + amv = self._parse_active_material(active_material[i]) + sc = float(capacity[i]) + act_mass = round((pw - cm) * amv, 4) + if act_mass <= 0: + raise ValueError( + f"活性物质质量异常: pole_weight={pw}mg, collector_mass={cm}mg, " + f"active_material={amv}, act_mass={act_mass}" + ) + cap_mAh = round(act_mass * sc / 1000.0, 4) + if cap_mAh <= 0: + raise ValueError(f"容量计算异常: act_mass={act_mass}mg, capacity={sc}mAh/g, cap_mAh={cap_mAh}") + + # 4. 生成 XML 工步文件 + key = self._canon(battery_system[i]) + builder = self._get_xml_builder(gen_mod, key) + req_args = self._get_builder_required_positional_count(builder) + xml_content = builder(act_mass, cap_mAh) if req_args >= 2 else builder() + recipe_path = os.path.join(xml_dir, f"{coin_id}_{devid}_{subdevid}_{chlid}.xml") + self._save_xml(xml_content, recipe_path) + + # 5. TCP 下发测试 + resp = _start_test( + ip=self.ip, + port=int(self.port), + devid=devid, + subdevid=subdevid, + chlid=chlid, + CoinID=coin_id, + recipe_path=recipe_path, + backup_dir=backup_dir, + filetype=1, + ) + submitted += 1 + results.append({ + "index": i, + "coin_id": coin_id, + "channel": ch_name, + "act_mass_mg": act_mass, + "cap_mAh": cap_mAh, + "success": True, + "response": str(resp)[:300], + }) + if self._ros_node: + self._ros_node.lab_logger().info( + f"[test] 已下发 {coin_id} → {ch_name} " + f"act_mass={act_mass}mg cap={cap_mAh}mAh" + ) + + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"[test] 电池[{i}] 下发失败: {e}") + results.append({"index": i, "success": False, "error": str(e)}) + + summary = f"共 {n} 颗电池,成功下发 {submitted} 颗" + return { + "return_info": summary, + "success": submitted > 0, + "submitted_count": submitted, + "total_count": n, + "results": results, + } # ======================== # 示例和测试代码 diff --git a/unilabos/devices/neware_battery_test_system/neware_driver.py b/unilabos/devices/neware_battery_test_system/neware_driver.py new file mode 100644 index 000000000..a491bc71e --- /dev/null +++ b/unilabos/devices/neware_battery_test_system/neware_driver.py @@ -0,0 +1,56 @@ +import socket +END_MARKS = [b"\r\n#\r\n", b""] # 读到任一标志即可判定完整响应 + +def build_start_command(devid, subdevid, chlid, CoinID, + ip_in_xml="127.0.0.1", + devtype:int=27, + recipe_path:str=f"D:\\HHM_test\\A001.xml", + backup_dir:str=f"D:\\HHM_test\\backup", + filetype:int=1) -> str: + """ + filetype: 备份文件类型。0=NDA(新威原生),1=Excel。默认 1。 + """ + lines = [ + '', + '', + ' start', + ' ', + f' {recipe_path}', + f' ', + ' ', + '', + ] + # TCP 模式:请求必须以 #\r\n 结束(协议要求) + return "\r\n".join(lines) + "\r\n#\r\n" + +def recv_until_marks(sock: socket.socket, timeout=60): + sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2} + buf = bytearray() + while True: + chunk = sock.recv(8192) + if not chunk: + break + buf += chunk + # 读到结束标志就停,避免等对端断开 + for m in END_MARKS: + if m in buf: + return bytes(buf) + # 保险:读到完整 XML 结束标签也停 + if b"" in buf: + return bytes(buf) + return bytes(buf) + +def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1): + """ + filetype: 备份文件类型,0=NDA,1=Excel。默认 1。 + """ + xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype) + #print(xml_cmd) + with socket.create_connection((ip, port), timeout=60) as s: + s.sendall(xml_cmd.encode("utf-8")) + data = recv_until_marks(s, timeout=60) + return data.decode("utf-8", errors="replace") + +if __name__ == "__main__": + resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup") + print(resp) diff --git a/unilabos/devices/virtual/virtual_multiway_valve.py b/unilabos/devices/virtual/virtual_multiway_valve.py index 1512f33df..b6a95ddfe 100644 --- a/unilabos/devices/virtual/virtual_multiway_valve.py +++ b/unilabos/devices/virtual/virtual_multiway_valve.py @@ -2,6 +2,8 @@ import logging from typing import Union, Dict, Optional +from unilabos.registry.decorators import topic_config + class VirtualMultiwayValve: """ @@ -41,13 +43,11 @@ def current_position(self) -> int: def target_position(self) -> int: return self._target_position - def get_current_position(self) -> int: - """获取当前阀门位置 📍""" - return self._current_position - - def get_current_port(self) -> str: - """获取当前连接的端口名称 🔌""" - return self._current_position + @property + @topic_config() + def current_port(self) -> str: + """当前连接的端口名称 🔌""" + return self.port def set_position(self, command: Union[int, str]): """ @@ -169,12 +169,14 @@ def close(self): self._status = "Idle" self._valve_state = "Closed" - close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})" + close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})" self.logger.info(close_msg) return close_msg - def get_valve_position(self) -> int: - """获取阀门位置 - 兼容性方法 📍""" + @property + @topic_config() + def valve_position(self) -> int: + """阀门位置 📍""" return self._current_position def set_valve_position(self, command: Union[int, str]): @@ -229,19 +231,16 @@ def switch_between_pump_and_port(self, port_number: int): self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...") return self.set_to_pump_position() - def get_flow_path(self) -> str: - """获取当前流路路径描述 🌊""" - current_port = self.get_current_port() + @property + @topic_config() + def flow_path(self) -> str: + """当前流路路径描述 🌊""" if self._current_position == 0: - flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})" - else: - flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})" - - # 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}") - return flow_path + return f"🚰 转移泵已连接 (位置 {self._current_position})" + return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})" def __str__(self): - current_port = self.get_current_port() + current_port = self.current_port status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌" return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})" @@ -253,7 +252,7 @@ def __str__(self): print("🔄 === 虚拟九通阀门测试 === ✨") print(f"🏠 初始状态: {valve}") - print(f"🌊 当前流路: {valve.get_flow_path()}") + print(f"🌊 当前流路: {valve.flow_path}") # 切换到试剂瓶1(1号位) print(f"\n🔌 切换到1号位: {valve.set_position(1)}") diff --git a/unilabos/devices/virtual/virtual_stirrer.py b/unilabos/devices/virtual/virtual_stirrer.py index 8e95617f8..5bd4b9e1c 100644 --- a/unilabos/devices/virtual/virtual_stirrer.py +++ b/unilabos/devices/virtual/virtual_stirrer.py @@ -3,6 +3,7 @@ import time as time_module from typing import Dict, Any +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class VirtualStirrer: @@ -314,9 +315,11 @@ def max_speed(self) -> float: def min_speed(self) -> float: return self._min_speed - def get_device_info(self) -> Dict[str, Any]: - """获取设备状态信息 📊""" - info = { + @property + @topic_config() + def device_info(self) -> Dict[str, Any]: + """设备状态快照信息 📊""" + return { "device_id": self.device_id, "status": self.status, "operation_mode": self.operation_mode, @@ -325,12 +328,9 @@ def get_device_info(self) -> Dict[str, Any]: "is_stirring": self.is_stirring, "remaining_time": self.remaining_time, "max_speed": self._max_speed, - "min_speed": self._min_speed + "min_speed": self._min_speed, } - - # self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}") - return info - + def __str__(self): status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌" return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" \ No newline at end of file diff --git a/unilabos/devices/virtual/virtual_transferpump.py b/unilabos/devices/virtual/virtual_transferpump.py index 2d3c9d8ba..f7b24f18d 100644 --- a/unilabos/devices/virtual/virtual_transferpump.py +++ b/unilabos/devices/virtual/virtual_transferpump.py @@ -4,6 +4,7 @@ from typing import Union, Optional import logging +from unilabos.registry.decorators import topic_config from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -385,8 +386,10 @@ def get_current_volume(self) -> float: """获取当前体积""" return self._current_volume - def get_remaining_capacity(self) -> float: - """获取剩余容量""" + @property + @topic_config() + def remaining_capacity(self) -> float: + """剩余容量 (ml)""" return self.max_volume - self._current_volume def is_empty(self) -> bool: diff --git a/unilabos/devices/virtual/workbench.py b/unilabos/devices/virtual/workbench.py index d67db3985..80ae1bdf0 100644 --- a/unilabos/devices/virtual/workbench.py +++ b/unilabos/devices/virtual/workbench.py @@ -14,19 +14,30 @@ import logging import time -from typing import Dict, Any, Optional, List from dataclasses import dataclass from enum import Enum from threading import Lock, RLock +from typing import Any, Dict, List, Optional, cast from typing_extensions import TypedDict from unilabos.registry.decorators import ( - device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action + ActionInputHandle, + ActionOutputHandle, + DataSource, + NodeType, + action, + device, + not_action, + topic_config, +) +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode +from unilabos.resources.resource_tracker import ( + SampleUUIDsType, + LabSample, + ResourceTreeSet, ) -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode -from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample - # ============ TypedDict 返回类型定义 ============ @@ -111,6 +122,7 @@ class HeatingStation: @device( id="virtual_workbench", + display_name="虚拟工作台", category=["virtual_device"], description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", ) @@ -136,7 +148,19 @@ class VirtualWorkbench: HEATING_TIME: float = 60.0 # 加热时间(秒) NUM_HEATING_STATIONS: int = 3 # 加热台数量 - def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, + device_id: Optional[str] = None, + config: Optional[Dict[str, Any]] = None, + **kwargs, + ): + """ + 初始化虚拟工作台。 + + Args: + device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。 + config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。 + """ # 处理可能的不同调用方式 if device_id is None and "id" in kwargs: device_id = kwargs.pop("id") @@ -150,9 +174,13 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A self.data: Dict[str, Any] = {} # 从config中获取可配置参数 - self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)) + self.ARM_OPERATION_TIME = float( + self.config.get("arm_operation_time", self.ARM_OPERATION_TIME) + ) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME)) - self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)) + self.NUM_HEATING_STATIONS = int( + self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS) + ) # 机械臂状态和锁 self._arm_lock = Lock() @@ -161,7 +189,8 @@ def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, A # 加热台状态 self._heating_stations: Dict[int, HeatingStation] = { - i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1) + i: HeatingStation(station_id=i) + for i in range(1, self.NUM_HEATING_STATIONS + 1) } self._stations_lock = RLock() @@ -290,20 +319,292 @@ def _release_arm(self): self._update_data_status(f"机械臂已释放 (完成: {task})") self.logger.info(f"机械臂已释放 (完成: {task})") + @action( + always_free=True, + node_type=NodeType.MANUAL_CONFIRM, + placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}, + goal_default={"timeout_seconds": 3600, "assignee_user_ids": []}, + feedback_interval=300, + handles=[ + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), + # transfer使用 + ActionOutputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource.@flatten", + data_source=DataSource.EXECUTOR, + ), + # test使用 + ActionOutputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.EXECUTOR, + ), + ], + ) + def manual_confirm( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: List[str], + timeout_seconds: int, + assignee_user_ids: list[str], + **kwargs, + ) -> dict: + """ + 人工确认资源转移和扣电测试参数。 + + Args: + resource[待转移资源]: 需要人工确认的资源列表。 + target_device[目标设备]: 资源要转移到的目标设备 ID。 + mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + timeout_seconds[超时时间]: 人工确认超时时间,单位秒。 + assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。 + + Note: + 修改的结果无效,是只读的。 + """ + resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump() + mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump() + kwargs.update(locals()) + kwargs.pop("kwargs") + kwargs.pop("self") + kwargs["resource"] = resource_tree + kwargs["mount_resource"] = mount_resource_tree + kwargs.pop("resource_tree") + kwargs.pop("mount_resource_tree") + return kwargs + + @action( + description="转移物料", + handles=[ + ActionInputHandle( + key="target_device", + data_type="device_id", + label="目标设备", + data_key="target_device", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ], + ) + async def transfer( + self, + resource: List[ResourceSlot], + target_device: DeviceSlot, + mount_resource: List[ResourceSlot], + ): + """ + 转移资源到目标设备。 + + Args: + resource[待转移资源]: 待转移的资源列表。 + target_device[目标设备]: 接收资源的目标设备 ID。 + mount_resource[目标孔位]: 目标设备上的挂载孔位列表。 + """ + future = ROS2DeviceNode.run_async_func( + self._ros_node.transfer_resource_to_another, + True, + **{ + "plr_resources": resource, + "target_device_id": target_device, + "target_resources": mount_resource, + "sites": [None] * len(mount_resource), + }, + ) + result = await future + return result + + @action( + description="扣电测试启动", + handles=[ + ActionInputHandle( + key="resource", + data_type="resource", + label="待转移资源", + data_key="resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="mount_resource", + data_type="resource", + label="目标孔位", + data_key="mount_resource", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="collector_mass", + data_type="collector_mass", + label="极流体质量", + data_key="collector_mass", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="active_material", + data_type="active_material", + label="活性物质含量", + data_key="active_material", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="capacity", + data_type="capacity", + label="克容量", + data_key="capacity", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="battery_system", + data_type="battery_system", + label="电池体系", + data_key="battery_system", + data_source=DataSource.HANDLE, + ), + ], + ) + async def test( + self, + resource: List[ResourceSlot], + mount_resource: List[ResourceSlot], + collector_mass: List[float], + active_material: List[float], + capacity: List[float], + battery_system: list[str], + ): + """ + 启动扣电测试。 + + Args: + resource[待测试资源]: 需要进行扣电测试的资源列表。 + mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。 + collector_mass[极流体质量]: 每个样品对应的极流体质量。 + active_material[活性物质含量]: 每个样品对应的活性物质含量。 + capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。 + battery_system[电池体系]: 每个样品对应的电池体系名称。 + """ + print(resource) + print(mount_resource) + print(collector_mass) + print(active_material) + print(capacity) + print(battery_system) + @action( auto_prefix=True, description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", handles=[ - ActionOutputHandle(key="channel_1", data_type="workbench_material", - label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_2", data_type="workbench_material", - label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_3", data_type="workbench_material", - label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_4", data_type="workbench_material", - label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="channel_5", data_type="workbench_material", - label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), + ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501 + ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501 ], ) def prepare_materials( @@ -316,6 +617,9 @@ def prepare_materials( 作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。 输出5个handle (material_1 ~ material_5), 分别对应实验1~5。 + + Args: + count[物料数量]: 要生成的物料数量,默认生成 5 个。 """ materials = [i for i in range(1, count + 1)] @@ -336,7 +640,11 @@ def prepare_materials( LabSample( sample_uuid=sample_uuid, oss_path="", - extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}), + extra=( + {"material_uuid": content} + if isinstance(content, str) + else (content.serialize() if content else {}) + ), ) for sample_uuid, content in sample_uuids.items() ], @@ -346,12 +654,27 @@ def prepare_materials( auto_prefix=True, description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID", handles=[ - ActionInputHandle(key="material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_station_output", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="material_number_output", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_station_output", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="material_number_output", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def move_to_heating_station( @@ -363,6 +686,9 @@ def move_to_heating_station( 将物料从An位置移动到加热台 多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台 + + Args: + material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。 """ material_id = f"A{material_number}" task_desc = f"移动{material_id}到加热台" @@ -425,7 +751,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -448,7 +775,8 @@ def move_to_heating_station( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -460,14 +788,34 @@ def move_to_heating_station( always_free=True, description="启动指定加热台的加热程序", handles=[ - ActionInputHandle(key="station_id_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="material_number_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), - ActionOutputHandle(key="heating_done_station", data_type="workbench_station", - label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), - ActionOutputHandle(key="heating_done_material", data_type="workbench_material", - label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), + ActionInputHandle( + key="station_id_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="material_number_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), + ActionOutputHandle( + key="heating_done_station", + data_type="workbench_station", + label="加热完成-加热台ID", + data_key="station_id", + data_source=DataSource.EXECUTOR, + ), + ActionOutputHandle( + key="heating_done_material", + data_type="workbench_material", + label="加热完成-物料编号", + data_key="material_number", + data_source=DataSource.EXECUTOR, + ), ], ) def start_heating( @@ -478,6 +826,10 @@ def start_heating( ) -> StartHeatingResult: """ 启动指定加热台的加热程序 + + Args: + station_id[加热台ID]: 要启动加热的加热台编号。 + material_number[物料编号]: 当前加热台上的物料编号。 """ self.logger.info(f"[加热台{station_id}] 开始加热") @@ -494,7 +846,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -517,7 +870,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -537,7 +891,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -577,7 +932,9 @@ def start_heating( self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") if time.time() - last_countdown_log >= 5.0: - self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s") + self.logger.info( + f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s" + ) last_countdown_log = time.time() if elapsed >= self.HEATING_TIME: @@ -594,7 +951,9 @@ def start_heating( self._active_tasks[material_id]["status"] = "heating_completed" self._update_data_status(f"加热台{station_id}加热完成") - self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") + self.logger.info( + f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)" + ) return { "success": True, @@ -608,7 +967,8 @@ def start_heating( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -619,10 +979,20 @@ def start_heating( auto_prefix=True, description="将物料从加热台移动到输出位置Cn", handles=[ - ActionInputHandle(key="output_station_input", data_type="workbench_station", - label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), - ActionInputHandle(key="output_material_input", data_type="workbench_material", - label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), + ActionInputHandle( + key="output_station_input", + data_type="workbench_station", + label="加热台ID", + data_key="station_id", + data_source=DataSource.HANDLE, + ), + ActionInputHandle( + key="output_material_input", + data_type="workbench_material", + label="物料编号", + data_key="material_number", + data_source=DataSource.HANDLE, + ), ], ) def move_to_output( @@ -633,6 +1003,10 @@ def move_to_output( ) -> MoveToOutputResult: """ 将物料从加热台移动到输出位置Cn + + Args: + station_id[加热台ID]: 已完成加热的加热台编号。 + material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。 """ output_number = material_number @@ -649,7 +1023,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -673,7 +1048,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -693,7 +1069,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() @@ -775,7 +1152,8 @@ def move_to_output( oss_path="", extra=( {"material_uuid": content} - if isinstance(content, str) else (content.serialize() if content else {}) + if isinstance(content, str) + else (content.serialize() if content else {}) ), ) for sample_uuid, content in sample_uuids.items() diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/202601091.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/202601091.xlsx deleted file mode 100644 index 9088a16ae..000000000 Binary files a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/202601091.xlsx and /dev/null differ diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py index 333b7b28e..2ee255214 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from cgi import print_arguments from doctest import debug -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Tuple, Union import requests from pylabrobot.resources.resource import Resource as ResourcePLR +from pylabrobot.resources.carrier import ResourceHolder from pathlib import Path import pandas as pd import time @@ -11,13 +12,18 @@ import re import threading import json +import csv +import os +import uuid from copy import deepcopy from urllib3 import response from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation, BioyondResourceSynchronizer +from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException # ⚠️ config.py 已废弃 - 所有配置现在从 JSON 文件加载 # from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG, ... from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService -from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck +from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck, bioyond_electrolyte_deck +from unilabos.utils.datacore import push_csv_by_config from unilabos.utils.log import logger from unilabos.registry.registry import lab_registry @@ -107,12 +113,200 @@ def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, * t.start() logger.info("HTTP 服务线程已启动") - # 初始化订单报送事件 + # 初始化订单报送事件(配液 wait_for_order_finish 单值机制,原样保留) self.order_finish_event = threading.Event() self.last_order_status = None self.last_order_code = None - + + # ========== 订单 finish 缓存(配液 / 电导分库,防跨路径 pop 串扰)========== + # 共享锁;配液走 _order_finish_*,电导走 _cond_finish_*,互不 pop。 + # process_order_finish_report 末尾旁路同时写入两套缓存。 + self._order_finish_lock = threading.Lock() + self._order_finish_events: Dict[str, threading.Event] = {} + self._order_finish_reports: Dict[str, Dict[str, Any]] = {} + self._cond_finish_events: Dict[str, threading.Event] = {} + self._cond_finish_reports: Dict[str, Dict[str, Any]] = {} + + # ========== 配液/分液进度统计(2026-07-15 追加)========== + # 由 _submit_and_wait_orders 提交批次时重置,由 process_step_finish_report 累加。 + # 全部限定在本批 orderCode 集合内,避免跨批次/其他订单串入。 + # 另存 orderCode→orderId:真机/仿真机 orderCode 可能撞号,step 带 orderId 时双字段过滤。 + self._progress_lock = threading.Lock() + # 本批订单编号集合,以及每单预期分液瓶数(扣电?1:0 + 软包?1:0 + 电导?bottleCount:0) + self._batch_order_codes: set = set() + self._batch_order_ids: Dict[str, str] = {} # orderCode → orderId(建单返回) + self._batch_order_dispense: Dict[str, int] = {} + # 配液(按订单):分母 N、已收到「开始混匀」的订单、已计完成(混匀→三轴取)的订单 + self._formulation_total: int = 0 + self._formulation_mixed: set = set() + self._formulation_completed: set = set() + # 分液(按瓶):分母 Σ、已完成的 (orderCode, stepId) 集合 + self._dispense_total: int = 0 + self._dispense_done: set = set() + + # ========== 多任务互锁(wait_orderlist_idle)========== + # 同一把锁保护下面四个字段,避免门控扫描与建单写缓存竞态。 + self._interlock_lock = threading.Lock() + # 上一批已建成的订单:orderCode → orderId。门控核对它全部到 80/100 后才清空。 + self._interlock_batch: Dict[str, str] = {} + # 本批附加信息:scenario / 写入时间 / 逐单最终状态(供下一轮 blocked 日志点名失败单) + self._interlock_batch_meta: Dict[str, Any] = {} + # 放行凭证:门控放行时占据,_submit_and_wait_orders 写缓存时接管清除。 + # 没有它,门控 return 后到 LIMS 把订单登记成 60 之间有一段窗口, + # 第二个任务会看到「无 60 + 缓存空」而同样放行,两批同时建单击穿库位容量。 + self._interlock_claim: Optional[Dict[str, Any]] = None + # 建单未全部成功时会主动 scheduler_pause(),这里记下原因。 + # 否则门控看到 Pause 会当成「人工临时暂停」一路轮询到 24h 超时,掩盖真实原因。 + self._interlock_last_failure: Optional[Dict[str, Any]] = None + # 交接堆栈预约:stack_inquiry_2to1 放行时占据,transfer_3_to_2_to_1_auto 结束时释放。 + # 语义 =「本轮已查到 1号2号交接堆栈空、但 321 还没把板放进去」。共用 _interlock_lock。 + # 没有它,门控 return 后到 LIMS 库存显示占用之间有一段窗口(实测 380ms), + # 下一轮会同样看到「物理空」而放行,其 321 必被 LIMS 拒(详见 stack_inquiry_2to1)。 + self._handoff_claim: Optional[Dict[str, Any]] = None + logger.info(f"✅ BioyondCellWorkstation 初始化完成 (debug_mode={self.debug_mode})") + logger.info( + "提示:真机与仿真机 orderCode 可能撞号;finish/进度以 orderCode+orderId 双字段判定。" + "真机作业时请勿让仿真机 LIMS 推送到同一 HTTP 回调。" + ) + + # 奔曜全 0 GUID:电导建单偶发占位,此时无法用 orderId 强校验 + _EMPTY_ORDER_ID = "00000000-0000-0000-0000-000000000000" + + # 交接堆栈预约的兜底作废时长。预约只需覆盖「门控放行 → 321 放完板」这一段, + # 实测约 7s(放行 17:09:41.047 → 321 下发 17:09:41.247 → 转运完成 17:09:48.095), + # 10 分钟已是 80 倍余量。刻意不复用门控的 timeout(1h):正常路径由 + # transfer_3_to_2_to_1_auto 的 finally 主动释放,作废只兜「321 压根没被下发」这类 + # 异常(如云端因别轮失败停发了后续节点),此时预约挂多久就白挡多久,越短越好。 + _HANDOFF_CLAIM_TTL_SECONDS = 600.0 + + # ========== 仓库 whId 常量(来源:包含库位的仓库信息0610.json)========== + # 只用 whId 定位仓库:code「0018」同时是「适配器块」的 materialTypeCode,用 code 匹配会撞。 + # + # 3 号箱自动堆栈-左(name="自动堆栈-左", code="0008"),即 3→2→1 / 3→2 里的 "3"。 + # 转运接口只认这个仓库当来源,与报告 usedMaterials 里的 locationId(可能指向 + # 配液站内的临时槽位)无关。 + _WH_ID_AUTO_STACK_LEFT = "3a19debc-84b4-0359-e2d4-b3beea49348b" + # 1号2号手套箱交接堆栈(code=0016):**只有 1 个库位**,是 321 转运的瓶颈通道 + _WH_ID_TRANSFER_2TO1 = "3a1baa49-7f76-b88a-44d5-d478c48aae3e" + # 2号手套箱内部堆栈(code=0017):9 个库位,1→2 回流的落点,容量充足非瓶颈 + _WH_ID_STACK_2_INNER = "3a1baa4b-393d-ed16-5758-edec5cf20749" + # 5号自动传递窗(code=0018):**只有 4 个库位**,电导板一板一温度点,是限量通道 + _WH_ID_TRANSFER_WINDOW_5 = "3a1c68b5-65e1-f662-93bb-3c2c5b42744d" + # 5号右侧手动堆栈(code=0027):8 个库位,电导测完的落点,容量充足非瓶颈 + _WH_ID_MANUAL_STACK_5_RIGHT = "3a1c7325-8998-49f6-c5a4-d20c31d132e1" + + # 5 号自动传递窗物理库位数,电导一轮最多同时测这么多温度点 + _WINDOW_5_CAPACITY = 4 + + # ========== 场景键 → 配液类 workflowName 关键字(子串匹配)========== + # ⚠️ 仅用于分场景统计/日志,**不可**用来收窄互锁的阻断范围。 + # 配液共用同一台奔曜分液硬件,两批配液物理上不可能并行;若拿 "coin" 收窄, + # 扣电任务就会无视正在跑的大瓶配液单而放行,把第二批冲进同一台硬件。 + # + # 命名陷阱:`配液分液(小瓶)(电导)` 里的「(电导)」是 LIMS 历史遗留,它其实是**扣电**路 + # (源料 V05ML66 @ 小分液瓶堆栈)。而 `配液分液(大瓶)` 一对三,覆盖软包/电导/扣电+电导—— + # 实测场景 4 的 8 单全是大瓶,一个大瓶同时分出扣电的 5ml 与电导的 20ml 瓶。 + # 因此**不能用 workflowName 推断板型或链路**,判断是否走扣电路要看板型或 coin_cell_volume。 + SCENARIO_ORDER_KEYWORDS: Dict[str, List[str]] = { + "all": ["配液"], # 默认:站级全局,覆盖全部配液单 + "coin": ["配液分液(小瓶)"], # 场景1 配液+扣电 + "pouch": ["配液分液(大瓶)"], # 场景2 配液+软包 + "cond": ["配液分液(大瓶)"], # 场景3 配液+电导(场景4 同此,不单列键) + } + + # order-list **响应** items[].status:60 执行中 / 80 成功 / 90 失败 / 100 已取出 + _ORDER_STATUS_RUNNING_CODE = 60 + _ORDER_STATUS_FAILED_CODE = 90 + _ORDER_TERMINAL_OK = (80, 100) + + # ⚠️ order-list **请求** data.status 与上面**不是同一套枚举**:请求 60 = 未取出、 + # 请求 100 = 已取出,两者都可能对应响应里的 80 Succeed(实测见 `_paginate_order_list`)。 + # 故此处**故意不定义**请求侧状态常量——判状态一律请求侧传空串、只认响应 status 字段。 + + @staticmethod + def _normalize_order_id(order_id: Optional[str]) -> str: + return (order_id or "").strip() + + def _is_usable_order_id(self, order_id: Optional[str]) -> bool: + oid = self._normalize_order_id(order_id) + return bool(oid) and oid != self._EMPTY_ORDER_ID + + def _report_matches_expected( + self, + order_code: str, + expected_order_id: Optional[str], + report: Optional[Dict[str, Any]], + ) -> bool: + """finish 接受条件:orderCode 必须一致;expected_order_id 可用时 orderId 也必须一致。""" + if not report: + return False + if (report.get("orderCode") or "") != order_code: + return False + exp = self._normalize_order_id(expected_order_id) + if not self._is_usable_order_id(exp): + return True + return self._normalize_order_id(report.get("orderId")) == exp + + # 三类分液的 step 名 → 对应订单字段(分液完成信号) + # 三者均已从真机日志验证:电导分液 / 扣电分液见 0714~0722 批次; + # 软包分液于 2026-07-23 (orderCode=BSO2026072300007) 验证命中并正确计数。 + _DISPENSE_STEP_NAMES = ("电导分液", "扣电分液", "软包分液") + + def _reset_progress_tracking(self, orders: List[Dict[str, Any]], order_codes: List[str], data_list: List[Dict[str, Any]]): + """提交批次时重置配液/分液进度统计。 + + 按 orderName 把响应 data_list(含 orderCode)与入参 orders(含分液配置)关联, + 计算每单分液瓶数与两个分母。orderName 缺失时按索引兜底对齐。 + """ + # orderName → 订单分液配置 + cfg_by_name: Dict[str, Dict[str, Any]] = {} + for od in orders: + name = od.get("orderName") + if name: + cfg_by_name[name] = od + + def _num(v) -> float: + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + batch_codes: set = set() + batch_ids: Dict[str, str] = {} + dispense_map: Dict[str, int] = {} + for idx, item in enumerate(data_list): + code = item.get("orderCode") + if not code: + continue + name = item.get("orderName") + cfg = cfg_by_name.get(name) + if cfg is None and idx < len(orders): + cfg = orders[idx] # 索引兜底 + cfg = cfg or {} + coin = 1 if _num(cfg.get("loadSheddingInfo")) > 0 else 0 + pouch = 1 if _num(cfg.get("pouchCellInfo")) > 0 else 0 + cond = int(_num(cfg.get("conductivityBottleCount"))) if _num(cfg.get("conductivityInfo")) > 0 else 0 + batch_codes.add(code) + oid = self._normalize_order_id(item.get("orderId")) + if self._is_usable_order_id(oid): + batch_ids[code] = oid + dispense_map[code] = coin + pouch + cond + + with self._progress_lock: + self._batch_order_codes = batch_codes + self._batch_order_ids = batch_ids + self._batch_order_dispense = dispense_map + self._formulation_total = len(order_codes) + self._formulation_mixed = set() + self._formulation_completed = set() + self._dispense_total = sum(dispense_map.values()) + self._dispense_done = set() + + logger.info( + f"[进度统计] 重置:配液分母={len(order_codes)} 单,分液分母={sum(dispense_map.values())} 瓶," + f"本批订单={sorted(batch_codes)}, 已登记orderId={len(batch_ids)}" + ) @property def device_id(self): @@ -137,11 +331,97 @@ def _start_http_service(self): logger.error(f"启动 WorkstationHTTPService 失败: {e}", exc_info=True) + # ========== 配液/分液进度属性(前端可轮询,对齐扣电站 data_order_completion_percentage)========== + @property + def data_formulation_total_count(self) -> int: + """本批配液订单总数(配液进度分母)""" + return self._formulation_total + + @property + def data_formulation_completed_count(self) -> int: + """已完成配液(混匀→三轴取)的订单数(配液进度分子)""" + return len(self._formulation_completed) + + @property + def data_formulation_completion_percentage(self) -> float: + """配液完成百分比 (%)""" + try: + total = self._formulation_total + if total <= 0: + return 0.0 + return round(len(self._formulation_completed) / total * 100.0, 2) + except Exception as e: + logger.warning(f"计算配液完成百分比失败,返回 0.0: {e}") + return 0.0 + + @property + def data_dispense_total_bottles(self) -> int: + """本批分液瓶总数 = Σ(扣电?1:0 + 软包?1:0 + 电导?bottleCount:0)(分液进度分母)""" + return self._dispense_total + + @property + def data_dispense_completed_bottles(self) -> int: + """已完成分液的瓶数(分液进度分子)""" + return min(len(self._dispense_done), self._dispense_total) if self._dispense_total > 0 else len(self._dispense_done) + + @property + def data_dispense_completion_percentage(self) -> float: + """分液完成百分比 (%)""" + try: + total = self._dispense_total + if total <= 0: + return 0.0 + return round(min(len(self._dispense_done), total) / total * 100.0, 2) + except Exception as e: + logger.warning(f"计算分液完成百分比失败,返回 0.0: {e}") + return 0.0 + # http报送服务,返回数据部分 def process_step_finish_report(self, report_request): - stepId = report_request.data.get("stepId") - logger.info(f"步骤完成: stepId: {stepId}, stepName:{report_request.data.get('stepName')}") - return report_request.data.get('executionStatus') + data = report_request.data + stepId = data.get("stepId") + stepName = data.get("stepName") + orderCode = data.get("orderCode") + orderId = self._normalize_order_id(data.get("orderId")) + logger.info(f"步骤完成: stepId: {stepId}, stepName:{stepName}") + + # 仅统计本批订单;有 orderId 时与建单双字段校验,挡真机/仿真撞号 + try: + if orderCode and orderCode in self._batch_order_codes: + expected_oid = self._batch_order_ids.get(orderCode) + if ( + self._is_usable_order_id(expected_oid) + and self._is_usable_order_id(orderId) + and orderId != expected_oid + ): + logger.warning( + f"[进度统计] 忽略异 orderId 步骤: orderCode={orderCode}, " + f"期望={expected_oid[:8]}..., 报文={orderId[:8]}..., step={stepName}" + ) + else: + with self._progress_lock: + if stepName == "开始混匀": + self._formulation_mixed.add(orderCode) + elif stepName == "三轴取": + # 混匀完成信号:同一订单先「开始混匀」再「三轴取」 + if orderCode in self._formulation_mixed and orderCode not in self._formulation_completed: + self._formulation_completed.add(orderCode) + logger.info( + f"[进度统计] 配液完成 {len(self._formulation_completed)}/{self._formulation_total} " + f"({self.data_formulation_completion_percentage}%) orderCode={orderCode}" + ) + elif stepName in self._DISPENSE_STEP_NAMES: + key = (orderCode, stepId) + if key not in self._dispense_done: + self._dispense_done.add(key) + logger.info( + f"[进度统计] 分液完成 {self.data_dispense_completed_bottles}/{self._dispense_total} " + f"({self.data_dispense_completion_percentage}%) orderCode={orderCode} step={stepName}" + ) + except Exception as e: + logger.warning(f"[进度统计] 更新失败(不影响报送处理): {e}") + + return data.get('executionStatus') def process_sample_finish_report(self, report_request): logger.info(f"通量完成: {report_request.data.get('sampleId')}") @@ -149,22 +429,24 @@ def process_sample_finish_report(self, report_request): def process_order_finish_report(self, report_request, used_materials=None): order_code = report_request.data.get("orderCode") + order_id = report_request.data.get("orderId") status = report_request.data.get("status") # 🔍 详细调试日志 logger.info(f"[DEBUG] ========== 收到 order_finish 报送 ==========") logger.info(f"[DEBUG] 报送的 orderCode: '{order_code}' (type: {type(order_code).__name__})") + logger.info(f"[DEBUG] 报送的 orderId: '{order_id}'") logger.info(f"[DEBUG] 当前等待的 last_order_code: '{self.last_order_code}' (type: {type(self.last_order_code).__name__})") logger.info(f"[DEBUG] 报送状态: {status}") logger.info(f"[DEBUG] orderCode 是否匹配: {self.last_order_code == order_code}") logger.info(f"[DEBUG] Event 当前状态 (触发前): is_set={self.order_finish_event.is_set()}") logger.info(f"report_request: {report_request}") - logger.info(f"任务完成: {order_code}, status={status}") + logger.info(f"任务完成: {order_code}, orderId={order_id}, status={status}") # 保存完整报文 self.last_order_report = report_request.data - # 如果是当前等待的订单,触发事件 + # 如果是当前等待的订单,触发事件(最终是否接受由 wait 侧 orderCode+orderId 双校验决定) if self.last_order_code == order_code: logger.info(f"[DEBUG] ✅ orderCode 匹配!触发 order_finish_event") self.order_finish_event.set() @@ -175,14 +457,91 @@ def process_order_finish_report(self, report_request, used_materials=None): logger.warning(f"[DEBUG] 实际: '{order_code}'") logger.info(f"[DEBUG] ========================================") + + # ========== finish 旁路缓存(配液 + 电导分库,互不 pop)========== + # 配液 wait_for_order_finish 读 _order_finish_*; + # 电导 _wait_conductivity_finish 读 _cond_finish_*。 + # 注意:真机/仿真机 orderCode 可能撞号,wait 侧会再用 orderId 过滤。 + if order_code: + with self._order_finish_lock: + self._order_finish_reports[order_code] = report_request.data + ev = self._order_finish_events.get(order_code) + if ev is not None: + ev.set() + self._cond_finish_reports[order_code] = report_request.data + cond_ev = self._cond_finish_events.get(order_code) + if cond_ev is not None: + cond_ev.set() + return {"status": "received"} - def wait_for_order_finish(self, order_code: str, timeout: int = 36000) -> Dict[str, Any]: + def _classify_finish_report( + self, + order_code: str, + report: Dict[str, Any], + expected_order_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 把 LIMS finish 报文按 status 字段映射为 wait 函数的统一返回格式。 + - 30 → success,-11 → abnormal_stop,-12 → manual_stop,其他 → unknown_ + - 报文 orderCode 必须与等待 orderCode 一致;expected_order_id 可用时 orderId 也必须一致 + """ + if not report: + logger.warning(f"[wait_for_order_finish] 报文为空: orderCode={order_code}") + return {"status": "mismatch", "report": {}} + + report_code = report.get("orderCode") + status_raw = str(report.get("status", "")) + + if report_code != order_code: + logger.warning( + f"[wait_for_order_finish] 报文 orderCode 与请求不一致: " + f"报文={report_code} ≠ 请求={order_code}" + ) + return {"status": "mismatch", "report": report} + + if not self._report_matches_expected(order_code, expected_order_id, report): + logger.error( + f"[wait_for_order_finish] 报文 orderId 与期望不一致(疑似真机/仿真撞号): " + f"orderCode={order_code}, 期望={expected_order_id}, " + f"报文={report.get('orderId')}, orderName={report.get('orderName')}" + ) + return {"status": "mismatch", "report": report} + + if status_raw == "30": + logger.info( + f"[wait_for_order_finish] ✓ 任务成功 " + f"(orderCode={order_code}, orderId={report.get('orderId')})" + ) + return {"status": "success", "report": report} + elif status_raw == "-11": + logger.error(f"[wait_for_order_finish] ✗ 任务异常停止 (orderCode={order_code})") + return {"status": "abnormal_stop", "report": report} + elif status_raw == "-12": + logger.warning(f"[wait_for_order_finish] 任务人工停止 (orderCode={order_code})") + return {"status": "manual_stop", "report": report} + else: + logger.warning( + f"[wait_for_order_finish] 任务未知状态 status={status_raw} (orderCode={order_code})" + ) + return {"status": f"unknown_{status_raw}", "report": report} + + def wait_for_order_finish( + self, + order_code: str, + timeout: int = 36000, + expected_order_id: Optional[str] = None, + ) -> Dict[str, Any]: """ 等待指定 orderCode 的 /report/order_finish 报送。 + + 接受条件:orderCode 匹配,且(若 expected_order_id 可用)orderId 也匹配。 + 同 orderCode、异 orderId 的报文视为真机/仿真撞号,拒绝并继续等待,直到超时或命中正确单。 + Args: order_code: 任务编号 timeout: 超时时间(秒) + expected_order_id: 建单返回的 orderId;可用时与 finish 双字段校验 Returns: 完整的报送数据 + 状态判断结果 """ @@ -190,39 +549,78 @@ def wait_for_order_finish(self, order_code: str, timeout: int = 36000) -> Dict[s logger.error("wait_for_order_finish() 被调用,但 order_code 为空!") return {"status": "error", "message": "empty order_code"} + # 兼容旧单值机制(调试日志 / wait_for_order_finish_polling) self.last_order_code = order_code self.last_order_report = None self.order_finish_event.clear() - logger.info(f"等待任务完成报送: orderCode={order_code} (timeout={timeout}s)") + deadline = time.monotonic() + max(float(timeout), 0.0) + exp_oid = self._normalize_order_id(expected_order_id) - if not self.order_finish_event.wait(timeout=timeout): - logger.error(f"等待任务超时: orderCode={order_code}") - return {"status": "timeout", "orderCode": order_code} + with self._order_finish_lock: + ev = self._order_finish_events.get(order_code) + if ev is None: + ev = threading.Event() + self._order_finish_events[order_code] = ev + else: + ev.clear() - # 报送数据匹配验证 - report = self.last_order_report or {} - report_code = report.get("orderCode") - status = str(report.get("status", "")) + logger.info( + f"等待任务完成报送: orderCode={order_code}, " + f"expected_orderId={exp_oid or '<未校验>'} (timeout={timeout}s)" + ) - if report_code != order_code: - logger.warning(f"收到的报送 orderCode 不匹配: {report_code} ≠ {order_code}") - return {"status": "mismatch", "report": report} + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.error(f"等待任务超时: orderCode={order_code}") + return {"status": "timeout", "orderCode": order_code} + + with self._order_finish_lock: + report = self._order_finish_reports.pop(order_code, None) + + if report is None: + triggered = ev.wait(timeout=remaining) + with self._order_finish_lock: + report = self._order_finish_reports.pop(order_code, None) + if report is None: + if not triggered: + # 超时后再捞一次:报文可能刚好在 wait 返回与取锁之间到达 + with self._order_finish_lock: + report = self._order_finish_reports.pop(order_code, None) + if report is None: + logger.error(f"等待任务超时: orderCode={order_code}") + return {"status": "timeout", "orderCode": order_code} + else: + # Event 触发但缓存已被其他路径取走,继续等 + continue + ev.clear() + + if not self._report_matches_expected(order_code, exp_oid, report): + logger.error( + f"[配液wait] 拒绝异源/撞号 finish,继续等待: orderCode={order_code}, " + f"期望orderId={exp_oid or '<未校验>'}, " + f"报文orderId={report.get('orderId')}, " + f"orderName={report.get('orderName')}" + ) + continue - if status == "30": - logger.info(f"任务成功完成 (orderCode={order_code})") - return {"status": "success", "report": report} - elif status == "-11": - logger.error(f"任务异常停止 (orderCode={order_code})") - return {"status": "abnormal_stop", "report": report} - elif status == "-12": - logger.warning(f"任务人工停止 (orderCode={order_code})") - return {" status": "manual_stop", "report": report} - else: - logger.warning(f"任务未知状态 ({status}) (orderCode={order_code})") - return {"status": f"unknown_{status}", "report": report} + self.last_order_report = report + return self._classify_finish_report(order_code, report, expected_order_id=exp_oid) + finally: + with self._order_finish_lock: + cur = self._order_finish_events.get(order_code) + if cur is ev: + self._order_finish_events.pop(order_code, None) - def wait_for_order_finish_polling(self, order_code: str, timeout: int = 36000, poll_interval: float = 0.5) -> Dict[str, Any]: + def wait_for_order_finish_polling( + self, + order_code: str, + timeout: int = 36000, + poll_interval: float = 0.5, + expected_order_id: Optional[str] = None, + ) -> Dict[str, Any]: """ 等待指定 orderCode 的 /report/order_finish 报送(非阻塞轮询版本)。 @@ -230,11 +628,13 @@ def wait_for_order_finish_polling(self, order_code: str, timeout: int = 36000, p - 使用轮询而非阻塞等待,每隔 poll_interval 秒检查一次 - 允许 ROS2 在等待期间处理 feedback 消息 - 适用于长时间运行的 ROS2 Action + - 同样要求 orderCode +(可用时)orderId 双字段匹配 Args: order_code: 任务编号 timeout: 超时时间(秒) poll_interval: 轮询间隔(秒),默认 0.5 秒 + expected_order_id: 建单 orderId,可用时双字段校验 Returns: 完整的报送数据 + 状态判断结果 """ @@ -245,58 +645,348 @@ def wait_for_order_finish_polling(self, order_code: str, timeout: int = 36000, p self.last_order_code = order_code self.last_order_report = None self.order_finish_event.clear() + exp_oid = self._normalize_order_id(expected_order_id) - logger.info(f"[轮询模式] 等待任务完成报送: orderCode={order_code} (timeout={timeout}s, poll_interval={poll_interval}s)") - logger.info(f"[轮询模式] [DEBUG] last_order_code 已设置为: '{self.last_order_code}'") - logger.info(f"[轮询模式] [DEBUG] Event 初始状态: is_set={self.order_finish_event.is_set()}") + logger.info( + f"[轮询模式] 等待任务完成报送: orderCode={order_code}, " + f"expected_orderId={exp_oid or '<未校验>'} " + f"(timeout={timeout}s, poll_interval={poll_interval}s)" + ) start_time = time.time() poll_count = 0 - while not self.order_finish_event.is_set(): + while True: poll_count += 1 elapsed = time.time() - start_time - - # 每 10 次轮询(约 5 秒)输出一次状态 - if poll_count % 10 == 0: - logger.info(f"[轮询模式] [DEBUG] 轮询中... 已等待 {elapsed:.1f}s (第{poll_count}次检查)") - logger.info(f"[轮询模式] [DEBUG] Event.is_set() = {self.order_finish_event.is_set()}") - - # 检查是否超时 if elapsed > timeout: logger.error(f"[轮询模式] 等待任务超时: orderCode={order_code}") - logger.error(f"[轮询模式] [DEBUG] 总共轮询了 {poll_count} 次,耗时 {elapsed:.1f}s") return {"status": "timeout", "orderCode": order_code} - - # 短暂 sleep,让出控制权给 ROS2 处理 feedback + + report = None + with self._order_finish_lock: + report = self._order_finish_reports.pop(order_code, None) + + if report is None and self.order_finish_event.is_set(): + report = self.last_order_report + self.order_finish_event.clear() + + if report is not None: + if not self._report_matches_expected(order_code, exp_oid, report): + logger.error( + f"[轮询模式] 拒绝异源/撞号 finish,继续等待: orderCode={order_code}, " + f"期望orderId={exp_oid or '<未校验>'}, " + f"报文orderId={report.get('orderId')}, " + f"orderName={report.get('orderName')}" + ) + self.last_order_report = None + time.sleep(poll_interval) + continue + self.last_order_report = report + return self._classify_finish_report(order_code, report, expected_order_id=exp_oid) + + if poll_count % 10 == 0: + logger.info(f"[轮询模式] [DEBUG] 轮询中... 已等待 {elapsed:.1f}s (第{poll_count}次检查)") time.sleep(poll_interval) - # 事件已触发,获取报送数据 - logger.info(f"[轮询模式] [DEBUG] ✅ Event 已触发!共轮询 {poll_count} 次") - report = self.last_order_report or {} - report_code = report.get("orderCode") - status = str(report.get("status", "")) - - logger.info(f"[轮询模式] [DEBUG] 报送数据: orderCode='{report_code}', status={status}") + def _wait_conductivity_finish( + self, + order_code: str, + timeout: int = 36000, + expected_order_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + 电导专用:等待指定 orderCode 的 /report/order_finish 报送(并发安全)。 - # 报送数据匹配验证 - if report_code != order_code: - logger.warning(f"[轮询模式] 收到的报送 orderCode 不匹配: {report_code} ≠ {order_code}") - return {"status": "mismatch", "report": report} + 与 wait_for_order_finish(配液用)的关键区别: + - 使用独立的 _cond_finish_events / _cond_finish_reports,不与配液共享缓存 + - 推送先于 wait 调用时报文已缓存,wait 进来可直接命中 + - expected_order_id 可用时与 finish 双字段校验;不可用时要求报文 orderId 非空非全 0 + - 配液的 last_order_code / order_finish_event / _order_finish_* 完全不动 - # 状态判断 - if status == "30": - logger.info(f"[轮询模式] 任务成功完成 (orderCode={order_code})") - return {"status": "success", "report": report} - elif status == "-11": - logger.error(f"[轮询模式] 任务异常停止 (orderCode={order_code})") - return {"status": "abnormal_stop", "report": report} - elif status == "-12": - logger.warning(f"[轮询模式] 任务人工停止 (orderCode={order_code})") - return {"status": "manual_stop", "report": report} - else: - logger.warning(f"[轮询模式] 任务未知状态 ({status}) (orderCode={order_code})") - return {"status": f"unknown_{status}", "report": report} + Args: + order_code: LIMS 电导单号 (BSO...) + timeout: 超时时间(秒) + expected_order_id: 建单返回的 orderId(可能为全 0 占位) + Returns: + 同 wait_for_order_finish 的返回格式 + """ + if not order_code: + logger.error("_wait_conductivity_finish() 被调用,但 order_code 为空!") + return {"status": "error", "message": "empty order_code"} + + exp_oid = self._normalize_order_id(expected_order_id) + deadline = time.monotonic() + max(float(timeout), 0.0) + + with self._order_finish_lock: + ev = self._cond_finish_events.get(order_code) + if ev is None: + ev = threading.Event() + self._cond_finish_events[order_code] = ev + else: + ev.clear() + + logger.info( + f"[电导wait] 等待电导单完成: orderCode={order_code}, " + f"expected_orderId={exp_oid or '<未校验>'} (timeout={timeout}s)" + ) + + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.error(f"[电导wait] 等待电导单超时: orderCode={order_code}") + return {"status": "timeout", "orderCode": order_code} + + with self._order_finish_lock: + report = self._cond_finish_reports.pop(order_code, None) + + if report is None: + triggered = ev.wait(timeout=remaining) + with self._order_finish_lock: + report = self._cond_finish_reports.pop(order_code, None) + if report is None: + if not triggered: + with self._order_finish_lock: + report = self._cond_finish_reports.pop(order_code, None) + if report is None: + logger.error(f"[电导wait] 等待电导单超时: orderCode={order_code}") + return {"status": "timeout", "orderCode": order_code} + else: + continue + ev.clear() + + cached_oid = self._normalize_order_id(report.get("orderId")) + if not self._is_usable_order_id(cached_oid): + logger.warning( + f"[电导wait] 报文 orderId 无效({cached_oid!r})," + f"丢弃并继续等待: orderCode={order_code}" + ) + continue + + if not self._report_matches_expected(order_code, exp_oid, report): + logger.error( + f"[电导wait] 拒绝异源/撞号 finish,继续等待: orderCode={order_code}, " + f"期望orderId={exp_oid or '<未校验>'}, " + f"报文orderId={cached_oid}, orderName={report.get('orderName')}" + ) + continue + + return self._classify_finish_report(order_code, report, expected_order_id=exp_oid) + finally: + with self._order_finish_lock: + cur = self._cond_finish_events.get(order_code) + if cur is ev: + self._cond_finish_events.pop(order_code, None) + + def get_conductivity_order_result(self, order_id: str) -> Dict[str, Any]: + """ + 查询单条电导单的测试结果(电导率 / 温度等)。 + + 接口:POST /api/lims/order/conductivity-order-result + 请求体 data 直接是电导单的 orderId(GUID 字符串)。 + + Args: + order_id: 电导单 orderId(GUID) + + Returns: + LIMS 返回的 data 对象,结构形如: + { + "orderId": "...", "bottleId": "...", "bottleBarCode": "", + "boardId": "...", "boardBarCode": "CD2026062613", + "bottleInnerX": 1, "bottleInnerY": 1, "bottleInnerZ": 1, + "conductivity": 10, "conductivityUnit": "ms/cm", + "temperature": 10, "targetTemperature": 40 + } + 失败时返回 {}(并打 warning)。 + """ + if not order_id: + logger.warning("[电导结果] get_conductivity_order_result 被调用,但 order_id 为空") + return {} + resp = self._post_lims("/api/lims/order/conductivity-order-result", order_id) + if not isinstance(resp, dict) or resp.get("code") != 1: + logger.warning( + f"[电导结果] 查询失败 orderId={order_id}: {resp}" + ) + return {} + return resp.get("data") or {} + + def _collect_conductivity_results( + self, order_pairs: List[Tuple[str, str, str]] + ) -> List[Dict[str, Any]]: + """ + 逐个 (orderCode, resolved_oid, creation_oid) 调电导结果接口,标准化成行字典列表。 + + 先用 resolved_oid 查询;若返回空且 creation_oid 与之不同,则用 creation_oid + 兜底重试(防止消费过期 finish 报文导致 resolved_oid 用错)。 + Args: + order_pairs: [(orderCode, resolved_orderId, creation_orderId), ...] + + Returns: + [{ + "orderCode": str, "orderId": str, + "boardBarCode": str, "bottleBarCode": str, + "bottleInnerX": int, "bottleInnerY": int, "bottleInnerZ": int, + "conductivity": float, "conductivityUnit": str, + "temperature": float, "targetTemperature": float, + "report_time": "YYYY-MM-DD HH:MM:SS", + }, ...] + 查询失败的订单仍保留一行(数值字段留空 / None),便于排查。 + """ + EMPTY_GUID = "00000000-0000-0000-0000-000000000000" + results: List[Dict[str, Any]] = [] + for order_code, resolved_oid, creation_oid in order_pairs: + report_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + order_id = (resolved_oid or "").strip() + creation = (creation_oid or "").strip() + + if not order_id or order_id == EMPTY_GUID: + # resolved 无效时直接试 creation + if creation and creation != EMPTY_GUID: + logger.info( + f"[电导结果] orderCode={order_code} resolved_oid 无效," + f"改用 creation_oid={creation[:8]}... 查询" + ) + order_id = creation + else: + logger.warning( + f"[电导结果] orderCode={order_code} 的 orderId 无效" + f"(resolved={resolved_oid}, creation={creation_oid}),跳过结果查询" + ) + results.append({ + "orderCode": order_code, "orderId": order_id or "", + "boardBarCode": "", "bottleBarCode": "", + "bottleInnerX": None, "bottleInnerY": None, "bottleInnerZ": None, + "conductivity": None, "conductivityUnit": None, + "temperature": None, "targetTemperature": None, + "report_time": report_time, + }) + continue + + data = self.get_conductivity_order_result(order_id) + # 兜底:resolved 查空且 creation 不同,再用 creation 重试 + if ( + not data + and creation + and creation != EMPTY_GUID + and creation != order_id + ): + logger.info( + f"[电导结果] orderCode={order_code} resolved_oid={order_id[:8]}... " + f"查无数据,改用 creation_oid={creation[:8]}... 兜底重试" + ) + data = self.get_conductivity_order_result(creation) + if data: + order_id = creation + + results.append({ + "orderCode": order_code, + "orderId": order_id, + "boardBarCode": data.get("boardBarCode", "") or "", + "bottleBarCode": data.get("bottleBarCode", "") or "", + "bottleInnerX": data.get("bottleInnerX"), + "bottleInnerY": data.get("bottleInnerY"), + "bottleInnerZ": data.get("bottleInnerZ"), + "conductivity": data.get("conductivity"), + "conductivityUnit": data.get("conductivityUnit"), + "temperature": data.get("temperature"), + "targetTemperature": data.get("targetTemperature"), + "report_time": report_time, + }) + logger.info( + f"[电导结果] orderCode={order_code} orderId={order_id[:8]}...: " + f"board={data.get('boardBarCode')}, bottle={data.get('bottleBarCode')}, " + f"X={data.get('bottleInnerX')}, Y={data.get('bottleInnerY')}, " + f"cond={data.get('conductivity')}, T={data.get('temperature')}/" + f"{data.get('targetTemperature')}" + ) + return results + + def _wait_conductivity_and_resolve_ids( + self, + order_pairs: List[Tuple[str, str]], + wait_timeout_seconds: int, + tag: str = "conductivity", + ) -> Tuple[List[Tuple[str, str, str]], Dict[str, int]]: + """ + 逐单阻塞等 /report/order_finish 推送,并回填真实 orderId。 + + 建单返回的 orderId 可能是全 0 GUID(执行时才填),finish 报文里带的是 + 真实 orderId;这里优先用 finish 报文的 orderId,零 GUID 时回退创建时的值, + 供后续 /conductivity-order-result 按 orderId 查询。 + + 进入 wait 循环前会按本批 orderCode 清掉电导专用缓存中的历史残留, + 避免「报文已缓存立即返回」消费到过期 finish 推送。 + + Args: + order_pairs: [(orderCode, creation_orderId), ...] + wait_timeout_seconds: 单个订单 wait 超时秒数 + tag: 日志标签 + + Returns: + (resolved_pairs, summary) + resolved_pairs: [(orderCode, resolved_orderId, creation_orderId), ...] + summary: {"success": int, "timeout": int, "abnormal_stop": int, + "manual_stop": int, "mismatch": int, "other": int} + """ + EMPTY_GUID = "00000000-0000-0000-0000-000000000000" + summary = { + "success": 0, "timeout": 0, "abnormal_stop": 0, + "manual_stop": 0, "mismatch": 0, "other": 0, + } + resolved: List[Tuple[str, str, str]] = [] + total = len(order_pairs) + + # Fix①:进 wait 前清掉本批 orderCode 在电导缓存中的历史残留 + batch_codes = [oc for oc, _ in order_pairs if oc] + if batch_codes: + with self._order_finish_lock: + cleared = 0 + for oc in batch_codes: + if self._cond_finish_reports.pop(oc, None) is not None: + cleared += 1 + self._cond_finish_events.pop(oc, None) + if cleared: + logger.info( + f"[{tag}] 进 wait 前清理电导缓存残留 {cleared}/{len(batch_codes)} 条" + ) + + logger.info( + f"[{tag}] 开始阻塞等待 {total} 个电导单完成 " + f"(单订单 timeout={wait_timeout_seconds}s)..." + ) + for idx, (order_code, creation_oid) in enumerate(order_pairs, 1): + logger.info(f"[{tag}] 等待第 {idx}/{total} 个电导单: {order_code}") + wait_result = self._wait_conductivity_finish( + order_code, + timeout=wait_timeout_seconds, + expected_order_id=creation_oid, + ) + wait_status = wait_result.get("status", "other") + if wait_status == "success": + summary["success"] += 1 + logger.info(f"[{tag}] ✓ 电导单 {order_code} 完成") + elif wait_status in summary: + summary[wait_status] += 1 + logger.warning( + f"[{tag}] ⚠ 电导单 {order_code} 非正常结束: status={wait_status}" + ) + else: + summary["other"] += 1 + logger.warning( + f"[{tag}] ⚠ 电导单 {order_code} 未知 wait status: {wait_status}" + ) + + report = wait_result.get("report") or {} + report_oid = (report.get("orderId") or "").strip() + if report_oid and report_oid != EMPTY_GUID: + resolved_oid = report_oid + else: + resolved_oid = creation_oid or "" + resolved.append((order_code, resolved_oid, creation_oid or "")) + + logger.info(f"[{tag}] 全部电导单等待结束: summary={summary}") + return resolved, summary def get_material_info(self, material_id: str) -> Dict[str, Any]: """查询物料详细信息(物料详情接口) @@ -305,10 +995,91 @@ def get_material_info(self, material_id: str) -> Dict[str, Any]: material_id: 物料 ID (GUID) Returns: - 物料详情,包含 name, typeName, locations 等 + 物料详情,包含 name, typeName, locations 等;失败返回空 dict """ result = self._post_lims("/api/lims/storage/material-info", material_id) - return result.get("data", {}) + if result.get("error"): + logger.error( + f"[material-info] 请求失败: materialId={material_id}, error={result.get('error')}" + ) + return {} + if result.get("code") != 1: + logger.error( + f"[material-info] 业务失败: materialId={material_id}, " + f"code={result.get('code')}, message={result.get('message')}" + ) + return {} + data = result.get("data") + return data if isinstance(data, dict) else {} + + def _enrich_report_materials_from_create( + self, + report: Optional[Dict[str, Any]], + create_entry: Optional[Dict[str, Any]], + ) -> None: + """用建单 usedMaterials 的 materialTypeName/materialName 补全 finish 报文(material-info 降级)。""" + if not report or not create_entry: + return + by_id = { + m.get("materialId"): m + for m in (create_entry.get("usedMaterials") or []) + if isinstance(m, dict) and m.get("materialId") + } + if not by_id: + return + for m in report.get("usedMaterials") or []: + if not isinstance(m, dict): + continue + src = by_id.get(m.get("materialId")) + if not src: + continue + if not m.get("materialTypeName") and src.get("materialTypeName"): + m["materialTypeName"] = src["materialTypeName"] + if not m.get("materialName") and src.get("materialName"): + m["materialName"] = src["materialName"] + + def _resolve_material_type_info(self, material: Dict[str, Any], material_id: str) -> Dict[str, Any]: + """解析物料类型与条码:typeName 可从报文快取,barCode 必须以真实条码为准。 + + finish 推送 / 建单 usedMaterials 通常无 barCode;真实条码只在 material-info。 + 禁止用 materialName(如占位名 999/888)冒充条码。 + """ + type_name = (material.get("materialTypeName") or material.get("typeName") or "").strip() + bar_code = (material.get("barCode") or "").strip() # 不再用 materialName 冒充条码 + need_barcode = (not type_name) or ("瓶" in type_name) # 仅瓶/板需要真实条码 + if type_name and bar_code: + # 报文已含真实条码,直接用 + return { + "typeName": type_name, + "barCode": bar_code, + "associateId": material.get("associateId") or "", + "locations": material.get("locations") or [], + "name": material.get("materialName") or material.get("name") or "", + } + if not need_barcode and type_name: + # 消耗品等:只要类型名,不查接口 + return { + "typeName": type_name, + "barCode": "", + "associateId": material.get("associateId") or "", + "locations": material.get("locations") or [], + "name": material.get("materialName") or material.get("name") or "", + } + # 需要补 barCode(或连 typeName 都缺)→ 查一次 material-info + try: + info = self._query_material_info(material_id) + except Exception as e: + logger.warning( + f"[物料类型] material-info 失败,降级: materialId={material_id}, 错误={e}" + ) + info = {} + return { + "typeName": type_name or (info.get("typeName") or ""), + "barCode": bar_code or (info.get("barCode") or ""), # 真实条码来源 + "associateId": material.get("associateId") or info.get("associateId") or "", + "locations": info.get("locations") or material.get("locations") or [], + "name": material.get("materialName") or info.get("name") or material.get("name") or "", + } def _process_order_reagents(self, report: Dict[str, Any]) -> Dict[str, Any]: """处理订单完成报文中的试剂数据,计算质量比 @@ -336,7 +1107,7 @@ def _process_order_reagents(self, report: Dict[str, Any]) -> Dict[str, Any]: "reagent_details": [] } - # 2. 查询试剂名称 + # 2. 查询试剂名称(material-info 失败时回退 materialName / Unknown) reagent_data = [] for reagent in reagents: material_id = reagent.get("materialId") @@ -345,7 +1116,11 @@ def _process_order_reagents(self, report: Dict[str, Any]) -> Dict[str, Any]: try: info = self.get_material_info(material_id) - name = info.get("name", f"Unknown_{material_id[:8]}") + name = ( + (info.get("name") if info else None) + or reagent.get("materialName") + or f"Unknown_{material_id[:8]}" + ) real_qty = float(reagent.get("realQuantity", 0.0)) used_qty = float(reagent.get("usedQuantity", 0.0)) @@ -374,16 +1149,41 @@ def calculate_mass_ratio(items: List[Dict], key: str) -> Dict[str, float]: logger.warning(f"总质量为0,无法计算{key}质量比") return {item["name"]: 0.0 for item in items} return {item["name"]: round(item[key] / total, 4) for item in items} - + + # 4. 计算各试剂允差:(真实质量 - 目标质量) / 目标质量 + def calculate_mass_tolerance(items: List[Dict]) -> Dict[str, float]: + result = {} + for item in items: + target = item["used_quantity"] + real = item["real_quantity"] + if target == 0: + result[item["name"]] = None + else: + result[item["name"]] = round((real - target) / target, 6) + return result + + # 5. 计算总质量允差:(Σ真实质量 - Σ目标质量) / Σ目标质量 + total_real = sum(item["real_quantity"] for item in reagent_data) + total_used = sum(item["used_quantity"] for item in reagent_data) + if total_used == 0: + total_mass_tolerance = None + else: + total_mass_tolerance = round((total_real - total_used) / total_used, 6) + real_mass_ratio = calculate_mass_ratio(reagent_data, "real_quantity") target_mass_ratio = calculate_mass_ratio(reagent_data, "used_quantity") - + mass_tolerance = calculate_mass_tolerance(reagent_data) + logger.info(f"真实质量比: {real_mass_ratio}") logger.info(f"目标质量比: {target_mass_ratio}") - + logger.info(f"各试剂允差: {mass_tolerance}") + logger.info(f"总质量允差: {total_mass_tolerance}") + return { "real_mass_ratio": real_mass_ratio, "target_mass_ratio": target_mass_ratio, + "mass_tolerance": mass_tolerance, + "total_mass_tolerance": total_mass_tolerance, "reagent_details": reagent_data } @@ -614,12 +1414,17 @@ def auto_feeding4to3( response = self._post_lims("/api/lims/order/auto-feeding4to3", items) # 等待任务报送成功 - order_code = response.get("data", {}).get("orderCode") + if response is None: + logger.error("上料 API 返回了空响应(None),服务端可能因入参问题返回了 null body,请检查物料条目是否合法。") + return {"code": -1, "message": "API returned None response"} + order_data = response.get("data") or {} + order_code = order_data.get("orderCode") if not order_code: - logger.error("上料任务未返回有效 orderCode!") + logger.error(f"上料任务未返回有效 orderCode!完整响应:{response}") return response - # 等待完成报送 - result = self.wait_for_order_finish(order_code) + result = self.wait_for_order_finish( + order_code, expected_order_id=order_data.get("orderId") + ) print("\n" + "="*60) print("实验记录本结果auto_feeding4to3") print("="*60) @@ -694,432 +1499,1348 @@ def as_str(v, d=""): self.wait_for_response_orders(response, "auto_batch_outbound_from_xlsx") return response - # 2.14 新建实验 - def create_orders(self, xlsx_path: str) -> Dict[str, Any]: - """ - 从 Excel 解析并创建实验(2.14) - 约定: - - batchId = Excel 文件名(不含扩展名) - - 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列) - - totalMass 自动计算为所有物料质量之和 - - createTime 缺失或为空时自动填充为当前日期(YYYY/M/D) - """ - default_path = Path("D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\2025122301.xlsx") - path = Path(xlsx_path) if xlsx_path else default_path - print(f"[create_orders] 使用 Excel 路径: {path}") - if path != default_path: - print("[create_orders] 来源: 调用方传入自定义路径") - else: - print("[create_orders] 来源: 使用默认模板路径") + # -------------------- 订单提交/等待/后处理(公共逻辑) -------------------- + def _sum_required_reagents(self, orders: List[Dict[str, Any]]) -> Dict[str, float]: + """把整批订单的 materialInfos 按试剂名汇总成总需求量(g)。""" + required: Dict[str, float] = {} + for od in orders: + for mat in od.get("materialInfos") or []: + if not isinstance(mat, dict): + continue + name = str(mat.get("name") or "").strip() + if not name: + continue + try: + mass = float(mat.get("mass") or 0) + except (TypeError, ValueError): + continue + if mass > 0: + required[name] = required.get(name, 0.0) + mass + return required - if not path.exists(): - print(f"[create_orders] ⚠️ Excel 文件不存在: {path}") - raise FileNotFoundError(f"未找到 Excel 文件:{path}") + # 预检按克比对,非质量单位的试剂无法直接对比,只能跳过该项 + _MASS_UNITS = ("g", "gram", "grams", "克") - try: - df = pd.read_excel(path, sheet_name=0, engine="openpyxl") - except Exception as e: - raise RuntimeError(f"读取 Excel 失败:{e}") - print(f"[create_orders] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}") + def _query_reagent_stock(self) -> Optional[Dict[str, Any]]: + """查 2.3 stock-material 取在库试剂可用量(g),按试剂名累加同名多瓶。 - # 列名容错:返回可选列名,找不到则返回 None - def _pick(col_names: List[str]) -> Optional[str]: - for c in col_names: - if c in df.columns: - return c + typeMode=2 即「试剂」(1=样品、0=耗材),不会漏查其它 typeMode。 + + Returns: + { + "stock": {试剂名: 可用量(g)}, + "unit_skipped": {试剂名: 非质量单位}, # 这些项预检时跳过 + "names": [在库全部试剂名], # 供失配时列清单排查 + } + 查询失败返回 None,由调用方决定降级策略——预检是拦截手段, + 不能因为查询本身出问题就挡住正常建单。 + """ + resp = self._post_lims( + "/api/lims/storage/stock-material", + {"typeMode": 2, "includeDetail": False}, + ) + if not isinstance(resp, dict) or resp.get("code") != 1: + return None + data = resp.get("data") + if not isinstance(data, list): return None - col_order_name = _pick(["配方ID", "orderName", "订单编号"]) - col_create_time = _pick(["创建日期", "createTime"]) - col_bottle_type = _pick(["配液瓶类型", "bottleType"]) - col_mix_time = _pick(["混匀时间(s)", "mixTime"]) - col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"]) - col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"]) - col_cond = _pick(["电导测试分液体积", "conductivityInfo"]) - col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"]) - print("[create_orders] 列匹配结果:", { - "order_name": col_order_name, - "create_time": col_create_time, - "bottle_type": col_bottle_type, - "mix_time": col_mix_time, - "load": col_load, - "pouch": col_pouch, - "conductivity": col_cond, - "conductivity_bottle_count": col_cond_cnt, - }) + stock: Dict[str, float] = {} + unit_skipped: Dict[str, str] = {} + names: List[str] = [] + for item in data: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + names.append(name) - # 物料列:所有以 (g) 结尾 - material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")] - print(f"[create_orders] 识别到的物料列: {material_cols}") - if not material_cols: - raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。") + # 单位校验:materialInfos.mass 是克,拿克跟 mL 比是静默错误,只能跳过该项 + unit = str(item.get("unit") or "g").strip() + if unit.casefold() not in self._MASS_UNITS: + unit_skipped[name] = unit + continue - batch_id = path.stem + try: + quantity = float(item.get("quantity") or 0) + except (TypeError, ValueError): + continue + # lockQuantity 常为负(LIMS 记的是余量修正),只有正值才是真占用,扣掉更保守 + try: + locked = max(float(item.get("lockQuantity") or 0), 0.0) + except (TypeError, ValueError): + locked = 0.0 + stock[name] = stock.get(name, 0.0) + quantity - locked - def _to_ymd_slash(v) -> str: - # 统一为 "YYYY/M/D";为空或解析失败则用当前日期 - if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": - ts = datetime.now() - else: - try: - ts = pd.to_datetime(v) - except Exception: - ts = datetime.now() - return f"{ts.year}/{ts.month}/{ts.day}" + return {"stock": stock, "unit_skipped": unit_skipped, "names": sorted(set(names))} - def _as_int(val, default=0) -> int: - try: - if pd.isna(val): - return default - return int(val) - except Exception: - return default + def _compare_reagents( + self, required: Dict[str, float], snapshot: Dict[str, Any] + ) -> Dict[str, Any]: + """把需求量与在库快照逐项比对,返回结构化报告(不抛错)。 - def _as_float(val, default=0.0) -> float: - try: - if pd.isna(val): - return default - return float(val) - except Exception: - return default + `_preflight_check_reagents`(单批建单前)与 `precheck_reagents_total` + (多轮提交前)共用同一套判据,避免两处口径漂移。 - def _as_str(val, default="") -> str: - if val is None or (isinstance(val, float) and pd.isna(val)): - return default - s = str(val).strip() - return s if s else default + 判据取舍: + - 单位非克 → 跳过该项(放行),单位对不上时无法比较,硬拦属于自伤 + - 在库查无此名 → **硬拦**。已决策宁可误拦不漏拦;但报错里附在库试剂名清单, + 便于一眼区分「名称失配」与「真的没有这个试剂」 + """ + stock: Dict[str, float] = snapshot.get("stock") or {} + unit_skipped: Dict[str, str] = snapshot.get("unit_skipped") or {} + stock_names: List[str] = snapshot.get("names") or [] + # LIMS 里试剂名大小写偶有出入,精确匹配不中时降级按大小写不敏感再找一次 + stock_ci = {k.casefold(): v for k, v in stock.items()} + skipped_ci = {k.casefold(): v for k, v in unit_skipped.items()} - orders: List[Dict[str, Any]] = [] + items: List[Dict[str, Any]] = [] + shortages: List[str] = [] + skipped: List[str] = [] + for name in sorted(required): + need = required[name] + + bad_unit = unit_skipped.get(name) or skipped_ci.get(name.casefold()) + if bad_unit is not None: + skipped.append(f"{name}(单位 {bad_unit} 非质量单位)") + items.append({ + "name": name, "required": round(need, 4), + "available": None, "gap": None, "status": "skipped_unit", + }) + continue - for idx, row in df.iterrows(): - mats: List[Dict[str, Any]] = [] - total_mass = 0.0 + have = stock.get(name) + if have is None: + have = stock_ci.get(name.casefold()) + + if have is None: + shortages.append(f"{name}: 需要 {need:.4f} g,在库无此试剂") + items.append({ + "name": name, "required": round(need, 4), + "available": None, "gap": None, "status": "not_found", + }) + elif have < need: + shortages.append( + f"{name}: 需要 {need:.4f} g,可用 {have:.4f} g,缺 {need - have:.4f} g" + ) + items.append({ + "name": name, "required": round(need, 4), + "available": round(have, 4), "gap": round(need - have, 4), + "status": "short", + }) + else: + items.append({ + "name": name, "required": round(need, 4), + "available": round(have, 4), "gap": 0.0, "status": "ok", + }) + + return { + "ok": not shortages, + "items": items, + "shortages": shortages, + "skipped": skipped, + "has_not_found": any(i["status"] == "not_found" for i in items), + "stock_names": stock_names, + } + + @staticmethod + def _format_stock_name_hint(stock_names: List[str], limit: int = 30) -> str: + """「在库无此试剂」时附上在库试剂名清单,用于区分名称失配与真缺料。""" + if not stock_names: + return "(在库试剂名清单为空,可能是库存查询口径有误)" + shown = stock_names[:limit] + more = f" ...等共 {len(stock_names)} 种" if len(stock_names) > limit else "" + return f"在库试剂名清单: {', '.join(shown)}{more}" + + def _preflight_check_reagents(self, orders: List[Dict[str, Any]], tag: str) -> None: + """建单前校验整批试剂需求是否够,不够就一单都不建。 + + LIMS 的 /order/orders 是逐单校验逐单建:某单试剂不足只有它 errorMessage + 非空,其余单照样建出来并进调度队列,人工得去撤半批单。这里在提交前把整批 + 需求汇总跟在库量比一次,缺料直接抛错,避免产生需要善后的半批订单。 + + Raises: + BioyondException: 有任一试剂不足时抛出,附每种试剂的需求/可用/缺口 + """ + required = self._sum_required_reagents(orders) + if not required: + return + + snapshot = self._query_reagent_stock() + if snapshot is None: + logger.warning( + f"[{tag}] ⚠️ 试剂库存查询失败,跳过建单前预检,改由 LIMS 逐单校验兜底" + ) + return + + report = self._compare_reagents(required, snapshot) + + for note in report["skipped"]: + logger.warning(f"[{tag}] ⚠️ 预检跳过 {note}——无法与克数比较,该项交由 LIMS 校验") + + if not report["ok"]: + shortage_text = "; ".join(report["shortages"]) + # 「在库无此试剂」既可能是真缺料,也可能是名称失配,附清单让人当场分辨 + hint = ( + f" {self._format_stock_name_hint(report['stock_names'])}" + if report["has_not_found"] else "" + ) + logger.error( + f"[{tag}] ❌ 建单前预检未通过,{len(orders)} 单全部不下发: {shortage_text}{hint}" + ) + raise BioyondException( + f"[{tag}] 试剂余量不足,{len(orders)} 单全部未下发(避免产生半批待处置订单)。" + f"缺口: {shortage_text}。请补齐试剂后重新下发。{hint}" + ) + + logger.info( + f"[{tag}] ✅ 建单前预检通过,{len(required)} 种试剂余量充足: " + + ", ".join( + f"{i['name']} 需{i['required']:.4f}/余{i['available']:.4f}g" + for i in report["items"] if i["status"] == "ok" + ) + ) + + @staticmethod + def _sum_formulation_reagents(formulation: List[Dict[str, Any]]) -> Dict[str, float]: + """把一轮 formulation(create_orders_formulation 入参形状)按试剂名汇总(g)。 + + 形状:[{"order_name": ..., "materials": [{"name": ..., "mass": ...}]}, ...] + """ + required: Dict[str, float] = {} + for entry in formulation or []: + if not isinstance(entry, dict): + continue + for mat in entry.get("materials") or []: + if not isinstance(mat, dict): + continue + name = str(mat.get("name") or "").strip() + if not name: + continue + try: + mass = float(mat.get("mass") or 0) + except (TypeError, ValueError): + continue + if mass > 0: + required[name] = required.get(name, 0.0) + mass + return required + + def _sum_xlsx_reagents(self, xlsx_path: str) -> Dict[str, float]: + """把一轮 Excel(create_orders 入参)按试剂名汇总(g)。 + + 物料列口径与 create_orders 保持一致:以「(g)」结尾且不是总质量聚合列。 + """ + path = Path(xlsx_path) + if not path.exists(): + raise FileNotFoundError(f"未找到 Excel 文件:{path}") + try: + df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败({path}):{e}") + + _NON_MATERIAL_NAMES = {"总质量", "totalMass", "TotalMass", "total_mass"} + material_cols = [ + c for c in df.columns + if isinstance(c, str) and c.endswith("(g)") + and c.replace("(g)", "").strip() not in _NON_MATERIAL_NAMES + ] + if not material_cols: + raise KeyError(f"{path} 中未发现任何以「(g)」结尾的物料列,请检查表头。") + required: Dict[str, float] = {} + for _, row in df.iterrows(): for mcol in material_cols: val = row.get(mcol, None) if val is None or (isinstance(val, float) and pd.isna(val)): continue try: mass = float(val) - except Exception: + except (TypeError, ValueError): continue if mass > 0: - mats.append({"name": mcol.replace("(g)", ""), "mass": mass}) - total_mass += mass - else: - if mass < 0: - print(f"[create_orders] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}") + name = mcol.replace("(g)", "").strip() + required[name] = required.get(name, 0.0) + mass + return required - order_data = { - "batchId": batch_id, - "orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}", - "createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None), - "bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶", - "mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0, - "loadSheddingInfo": _as_float(row[col_load]) if col_load else 0.0, - "pouchCellInfo": _as_float(row[col_pouch]) if col_pouch else 0, - "conductivityInfo": _as_float(row[col_cond]) if col_cond else 0, - "conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0, - "materialInfos": mats, - "totalMass": round(total_mass, 4) # 自动汇总 + def precheck_reagents_total( + self, + formulations: Optional[List[List[Dict[str, Any]]]] = None, + xlsx_paths: Optional[List[str]] = None, + raise_on_shortage: bool = False, + ) -> Dict[str, Any]: + """多批次总量预检:汇总所有待提交轮次的试剂需求,与在库余量比对一次。 + + 为什么放在提交之前:此刻**没有任何在飞批次**,库存不含未决占用, + 因此不需要扣减、也不用判断 LIMS 是建单即锁还是消耗才扣——一次 + `在库 >= 总需求` 就定论。这是最早、也是唯一无歧义的检查点。 + + 它是**建议性**的,不是保证:从本次检查到最后一轮跑完,中间可能有 + 别人提交的任务、手工取用、其它工站消耗使结论失效。所以 + `create_orders` 内的建单前预检必须保留,那才是最终权威判据。 + + Args: + formulations: 多轮 formulation 列表,每个元素是一轮的 formulation + (形状同 create_orders_formulation 的入参) + xlsx_paths: 多轮 Excel 路径列表(形状同 create_orders 的入参) + raise_on_shortage: 缺料时是否抛错。默认 False —— 它是提交侧的查询, + 由调用方决定要不要提交;置 True 可当工作流节点用 + + Returns: + { + "ok": bool, # 总需求是否都够 + "rounds": int, # 汇总了几轮 + "items": [{name, required, available, gap, status}], + "shortages": [...], # 缺口描述,缺料时即补料清单 + "skipped": [...], # 因单位非克而跳过的项 + "stock_query_ok": bool, # 库存查询是否成功 } - print(f"[create_orders] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, " - f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, " - f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, " - f"material_count={len(mats)}") - if order_data["totalMass"] <= 0: - print(f"[create_orders] ⚠️ 第 {idx+1} 行总质量 <= 0,可能导致 LIMS 校验失败") - if not mats: - print(f"[create_orders] ⚠️ 第 {idx+1} 行未找到有效物料") + Raises: + BioyondException: raise_on_shortage=True 且有缺口时 + """ + rounds = 0 + required: Dict[str, float] = {} + + for formulation in (formulations or []): + part = self._sum_formulation_reagents(formulation) + for name, mass in part.items(): + required[name] = required.get(name, 0.0) + mass + rounds += 1 + + for xlsx_path in (xlsx_paths or []): + part = self._sum_xlsx_reagents(xlsx_path) + for name, mass in part.items(): + required[name] = required.get(name, 0.0) + mass + rounds += 1 + + if not required: + logger.warning("[总量预检] 未解析出任何试剂需求,请检查 formulations / xlsx_paths 入参") + return { + "ok": True, "rounds": rounds, "items": [], "shortages": [], + "skipped": [], "stock_query_ok": True, + "message": "未解析出任何试剂需求,跳过比对", + } - orders.append(order_data) - print("================================================") - print("orders:", orders) + snapshot = self._query_reagent_stock() + if snapshot is None: + logger.warning("[总量预检] ⚠️ 试剂库存查询失败,无法给出结论,交由建单前预检兜底") + return { + "ok": True, "rounds": rounds, "items": [], "shortages": [], + "skipped": [], "stock_query_ok": False, + "message": "库存查询失败,未能比对;请依赖 create_orders 内的建单前预检", + } - print(f"[create_orders] 即将提交订单数量: {len(orders)}") - response = self._post_lims("/api/lims/order/orders", orders) - print(f"[create_orders] 接口返回: {response}") - - # 提取所有返回的 orderCode - data_list = response.get("data", []) - if not data_list: - logger.error("创建订单未返回有效数据!") - return response - - # 收集所有 orderCode - order_codes = [] - for order_item in data_list: - code = order_item.get("orderCode") - if code: - order_codes.append(code) - - if not order_codes: - logger.error("未找到任何有效的 orderCode!") - return response - - print(f"[create_orders] 等待 {len(order_codes)} 个订单完成: {order_codes}") - - # 等待所有订单完成并收集报文 - all_reports = [] - for idx, order_code in enumerate(order_codes, 1): - print(f"[create_orders] 正在等待第 {idx}/{len(order_codes)} 个订单: {order_code}") - result = self.wait_for_order_finish(order_code) - - # 提取报文数据 - if result.get("status") == "success": - report = result.get("report", {}) - - # [新增] 处理试剂数据,计算质量比 - try: - mass_ratios = self._process_order_reagents(report) - report["mass_ratios"] = mass_ratios # 添加到报文中 - logger.info(f"已计算订单 {order_code} 的试剂质量比") - except Exception as e: - logger.error(f"计算试剂质量比失败: {e}") - report["mass_ratios"] = { - "real_mass_ratio": {}, - "target_mass_ratio": {}, - "reagent_details": [], - "error": str(e) - } - - all_reports.append(report) - print(f"[create_orders] ✓ 订单 {order_code} 完成") - else: - logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") - # 即使订单失败,也记录下这个结果 - all_reports.append({ - "orderCode": order_code, - "status": result.get("status"), - "error": result.get("message", "未知错误") - }) - - print(f"[create_orders] 所有订单已完成,共收集 {len(all_reports)} 个报文") - print("实验记录本========================create_orders========================") - - # 返回所有订单的完成报文 - final_result = { - "status": "all_completed", - "total_orders": len(order_codes), - "reports": all_reports, - "original_response": response + report = self._compare_reagents(required, snapshot) + result = { + "ok": report["ok"], + "rounds": rounds, + "items": report["items"], + "shortages": report["shortages"], + "skipped": report["skipped"], + "stock_query_ok": True, } - - print(f"返回报文数量: {len(all_reports)}") - for i, report in enumerate(all_reports, 1): - print(f"报文 {i}: orderCode={report.get('orderCode', 'N/A')}, status={report.get('status', 'N/A')}") - print("========================") - - return final_result - def create_orders_v2(self, xlsx_path: str) -> Dict[str, Any]: + for note in report["skipped"]: + logger.warning(f"[总量预检] ⚠️ 跳过 {note}——无法与克数比较") + + if report["ok"]: + logger.info( + f"[总量预检] ✅ {rounds} 轮共 {len(required)} 种试剂余量充足: " + + ", ".join( + f"{i['name']} 需{i['required']:.4f}/余{i['available']:.4f}g" + for i in report["items"] if i["status"] == "ok" + ) + ) + return result + + shortage_text = "; ".join(report["shortages"]) + hint = ( + f" {self._format_stock_name_hint(report['stock_names'])}" + if report["has_not_found"] else "" + ) + result["message"] = f"{rounds} 轮总需求超出在库余量。缺口: {shortage_text}。{hint}" + logger.error(f"[总量预检] ❌ {result['message']}") + + if raise_on_shortage: + raise BioyondException( + f"[总量预检] 试剂总量不足,请勿提交这 {rounds} 轮实验。" + f"缺口: {shortage_text}。{hint}" + ) + return result + + # -------------------- 多任务互锁:order-list 门控 -------------------- + + @staticmethod + def _lims_time_window(lookback_minutes: int) -> Tuple[str, str, datetime]: + """构造 order-list 的时间窗,并返回**精确**的客户端截断时刻。 + + 实测口径(2026-08-04 对 172.16.28.127 实测,勿凭直觉改): + + 1. `timeType` 必须是**被过滤字段名** `"requestTime"`。填 `"beginTime"` + (请求参数名)会被 LIMS 静默忽略 → 时间窗完全不生效、返回全部历史单。 + `createTime`/`startTime`/`endTime`/`finishTime` 同样是静默忽略。 + 2. 过滤粒度是**按天**,起止两端都取日期闭区间,时分秒被丢弃: + `[08-03T12:30, 08-03T23:59]` 与 `[08-03T00:00, 08-03T00:00]` 返回 + 完全相同的结果(含当天 11:06 的单)。所以服务端窗口只能粗筛到「天」, + 分钟级的 lookback 必须由调用方用返回的 `requestTime` 再精确过滤一次, + 这就是第三个返回值 `cutoff` 的用途。 + 3. 时间字面量用 ISO `T` 分隔即可,毫秒与 `Z` 后缀都可省略、结果一致; + 但换成空格分隔 `"YYYY-MM-DD HH:MM:SS"` 会让过滤静默失效(返回全量)。 + 4. 按裸本地时间比较,Z 纯装饰——不要「顺手修正」成真 UTC,否则偏 8 小时。 + + Returns: + (beginTime 字面量, endTime 字面量, 精确截断时刻) """ - 从 Excel 解析并创建实验(2.14)- V2版本 - 约定: - - batchId = Excel 文件名(不含扩展名) - - 物料列:所有以 "(g)" 结尾(不再读取"总质量(g)"列) - - totalMass 自动计算为所有物料质量之和 - - createTime 缺失或为空时自动填充为当前日期(YYYY/M/D) + now = datetime.now() + cutoff = now - timedelta(minutes=lookback_minutes) + # 服务端按天取闭区间,故窗口起点必然放宽到 cutoff 当天 00:00,只作粗筛 + begin = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ") + end = now.strftime("%Y-%m-%dT%H:%M:%SZ") + return begin, end, cutoff + + def _resolve_order_keywords( + self, scenario: str = "all", workflow_name_keyword: str = "" + ) -> List[str]: + """解析门控过滤关键字。 + + 优先级:显式 workflow_name_keyword > scenario 查表 > 回落 all。 + 未知 scenario 打 warning 后回落 ["配液"],绝不静默放宽或收紧—— + 静默收紧比报错危险,会让门控漏掉本该挡住的单。 """ - default_path = Path("D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\2025122301.xlsx") - path = Path(xlsx_path) if xlsx_path else default_path - print(f"[create_orders_v2] 使用 Excel 路径: {path}") - if path != default_path: - print("[create_orders_v2] 来源: 调用方传入自定义路径") - else: - print("[create_orders_v2] 来源: 使用默认模板路径") + kw = (workflow_name_keyword or "").strip() + if kw: + return [kw] + key = (scenario or "all").strip() or "all" + keywords = self.SCENARIO_ORDER_KEYWORDS.get(key) + if keywords is None: + logger.warning( + f"[互锁门控] 未知 scenario={key!r},回落到 all(关键字「配液」)。" + f"可选: {list(self.SCENARIO_ORDER_KEYWORDS)}" + ) + return list(self.SCENARIO_ORDER_KEYWORDS["all"]) + return list(keywords) - if not path.exists(): - print(f"[create_orders_v2] ⚠️ Excel 文件不存在: {path}") - raise FileNotFoundError(f"未找到 Excel 文件:{path}") + def _query_scheduler_status(self) -> Optional[str]: + """查调度状态(2.x scheduler-status),返回 data.schedulerStatus。 + 取值:Init / Stop / Running / Pause / ErrorPause / ErrorStop。 + 查询失败返回 None——门控只把它当参考,不因状态查询本身失败而阻断。 + """ try: - df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + resp = self.scheduler_status() except Exception as e: - raise RuntimeError(f"读取 Excel 失败:{e}") - print(f"[create_orders_v2] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}") + logger.warning(f"[互锁门控] 调度状态查询异常: {e}") + return None + if not isinstance(resp, dict) or resp.get("code") != 1: + logger.warning(f"[互锁门控] 调度状态查询返回异常: {resp}") + return None + status = ((resp.get("data") or {}).get("schedulerStatus") or "").strip() + return status or None - # 列名容错:返回可选列名,找不到则返回 None - def _pick(col_names: List[str]) -> Optional[str]: - for c in col_names: - if c in df.columns: - return c + @staticmethod + def _parse_lims_time(value: Any) -> Optional[datetime]: + """解析 LIMS 返回的裸本地时间(如 2026-08-03T11:06:04.565674),失败返回 None。""" + if not isinstance(value, str) or not value: + return None + text = value.strip().rstrip("Z") + try: + return datetime.fromisoformat(text) + except ValueError: return None - col_order_name = _pick(["配方ID", "orderName", "订单编号"]) - col_create_time = _pick(["创建日期", "createTime"]) - col_bottle_type = _pick(["配液瓶类型", "bottleType"]) - col_mix_time = _pick(["混匀时间(s)", "mixTime"]) - col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"]) - col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"]) - col_cond = _pick(["电导测试分液体积", "conductivityInfo"]) - col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"]) - print("[create_orders_v2] 列匹配结果:", { - "order_name": col_order_name, - "create_time": col_create_time, - "bottle_type": col_bottle_type, - "mix_time": col_mix_time, - "load": col_load, - "pouch": col_pouch, - "conductivity": col_cond, - "conductivity_bottle_count": col_cond_cnt, - }) + def _paginate_order_list( + self, + begin_time: str, + end_time: str, + keywords: List[str], + page_size: int = 100, + max_pages: int = 40, + cutoff: Optional[datetime] = None, + ) -> Dict[int, List[Dict[str, Any]]]: + """分页扫窗口内命中关键字的配液单,按**响应里的** status 字段分桶返回。 + + ⚠️ 关键口径:**请求侧 `status` 与响应侧 `status` 不是同一套枚举**, + 所以这里请求侧传空串(=不按状态过滤),状态判定完全交给响应字段。 + 2026-08-04 实测(近 60h 窗口): + + | 请求 status | 命中数 | 这些单响应里的 status | + |-------------|--------|------------------------| + | "" | 33 | 80×31, 90×2 | + | "60" | 11 | **全是 80 Succeed** | + | "80" | 31 | 80×31 | + | "90" | 2 | 90×2 | + | "100" | 22 | 80×20, 90×2 | + + 请求 `"60"` 的 11 条**全部带 completeTime**(早已跑完),且 + `请求60 ∩ 请求100 = 0`、`11 + 20 = 31`(成功单被二分)——可见请求侧 + `60`/`100` 是「**未取出**/**已取出**」,而不是响应侧的「运行中/已取出」。 + 照请求侧 `status=60` 当「正在运行」用,会把「配液已完成但板子还没取出」 + 的单误判成在跑,门控就会一直空等到超时。 + + 请求侧 `""` 实测是所有分桶的超集(`"60"/"80"/"90"/"100"` 均 ⊆ `""`), + 所以单次扫描足够,不会漏掉真正在跑的单。窗口内总量也不大(近 60h 33 条、 + 7 天 98 条、14 天 394 条),全量翻页成本可接受。 + + `cutoff` 是精确的时间下界:服务端时间窗只有「天」粒度(见 + `_lims_time_window`),会把 begin 当天 00:00 起的单全都带回来,所以这里必须 + 再按 `requestTime` 精确过滤一次,否则 lookback 会被静默放宽最多一天。 + `requestTime` 缺失或解析失败的条目保守保留(宁可多拦不可漏拦)。 + + 注意 pageCount 是「每页条数」而非「页数」。 - # 物料列:所有以 (g) 结尾 - material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")] - print(f"[create_orders_v2] 识别到的物料列: {material_cols}") - if not material_cols: - raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。") + Returns: + {响应status: [订单摘要]},未出现的状态不建键 + """ + buckets: Dict[int, List[Dict[str, Any]]] = {} + skip = 0 + stale = 0 + scanned = 0 + for page in range(max_pages): + try: + resp = self.order_list_v2( + timeType="requestTime", + beginTime=begin_time, + endTime=end_time, + status="", # 状态判定见 docstring,一律交给响应字段 + skipCount=skip, + pageCount=page_size, + ) + except Exception as e: + logger.warning(f"[互锁门控] order-list 查询异常(page={page}): {e}") + return buckets - batch_id = path.stem + data = (resp or {}).get("data") or {} + items = data.get("items") or [] + total = int(data.get("totalCount") or 0) - def _to_ymd_slash(v) -> str: - # 统一为 "YYYY/M/D";为空或解析失败则用当前日期 - if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": - ts = datetime.now() - else: + for it in items: + if not isinstance(it, dict): + continue + wf_name = it.get("workflowName") or "" + if not any(kw in wf_name for kw in keywords): + continue + req_dt = self._parse_lims_time(it.get("requestTime")) + if cutoff is not None and req_dt is not None and req_dt < cutoff: + # 服务端只按天粗筛带回来的陈旧单,不计入门控 + stale += 1 + continue try: - ts = pd.to_datetime(v) - except Exception: - ts = datetime.now() - return f"{ts.year}/{ts.month}/{ts.day}" + st = int(it.get("status")) + except (TypeError, ValueError): + # 状态读不出来时保守当「运行中」,宁可多拦不可漏拦 + logger.warning( + f"[互锁门控] 订单 {it.get('name')} 的 status 无法解析" + f"(原值={it.get('status')!r}),保守按运行中处理" + ) + st = self._ORDER_STATUS_RUNNING_CODE + buckets.setdefault(st, []).append({ + "orderName": it.get("name"), + "orderId": it.get("id") or it.get("orderId"), + "workflowName": wf_name, + "status": st, + "statusName": it.get("statusName"), + "requestTime": it.get("requestTime"), + "completeTime": it.get("completeTime"), + }) - def _as_int(val, default=0) -> int: - try: - if pd.isna(val): - return default - return int(val) - except Exception: - return default + scanned += len(items) + skip += len(items) + if not items or skip >= total: + break + + if page == max_pages - 1: + logger.warning( + f"[互锁门控] 翻页达上限 {max_pages} 页(已扫 {scanned}/{total} 条)," + f"剩余未扫,门控判定可能不完整" + ) + + logger.info( + f"[互锁门控] 扫描窗口内 {scanned} 条订单,命中配液单按状态分桶: " + f"{ {k: len(v) for k, v in sorted(buckets.items())} }" + + (f",另有 {stale} 条超出精确回溯窗口已忽略" if stale else "") + ) + return buckets + + @staticmethod + def _count_by_workflow(items: List[Dict[str, Any]]) -> Dict[str, int]: + """按 workflowName 分场景计数,供日志观测跨场景互相挡的频率。""" + counts: Dict[str, int] = {} + for it in items: + name = it.get("workflowName") or "<未知>" + counts[name] = counts.get(name, 0) + 1 + return counts + + def _scan_workflow_statuses( + self, + keywords: List[str], + lookback_minutes: int, + page_size: int = 100, + max_pages: int = 40, + ) -> Dict[str, List[Dict[str, Any]]]: + """扫一轮窗口内的执行中(60)与失败(90)配液单。 + + 单次分页扫描 + 按响应 status 分桶;不要改回「按请求 status 各查一次」, + 请求侧 60 是「未取出」而非「运行中」,详见 `_paginate_order_list`。 + """ + begin_time, end_time, cutoff = self._lims_time_window(lookback_minutes) + buckets = self._paginate_order_list( + begin_time, end_time, keywords, page_size, max_pages, cutoff + ) + return { + "running": buckets.get(self._ORDER_STATUS_RUNNING_CODE, []), + "failed": buckets.get(self._ORDER_STATUS_FAILED_CODE, []), + "begin_time": begin_time, + "end_time": end_time, + } - def _as_float(val, default=0.0) -> float: - try: - if pd.isna(val): - return default - return float(val) - except Exception: - return default + def _pick_order_by_name( + self, + items: List[Dict[str, Any]], + expect_name: str, + registered_at: Optional[float], + order_code: str, + ) -> Optional[Dict[str, Any]]: + """在同名多轮订单里挑出「本轮」那一个。 + + orderName 不唯一(见 `_check_batch_all_finished`),单纯取第一条同名的会撞到 + 上一轮。建单发生在 `register_interlock_batch` 之前几秒,所以本轮那一单的 + requestTime 应该是**不晚于 registered_at、且离它最近**的那个。 + """ + candidates = [it for it in items if it.get("name") == expect_name] + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] - def _as_str(val, default="") -> str: - if val is None or (isinstance(val, float) and pd.isna(val)): - return default - s = str(val).strip() - return s if s else default + if registered_at is None: + logger.warning( + f"[互锁门控] {order_code}({expect_name})有 {len(candidates)} 个同名单," + f"但缓存里没有 registered_at 可供消歧,取 requestTime 最新的一条" + ) + return max(candidates, key=lambda it: it.get("requestTime") or "") + + # 容忍少量时钟偏差:建单在注册之前,允许晚 60s + limit = datetime.fromtimestamp(registered_at) + timedelta(seconds=60) + in_window = [ + it for it in candidates + if (dt := self._parse_lims_time(it.get("requestTime"))) is not None and dt <= limit + ] + pool = in_window or candidates + picked = max(pool, key=lambda it: it.get("requestTime") or "") + logger.info( + f"[互锁门控] {order_code}({expect_name})命中 {len(candidates)} 个同名单," + f"按 registered_at={limit:%Y-%m-%d %H:%M:%S} 消歧后取 " + f"requestTime={picked.get('requestTime')} / orderId={picked.get('id')}" + ) + if not in_window: + logger.warning( + f"[互锁门控] {order_code}({expect_name})的同名单 requestTime 全都晚于" + f" registered_at,消歧依据不可靠,请核对是否有并发建单" + ) + return picked - orders: List[Dict[str, Any]] = [] + def _check_batch_all_finished(self) -> Tuple[bool, List[Dict[str, Any]]]: + """核对互锁缓存里的上一批订单是否已全部到达终态(80/100)。 - for idx, row in df.iterrows(): - mats: List[Dict[str, Any]] = [] - total_mass = 0.0 + 用 orderCode 查 order-list,再用 orderId 双字段确认,防真机/仿真机撞号。 - for mcol in material_cols: - val = row.get(mcol, None) - if val is None or (isinstance(val, float) and pd.isna(val)): - continue - try: - mass = float(val) - except Exception: - continue - if mass > 0: - mats.append({"name": mcol.replace("(g)", ""), "mass": mass}) - total_mass += mass - else: - if mass < 0: - print(f"[create_orders_v2] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}") + ⚠️ orderName **不唯一**:实测同一批名字(如 DDCC1001~1008)会被多轮实验重复 + 使用,2026-08-04 的 60h 窗口里 DDCC1008 就有 3 个不同 orderId(02:02/02:13/ + 02:23 三轮)。所以 orderId 不可用时的 orderName 兜底必须再用 `registered_at` + 挑出「本轮那一个」,否则可能匹配到上一轮早已 Succeed 的同名单, + 把还在跑的本轮误判成已完成、提前放行下一批。 + """ + with self._interlock_lock: + batch = dict(self._interlock_batch) + if not batch: + return True, [] + + with self._interlock_lock: + meta = self._interlock_batch_meta or {} + name_by_code = dict(meta.get("order_name_by_code") or {}) + registered_at = meta.get("registered_at") + + pending: List[Dict[str, Any]] = [] + for order_code, expect_oid in batch.items(): + try: + resp = self.order_list_v2(filter=order_code, pageCount=20) + except Exception as e: + logger.warning(f"[互锁门控] 核对批次单 {order_code} 查询异常: {e}") + pending.append({"orderCode": order_code, "reason": f"查询异常: {e}"}) + continue - order_data = { - "batchId": batch_id, - "orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}", - "createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None), - "bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶", - "mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0, - "loadSheddingInfo": _as_float(row[col_load]) if col_load else 0.0, - "pouchCellInfo": _as_float(row[col_pouch]) if col_pouch else 0, - "conductivityInfo": _as_float(row[col_cond]) if col_cond else 0, - "conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0, - "materialInfos": mats, - "totalMass": round(total_mass, 4) # 自动汇总 + items = ((resp or {}).get("data") or {}).get("items") or [] + matched = None + # 优先按 orderId 匹配,避免真机/仿真机 orderCode 撞号时捞错单 + exp = self._normalize_order_id(expect_oid) + if self._is_usable_order_id(exp): + for it in items: + if self._normalize_order_id(it.get("id") or it.get("orderId")) == exp: + matched = it + break + # orderId 不可用(如电导建单偶发的全 0 占位)时退化按 orderName 匹配, + # 口径与 _recover_timeout_order_report 一致。没有这条兜底的话, + # 多行命中会一直判为「未到终态」,把门控挂到 24h 超时 + expect_name = name_by_code.get(order_code) + if matched is None and expect_name: + matched = self._pick_order_by_name(items, expect_name, registered_at, order_code) + if matched is None and len(items) == 1: + matched = items[0] + + if matched is None: + pending.append({"orderCode": order_code, "reason": "order-list 未查到该单"}) + continue + + st = matched.get("status") + if st not in self._ORDER_TERMINAL_OK: + pending.append({ + "orderCode": order_code, + "status": st, + "statusName": matched.get("statusName"), + "reason": f"尚未到终态(status={st})", + }) + return (not pending), pending + + def _acquire_interlock_claim( + self, scenario: str, task_hint: str, claim_timeout: float + ) -> Tuple[bool, Optional[Dict[str, Any]]]: + """尝试占据放行凭证。已有未交接凭证则占据失败(调用方继续轮询)。 + + 凭证语义 =「本轮门控已通过、尚未建单」。它填的是门控 return 到 + LIMS 把订单登记成 60 之间的空窗,否则第二个任务会同样看到「无 60 + + 缓存空」而放行,两批同时建单击穿 0016(1 位)/0018(4 位) 容量。 + + claim_timeout 只兜进程崩溃这类未知情况;预检抛错、建单失败这两条已知路径 + 由 _submit_and_wait_orders 的 finally 主动释放,不能靠超时干等。 + """ + now = time.time() + with self._interlock_lock: + claim = self._interlock_claim + if claim is not None: + # granted_at 缺失/非法时按「无限旧」处理,让它立刻作废。 + # 不能回落成 now——那样一张时间戳损坏的凭证会永远不过期, + # 把整站锁死,正是 claim_timeout 要防的情况。 + granted_at = claim.get("granted_at") + age = now - (float(granted_at) if isinstance(granted_at, (int, float)) else 0.0) + if age <= claim_timeout: + return False, claim + logger.error( + f"[互锁门控] 放行凭证超时作废(已持有 {age:.0f}s > {claim_timeout:.0f}s," + f"token={claim.get('token')}, 持有者={claim.get('task_hint')})。" + f"说明拿到放行权的任务在建单前就异常退出了,请检查上一个任务的日志。" + ) + token = uuid.uuid4().hex + self._interlock_claim = { + "token": token, + "granted_at": now, + "scenario": scenario, + "task_hint": task_hint, } - print(f"[create_orders_v2] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, " - f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, " - f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, " - f"material_count={len(mats)}") + return True, self._interlock_claim - if order_data["totalMass"] <= 0: - print(f"[create_orders_v2] ⚠️ 第 {idx+1} 行总质量 <= 0,可能导致 LIMS 校验失败") - if not mats: - print(f"[create_orders_v2] ⚠️ 第 {idx+1} 行未找到有效物料") + def _release_interlock_claim(self, reason: str = "", token: Optional[str] = None) -> None: + """释放放行凭证(未交接给批次时的兜底路径)。 - orders.append(order_data) - print("================================================") - print("orders:", orders) + 传 token 时只释放匹配的那张:万一本任务的凭证已因超时作废、且被下一个 + 任务重新占据,无差别释放会把别人的凭证抹掉。 + """ + with self._interlock_lock: + claim = self._interlock_claim + if claim is None: + return + if token is not None and claim.get("token") != token: + logger.warning( + f"[互锁门控] 跳过释放:当前凭证 token={claim.get('token')} " + f"不属于本任务(token={token}),可能是本任务凭证已超时作废并被他人重新占据" + ) + return + self._interlock_claim = None + logger.info(f"[互锁门控] 已释放放行凭证 token={claim.get('token')}({reason})") - print(f"[create_orders_v2] 即将提交订单数量: {len(orders)}") - response = self._post_lims("/api/lims/order/orders", orders) - print(f"[create_orders_v2] 接口返回: {response}") - - # 提取所有返回的 orderCode - data_list = response.get("data", []) - if not data_list: - logger.error("创建订单未返回有效数据!") - return response - - # 收集所有 orderCode - order_codes = [] - for order_item in data_list: - code = order_item.get("orderCode") + def has_interlock_claim(self) -> bool: + """凭证是否仍在手。 + + 语义 =「本轮门控已通过但还没建单」。将来若把门控折进 + create_orders 的排程专用变体,内部调用可据此跳过重复门控—— + 否则第二次门控会看到自己那张未交接的凭证而永久等待。 + """ + with self._interlock_lock: + return self._interlock_claim is not None + + def register_interlock_batch( + self, + data_list: List[Dict[str, Any]], + scenario: str = "all", + tag: str = "", + ) -> None: + """建单成功后写入互锁缓存,并接管(清除)放行凭证,完成「凭证 → 批次」交接。""" + batch: Dict[str, str] = {} + name_by_code: Dict[str, str] = {} + for item in data_list or []: + if not isinstance(item, dict): + continue + code = (item.get("orderCode") or "").strip() if code: - order_codes.append(code) - - if not order_codes: - logger.error("未找到任何有效的 orderCode!") - return response - - print(f"[create_orders_v2] 等待 {len(order_codes)} 个订单完成: {order_codes}") - - # ========== 步骤1: 等待所有订单完成并收集报文(不计算质量比)========== + batch[code] = self._normalize_order_id(item.get("orderId")) + # orderName 是 orderId 不可用时的兜底匹配依据,见 _check_batch_all_finished + if item.get("orderName"): + name_by_code[code] = item["orderName"] + if not batch: + logger.warning(f"[{tag}] 建单返回里没有可用 orderCode,互锁缓存未写入") + return + + with self._interlock_lock: + self._interlock_batch = batch + self._interlock_batch_meta = { + "scenario": scenario, + "tag": tag, + "registered_at": time.time(), + "order_name_by_code": name_by_code, + "order_status": {}, # 逐单最终状态,由 _record_batch_order_status 补 + } + claim = self._interlock_claim + self._interlock_claim = None + + logger.info( + f"[{tag}] 互锁缓存已写入 {len(batch)} 单(scenario={scenario}): {list(batch)}" + + (f",并接管放行凭证 token={claim.get('token')}" if claim else "") + ) + + def _record_batch_order_status(self, order_code: str, status: str, detail: str = "") -> None: + """记录本批逐单最终状态,供下一轮门控 blocked 时点名失败单。""" + with self._interlock_lock: + if not self._interlock_batch_meta: + return + self._interlock_batch_meta.setdefault("order_status", {})[order_code] = { + "status": status, + "detail": detail, + } + + def clear_interlock_failure(self) -> Dict[str, Any]: + """人工处置完建单失败后清除失败标记,让门控不再因 Pause 直接阻断。 + + `scheduler_continue` / `scheduler_start` 内部会自动调它, + 这里单独暴露一个入口供调试或人工在别处恢复调度后手动清除。 + """ + with self._interlock_lock: + cleared = self._interlock_last_failure + self._interlock_last_failure = None + if cleared: + logger.info(f"[互锁门控] 已清除建单失败标记: {cleared.get('reason')}") + return {"status": "cleared", "cleared": cleared} + return {"status": "noop", "message": "当前没有建单失败标记"} + + def _describe_failed_orders( + self, failed: List[Dict[str, Any]], lookback_minutes: int + ) -> str: + """给失败单生成可操作的说明:它什么时候滑出窗口、或该把 lookback 调到多少。 + + 没有失败确认(ack)机制,一次失败会把工站锁到该单滑出回溯窗口为止。 + 与其让人对着「blocked」发懵,不如直接告诉他等到几点、或调成多少能立刻放行。 + """ + lines = [] + oldest_age_min: Optional[float] = None + now = datetime.now() + for f in failed: + req = f.get("requestTime") or "" + expire_hint = "" + req_dt = self._parse_lims_time(req) + if req_dt is not None: + age_min = (now - req_dt).total_seconds() / 60.0 + out_at = req_dt + timedelta(minutes=lookback_minutes) + expire_hint = f",将于 {out_at.strftime('%Y-%m-%d %H:%M')} 滑出当前回溯窗口" + oldest_age_min = age_min if oldest_age_min is None else min(oldest_age_min, age_min) + lines.append( + f"{f.get('orderName')}(orderId={f.get('orderId')}, " + f"workflow={f.get('workflowName')}, requestTime={req}){expire_hint}" + ) + + text = "; ".join(lines) + if oldest_age_min is not None and oldest_age_min > 0: + text += ( + f"。若确认这些失败单已人工处置完毕,可临时把 lookback_minutes 调到 " + f"≤ {int(oldest_age_min)} 立即放行" + ) + return text + + def wait_orderlist_idle( + self, + lookback_minutes: int = 3600, + poll_interval: float = 10.0, + timeout: int = 86400, + scenario: str = "all", + workflow_name_keyword: str = "", + page_size: int = 100, + max_pages: int = 40, + check_scheduler: bool = True, + claim_timeout: float = 7200.0, + xlsx_path: str = "", + formulation: Optional[List[Dict[str, Any]]] = None, + task_hint: str = "", + ) -> Dict[str, Any]: + """多任务互锁门控:阻塞到「本工站可以安全发起下一批配液」为止。 + + 放在 `create_orders` **之前**,整条链路只调一次。建单之后的完成等待由 + `_submit_and_wait_orders` 内部负责,不需要二次门控。 + + 放行条件(全部满足): + + 1. 调度处于 Running(Init/Stop/Error* 直接阻断,Pause 视有无失败标记而定) + 2. 窗口内没有配液单处于失败(90) + 3. 窗口内没有配液单处于执行中(60) + 4. 上一批缓存为空,或缓存内订单已全部到达 80/100(到达则清空缓存) + 5. 能占据放行凭证(上一个放行者已完成建单交接) + + Args: + lookback_minutes: 回溯窗口(分钟),默认 3600(60 小时),覆盖多批 + 1–8 瓶的长周期与隔夜任务。现场遇到旧失败单锁站时可临时调小跨过去 + poll_interval: 未空闲时的轮询间隔(秒) + timeout: 整次门控最长等待(秒),默认 24 小时 + scenario: 场景键(all/coin/pouch/cond),**当前只影响过滤范围与统计**, + 不收窄阻断作用域——配液共用同一台硬件,收窄会放进第二批 + workflow_name_keyword: 显式覆盖过滤关键字,非空时优先于 scenario + page_size: order-list 单页条数(映射到接口的 pageCount) + max_pages: 翻页上限,防异常数据把门控拖死 + check_scheduler: 是否检查调度状态,调试可关 + claim_timeout: 放行凭证的兜底作废时长(秒)。必须大于后续 + stack_inquiry_window_5_free 的等待时长,因为门控排在它前面 + xlsx_path / formulation: 可选的本轮配方来源。给了就顺带做一次 + 「绝对不够」预检(不做在飞批次扣减,因此不可能误拦), + 把缺料从排队 40 分钟之后提前到排队第一分钟 + task_hint: 调用方标识,仅用于日志和凭证归属 + + Returns: + {"status": "idle", "reason": ..., "scheduler_status": ..., "scenario": ..., + "matched_keywords": [...], "lookback_minutes": ..., "running": [...], + "failed": [...], "batch": [...], "poll_count": int, "elapsed_seconds": float} + + Raises: + BioyondException: 阻断(blocked)或超时(timeout) + """ + keywords = self._resolve_order_keywords(scenario, workflow_name_keyword) + hint = task_hint or "<未命名任务>" + start = time.time() + poll_count = 0 + scheduler_status: Optional[str] = None + + logger.info( + f"[互锁门控] 进入(任务={hint}, scenario={scenario}, 关键字={keywords}, " + f"回溯 {lookback_minutes} 分钟, 超时 {timeout}s)" + ) + + # 可选的「绝对不够」预检:只拦独占也不够的情况,不做扣减所以不可能误拦 + self._gate_precheck_reagents(xlsx_path, formulation) + + def _result(status: str, reason: str, scan: Dict[str, Any]) -> Dict[str, Any]: + with self._interlock_lock: + batch_snapshot = list(self._interlock_batch) + return { + "status": status, + "reason": reason, + "scheduler_status": scheduler_status, + "scenario": scenario, + "matched_keywords": keywords, + "lookback_minutes": lookback_minutes, + "running": scan.get("running", []), + "failed": scan.get("failed", []), + "batch": batch_snapshot, + "poll_count": poll_count, + "elapsed_seconds": round(time.time() - start, 1), + } + + while True: + elapsed = time.time() - start + + # ---------- 0. 调度状态 ---------- + if check_scheduler: + scheduler_status = self._query_scheduler_status() + if scheduler_status in ("Init", "Stop"): + raise BioyondException( + f"[互锁门控] 调度未启动(schedulerStatus={scheduler_status}),拒绝下发新任务。" + f"请先执行 scheduler_start_and_auto_feeding 起调度后重试。" + f"(不拦的话建单会成功但没人消费,最后表现成「配液超时失败」,极难定位)" + ) + if scheduler_status in ("ErrorPause", "ErrorStop"): + raise BioyondException( + f"[互锁门控] 调度处于错误状态(schedulerStatus={scheduler_status})," + f"需人工在现场/LIMS 清错后再重试。" + ) + if scheduler_status == "Pause": + with self._interlock_lock: + last_failure = self._interlock_last_failure + if last_failure: + # 这不是人工临时暂停,是建单失败时自动暂停的,等下去不会好 + raise BioyondException( + f"[互锁门控] 调度处于暂停状态,且是上一批" + f"{last_failure.get('reason')}后自动暂停的,不会自行恢复。" + f"详情: {last_failure.get('detail')}。" + f"已建成待处置的订单: {last_failure.get('created_order_codes')}。" + f"请补齐试剂后执行 scheduler_continue,或撤单后重新下发。" + ) + logger.info( + f"[互锁门控] 调度处于人工暂停(Pause,无建单失败标记)," + f"等待恢复中…(已等待 {elapsed:.0f}s)" + ) + if elapsed > timeout: + raise BioyondException( + f"[互锁门控] 等待调度从 Pause 恢复超时({timeout}s)" + ) + poll_count += 1 + time.sleep(poll_interval) + continue + + # ---------- 1. 扫 60 / 90 ---------- + scan = self._scan_workflow_statuses(keywords, lookback_minutes, page_size, max_pages) + failed = scan["failed"] + running = scan["running"] + + if failed: + counts = self._count_by_workflow(failed) + detail = self._describe_failed_orders(failed, lookback_minutes) + logger.error(f"[互锁门控] ❌ 窗口内存在失败配液单,分场景计数: {counts}") + raise BioyondException( + f"[互锁门控] 窗口内存在 {len(failed)} 条失败配液单,禁止下发新任务。" + f"失败单: {detail}" + ) + + if running: + counts = self._count_by_workflow(running) + if elapsed > timeout: + raise BioyondException( + f"[互锁门控] 等待配液空闲超时({timeout}s)," + f"仍有 {len(running)} 条执行中: {counts}" + ) + logger.info( + f"[互锁门控] 仍有 {len(running)} 条配液单执行中,分场景计数: {counts}," + f"{poll_interval}s 后重试…(已等待 {elapsed:.0f}s)" + ) + poll_count += 1 + time.sleep(poll_interval) + continue + + # ---------- 2. 核对上一批缓存 ---------- + all_done, pending = self._check_batch_all_finished() + if not all_done: + if elapsed > timeout: + raise BioyondException( + f"[互锁门控] 等待上一批订单终态超时({timeout}s),未完成: {pending}" + ) + logger.info( + f"[互锁门控] 上一批仍有 {len(pending)} 单未到终态: {pending}," + f"{poll_interval}s 后重试…(已等待 {elapsed:.0f}s)" + ) + poll_count += 1 + time.sleep(poll_interval) + continue + + with self._interlock_lock: + cleared_batch = list(self._interlock_batch) + self._interlock_batch = {} + self._interlock_batch_meta = {} + if cleared_batch: + logger.info(f"[互锁门控] 上一批 {len(cleared_batch)} 单已全部完成,清空缓存: {cleared_batch}") + + # ---------- 3. 占据放行凭证 ---------- + acquired, holder = self._acquire_interlock_claim(scenario, hint, claim_timeout) + if not acquired: + if elapsed > timeout: + raise BioyondException( + f"[互锁门控] 等待上一个放行者建单超时({timeout}s)," + f"当前凭证持有者={holder.get('task_hint')}" + ) + logger.info( + f"[互锁门控] 上一个放行者({holder.get('task_hint')})尚未完成建单," + f"{poll_interval}s 后重试…(已等待 {elapsed:.0f}s)" + ) + poll_count += 1 + time.sleep(poll_interval) + continue + + logger.info( + f"[互锁门控] ✅ 放行(任务={hint}, 轮询 {poll_count} 次, " + f"耗时 {time.time() - start:.1f}s, 凭证 token={holder.get('token')})" + ) + return _result("idle", "无执行中/失败配液单,上一批已完成,凭证已占据", scan) + + def _gate_precheck_reagents( + self, xlsx_path: str = "", formulation: Optional[List[Dict[str, Any]]] = None + ) -> None: + """门控里的兜底预检:只拦「绝对不够」(独占也不够)。 + + 主机制是提交前的 `precheck_reagents_total`(那时没有在飞批次,结论无歧义)。 + 这层只覆盖「有人绕过提交 skill 直接下发任务」的情况。 + + **刻意不做在飞批次扣减**:入口看到的库存不含在飞批次的消耗,扣多少取决于 + LIMS 是建单即锁还是消耗才扣,猜错方向就会误拦——把本来能跑的批次挡住, + 比漏拦更难接受。这里只拦「就算独占也不够」,因此不可能误拦。 + """ + if not xlsx_path and not formulation: + return + try: + report = self.precheck_reagents_total( + formulations=[formulation] if formulation else None, + xlsx_paths=[xlsx_path] if xlsx_path else None, + raise_on_shortage=False, + ) + except Exception as e: + logger.warning(f"[互锁门控] 兜底预检执行异常,跳过(不阻断): {e}") + return + + if not report.get("ok"): + raise BioyondException( + f"[互锁门控] 本轮配方所需试剂在库余量绝对不足(就算独占整台设备也不够)," + f"提前拦下、一单未建。缺口: {'; '.join(report.get('shortages') or [])}" + ) + + def _submit_and_wait_orders( + self, + orders: List[Dict[str, Any]], + tag: str = "create_orders", + batch_id: str = "", + ) -> Dict[str, Any]: + """公共流程的对外入口:只负责保证放行凭证一定会被释放。 + + 本体在 `_submit_and_wait_orders_impl`。这里包一层 try/finally 是因为 + 本体有两条在 `register_interlock_batch` **之前**就抛出的路径——试剂预检 + 不通过、建单未全部成功。这两条路径下凭证没人接管,只能干等 + claim_timeout(默认 2 小时),等于一次缺料把整站锁 2 小时, + 比它要解决的「半批订单」更难受。claim_timeout 只用来兜进程崩溃这类 + 未知情况,已知的异常路径必须在这里主动释放。 + """ + with self._interlock_lock: + claim_token = (self._interlock_claim or {}).get("token") + + try: + return self._submit_and_wait_orders_impl(orders, tag=tag, batch_id=batch_id) + finally: + # 建单成功时 register_interlock_batch 已把凭证接管清除,这里查不到匹配的 token; + # 只有异常路径才会真的释放。 + if claim_token: + self._release_interlock_claim( + reason=f"{tag} 未完成建单交接,异常路径兜底释放", token=claim_token + ) + + def _submit_and_wait_orders_impl( + self, + orders: List[Dict[str, Any]], + tag: str = "create_orders", + batch_id: str = "", + ) -> Dict[str, Any]: + """ + 公共流程:提交 orders → 等待完成 → 计算质量比 → 提取分液瓶板 → 返回结果。 + 由 create_orders / create_orders_formulation 调用。 + + batch_id 由调用方传入(约定 = Excel 文件名 / 入参 batch_id),随 final_result 透出, + 供下游通过 UniLab output handle 引用。 + """ + logger.info(f"[{tag}] 即将提交 {len(orders)} 个订单") + self._preflight_check_reagents(orders, tag) + response = self._post_lims("/api/lims/order/orders", orders) + logger.info(f"[{tag}] 接口返回: {response}") + + # ========== 逐条判定建单结果 ========== + # 判据与 conductivity_test_inline 一致:errorMessage 为空 + orderCode 非空才算建成。 + # LIMS 按订单全有或全无:某单试剂不够时它那条 orderCode=null、errorMessage 写明原因, + # 其余单照常建出来。原先只按 orderCode 真值过滤,失败条目被列表推导静默丢掉, + # 请求 8 单只建成 4 单也照样往下走,最后还报 all_completed。 + data_list = response.get("data") or [] + if not isinstance(data_list, list): + data_list = [] + + created_entries: List[Dict[str, Any]] = [] + failed_entries: List[Dict[str, Any]] = [] + for item in data_list: + if not isinstance(item, dict): + continue + err_msg = (item.get("errorMessage") or "").strip() + code = (item.get("orderCode") or "").strip() + if code and not err_msg: + created_entries.append(item) + else: + failed_entries.append(item) + + order_codes = [(item.get("orderCode") or "").strip() for item in created_entries] + + # 数量也要对账:LIMS 可能少返条目,此时既没有 orderCode 也没有 errorMessage + if failed_entries or len(created_entries) != len(orders): + # orderName 是入参带过来的,失败条目也有,用它定位是哪一单 + fail_desc = "; ".join( + f"{e.get('orderName') or '<无名>'}: " + f"{(e.get('errorMessage') or '').strip() or '未返回 orderCode'}" + for e in failed_entries + ) or f"LIMS 只返回 {len(data_list)} 条条目(请求 {len(orders)} 单)" + + logger.error( + f"[{tag}] ❌ 建单未全部成功: 请求 {len(orders)} 单,建成 {len(created_entries)} 单," + f"失败 {len(failed_entries)} 单 → {fail_desc}" + ) + + # 先暂停调度:已建成的单还在 LIMS 队列里,暂停后它们不会继续执行, + # 给人工留出补试剂或撤单的处置窗口。暂停失败不能盖掉建单失败本身,只记日志。 + try: + pause_resp = self.scheduler_pause() + logger.warning(f"[{tag}] 已暂停调度,等待人工处置: {pause_resp}") + paused_hint = ( + "调度已暂停,请补齐试剂后调 scheduler_continue 继续,或撤单后重新下发。" + ) + # 记下「这次 Pause 是自动暂停的」。否则下一个任务的门控看到 Pause + # 会当成人工临时暂停一路轮询到 24h 超时,只报个「超时」把真实原因埋掉。 + with self._interlock_lock: + self._interlock_last_failure = { + "reason": "建单未全部成功", + "detail": fail_desc, + "created_order_codes": order_codes, + "paused_at": time.time(), + "tag": tag, + } + except Exception as e: + logger.error(f"[{tag}] ⚠️ 暂停调度失败,请立即人工干预: {e}") + paused_hint = f"调度暂停失败({e}),已建成的订单可能仍在执行,请立即人工停机处置。" + + # 必须抛:一是调度已暂停,已建成的单永远不会 finish,继续等只会挂到超时; + # 二是抛异常才会让 edge 把 job_status=failed + 本消息推到云端提醒人工。 + # 订单尚未执行,也就还没产出瓶板,跳过后处理不会让资源树漂移。 + raise BioyondException( + f"[{tag}] 建单未全部成功(请求 {len(orders)} 单 / 建成 {len(created_entries)} 单)。" + f"失败原因: {fail_desc}。" + f"已建成待处置的订单: {order_codes or '无'}。{paused_hint}" + ) + + logger.info(f"[{tag}] 等待 {len(order_codes)} 个订单完成: {order_codes}") + + # 建单数量已对账,写入互锁缓存并接管放行凭证(完成「凭证 → 批次」交接)。 + # 必须在等待完成之前写:本批还在跑的这几十分钟里,其它任务的门控要靠 + # 这份缓存 + order-list 的 60 才知道工站正忙。 + try: + self.register_interlock_batch(created_entries, scenario="all", tag=tag) + except Exception as e: + logger.warning(f"[{tag}] 写入互锁缓存失败(不影响本批执行): {e}") + + # 重置配液/分液进度统计(供前端轮询 data_*_completion_percentage 属性) + try: + self._reset_progress_tracking(orders, order_codes, data_list) + except Exception as e: + logger.warning(f"[{tag}] 重置进度统计失败(不影响下单): {e}") + + # orderCode → 建单条目(含 orderId / usedMaterials),供 wait 双字段校验与后处理降级 + # 键取自 created_entries 并同样 strip,保证和 order_codes 对得上 + create_entry_by_code = { + (item.get("orderCode") or "").strip(): item + for item in created_entries + } + + # ========== 等待所有订单完成 ========== all_reports = [] for idx, order_code in enumerate(order_codes, 1): - print(f"[create_orders_v2] 正在等待第 {idx}/{len(order_codes)} 个订单: {order_code}") - result = self.wait_for_order_finish(order_code) - - # 提取报文数据 + create_entry = create_entry_by_code.get(order_code) or {} + expected_oid = self._normalize_order_id(create_entry.get("orderId")) + logger.info( + f"[{tag}] 等待第 {idx}/{len(order_codes)} 个订单: {order_code} " + f"(orderId={expected_oid or '<无>'})" + ) + result = self.wait_for_order_finish( + order_code, expected_order_id=expected_oid or None + ) if result.get("status") == "success": report = result.get("report", {}) + self._enrich_report_materials_from_create(report, create_entry) all_reports.append(report) - print(f"[create_orders_v2] ✓ 订单 {order_code} 完成") + self._record_batch_order_status(order_code, "success") + logger.info(f"[{tag}] ✓ 订单 {order_code} 完成") else: logger.warning(f"订单 {order_code} 状态异常: {result.get('status')}") - # 即使订单失败,也记录下这个结果 all_reports.append({ "orderCode": order_code, + "orderId": expected_oid, "status": result.get("status"), - "error": result.get("message", "未知错误") + "error": result.get("message", "未知错误"), }) - - print(f"[create_orders_v2] 所有订单已完成,共收集 {len(all_reports)} 个报文") - - # ========== 步骤2: 统一计算所有订单的质量比 ========== - print(f"[create_orders_v2] 开始统一计算 {len(all_reports)} 个订单的质量比...") - all_mass_ratios = [] # 存储所有订单的质量比,与reports顺序一致 - + # 记进批次 meta,下一轮门控 blocked 时能点名是哪一单挂的, + # 把定位指回配液侧(否则报错只出现在下游转运节点上) + self._record_batch_order_status( + order_code, result.get("status") or "failed", + result.get("message", "未知错误"), + ) + + # ========== timeout 兜底:隔夜暂停等场景下,迟到的 finish 报文可能已被缓存 ========== + # wait 超时后报文才到达 `_order_finish_reports`,此处再捞一次,把 timeout 升级为成功。 + recovered = 0 + for i, report in enumerate(all_reports): + # 仅兜底占位行(wait 失败时写入的 {orderCode, status, error}) + if "error" not in report: + continue + order_code = report.get("orderCode") or "" + if not order_code: + continue + expected_oid = self._normalize_order_id( + (create_entry_by_code.get(order_code) or {}).get("orderId") + ) + with self._order_finish_lock: + cached = self._order_finish_reports.pop(order_code, None) + if not cached: + continue + if not self._report_matches_expected(order_code, expected_oid, cached): + logger.warning( + f"[{tag}] timeout 兜底拒绝撞号报文: orderCode={order_code}, " + f"期望orderId={expected_oid}, 报文orderId={cached.get('orderId')}" + ) + continue + classified = self._classify_finish_report( + order_code, cached, expected_order_id=expected_oid or None + ) + if classified.get("status") == "success": + recovered_report = classified.get("report", cached) + self._enrich_report_materials_from_create( + recovered_report, create_entry_by_code.get(order_code) + ) + all_reports[i] = recovered_report + recovered += 1 + logger.info( + f"[{tag}] ✓ timeout 兜底恢复订单 {order_code}(命中迟到缓存报文)" + ) + else: + logger.warning( + f"[{tag}] timeout 兜底命中但状态非 success: " + f"orderCode={order_code}, status={classified.get('status')}" + ) + if recovered: + logger.info(f"[{tag}] timeout 兜底共恢复 {recovered} 个订单") + + # ========== timeout 兜底(LIMS 核对)========== + # 本地缓存里也没有迟到报文时(如隔夜暂停、进程重启导致推送彻底丢失), + # 直接查 order-list 确认 LIMS 是否已完成;完成则用建单 usedMaterials 合成 report, + # 让后续 prep/vial/plate 提取照常补全条码与类型。 + lims_recovered = 0 + for i, report in enumerate(all_reports): + if "error" not in report: + continue + oc = report.get("orderCode") or "" + synth = self._recover_timeout_order_report(oc, create_entry_by_code.get(oc)) + if synth: + all_reports[i] = synth + lims_recovered += 1 + if lims_recovered: + logger.info(f"[{tag}] LIMS 核对兜底共恢复 {lims_recovered} 个订单") + + logger.info(f"[{tag}] 所有订单已完成,共收集 {len(all_reports)} 个报文") + + # ========== 计算质量比 ========== + all_mass_ratios = [] for idx, report in enumerate(all_reports, 1): order_code = report.get("orderCode", "N/A") - print(f"[create_orders_v2] 计算第 {idx}/{len(all_reports)} 个订单 {order_code} 的质量比...") - - # 只为成功完成的订单计算质量比 if "error" not in report: try: mass_ratios = self._process_order_reagents(report) - # 精简输出,只保留核心质量比信息 all_mass_ratios.append({ "orderCode": order_code, "orderName": report.get("orderName", "N/A"), "real_mass_ratio": mass_ratios.get("real_mass_ratio", {}), - "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}) + "target_mass_ratio": mass_ratios.get("target_mass_ratio", {}), + "mass_tolerance": mass_ratios.get("mass_tolerance", {}), + "total_mass_tolerance": mass_ratios.get("total_mass_tolerance", None), }) - logger.info(f"✓ 已计算订单 {order_code} 的试剂质量比") + logger.info(f"✓ 已计算订单 {order_code} 的试剂质量比和允差") except Exception as e: logger.error(f"计算订单 {order_code} 质量比失败: {e}") all_mass_ratios.append({ @@ -1127,40 +2848,4272 @@ def _as_str(val, default="") -> str: "orderName": report.get("orderName", "N/A"), "real_mass_ratio": {}, "target_mass_ratio": {}, - "error": str(e) + "mass_tolerance": {}, + "total_mass_tolerance": None, + "error": str(e), }) else: - # 失败的订单不计算质量比 all_mass_ratios.append({ "orderCode": order_code, "orderName": report.get("orderName", "N/A"), "real_mass_ratio": {}, "target_mass_ratio": {}, - "error": "订单未成功完成" + "mass_tolerance": {}, + "total_mass_tolerance": None, + "error": "订单未成功完成", }) - - print(f"[create_orders_v2] 质量比计算完成") - print("实验记录本========================create_orders_v2========================") - - # 返回所有订单的完成报文 + + logger.info(f"[{tag}] 质量比计算完成") + + # ========== 提取分液瓶板信息 + 创建资源树对象 ========== + # 关键设计(2026-06-04 调整): + # 物理 plate 是按 materialId 唯一的;同一块物理 plate 可能在多个 order 的 + # usedMaterials 里都被列出(例如 2 个配液 order 把瓶子分别装到同一物理板的不同孔位)。 + # 因此 all_vial_plates 必须按 materialId 去重,每个 unique plate 用 order_refs 字段 + # 收集所有引用过它的 (orderId, orderCode) 对(保留供追溯)。瓶→单归属现由 + # vial_bottle_positions(detailMaterialId→orderCode 权威映射)承担,不再靠 associateId。 + plates_by_material: Dict[str, Dict[str, Any]] = {} + for report in all_reports: + plate_list = self._extract_vial_plate_from_report(report) + for vial_plate_info in plate_list: + material_id = vial_plate_info.get("materialId") or "" + if not material_id: + logger.warning( + f"[资源树] ⚠️ 跳过 materialId 为空的 plate_info: {vial_plate_info}" + ) + continue + + ref_entry = { + "orderId": vial_plate_info.get("orderId") or "", + "orderCode": vial_plate_info.get("orderCode") or "", + } + + if material_id in plates_by_material: + existing = plates_by_material[material_id] + if ref_entry not in existing["order_refs"]: + existing["order_refs"].append(ref_entry) + logger.info( + f"[资源树] ℹ️ 瓶板已存在,合并 order_ref: materialId={material_id[:20]}..., " + f"+orderCode={ref_entry['orderCode']} (共用同一物理瓶板)" + ) + continue + + # 首次出现,创建去重后的条目;保留 first 的 locationId/typeName/barCode + merged = { + "materialId": material_id, + "locationId": vial_plate_info.get("locationId") or "", + "orderCode": ref_entry["orderCode"], # = 第一次引用的 orderCode(向后兼容) + "orderId": ref_entry["orderId"], # 同上 + "typeName": vial_plate_info.get("typeName") or "", + "barCode": vial_plate_info.get("barCode") or "", + # 源坐标必须一起带过来:漏了会让下游 _find_plate_xyz / 321 转运 + # 拿不到坐标,退化成按槽位标签硬算(C03→(3,3,1)),指到不存在的库位 + "source_x": vial_plate_info.get("source_x", 1), + "source_y": vial_plate_info.get("source_y", 1), + "source_z": vial_plate_info.get("source_z", 1), + "source_found": bool(vial_plate_info.get("source_found")), + "batch_id": batch_id, + "order_refs": [ref_entry], # 完整列表,保留供追溯(归属改由 vial_bottle_positions 承担) + } + plates_by_material[material_id] = merged + + try: + self._create_vial_plate_resource(merged) + logger.info( + f"[资源树] ✅ 瓶板资源创建成功: orderCode={ref_entry['orderCode']}, " + f"materialId={material_id[:20]}..." + ) + except Exception as e: + logger.error( + f"[资源树] 创建失败: orderCode={ref_entry['orderCode']}, 错误={e}" + ) + + all_vial_plates: List[Dict[str, Any]] = list(plates_by_material.values()) + + logger.info( + f"[{tag}] 跨 {len(all_reports)} 个订单去重后得到 {len(all_vial_plates)} 块物理瓶板 " + f"(每块板的 order_refs 长度: " + f"{[len(p['order_refs']) for p in all_vial_plates]})" + ) + + # ========== 提取配液瓶 + 分液瓶信息(用于 CSV 导出)========== + # 配液瓶:逐单按 typeName 提取(已去掉库位白名单,兼容多配液瓶板) + all_prep_bottles = [] + for report in all_reports: + try: + prep_info = self._extract_prep_bottle_from_report(report) + all_prep_bottles.append(prep_info) + except Exception as e: + logger.error(f"[提取配液瓶] 异常: orderCode={report.get('orderCode')}, 错误={e}") + all_prep_bottles.append(None) + + # 分液瓶:主路径 = 逐单按 typeName 扫描单瓶物料(typeName=5ml/20ml分液瓶)。 + # 单瓶物料始终留在各单报文里,且每单报文天然只含自己的瓶,即便分液瓶板 + # 后续被电导站消耗、板 detail 清空也不受影响。 + # 板级路径(板 detail + associateId)作为兜底,用于报文里查不到单瓶、 + # 但板仍在库且 detail 完整的场景。 + order_codes_for_vial = [ + r.get("orderCode") for r in all_reports if r.get("orderCode") + ] + + # 权威 materialId → orderCode(来自 create 响应 usedMaterials,不依赖 associateId)。 + # 供板级兜底提取 + 分液瓶孔位映射(vial_bottle_positions)复用。 + mat2order = self._build_vial_bottle_mat2order(data_list) + + all_vial_bottles: List[List[Dict]] = [] + need_plate_fallback = False + for report in all_reports: + oc = report.get("orderCode") or "" + vial_list: List[Dict] = [] + if "error" not in report: + try: + vial_list = self._extract_vial_bottles_from_report(report) + except Exception as e: + logger.error(f"[提取分液瓶] 逐单异常: orderCode={oc}, 错误={e}") + vial_list = [] + if not vial_list and "error" not in report: + need_plate_fallback = True + all_vial_bottles.append(vial_list) + + # 兜底:仍有订单没提到分液瓶时,尝试板级路径补齐 + if need_plate_fallback and all_vial_plates: + try: + vials_by_order = self._extract_vial_bottles_from_plates( + all_vial_plates, order_codes_for_vial, mat2order=mat2order + ) + except Exception as e: + logger.error(f"[提取分液瓶-板级] 兜底异常: {e}") + vials_by_order = {} + for i, report in enumerate(all_reports): + oc = report.get("orderCode") or "" + if not all_vial_bottles[i] and oc and vials_by_order.get(oc): + all_vial_bottles[i] = vials_by_order[oc] + logger.info( + f"[提取分液瓶] 板级兜底补齐: orderCode={oc}, " + f"数量={len(all_vial_bottles[i])}" + ) + + logger.info( + f"[{tag}] 配液瓶提取完成: {sum(1 for p in all_prep_bottles if p)} 个, " + f"分液瓶提取完成: {sum(len(v) for v in all_vial_bottles if isinstance(v, list))} 个" + ) + + # ========== 将条码 + 类型附加到 mass_ratios 中(给扣电组装站 / 电导 inline CSV 使用)========== + # 2026-06-26:额外附加 prep_bottle_type / vial_bottle_types,使电导 inline 仅靠 + # 单个 mass_ratios handle 即可生成"配液+电导"合并 CSV(含配液瓶类型 / 分液瓶类型)。 + for idx in range(len(all_mass_ratios)): + if idx < len(all_prep_bottles) and all_prep_bottles[idx]: + all_mass_ratios[idx]["prep_bottle_barcode"] = all_prep_bottles[idx].get("barCode", "") + all_mass_ratios[idx]["prep_bottle_type"] = all_prep_bottles[idx].get("typeName", "") + else: + all_mass_ratios[idx]["prep_bottle_barcode"] = "" + all_mass_ratios[idx]["prep_bottle_type"] = "" + + if idx < len(all_vial_bottles): + vials = all_vial_bottles[idx] + if len(vials) == 0: + all_mass_ratios[idx]["vial_bottle_barcodes"] = "" + all_mass_ratios[idx]["vial_bottle_types"] = "" + elif len(vials) == 1: + all_mass_ratios[idx]["vial_bottle_barcodes"] = vials[0].get("barCode", "") + all_mass_ratios[idx]["vial_bottle_types"] = vials[0].get("typeName", "") + else: + all_mass_ratios[idx]["vial_bottle_barcodes"] = json.dumps([v.get("barCode", "") for v in vials], ensure_ascii=False) + all_mass_ratios[idx]["vial_bottle_types"] = json.dumps([v.get("typeName", "") for v in vials], ensure_ascii=False) + else: + all_mass_ratios[idx]["vial_bottle_barcodes"] = "" + all_mass_ratios[idx]["vial_bottle_types"] = "" + + # ========== 构建分液瓶孔位映射(vial_bottle_positions)========== + # 板状态干净时查每块板 detail,按 detailMaterialId 命中权威 mat2order 归属订单, + # 过滤空占位孔/非本批瓶。供下游电导 conductivity_test_inline 直接组装 entry + # (不再靠 associateId),并注入 mass_ratios 激活导出位置回退匹配。 + all_vial_bottle_positions: List[Dict[str, Any]] = [] + try: + all_vial_bottle_positions = self._build_vial_bottle_positions(all_vial_plates, mat2order) + # 按 orderCode 分组注入到对应 mass_ratios 项(激活 _match_formula_by_position 位置回退) + pos_by_order: Dict[str, List[Dict[str, Any]]] = {} + for pos in all_vial_bottle_positions: + oc = pos.get("orderCode") or "" + if oc: + pos_by_order.setdefault(oc, []).append(pos) + for mr in all_mass_ratios: + if isinstance(mr, dict): + mr["vial_bottle_positions"] = pos_by_order.get(mr.get("orderCode") or "", []) + except Exception as e: + logger.warning(f"[{tag}] 构建分液瓶孔位映射失败(不影响下单/导出): {e}") + + # ========== 提取各类瓶板的源坐标(用于 321/32 任务 handles 传参)========== + def _find_plate_xyz(plates, type_keyword): + """取该板型在自动堆栈-左的源坐标;未命中则返回占位 (1,1,1) 并告警。 + + 占位值不可直接用于建单:A01 上可能是另一块板。手动调 321/32 时若发现 + 日志里是占位值,说明配液刚结束板还没进自动堆栈-左,应改用 *_auto + 版本(转运前会实时查库定位)。 + """ + for p in plates: + if p and type_keyword in p.get("typeName", ""): + if not p.get("source_found"): + logger.warning( + f"[{tag}] ⚠️ {type_keyword} 未在自动堆栈-左命中库位," + f"源坐标输出为占位值 (1,1,1),不可直接用于手动转运建单" + ) + return p.get("source_x", 1), p.get("source_y", 1), p.get("source_z", 1) + return 1, 1, 1 + + vial_321_x, vial_321_y, vial_321_z = _find_plate_xyz(all_vial_plates, "5ml分液瓶板") + vial_32_x, vial_32_y, vial_32_z = _find_plate_xyz(all_vial_plates, "20ml分液瓶板") + logger.info( + f"[{tag}] 3-2-1源坐标(5ml): ({vial_321_x},{vial_321_y},{vial_321_z}), " + f"3-2源坐标(20ml): ({vial_32_x},{vial_32_y},{vial_32_z})" + ) + + # ========== 构造最终结果 ========== final_result = { + # 走到这里说明建单数量已和请求对齐(不齐的在上面就暂停调度并抛了)。 + # total_orders 用请求数而非建成数:拿建成数当分母,4/8 会显示成 4/4。 "status": "all_completed", - "total_orders": len(order_codes), - "bottle_count": len(order_codes), # 明确标注瓶数,用于下游check - "reports": all_reports, # 原始订单报文(不含质量比) - "mass_ratios": all_mass_ratios, # 所有质量比统一放在这里 - "original_response": response + "total_orders": len(orders), + "bottle_count": len(order_codes), + "reports": all_reports, + "mass_ratios": all_mass_ratios, + "vial_plates": all_vial_plates, + "prep_bottles": all_prep_bottles, + "vial_bottles": all_vial_bottles, + "vial_bottle_positions": all_vial_bottle_positions, + "original_response": response, + "vial_321_source_pos": {"x": vial_321_x, "y": vial_321_y, "z": vial_321_z}, + "vial_32_source_pos": {"x": vial_32_x, "y": vial_32_y, "z": vial_32_z}, + } + + logger.info("=" * 80) + logger.info(f"[{tag}] 返回报文数量: {len(all_reports)}, 分液瓶板数量: {len(all_vial_plates)}") + for idx, vial_plate in enumerate(all_vial_plates, 1): + logger.info( + f" [{idx}] orderCode={vial_plate.get('orderCode', 'N/A')}, " + f"materialId={vial_plate.get('materialId', 'N/A')[:20]}..., " + f"locationId={vial_plate.get('locationId', 'N/A')[:20]}..., " + f"typeName={vial_plate.get('typeName', 'N/A')}" + ) + logger.info("=" * 80) + + return final_result + + # -------------------- 2.14 新建实验(Excel 入口) -------------------- + def create_orders(self, xlsx_path: str, csv_export_path: str = "") -> Dict[str, Any]: + """ + 从 Excel 解析并创建实验(2.14)- V2版本 + 约定: + - batchId = Excel 文件名(不含扩展名) + - 物料列:所有以 "(g)" 结尾(不再读取"总质量(g)"列) + - totalMass 自动计算为所有物料质量之和 + - createTime 缺失或为空时自动填充为当前日期(YYYY/M/D) + """ + default_path = Path("D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\2025122301.xlsx") + path = Path(xlsx_path) if xlsx_path else default_path + print(f"[create_orders_v2] 使用 Excel 路径: {path}") + if path != default_path: + print("[create_orders_v2] 来源: 调用方传入自定义路径") + else: + print("[create_orders_v2] 来源: 使用默认模板路径") + + if not path.exists(): + print(f"[create_orders_v2] ⚠️ Excel 文件不存在: {path}") + raise FileNotFoundError(f"未找到 Excel 文件:{path}") + + try: + df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败:{e}") + print(f"[create_orders_v2] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}") + + # 列名容错:返回可选列名,找不到则返回 None + def _pick(col_names: List[str]) -> Optional[str]: + for c in col_names: + if c in df.columns: + return c + return None + + col_order_name = _pick(["配方ID", "orderName", "订单编号"]) + col_create_time = _pick(["创建日期", "createTime"]) + col_bottle_type = _pick(["配液瓶类型", "bottleType"]) + col_mix_time = _pick(["混匀时间(s)", "mixTime"]) + col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"]) + col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"]) + col_cond = _pick(["电导测试分液体积", "conductivityInfo"]) + col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"]) + print("[create_orders_v2] 列匹配结果:", { + "order_name": col_order_name, + "create_time": col_create_time, + "bottle_type": col_bottle_type, + "mix_time": col_mix_time, + "load": col_load, + "pouch": col_pouch, + "conductivity": col_cond, + "conductivity_bottle_count": col_cond_cnt, + }) + + # 物料列:所有以 (g) 结尾,但排除"总质量(g)"等聚合列 + # (totalMass 自动按物料质量求和,不能把"总质量"当物料发给 LIMS,否则 LIMS 报"物料总质量不可用") + _NON_MATERIAL_NAMES = {"总质量", "totalMass", "TotalMass", "total_mass"} + material_cols = [ + c for c in df.columns + if isinstance(c, str) and c.endswith("(g)") + and c.replace("(g)", "").strip() not in _NON_MATERIAL_NAMES + ] + print(f"[create_orders_v2] 识别到的物料列: {material_cols}") + if not material_cols: + raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。") + + batch_id = path.stem + + def _to_ymd_slash(v) -> str: + # 统一为 "YYYY/M/D";为空或解析失败则用当前日期 + if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": + ts = datetime.now() + else: + try: + ts = pd.to_datetime(v) + except Exception: + ts = datetime.now() + return f"{ts.year}/{ts.month}/{ts.day}" + + def _as_int(val, default=0) -> int: + try: + if pd.isna(val): + return default + return int(val) + except Exception: + return default + + def _as_float(val, default=0.0) -> float: + try: + if pd.isna(val): + return default + return float(val) + except Exception: + return default + + def _as_str(val, default="") -> str: + if val is None or (isinstance(val, float) and pd.isna(val)): + return default + s = str(val).strip() + return s if s else default + + orders: List[Dict[str, Any]] = [] + + for idx, row in df.iterrows(): + mats: List[Dict[str, Any]] = [] + total_mass = 0.0 + + for mcol in material_cols: + val = row.get(mcol, None) + if val is None or (isinstance(val, float) and pd.isna(val)): + continue + try: + mass = float(val) + except Exception: + continue + if mass > 0: + mats.append({"name": mcol.replace("(g)", ""), "mass": mass}) + total_mass += mass + else: + if mass < 0: + print(f"[create_orders_v2] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}") + + order_data = { + "batchId": batch_id, + "orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}", + "createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None), + "bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶", + "mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0, + "loadSheddingInfo": _as_float(row[col_load]) if col_load else 0.0, + "pouchCellInfo": _as_float(row[col_pouch]) if col_pouch else 0, + "conductivityInfo": _as_float(row[col_cond]) if col_cond else 0, + "conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0, + "materialInfos": mats, + "totalMass": round(total_mass, 4) # 自动汇总 + } + print(f"[create_orders_v2] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, " + f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, " + f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, " + f"material_count={len(mats)}") + + if order_data["totalMass"] <= 0: + print(f"[create_orders_v2] ⚠️ 第 {idx+1} 行总质量 <= 0,可能导致 LIMS 校验失败") + if not mats: + print(f"[create_orders_v2] ⚠️ 第 {idx+1} 行未找到有效物料") + + orders.append(order_data) + + if not orders: + logger.error("[create_orders] 没有有效的订单可提交") + return {"status": "error", "message": "没有有效订单数据"} + + result = self._submit_and_wait_orders(orders, tag="create_orders", batch_id=batch_id) + + # ========== CSV 导出 ========== + if csv_export_path: + try: + csv_file = self._export_order_csv(result, csv_export_path) + result["csv_file"] = csv_file + except Exception as e: + logger.error(f"[create_orders] CSV 导出失败: {e}") + + return result + + def create_orders_formulation( + self, + formulation: List[Dict[str, Any]], + batch_id: str = "", + order_names: List[str] = [], + bottle_type: str = "配液小瓶", + mix_time: List[int] = [], + coin_cell_volume: float = 0.0, + pouch_cell_volume: float = 0.0, + conductivity_volume: float = 0.0, + conductivity_bottle_count: int = 0, + csv_export_path: str = "", + ) -> Dict[str, Any]: + """ + 配方批量输入版本的 create_orders —— 等价于 create_orders, + 但参数来源于前端 FormulationBatchWidget,而非 Excel 文件。 + + Args: + formulation: 配方列表,每个元素代表一个订单(一瓶),格式: + [ + { + "order_name": "配方A", # 可选,配方名称 + "materials": [ # 物料列表 + {"name": "LiPF6", "mass": 12.5}, + {"name": "EC", "mass": 50.0}, + ] + }, + ... + ] + batch_id: 批次ID,若为空则用当前时间戳 + order_names: 配方ID/订单编号列表,与 formulation 一一对应。 + 用于填写 DoE 撒点编号等自定义标识,便于后续扣电组装、测试环节追溯。 + 优先级:order_names > formulation 内的 order_name > 自动生成({batch_id}_order_{序号}) + bottle_type: 配液瓶类型,默认 "配液小瓶" + mix_time: 混匀时间列表(秒),与 formulation 一一对应,不足则补 0 + coin_cell_volume: 纽扣电池组装分液体积 + pouch_cell_volume: 软包电池注液组装分液体积 + conductivity_volume: 电导率测试分液体积 + conductivity_bottle_count: 电导测试分液瓶数 + + Returns: + 与 create_orders 返回格式一致的结果字典 + """ + if not formulation: + raise ValueError("formulation 参数不能为空") + + if not batch_id: + batch_id = f"formulation_{datetime.now().strftime('%Y%m%d%H%M%S')}" + + create_time = f"{datetime.now().year}/{datetime.now().month}/{datetime.now().day}" + + # 将 formulation 转换为 LIMS orders 格式(与 create_orders 中的格式一致) + orders: List[Dict[str, Any]] = [] + for idx, item in enumerate(formulation): + materials = item.get("materials", []) + item.get("liquids", []) # 兼容两种物料列表命名 + if idx < len(order_names) and order_names[idx]: + order_name = str(order_names[idx]) + else: + order_name = str(item.get("order_name", f"{batch_id}_order_{idx + 1}")) + + mats: List[Dict[str, Any]] = [] + total_mass = 0.0 + for mat in materials: + name = mat.get("name", "") + mass = float(mat.get("mass", mat.get("volume", 0.0))) + if name and mass > 0: + mats.append({"name": name, "mass": mass}) + total_mass += mass + + if not mats: + logger.warning(f"[create_orders_formulation] 第 {idx + 1} 个配方无有效物料,跳过") + continue + + if isinstance(mix_time, (int, float)): + raw_mix_time = mix_time + else: + raw_mix_time = mix_time[idx] if idx < len(mix_time) else None + try: + item_mix_time = int(raw_mix_time) if raw_mix_time not in (None, "", "null") else 0 + except (ValueError, TypeError): + item_mix_time = 0 + logger.info(f"[create_orders_formulation] 第 {idx + 1} 个配方: orderName={order_name}, " + f"coinCellVolume={coin_cell_volume}, pouchCellVolume={pouch_cell_volume}, " + f"conductivityVolume={conductivity_volume}, totalMass={total_mass}, " + f"material_count={len(mats)}") + + orders.append({ + "batchId": batch_id, + "orderName": order_name, + "createTime": create_time, + "bottleType": bottle_type, + "mixTime": item_mix_time, + "loadSheddingInfo": coin_cell_volume, + "pouchCellInfo": pouch_cell_volume, + "conductivityInfo": conductivity_volume, + "conductivityBottleCount": conductivity_bottle_count, + "materialInfos": mats, + "totalMass": round(total_mass, 4), + }) + + if not orders: + logger.error("[create_orders_formulation] 没有有效的订单可提交") + return {"status": "error", "message": "没有有效配方数据"} + + result = self._submit_and_wait_orders(orders, tag="create_orders_formulation", batch_id=batch_id) + + # ========== CSV 导出 ========== + if csv_export_path: + try: + csv_file = self._export_order_csv(result, csv_export_path) + result["csv_file"] = csv_file + except Exception as e: + logger.error(f"[create_orders_formulation] CSV 导出失败: {e}") + + return result + + # -------------------- 2.37 5号站新建实验(手动 Excel 入口) -------------------- + def _validate_plate_barcode(self, barcode: str) -> Dict[str, Any]: + """ + 在 LIMS 物料系统中按 barCode 查找分液板物料,命中返回物料字典,未命中抛 BioyondException。 + + - 先用 typeMode=1(样品)+ filter=barcode 查询;未命中再用 typeMode=2(试剂)兜底。 + - 实例级缓存:同一次 Excel 提交中多行复用同一板条码时只查 1 次 LIMS。 + + Args: + barcode: 分液板条码(必须与 LIMS 物料系统中的 barCode 严格相等) + + Returns: + dict: 物料完整记录(含 id / name / typeName / barCode 等) + + Raises: + BioyondException: 物料系统中不存在该 barCode + """ + if not barcode: + raise BioyondException("板条码不能为空") + + # 懒初始化缓存 + if not hasattr(self, "_validated_plate_barcode_cache"): + self._validated_plate_barcode_cache: Dict[str, Dict[str, Any]] = {} + + if barcode in self._validated_plate_barcode_cache: + logger.debug(f"[校验条码] 缓存命中: {barcode}") + return self._validated_plate_barcode_cache[barcode] + + # typeMode: 1=样品, 2=试剂, 0=耗材;分液板通常在 1,兜底查 2 + for type_mode in (1, 2): + query = {"typeMode": type_mode, "filter": barcode, "includeDetail": False} + resp = self._post_lims("/api/lims/storage/stock-material", query) + if not isinstance(resp, dict) or resp.get("code") != 1: + logger.warning( + f"[校验条码] stock-material 查询返回异常: typeMode={type_mode}, " + f"barcode={barcode}, resp={resp}" + ) + continue + + for mat in resp.get("data", []) or []: + if mat.get("barCode") == barcode: + logger.info( + f"[校验条码] ✅ 命中: barCode={barcode}, typeName={mat.get('typeName')}, " + f"name={mat.get('name')}, typeMode={type_mode}" + ) + self._validated_plate_barcode_cache[barcode] = mat + return mat + + raise BioyondException( + f"板条码 {barcode} 未在物料系统中找到,请先在 LIMS 建立对应的分液板物料" + ) + + def conductivity_test_external( + self, + xlsx_path: str, + validate_barcode: bool = True, + start_scheduler: bool = True, + wait_for_finish: bool = True, + wait_timeout_seconds: int = 36000, + csv_export_path: str = "", + ) -> Dict[str, Any]: + """ + 2.37 5号电导工作站手动新建实验(外部进样 / Excel 入口)。 + + 外部进样:操作员手动填 Excel(板条码 + 瓶位 + 温控点)后提交, + 不接配液 handle。手动电导路径上没有"启动调度+上料"的前置流程 + (那是配液专用的 scheduler_start_and_auto_feeding),因此本函数会在 + 提交订单前主动调用 scheduler_start,确保订单进入队列后立即被调度消费。 + + 2026-06-26 调整:POST 建单后不再立即 return,而是: + - wait_for_finish=True(默认):逐单阻塞等 /report/order_finish 推送, + 全部完成后用 /api/lims/order/conductivity-order-result 按 orderId 拉取 + 电导率/温度结果,写入 External_conductivity_*.csv 并放进 return。 + + Excel 列(中文 / 英文均支持): + 算法批次ID / batchId + 配方ID / orderId / orderName + 创建日期 / createTime + 板BarCode / plateBarCode + 内部瓶位置X / bottleX + 内部瓶位置Y / bottleY + 温控点 / temperaturePoint + + Args: + xlsx_path: Excel 模板路径 + validate_barcode: 是否在提交前对 plateBarCode 做物料系统强校验,默认 True + start_scheduler: 提交订单前是否先启动调度,默认 True。 + 调度若已 Running,再次启动是幂等的;置 False 可在外部控制时机。 + wait_for_finish: 是否阻塞等所有电导单完成并拉取结果,默认 True。 + False 时回退到 fire-and-forget,POST 完即返回。 + wait_timeout_seconds: 单个电导单 wait 超时秒数,默认 36000s = 10h。 + csv_export_path: 电导结果 CSV 导出目录,为空则不导出。 + + Returns: + { + "status": "submitted" | "partial" | "error" | "all_completed" | "partial_completed", + "total_entries": int, + "validated_barcodes": List[str], + "response": , + "scheduler_start_response": <可选,调度启动返回>, + # 仅 wait_for_finish=True 时有: + "conductivity_results": List[Dict], + "completion_summary": {"success": int, "timeout": int, ...}, + "csv_file": <可选,External_conductivity_*.csv 路径>, + } + + Raises: + FileNotFoundError: Excel 文件不存在 + ValueError: Excel 缺少必要列或没有有效行 + BioyondException: validate_barcode=True 时板条码在物料系统中找不到 + """ + path = Path(xlsx_path) if xlsx_path else None + if path is None or not path.exists(): + raise FileNotFoundError(f"未找到电导实验 Excel: {xlsx_path}") + + logger.info(f"[conductivity_test_external] 读取 Excel: {path}") + try: + df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败: {e}") + logger.info( + f"[conductivity_test_external] 读取成功,行数={len(df)}, 列={list(df.columns)}" + ) + + def _pick(col_names: List[str]) -> Optional[str]: + for c in col_names: + if c in df.columns: + return c + return None + + col_batch = _pick(["算法批次ID", "batchId"]) + col_order = _pick(["配方ID", "orderId", "orderName"]) + col_ctime = _pick(["创建日期", "createTime"]) + col_code = _pick(["板BarCode", "plateBarCode"]) + col_x = _pick(["内部瓶位置X", "bottleX"]) + col_y = _pick(["内部瓶位置Y", "bottleY"]) + col_temp = _pick(["温控点", "temperaturePoint"]) + + required_map = { + "batchId": col_batch, + "orderId": col_order, + "plateBarCode": col_code, + "bottleX": col_x, + "bottleY": col_y, + "temperaturePoint": col_temp, + } + missing = [k for k, v in required_map.items() if not v] + if missing: + raise ValueError( + f"Excel 缺少必要列: {missing},请检查表头(支持中文或英文列名)" + ) + + def _to_ymd_slash(v) -> str: + if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": + ts = datetime.now() + else: + try: + ts = pd.to_datetime(v) + except Exception: + ts = datetime.now() + return ( + f"{ts.year}/{ts.month}/{ts.day} " + f"{ts.hour:02d}:{ts.minute:02d}:{ts.second:02d}" + ) + + def _as_int(val, default=0) -> int: + try: + if pd.isna(val): + return default + return int(val) + except Exception: + return default + + def _as_float(val, default=0.0) -> float: + try: + if pd.isna(val): + return default + return float(val) + except Exception: + return default + + def _as_str(val, default="") -> str: + if val is None or (isinstance(val, float) and pd.isna(val)): + return default + s = str(val).strip() + return s if s else default + + entries: List[Dict[str, Any]] = [] + for idx, row in df.iterrows(): + plate_barcode = _as_str(row[col_code]) + if not plate_barcode: + logger.warning( + f"[conductivity_test_external] 第 {idx + 1} 行 plateBarCode 为空,跳过" + ) + continue + + batch_id = _as_str(row[col_batch]) + order_id = _as_str(row[col_order]) + if not batch_id or not order_id: + logger.warning( + f"[conductivity_test_external] 第 {idx + 1} 行 batchId/orderId 为空,跳过" + ) + continue + + entry = { + "batchId": batch_id, + "orderId": order_id, + "createTime": _to_ymd_slash(row[col_ctime]) if col_ctime else _to_ymd_slash(None), + "plateBarCode": plate_barcode, + "bottleX": _as_int(row[col_x]), + "bottleY": _as_int(row[col_y]), + "temperaturePoint": _as_float(row[col_temp]), + } + entries.append(entry) + logger.info( + f"[conductivity_test_external] 第 {idx + 1} 行: " + f"orderId={entry['orderId']}, plateBarCode={entry['plateBarCode']}, " + f"X={entry['bottleX']}, Y={entry['bottleY']}, T={entry['temperaturePoint']}" + ) + + if not entries: + raise ValueError("Excel 没有有效行可提交,请检查模板内容") + + unique_barcodes = sorted({e["plateBarCode"] for e in entries}) + logger.info( + f"[conductivity_test_external] 共组装 {len(entries)} 条 entry," + f"涉及 {len(unique_barcodes)} 个板条码: {unique_barcodes}" + ) + + if validate_barcode: + logger.info( + f"[conductivity_test_external] 开始批量校验 plateBarCode " + f"({len(unique_barcodes)} 个)..." + ) + for bc in unique_barcodes: + self._validate_plate_barcode(bc) + logger.info("[conductivity_test_external] ✅ 所有 plateBarCode 校验通过") + else: + logger.warning( + "[conductivity_test_external] ⚠️ validate_barcode=False,跳过板条码校验" + ) + + scheduler_start_resp: Optional[Dict[str, Any]] = None + if start_scheduler: + logger.info( + "[conductivity_test_external] 启动调度(确保订单进队列后立即消费)..." + ) + try: + scheduler_start_resp = self.scheduler_start() + logger.info( + f"[conductivity_test_external] 调度启动返回: {scheduler_start_resp}" + ) + if not (isinstance(scheduler_start_resp, dict) and scheduler_start_resp.get("code") == 1): + logger.warning( + "[conductivity_test_external] ⚠️ 调度启动未返回 code=1," + "继续提交订单,但订单可能不会被立即消费" + ) + except Exception as e: + logger.warning( + f"[conductivity_test_external] ⚠️ 启动调度异常(继续提交订单): {e}" + ) + scheduler_start_resp = {"error": str(e)} + else: + logger.info( + "[conductivity_test_external] start_scheduler=False,跳过启动调度" + ) + + logger.info( + f"[conductivity_test_external] 提交 {len(entries)} 条 entry 到 " + f"/api/lims/order/conductivity-orders" + ) + response = self._post_lims("/api/lims/order/conductivity-orders", entries) + try: + response_dump = json.dumps(response, ensure_ascii=False) + except Exception: + response_dump = str(response) + logger.info( + f"[conductivity_test_external] LIMS 完整返回: {response_dump}" + ) + if isinstance(response, dict): + data_field = response.get("data") + if not data_field: + logger.warning( + "[conductivity_test_external] ⚠️ LIMS 返回中 data 为空," + "奔曜软件可能没有真正建任务,请检查 createTime 格式 / orderId 是否存在 / 板条码绑定情况" + ) + else: + logger.info( + f"[conductivity_test_external] LIMS data 含 {len(data_field) if isinstance(data_field, list) else 1} 条" + ) + + # 2026-06-04 修:成功判据从 orderId!=EMPTY_GUID 改为 errorMessage为空+orderCode非空。 + # LIMS 创建阶段总是返回 orderId=全 0 GUID,实际执行时才填。 + # 2026-06-26:同时收集 (orderCode, orderId) 对,结果接口要 orderId。 + status = "error" + new_order_pairs: List[Tuple[str, str]] = [] # [(orderCode, orderId), ...] + if isinstance(response, dict) and response.get("code") == 1: + data_field = response.get("data") or [] + if isinstance(data_field, list) and data_field: + valid_entries = [] + error_entries = [] + for d in data_field: + if not isinstance(d, dict): + continue + err_msg = (d.get("errorMessage") or "").strip() + new_code = (d.get("orderCode") or "").strip() + new_oid = (d.get("orderId") or "").strip() + if not err_msg and new_code: + valid_entries.append(d) + new_order_pairs.append((new_code, new_oid)) + else: + error_entries.append(d) + if len(valid_entries) == len(data_field): + status = "submitted" + elif valid_entries: + status = "partial" + else: + status = "error" + if error_entries: + logger.warning( + f"[conductivity_test_external] ⚠️ {len(error_entries)} 条 entry 创建失败:" + ) + for e in error_entries: + logger.warning( + f" - errorMessage={e.get('errorMessage')!r}, orderCode={e.get('orderCode')!r}" + ) + else: + status = "error" + new_order_codes_excel = [oc for oc, _ in new_order_pairs] + logger.info( + f"[conductivity_test_external] 最终 status={status}, " + f"LIMS 新建电导单号={new_order_codes_excel}" + ) + + result: Dict[str, Any] = { + "status": status, + "total_entries": len(entries), + "validated_barcodes": unique_barcodes, + "response": response, + "scheduler_start_response": scheduler_start_resp, + } + + # ========== 等待完成 + 拉取电导结果 + 导出 CSV ========== + if wait_for_finish and new_order_pairs: + resolved_pairs, summary = self._wait_conductivity_and_resolve_ids( + new_order_pairs, wait_timeout_seconds, tag="conductivity_test_external" + ) + if summary["success"] == len(new_order_pairs): + result["status"] = "all_completed" + elif summary["success"] > 0: + result["status"] = "partial_completed" + result["completion_summary"] = summary + + conductivity_results = self._collect_conductivity_results(resolved_pairs) + result["conductivity_results"] = conductivity_results + + if csv_export_path: + try: + csv_file = self._export_conductivity_csv( + conductivity_results, csv_export_path, "External_conductivity" + ) + result["csv_file"] = csv_file + except Exception as e: + logger.error(f"[conductivity_test_external] CSV 导出失败: {e}") + elif wait_for_finish and not new_order_pairs: + logger.warning( + "[conductivity_test_external] wait_for_finish=True 但无成功 orderCode,跳过等待" + ) + + return result + + # -------------------- 2.37 5号站新建实验(自动入口,接配液 handle) -------------------- + def conductivity_test_inline( + self, + vial_plates: List[Dict[str, Any]], + temperature_points: List[float], + vial_bottle_positions: Optional[List[Dict[str, Any]]] = None, + mass_ratios: Optional[List[Dict[str, Any]]] = None, + validate_barcode: bool = True, + wait_for_finish: bool = True, + wait_timeout_seconds: int = 36000, + csv_export_path: str = "", + **_legacy_kwargs: Any, + ) -> Dict[str, Any]: + """ + 2.37 5 号电导工作站自动新建实验(配液站传过来 / 接配液 output handle)。 + + 相比外部进样入口 (conductivity_test_external),本函数: + - 上游 vial_plates 自带 batch_id / orderCode / barCode / materialId + - 通过 LIMS material-info (2.4) 查每块板的 detail 拿真实瓶位 (X, Y) + - 额外接收配液配方信息 mass_ratios(来自配液 handle),测试完成后把 + 电导结果按"分液瓶二维码"与配方合并导出 Formulation_Conductivity_*.csv + + 2026-06-04 调整(与配液 _submit_and_wait_orders 保持一致): + - 不再 scheduler_stop / scheduler_start 切换。 + 实测配液 (`/api/lims/order/orders`) 在调度 Running 时也能成功创建订单; + 5 号站电导接口同理,stop/start 切换是冗余的过度设计。 + - 调度状态由上游 action 自行管理;本函数仅 POST 提交订单。 + - **_legacy_kwargs 兜住老 workflow JSON 残留的过期入参(如 stop_scheduler_timeout), + 静默丢弃避免 TypeError;新代码不应依赖此通道。 + + 2026-06-05 调整(与配液保持一致的"阻塞等完成"语义): + - 默认 wait_for_finish=True:POST 拿到 LIMS 新建电导单号后, + 逐个调 _wait_conductivity_finish 阻塞等 /report/order_finish 推送, + 所有电导单跑完才 return —— 节点会一直运行直到电导测试完成(数小时级)。 + - 电导用专属的 _wait_conductivity_finish(按 orderCode 字典 + 报文缓存), + 多订单乱序完成互不干扰;推送先于 wait 调用到达也不会丢失。 + - 配液仍走原 wait_for_order_finish 单值机制,路径完全不变。 + - wait_for_finish=False 时回退到 fire-and-forget 行为,仅创建不等。 + + 2026-06-10 调整(按 5 号自动传递窗过滤): + - 上游配液会把订单产出的**所有** 20ml 分液瓶板都传进 vial_plates,不区分用途。 + 真正要测电导的板 = 已转运到「5 号自动传递窗」的板。 + - 提交前查 warehouse-info(5号自动传递窗) 按 materialId 取交集,只保留在 5 号站的板。 + 这样即使配液传了扣电/软包等非电导板,也不会误建电导单。 + - 注意时序:要求板已转运到 5 号站后再调本 action;否则会被全部过滤而报错。 + - temperature_points 的长度校验在过滤之后进行(按过滤后的板数)。 + + Args: + vial_plates: 上游配液输出的分液瓶板列表,每项需含 + batch_id / materialId / barCode / orderCode(缺任一报错) + temperature_points: 温度点列表(℃)。 + 长度=1 → 广播到所有分液板;长度=N(=过滤后分液板数)→ 一一对应;其他长度报错。 + **同一块板上的所有分液瓶共享同一温度点(一块板一个温度)。** + mass_ratios: 配液配方信息列表(来自配液 auto-create_orders / + auto-create_orders_formulation 的 mass_ratios 输出 handle)。 + 用于测试完成后按"分液瓶二维码"合并出配液+电导报告;为空则只出电导报告。 + validate_barcode: 提交前是否对 plateBarCode 做物料系统强校验,默认 True + wait_for_finish: 是否阻塞等待所有 LIMS 新建电导单完成,默认 True + wait_timeout_seconds: 单个订单 wait 超时秒数,默认 36000s = 10h(与配液一致) + csv_export_path: CSV 导出目录,为空则不导出。非空时在同一目录生成两份: + Inline_conductivity_*.csv(电导-only)+ Formulation_Conductivity_*.csv(配液+电导) + + Returns: + { + "status": "submitted" | "partial" | "error" | "all_completed" | "partial_completed", + "total_entries": int, + "validated_barcodes": List[str], + "batch_id": str, + "new_order_codes": List[str], + "response": , + # 仅当 wait_for_finish=True 时有: + "completion_summary": {"success": int, "timeout": int, ...}, + "conductivity_results": List[Dict], # 每瓶电导结果 + "csv_file_conductivity": <可选,Inline_conductivity_*.csv 路径>, + "csv_file_formulation": <可选,Formulation_Conductivity_*.csv 路径>, + } + + Raises: + ValueError: 入参不合法(vial_plates 空 / temperature_points 长度异常 / batch_id 为空 / plate 缺关键字段) + BioyondException: barCode 校验失败 / detail 中无瓶位 + """ + if not vial_plates or not isinstance(vial_plates, list): + raise ValueError("vial_plates 不能为空") + if not temperature_points or not isinstance(temperature_points, list): + raise ValueError("temperature_points 不能为空") + # 一块板一个温度点,而 5 号自动传递窗只有 4 个库位 → 一轮最多 4 个温度点。 + # 超了必须拆轮,否则会建出永远排不进传递窗的单。 + if len(temperature_points) > self._WINDOW_5_CAPACITY: + raise ValueError( + f"单轮温度点数为 {len(temperature_points)},超过 5 号自动传递窗的" + f"{self._WINDOW_5_CAPACITY} 个库位上限(一块板对应一个温度点)。" + f"请拆成多轮,每轮不超过 {self._WINDOW_5_CAPACITY} 个温度点。" + ) + + # ========== 阶段0: 只保留已转运到 5 号自动传递窗的板 ========== + # 上游配液会把订单产出的全部 20ml 分液瓶板都传进来(不区分用途), + # 真正要测电导的 = 已搬到「5 号自动传递窗」的板。查 warehouse-info(2.38) 按 materialId 取交集。 + wh_resp = self._post_lims( + "/api/lims/storage/warehouse-info", + {"whId": self._WH_ID_TRANSFER_WINDOW_5, "includeDetail": True}, + ) + if not isinstance(wh_resp, dict) or wh_resp.get("code") != 1: + raise BioyondException(f"查询 5 号自动传递窗失败: {wh_resp}") + station_mids = { + (loc.get("holdMId") or "").strip() + for loc in ((wh_resp.get("data") or {}).get("locations") or []) + if isinstance(loc, dict) and loc.get("holdMId") + } + kept = [ + p for p in vial_plates + if isinstance(p, dict) and (p.get("materialId") or "").strip() in station_mids + ] + logger.info( + f"[conductivity_test_inline] 5 号自动传递窗过滤: " + f"{len(vial_plates)} → {len(kept)} 块板(5 号站现有板 {len(station_mids)} 块)" + ) + vial_plates = kept + if not vial_plates: + raise BioyondException( + "过滤后没有任何板在 5 号自动传递窗,请确认要测电导的板已转运到 5 号站。" + ) + + # 提取 batch_id(同 batch 内所有 plate 共享同一值) + batch_id_raw = vial_plates[0].get("batch_id") if isinstance(vial_plates[0], dict) else None + if not batch_id_raw: + raise ValueError("vial_plates[0] 缺少 batch_id 字段,请检查上游配液输出") + batch_id = str(batch_id_raw).strip() + if not batch_id: + raise ValueError("vial_plates[0].batch_id 为空字符串") + + n_plates = len(vial_plates) + n_temps = len(temperature_points) + if n_temps not in (1, n_plates): + raise ValueError( + f"temperature_points 长度 {n_temps} 非法:" + f"应为 1(广播)或 {n_plates}(与分液板数一致)。" + f"业务规则:一块板共享一个温度。" + ) + + logger.info( + f"[conductivity_test_inline] 开始:batch_id={batch_id}, " + f"分液板数={n_plates}, 温度点数={n_temps}" + ) + + def _to_ymd_slash_hms() -> str: + ts = datetime.now() + return ( + f"{ts.year}/{ts.month}/{ts.day} " + f"{ts.hour:02d}:{ts.minute:02d}:{ts.second:02d}" + ) + + # ========== 阶段1: 校验 + 按 vial_bottle_positions 组装 entries ========== + # 归属/瓶位来自上游配液在 create_orders 收尾(板状态干净)时构建的 + # vial_bottle_positions(detailMaterialId→orderCode 权威映射,已过滤空占位孔)。 + # 不再现场查 detail + associateId 反查,彻底避免串单 / 多算空孔。 + if not vial_bottle_positions or not isinstance(vial_bottle_positions, list): + raise ValueError( + "vial_bottle_positions 为空:本 action 已改为依赖上游配液输出的" + "「分液瓶孔位映射」handle。请把 create_orders / create_orders_formulation 的" + " vial_bottle_positions 输出接到本 action 的同名入参(旧 workflow 需同步更新接线)。" + ) + + EXPECTED_PLATE_TYPE = "20ml分液瓶板" + # 校验 kept 板 + 记录每块板的温度/条码(温度仍按 kept 板序 idx) + plate_temp_by_mid: Dict[str, float] = {} + plate_barcode_by_mid: Dict[str, str] = {} + for idx, plate in enumerate(vial_plates): + if not isinstance(plate, dict): + raise ValueError(f"vial_plates[{idx}] 不是 dict: {plate!r}") + plate_barcode = plate.get("barCode") + material_id = plate.get("materialId") + type_name = plate.get("typeName", "") + if not plate_barcode or not material_id: + raise ValueError( + f"vial_plates[{idx}] 缺少关键字段: " + f"barCode={plate_barcode!r}, materialId={material_id!r}" + ) + if type_name and type_name != EXPECTED_PLATE_TYPE: + raise BioyondException( + f"vial_plates[{idx}] typeName={type_name!r},电导测试要求 " + f"{EXPECTED_PLATE_TYPE!r}。请检查上游配液是否提取到了正确的板" + f"(参考 _extract_vial_plate_from_report 与 LIMS 工艺配置)。" + ) + if validate_barcode: + self._validate_plate_barcode(plate_barcode) + plate_temp_by_mid[material_id] = float( + temperature_points[0] if n_temps == 1 else temperature_points[idx] + ) + plate_barcode_by_mid[material_id] = plate_barcode + + kept_mids = set(plate_temp_by_mid.keys()) + kept_barcodes = set(plate_barcode_by_mid.values()) + + # 按 plateMaterialId(兜底 plateBarCode)把 positions 归到 kept 板 + positions_by_plate: Dict[str, List[Dict[str, Any]]] = {mid: [] for mid in kept_mids} + for pos in vial_bottle_positions: + if not isinstance(pos, dict): + continue + pmid = pos.get("plateMaterialId") or "" + pbar = pos.get("plateBarCode") or "" + if pmid in kept_mids: + positions_by_plate[pmid].append(pos) + elif pbar in kept_barcodes: + # 兜底:materialId 对不上但条码在 kept 中,按条码找回对应 mid + for mid, bc in plate_barcode_by_mid.items(): + if bc == pbar: + positions_by_plate[mid].append(pos) + break + + entries: List[Dict[str, Any]] = [] + for mid in kept_mids: + plate_barcode = plate_barcode_by_mid[mid] + plate_temp = plate_temp_by_mid[mid] + plate_positions = positions_by_plate.get(mid, []) + if not plate_positions: + logger.warning( + f"[conductivity_test_inline] ⚠️ kept 板 {plate_barcode} 在 " + f"vial_bottle_positions 中无对应分液瓶,跳过(请确认瓶在转运前已入板)。" + ) + continue + for pos in plate_positions: + entry = { + "batchId": batch_id, + "orderId": pos.get("orderCode") or "", + "createTime": _to_ymd_slash_hms(), + "plateBarCode": plate_barcode, + "bottleX": pos.get("x"), + "bottleY": pos.get("y"), + "temperaturePoint": plate_temp, + } + entries.append(entry) + logger.info( + f"[conductivity_test_inline] entry: orderId={entry['orderId']} " + f"plateBarCode={plate_barcode}, X={pos.get('x')}, Y={pos.get('y')}, " + f"bottle={pos.get('barCode')}, T={plate_temp}" + ) + + if not entries: + raise ValueError("entries 组装为空,无法提交") + + unique_barcodes = sorted({e["plateBarCode"] for e in entries}) + logger.info( + f"[conductivity_test_inline] 共组装 {len(entries)} 条 entry," + f"涉及 {len(unique_barcodes)} 个板条码" + ) + + # ========== 阶段2: 直接 POST(调度由上游/外部管理,与配液 _submit_and_wait_orders 一致)========== + logger.info( + f"[conductivity_test_inline] POST {len(entries)} 条 entry 到 " + f"/api/lims/order/conductivity-orders ..." + ) + response = self._post_lims("/api/lims/order/conductivity-orders", entries) + try: + response_dump = json.dumps(response, ensure_ascii=False) + except Exception: + response_dump = str(response) + logger.info(f"[conductivity_test_inline] LIMS 完整返回: {response_dump}") + + # ========== 阶段3: 解析 response 算 status ========== + # 2026-06-04 修:原先用 orderId != EMPTY_GUID 判断成功是错的—— + # LIMS 创建阶段总是返回 orderId="00000000-...-000000000000"(execution 时才填 GUID)。 + # 实际可用的成功判据是 errorMessage 为空 + orderCode 非空(=LIMS 已建出新电导单号)。 + # 失败示例:errorMessage="...没有在5号手套箱仓库..." & orderCode=null & usedMaterials=[] + # 成功示例:errorMessage=null & orderCode="BSO20260604000XX" & usedMaterials=[plate, bottle] + # 2026-06-26:同时收集 (orderCode, orderId) 对,结果接口要 orderId。 + status = "error" + new_order_pairs: List[Tuple[str, str]] = [] # [(orderCode, orderId), ...] + if isinstance(response, dict) and response.get("code") == 1: + data_field = response.get("data") or [] + if isinstance(data_field, list) and data_field: + valid_entries = [] + error_entries = [] + for d in data_field: + if not isinstance(d, dict): + continue + err_msg = (d.get("errorMessage") or "").strip() + new_code = (d.get("orderCode") or "").strip() + new_oid = (d.get("orderId") or "").strip() + if not err_msg and new_code: + valid_entries.append(d) + new_order_pairs.append((new_code, new_oid)) + else: + error_entries.append(d) + + if len(valid_entries) == len(data_field): + status = "submitted" + elif valid_entries: + status = "partial" + else: + status = "error" + + if error_entries: + logger.warning( + f"[conductivity_test_inline] ⚠️ {len(error_entries)} 条 entry 创建失败:" + ) + for e in error_entries: + logger.warning( + f" - errorMessage={e.get('errorMessage')!r}, " + f"orderCode={e.get('orderCode')!r}" + ) + new_order_codes = [oc for oc, _ in new_order_pairs] + logger.info( + f"[conductivity_test_inline] 最终 status={status}, " + f"LIMS 新建电导单号={new_order_codes}" + ) + + result: Dict[str, Any] = { + "status": status, + "total_entries": len(entries), + "validated_barcodes": unique_barcodes, + "batch_id": batch_id, + "new_order_codes": new_order_codes, # LIMS 新建的电导单号列表(成功 entry 对应的) + "response": response, + } + + # ========== 阶段4: (可选)阻塞等所有 LIMS 新建电导单跑完 + 拉结果 + 出两份 CSV ========== + # 节点会一直运行到所有电导单子收到 /report/order_finish 推送(成功/异常/超时), + # 然后按 orderId 调 /conductivity-order-result 拉电导率/温度,导出两份 CSV: + # Inline_conductivity_*.csv(电导-only)+ Formulation_Conductivity_*.csv(配液+电导)。 + if wait_for_finish and new_order_pairs: + resolved_pairs, summary = self._wait_conductivity_and_resolve_ids( + new_order_pairs, wait_timeout_seconds, tag="conductivity_test_inline" + ) + if summary["success"] == len(new_order_pairs): + result["status"] = "all_completed" + elif summary["success"] > 0: + result["status"] = "partial_completed" + result["completion_summary"] = summary + + conductivity_results = self._collect_conductivity_results(resolved_pairs) + result["conductivity_results"] = conductivity_results + + if csv_export_path: + try: + csv_file_cond = self._export_conductivity_csv( + conductivity_results, csv_export_path, "Inline_conductivity" + ) + result["csv_file_conductivity"] = csv_file_cond + except Exception as e: + logger.error(f"[conductivity_test_inline] 电导 CSV 导出失败: {e}") + try: + csv_file_form = self._export_conductivity_formulation_csv( + conductivity_results, mass_ratios or [], csv_export_path + ) + result["csv_file_formulation"] = csv_file_form + result["datacore_push_formulation"] = self._push_csv_to_datacore( + csv_file_form, tag="conductivity_test_inline" + ) + except Exception as e: + logger.error(f"[conductivity_test_inline] 配液+电导 CSV 导出失败: {e}") + elif wait_for_finish and not new_order_pairs: + logger.warning( + "[conductivity_test_inline] wait_for_finish=True 但 LIMS 未返回任何成功 orderCode,跳过等待" + ) + + return result + + def _extract_vial_plate_from_report(self, report: Dict) -> List[Dict[str, Any]]: + """ + 从 order_finish 报文中提取该订单**所有**的分液瓶板。 + + 2026-06-04 重构: + - 返回类型由 Optional[Dict] 改为 List[Dict]。 + 按协议 3.37 示例,一个 orderId 下完全允许多块不同 plateBarCode 的板, + 配液工艺也确实会把同一 orderCode 拆到多块 20ml 分液瓶板上(电导多温度场景)。 + - 扫描所有 typemode=1 物料,逐个查 material-info(2.4 接口拿 typeName / barCode); + 返回该单**全部** "*分液瓶板"(20ml 排在前,其它板型在后)。 + - 2026-07-26 修正:旧实现"找到 20ml 就只返回 20ml",会在混合实验 + (20ml 测电导 + 5ml 供扣电)里把 5ml 板整块丢掉,导致 321 转运找不到板。 + 现改为全部返回,按板型分流交由下游负责(321 只挑 5ml、电导只挑 20ml)。 + - 不再用 locationId 前缀("3a19debc-84b5-" 自动堆栈-左)做硬过滤, + 实测 LIMS 把 20ml 板放在 3a1c68b5-… 等其它库位,老规则会误漏。 + + Args: + report: LIMS 订单完成推送报文(2.23 push 报文,usedMaterials 仅含 + materialId / locationId / typeMode / usedQuantity / realQuantity) + + Returns: + List[Dict]:每块板一条字典,结构为 + { + "materialId": "GUID", + "locationId": "GUID", + "orderCode": "BSO...", + "orderId": "GUID", # 配液订单 GUID,用于跨 order 共用板时按瓶子的 associateId 反查归属 + "typeName": "20ml分液瓶板", + "barCode": "..." # 可能为空字符串(LIMS 端没建条码时) + } + 未找到任何分液瓶板时返回 []。 + """ + order_code = report.get("orderCode", "N/A") + order_id_guid = report.get("orderId", "") or "" + used_materials = report.get("usedMaterials", []) + + logger.info( + f"[提取分液瓶板] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + PREFERRED_TYPE = "20ml分液瓶板" + candidates_preferred: List[Dict[str, Any]] = [] + candidates_other_plate: List[Dict[str, Any]] = [] + seen_material_ids: set = set() + + for idx, material in enumerate(used_materials): + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") + location_id = material.get("locationId", "") or "" + if str(typemode) != "1" or not material_id: + continue + # 同一 materialId 在 usedMaterials 里可能被列多次(出库/入库等场景),去重 + if material_id in seen_material_ids: + continue + seen_material_ids.add(material_id) + + logger.debug( + f"[提取分液瓶板] 候选 typemode=1 物料 #{idx+1}: " + f"materialId={material_id[:20]}..., locationId={location_id[:20]}..." + ) + + try: + material_info = self._resolve_material_type_info(material, material_id) + except Exception as e: + logger.warning( + f"[提取分液瓶板] ⚠️ 查询物料详情失败: materialId={material_id}, 错误={e}" + ) + continue + + if not material_info: + logger.warning( + f"[提取分液瓶板] ⚠️ 无法解析物料类型: materialId={material_id}" + ) + continue + + type_name = material_info.get("typeName", "") or "" + if "分液瓶板" not in type_name: + logger.debug(f"[提取分液瓶板] 跳过非分液瓶板: typeName={type_name}") + continue + + # 从 locations 取"自动堆栈-左"仓库的 xyz 坐标(上游 851b923 引入), + # 用于 transfer_3_to_2_to_1 / transfer_3_to_2 通过 handle 接收源坐标。 + # 多 plate 场景下每块板独立预存自己的坐标,下游 _find_plate_xyz 仍能正确按板型选取。 + # source_found 必须显式记录:未命中时的 (1,1,1) 只是占位默认值, + # 与"真的在 A01"无法区分。下游 321/32 若拿占位值去建单,会去搬 + # A01 上那块**别的**板(转错板),或者坐标不存在直接建单失败。 + src_x, src_y, src_z = 1, 1, 1 + src_found = False + for loc in (material_info.get("locations") or []): + if loc.get("whid") == self._WH_ID_AUTO_STACK_LEFT: + src_x = loc.get("x", 1) + src_y = loc.get("y", 1) + src_z = loc.get("z", 1) + src_found = True + break + + plate_info = { + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "orderId": order_id_guid, + "typeName": type_name, + "barCode": material_info.get("barCode") or "", + "source_x": src_x, + "source_y": src_y, + "source_z": src_z, + "source_found": src_found, + } + if type_name == PREFERRED_TYPE: + candidates_preferred.append(plate_info) + logger.info( + f"[提取分液瓶板] ✅ 命中 {PREFERRED_TYPE}: " + f"materialId={material_id}, locationId={location_id}, " + f"barCode={plate_info['barCode']}, " + f"自动堆栈-左坐标=({src_x},{src_y},{src_z})" + f"{'' if src_found else ' [未在自动堆栈-左命中,为占位默认值]'}" + ) + else: + candidates_other_plate.append(plate_info) + logger.info( + f"[提取分液瓶板] 命中其它分液瓶板: typeName={type_name}, " + f"materialId={material_id}, barCode={plate_info['barCode']}, " + f"自动堆栈-左坐标=({src_x},{src_y},{src_z})" + f"{'' if src_found else ' [未在自动堆栈-左命中,为占位默认值]'}" + ) + + # 返回全部分液瓶板(20ml + 5ml 等),不再"有 20ml 就丢掉 5ml"。 + # 混合实验(20ml 测电导 + 5ml 供扣电)下,旧的二选一返回会让 5ml 板 + # 永远进不了 vial_plates,导致 321 转运找不到板。按板型分流由下游各自负责: + # 321 只挑 5ml、32/电导只挑 20ml(电导还会先按 5 号传递窗求交集过滤)。 + all_plates = candidates_preferred + candidates_other_plate + + if not all_plates: + logger.warning(f"[提取分液瓶板] ❌ 未找到任何分液瓶板: orderCode={order_code}") + return [] + + if candidates_preferred: + logger.info( + f"[提取分液瓶板] ✅ orderCode={order_code} 共找到 " + f"{len(candidates_preferred)} 块 {PREFERRED_TYPE}: " + f"{[(p['barCode'] or p['materialId'][:8]) for p in candidates_preferred]}" + ) + else: + # 配液+扣电 / 配液+软包 等模式本来就没有 20ml 板,属正常情况,不告警。 + logger.info( + f"[提取分液瓶板] orderCode={order_code} 无 {PREFERRED_TYPE}" + f"(配液+扣电等模式属正常);若本单需测电导,请检查 LIMS 工艺配置或上游入参。" + ) + + if candidates_other_plate: + logger.info( + f"[提取分液瓶板] 另含 {len(candidates_other_plate)} 块其它分液瓶板: " + f"{[(p['typeName'], p['barCode'] or p['materialId'][:8]) for p in candidates_other_plate]}" + f"(一并返回,由下游按板型分流)" + ) + + logger.info( + f"[提取分液瓶板] orderCode={order_code} 合计返回 {len(all_plates)} 块分液瓶板" + ) + return all_plates + + def _extract_prep_bottle_from_report(self, report: Dict) -> Optional[Dict]: + """ + 从 order_finish 报文中提取配液瓶信息 + + 2026-07-15 重构:去掉库位白名单硬过滤。多配液瓶板场景下,未完成的板仍停在 + 「配液站内配液大板仓库」(3a1a21dc-…) 等位置,旧白名单会漏提。 + 新规则与分液瓶板提取对齐: + - 扫描所有 typemode == "1" 且 realQuantity == 1 且 usedQuantity == 1 的物料 + - 调 LIMS API 2.4,typeName 精确匹配 "配液瓶(小)" / "配液瓶(大)" + (避免 "配液瓶(大)板" 子串误判) + - 同单命中多个候选时,用 material-info.associateId 与 orderId 匹配去歧义 + + Args: + report: LIMS order_finish 报文 + + Returns: + { + "materialId": "...", + "locationId": "...", + "orderCode": "...", + "typeName": "配液瓶(小)" or "配液瓶(大)", + "barCode": "..." + } + 未找到时返回 None + """ + order_code = report.get("orderCode", "N/A") + order_id_guid = report.get("orderId", "") or "" + used_materials = report.get("usedMaterials", []) + + logger.info( + f"[提取配液瓶] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + PREP_TYPES = ("配液瓶(小)", "配液瓶(大)") + candidates: List[Dict[str, Any]] = [] + seen_material_ids: set = set() + + for idx, material in enumerate(used_materials): + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") or "" + location_id = material.get("locationId", "") or "" + real_qty = material.get("realQuantity") + used_qty = material.get("usedQuantity") + + if str(typemode) != "1" or not material_id: + continue + if real_qty != 1 or used_qty != 1: + continue + if material_id in seen_material_ids: + continue + seen_material_ids.add(material_id) + + logger.debug( + f"[提取配液瓶] 候选物料 #{idx+1}: materialId={material_id[:20]}..., " + f"locationId={(location_id or '')[:20]}..." + ) + + try: + material_info = self._resolve_material_type_info(material, material_id) + except Exception as e: + logger.warning( + f"[提取配液瓶] ⚠️ 查询物料详情失败: materialId={material_id}, 错误={e}" + ) + continue + + if not material_info: + logger.warning( + f"[提取配液瓶] ⚠️ 无法解析物料类型: materialId={material_id}" + ) + continue + + type_name = material_info.get("typeName", "") or "" + if type_name not in PREP_TYPES: + logger.debug( + f"[提取配液瓶] 候选物料不是配液瓶: typeName={type_name}, 跳过" + ) + continue + + candidates.append({ + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "typeName": type_name, + "barCode": material_info.get("barCode") or "", + "associateId": material_info.get("associateId") or "", + }) + logger.info( + f"[提取配液瓶] ✅ 命中候选: orderCode={order_code}, " + f"typeName={type_name}, barCode={material_info.get('barCode')}, " + f"locationId={(location_id or '')[:20]}..., " + f"associateId={(material_info.get('associateId') or '')[:20]}..." + ) + + if not candidates: + logger.warning(f"[提取配液瓶] ❌ 未找到配液瓶: orderCode={order_code}") + return None + + # 多候选去歧义:associateId == orderId 优先 + if len(candidates) == 1: + chosen = candidates[0] + else: + chosen = None + if order_id_guid: + for c in candidates: + if c.get("associateId") and c["associateId"] == order_id_guid: + chosen = c + break + if chosen is None: + chosen = candidates[0] + logger.warning( + f"[提取配液瓶] ⚠️ orderCode={order_code} 命中 {len(candidates)} 个配液瓶候选," + f"associateId 未能与 orderId={order_id_guid[:20] if order_id_guid else ''}... " + f"匹配,取第一个: barCode={chosen.get('barCode')}" + ) + else: + logger.info( + f"[提取配液瓶] 多候选按 associateId 选定: " + f"barCode={chosen.get('barCode')}" + ) + + # 不把 associateId 暴露到导出结构,保持向后兼容 + return { + "materialId": chosen["materialId"], + "locationId": chosen["locationId"], + "orderCode": chosen["orderCode"], + "typeName": chosen["typeName"], + "barCode": chosen["barCode"], + } + + def _extract_vial_bottles_from_report(self, report: Dict) -> List[Dict]: + """ + 从 order_finish 报文中提取分液瓶信息(注意不是分液瓶板)—— 单报文本地路径。 + + 保留为兼容回退:当板级 `_extract_vial_bottles_from_plates` 不可用时仍可调用。 + 2026-07-15:去掉自动堆栈库位白名单;按 typeName 精确匹配 "5ml分液瓶"/"20ml分液瓶"。 + 多板场景请优先走 `_extract_vial_bottles_from_plates`(板 detail + associateId)。 + + Args: + report: LIMS order_finish 报文 + + Returns: + 分液瓶信息列表,每个元素: + { + "materialId": "...", + "locationId": "...", + "orderCode": "...", + "typeName": "5ml分液瓶" or "20ml分液瓶", + "barCode": "..." + } + """ + order_code = report.get("orderCode", "N/A") + used_materials = report.get("usedMaterials", []) + + logger.info( + f"[提取分液瓶] 开始处理订单 orderCode={order_code}, " + f"物料数量={len(used_materials)}" + ) + + VIAL_TYPES = ("5ml分液瓶", "20ml分液瓶") + vial_bottles: List[Dict] = [] + seen_material_ids: set = set() + + for idx, material in enumerate(used_materials): + typemode = material.get("typemode", "") + material_id = material.get("materialId", "") or "" + location_id = material.get("locationId", "") or "" + real_qty = material.get("realQuantity") + used_qty = material.get("usedQuantity") + + if str(typemode) != "1" or not material_id: + continue + if real_qty != 1 or used_qty != 1: + continue + if material_id in seen_material_ids: + continue + seen_material_ids.add(material_id) + + logger.debug( + f"[提取分液瓶] 候选物料 #{idx+1}: materialId={material_id[:20]}..." + ) + + try: + material_info = self._resolve_material_type_info(material, material_id) + except Exception as e: + logger.warning( + f"[提取分液瓶] ⚠️ 查询物料详情失败: materialId={material_id}, 错误={e}" + ) + continue + + if not material_info: + logger.warning( + f"[提取分液瓶] ⚠️ 无法解析物料类型: materialId={material_id}" + ) + continue + + type_name = material_info.get("typeName", "") or "" + if type_name in VIAL_TYPES: + bar_code = material_info.get("barCode") or "" + logger.info( + f"[提取分液瓶] ✅ 确认为分液瓶: orderCode={order_code}, " + f"typeName={type_name}, barCode={bar_code}" + ) + vial_bottles.append({ + "materialId": material_id, + "locationId": location_id, + "orderCode": order_code, + "typeName": type_name, + "barCode": bar_code, + }) + else: + logger.debug( + f"[提取分液瓶] 候选物料不是分液瓶: typeName={type_name}, 跳过" + ) + + if vial_bottles: + logger.info( + f"[提取分液瓶] 订单 {order_code} 共找到 {len(vial_bottles)} 个分液瓶: " + f"{[v['typeName'] for v in vial_bottles]}" + ) + else: + logger.warning(f"[提取分液瓶] ❌ 未找到分液瓶: orderCode={order_code}") + + return vial_bottles + + def _build_vial_bottle_mat2order(self, data_list: List[Dict[str, Any]]) -> Dict[str, str]: + """ + 从 create_orders 返回的 data[*].usedMaterials 构建 分液瓶 materialId → orderCode 映射。 + + 每个分液瓶只在其归属订单的 usedMaterials 中出现一次(1:1),因此该映射权威可靠, + 用于取代旧的 associateId 反查(associateId 属订单侧独立 GUID,恒不等于 orderId, + 多单共板时会全部 fallback 到第一单,导致电导串单/多算空孔)。 + """ + VIAL_TYPES = ("5ml分液瓶", "20ml分液瓶") + mat2order: Dict[str, str] = {} + for od in data_list or []: + if not isinstance(od, dict): + continue + order_code = od.get("orderCode") or "" + if not order_code: + continue + for m in od.get("usedMaterials", []) or []: + if not isinstance(m, dict): + continue + if (m.get("materialTypeName") or "") not in VIAL_TYPES: + continue + mid = m.get("materialId") or "" + if mid: + mat2order[mid] = order_code + logger.info(f"[分液瓶归属] materialId→orderCode 映射构建完成,共 {len(mat2order)} 个分液瓶") + return mat2order + + def _build_vial_bottle_positions( + self, + vial_plates: List[Dict[str, Any]], + mat2order: Dict[str, str], + ) -> List[Dict[str, Any]]: + """ + 在 create_orders 收尾(板状态干净)时,查每块分液瓶板的孔位 detail, + 用 detailMaterialId 命中 mat2order 得到归属订单,产出“分液瓶孔位映射”。 + + 仅保留 detailMaterialId ∈ 本批 mat2order 的孔(自动过滤空占位孔/非本批瓶), + 供下游电导 conductivity_test_inline 直接组装 entry(不再靠 associateId 反查)。 + + Returns: + [{plateBarCode, plateMaterialId, bottleMaterialId, x, y, orderCode, + typeName, barCode, batch_id}, ...] + """ + positions: List[Dict[str, Any]] = [] + for plate in vial_plates or []: + if not isinstance(plate, dict): + continue + plate_material_id = plate.get("materialId") or "" + plate_barcode = plate.get("barCode") or "" + batch_id = plate.get("batch_id") or "" + plate_type = plate.get("typeName") or "" + if not plate_material_id: + continue + vial_type = "" + if "5ml" in plate_type.lower(): + vial_type = "5ml分液瓶" + elif "20ml" in plate_type.lower(): + vial_type = "20ml分液瓶" + try: + bottles = self._query_plate_bottle_positions(plate_material_id) + except Exception as e: + logger.warning( + f"[分液瓶孔位] ⚠️ 查询板孔位失败,跳过: " + f"plate={plate_barcode or plate_material_id[:20]}, 错误={e}" + ) + continue + kept = 0 + for bottle in bottles: + bottle_mid = bottle.get("detailMaterialId") or "" + order_code = mat2order.get(bottle_mid) + if not order_code: + # 空占位孔 / 非本批瓶 → 跳过(解决旧逻辑把全部孔都当瓶的多算问题) + continue + positions.append({ + "plateBarCode": plate_barcode, + "plateMaterialId": plate_material_id, + "bottleMaterialId": bottle_mid, + "x": bottle.get("x"), + "y": bottle.get("y"), + "orderCode": order_code, + "typeName": (bottle.get("typeName") or "").strip() or vial_type, + "barCode": bottle.get("code") or "", + "batch_id": batch_id, + }) + kept += 1 + logger.info( + f"[分液瓶孔位] plate={plate_barcode}: detail {len(bottles)} 孔 → 命中本批 {kept} 瓶" + ) + logger.info(f"[分液瓶孔位] 汇总:共 {len(positions)} 个分液瓶孔位映射") + return positions + + def _extract_vial_bottles_from_plates( + self, + vial_plates: List[Dict[str, Any]], + order_codes: List[str], + mat2order: Optional[Dict[str, str]] = None, + ) -> Dict[str, List[Dict]]: + """ + 按物理分液瓶板提取分液瓶,用 detailMaterialId → mat2order 映射到归属订单。 + + 多配液瓶板 × 多分液瓶板场景下,单瓶报文常常只含「分液瓶板」、且库位停在 + 「大分液瓶堆栈」(3a19da3d-…) 而非自动堆栈,旧的逐单库位白名单会整列漏提。 + 本方法对去重后的每块板调 `_query_plate_bottle_positions`,再用权威的 + `mat2order`(materialId→orderCode,来自 create 响应 usedMaterials)按孔位 + detailMaterialId 归属;命中不到的孔(空占位/非本批)直接跳过。 + + Args: + vial_plates: `_submit_and_wait_orders` 去重后的 all_vial_plates + (含 materialId / typeName / barCode) + order_codes: 本批次全部 orderCode,用于保证返回 dict 覆盖所有订单 + mat2order: 分液瓶 materialId → orderCode 权威映射(`_build_vial_bottle_mat2order`) + + Returns: + {orderCode: [{materialId, locationId, orderCode, typeName, barCode}, ...]} + """ + result: Dict[str, List[Dict]] = {oc: [] for oc in order_codes if oc} + mat2order = mat2order or {} + + for plate in vial_plates or []: + if not isinstance(plate, dict): + continue + plate_material_id = plate.get("materialId") or "" + plate_barcode = plate.get("barCode") or "" + plate_location = plate.get("locationId") or "" + if not plate_material_id: + continue + + try: + bottles = self._query_plate_bottle_positions(plate_material_id) + except Exception as e: + logger.warning( + f"[提取分液瓶-板级] ⚠️ 查询板孔位失败: " + f"plate={plate_barcode or plate_material_id[:20]}, 错误={e}" + ) + continue + + for bottle in bottles: + bottle_mid = bottle.get("detailMaterialId") or "" + xy = (bottle.get("x", 0), bottle.get("y", 0)) + order_code = mat2order.get(bottle_mid) + if not order_code: + # 空占位孔 / 非本批瓶(detailMaterialId 不在权威映射中)→ 跳过 + continue + + # code 即板 detail 上的二维码;typeName 缺省时按板型推断 + bar_code = bottle.get("code") or "" + type_name = (bottle.get("typeName") or "").strip() + if not type_name: + plate_type = plate.get("typeName") or "" + if "5ml" in plate_type.lower() or "5mL" in plate_type: + type_name = "5ml分液瓶" + elif "20ml" in plate_type.lower() or "20mL" in plate_type: + type_name = "20ml分液瓶" + + entry = { + "materialId": bottle.get("detailMaterialId") or "", + "locationId": plate_location, + "orderCode": order_code, + "typeName": type_name, + "barCode": bar_code, + "plateBarCode": plate_barcode, + "x": bottle.get("x"), + "y": bottle.get("y"), + } + result.setdefault(order_code, []).append(entry) + logger.info( + f"[提取分液瓶-板级] ✅ plate={plate_barcode}, " + f"orderCode={order_code}, typeName={type_name}, " + f"barCode={bar_code}, XY={xy}" + ) + + total = sum(len(v) for v in result.values()) + logger.info( + f"[提取分液瓶-板级] 完成: 覆盖 {len(result)} 个订单, 共 {total} 个分液瓶" + ) + return result + + def _export_order_csv(self, final_result: Dict, csv_export_path: str) -> str: + """ + 将配液分液结果导出为 CSV 文件 + + CSV 表头: + orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, + 目标配液质量比, 真实配液质量比, 时间 + + Args: + final_result: _submit_and_wait_orders 返回的完整结果 + csv_export_path: CSV 文件保存目录路径 + + Returns: + 生成的 CSV 文件完整路径 + """ + # 确保目录存在 + os.makedirs(csv_export_path, exist_ok=True) + + # 生成文件名 + time_date = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_file = os.path.join(csv_export_path, f"electrolyte_orders_{time_date}.csv") + + # 从 final_result 提取数据 + reports = final_result.get("reports", []) + mass_ratios = final_result.get("mass_ratios", []) + prep_bottles = final_result.get("prep_bottles", []) + vial_bottles_all = final_result.get("vial_bottles", []) + + # 建立 orderCode → mass_ratio 的索引 + ratio_map = {} + for ratio_item in mass_ratios: + oc = ratio_item.get("orderCode") + if oc: + ratio_map[oc] = ratio_item + + # 建立 orderCode → prep_bottle 的索引 + prep_map = {} + for pb in prep_bottles: + if pb: + oc = pb.get("orderCode") + if oc: + prep_map[oc] = pb + + # 建立 orderCode → vial_bottles 的索引 + vial_map: Dict[str, List[Dict]] = {} + for vb_list in vial_bottles_all: + if isinstance(vb_list, list): + for vb in vb_list: + oc = vb.get("orderCode") + if oc: + vial_map.setdefault(oc, []).append(vb) + elif isinstance(vb_list, dict): + oc = vb_list.get("orderCode") + if oc: + vial_map.setdefault(oc, []).append(vb_list) + + export_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + logger.info(f"[CSV导出] 开始导出, 订单数={len(reports)}, 路径={csv_file}") + + with open(csv_file, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + # 写表头 + writer.writerow([ + "orderCode", "orderName", + "配液瓶类型", "配液瓶二维码", + "分液瓶类型", "分液瓶二维码", + "目标配液质量比", "真实配液质量比", + "各试剂允差", "总质量允差", + "时间", + ]) + + for report in reports: + order_code = report.get("orderCode", "N/A") + order_name = report.get("orderName", "N/A") + + # 配液瓶信息 + prep_info = prep_map.get(order_code, {}) + prep_type = prep_info.get("typeName", "") + prep_barcode = prep_info.get("barCode", "") + + # 分液瓶信息(可能多个) + vial_list = vial_map.get(order_code, []) + if len(vial_list) == 0: + vial_type_str = "" + vial_barcode_str = "" + elif len(vial_list) == 1: + vial_type_str = vial_list[0].get("typeName", "") + vial_barcode_str = vial_list[0].get("barCode", "") + else: + # 多个分液瓶用JSON数组表示 + vial_type_str = json.dumps( + [v.get("typeName", "") for v in vial_list], + ensure_ascii=False, + ) + vial_barcode_str = json.dumps( + [v.get("barCode", "") for v in vial_list], + ensure_ascii=False, + ) + + # 质量比信息 + ratio_info = ratio_map.get(order_code, {}) + target_ratio = ratio_info.get("target_mass_ratio", {}) + real_ratio = ratio_info.get("real_mass_ratio", {}) + mass_tolerance = ratio_info.get("mass_tolerance", {}) + total_mass_tolerance = ratio_info.get("total_mass_tolerance", None) + target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "" + real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "" + mass_tolerance_str = json.dumps(mass_tolerance, ensure_ascii=False) if mass_tolerance else "" + total_mass_tolerance_str = "" if total_mass_tolerance is None else str(total_mass_tolerance) + + writer.writerow([ + order_code, order_name, + prep_type, prep_barcode, + vial_type_str, vial_barcode_str, + target_ratio_str, real_ratio_str, + mass_tolerance_str, total_mass_tolerance_str, + export_time, + ]) + + logger.info( + f"[CSV导出] 写入: orderCode={order_code}, " + f"配液瓶={prep_type}({prep_barcode}), " + f"分液瓶数={len(vial_list)}" + ) + + f.flush() + + logger.info(f"[CSV导出] ✅ 导出完成: {csv_file}") + return csv_file + + # ==================== 按 orderCode 导出实验报告(不依赖 order_finish 推送)==================== + + # 与 _export_order_csv 保持同一套表头 + _ORDER_REPORT_CSV_HEADER = ( + "orderCode", "orderName", + "配液瓶类型", "配液瓶二维码", + "分液瓶类型", "分液瓶二维码", + "目标配液质量比", "真实配液质量比", + "各试剂允差", "总质量允差", + "时间", + ) + + # 报告 Excel 里三类分液小瓶二维码列,按此优先级取首个非空 + _REPORT_VIAL_QR_COLUMNS = ( + "电导测试任务瓶二维码", + "扣电组装小瓶二维码", + "软包组装小瓶二维码", + ) + + # orderCode 形如 BSO + 8 位日期 + 流水号 + _ORDER_CODE_PATTERN = re.compile(r"^(?P[A-Za-z]+)(?P\d{8})(?P\d+)$") + + # 区间一次最多展开多少单,防止笔误刷接口 + _ORDER_CODE_EXPAND_LIMIT = 500 + + def _expand_order_codes(self, spec: str) -> List[str]: + """把打印机页码式的订单号写法展开成 orderCode 列表 + + 支持三种写法,可用逗号混合: + - 单个:``BSO2026072800006`` + - 全码区间:``BSO2026072800006-BSO2026072800029`` + - 尾号简写区间:``BSO2026072800006-29``、``BSO2026072800006-00029`` + + Args: + spec: 订单号表达式,逗号(半角/全角)分隔 + + Returns: + 去重保序后的 orderCode 列表 + + Raises: + BioyondException: 表达式为空、格式无法识别、区间倒置或展开数超限 + """ + if not spec or not str(spec).strip(): + raise BioyondException("[导出实验报告] order_codes 不能为空") + + text = str(spec).replace(",", ",").replace("-", "-").replace("~", "-") + codes: List[str] = [] + for raw in text.split(","): + part = raw.strip() + if not part: + continue + if "-" in part: + codes.extend(self._expand_order_code_range(part)) + else: + codes.append(part) + + seen = set() + result: List[str] = [] + for code in codes: + if code not in seen: + seen.add(code) + result.append(code) + + if not result: + raise BioyondException(f"[导出实验报告] 未能从 {spec!r} 解析出任何订单号") + return result + + def _expand_order_code_range(self, part: str) -> List[str]: + """展开单个区间段,如 BSO2026072800006-29""" + lo_text, _, hi_text = part.partition("-") + lo_text, hi_text = lo_text.strip(), hi_text.strip() + + match_lo = self._ORDER_CODE_PATTERN.match(lo_text) + if not match_lo: + raise BioyondException( + f"[导出实验报告] 区间起始订单号格式无法识别: {lo_text!r},期望形如 BSO2026072800006" + ) + prefix = match_lo.group("prefix") + date = match_lo.group("date") + serial = match_lo.group("serial") + width = len(serial) + start = int(serial) + + match_hi = self._ORDER_CODE_PATTERN.match(hi_text) + if match_hi: + if match_hi.group("prefix") != prefix or match_hi.group("date") != date: + raise BioyondException( + f"[导出实验报告] 区间两端批次不一致({lo_text} ~ {hi_text}),跨批次请用逗号分开写" + ) + end = int(match_hi.group("serial")) + elif hi_text.isdigit(): + # 尾号简写:按右对齐塞回流水号,BSO2026072800006-29 -> 00029 + if len(hi_text) > width: + raise BioyondException( + f"[导出实验报告] 区间末尾 {hi_text!r} 位数超过流水号宽度 {width}" + ) + end = int(serial[: width - len(hi_text)] + hi_text) + else: + raise BioyondException(f"[导出实验报告] 区间末尾格式无法识别: {hi_text!r}") + + if end < start: + raise BioyondException(f"[导出实验报告] 区间末尾小于起始: {lo_text} ~ {hi_text}") + count = end - start + 1 + if count > self._ORDER_CODE_EXPAND_LIMIT: + raise BioyondException( + f"[导出实验报告] 区间 {part} 将展开 {count} 个订单," + f"超过上限 {self._ORDER_CODE_EXPAND_LIMIT},请确认写法" + ) + return [f"{prefix}{date}{str(n).zfill(width)}" for n in range(start, end + 1)] + + def _resolve_order_by_code(self, order_code: str) -> Optional[Dict[str, Any]]: + """按 orderCode 精确定位订单摘要 + + 2.5 order-list 的 filter 是模糊匹配,可能带回同前缀的其他单;用 + preIntakes[*].sampleCode(形如 BSO2026072800006-00001)确认归属, + 比多调一次 order-report 拿 code 更省。 + + Returns: + 命中的 order-list item,未命中返回 None + """ + try: + resp = self.order_list_v2(filter=order_code, pageCount=20) + except Exception as e: + logger.warning(f"[导出实验报告] order-list 查询失败: orderCode={order_code}, 错误={e}") + return None + + items = ((resp or {}).get("data") or {}).get("items") or [] + for item in items: + for pre_intake in item.get("preIntakes") or []: + sample_code = pre_intake.get("sampleCode") or "" + if sample_code == order_code or sample_code.startswith(f"{order_code}-"): + return item + return None + + def _report_bottles_by_kind( + self, report: Dict[str, Any] + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """从 order-report 的 sampleMaterials 拆出配液瓶与分液瓶(排除板) + + Returns: + (配液瓶列表, 分液瓶列表),元素含 typeName / barCode / materialCode + """ + preps: List[Dict[str, Any]] = [] + vials: List[Dict[str, Any]] = [] + for pre_intake in report.get("preIntakes") or []: + for sample_material in pre_intake.get("sampleMaterials") or []: + type_name = (sample_material.get("materialTypeName") or "").strip() + if not type_name or "板" in type_name: + continue + entry = { + "typeName": type_name, + "barCode": sample_material.get("materialBarCode") or "", + "materialCode": sample_material.get("materialCode") or "", + } + if "配液瓶" in type_name: + preps.append(entry) + elif "分液瓶" in type_name: + vials.append(entry) + return preps, vials + + def _fetch_lims_report_excel(self, rel_path: str, save_dir: str) -> Optional[str]: + """下载 LIMS 报告 Excel + + Args: + rel_path: order-list 里 preIntakes[].extraProperties.reportFile 的相对路径 + save_dir: 落盘目录(留档便于人工核对) + + Returns: + 本地文件路径,失败返回 None + """ + if not rel_path: + return None + url = self._url(re.sub(r"/{2,}", "/", str(rel_path))) + try: + resp = requests.get(url, timeout=self.bioyond_config.get("timeout", 30)) + except Exception as e: + logger.warning(f"[导出实验报告] 报告 Excel 下载失败: {url}, 错误={e}") + return None + if resp.status_code != 200: + logger.warning(f"[导出实验报告] 报告 Excel 返回 HTTP {resp.status_code}: {url}") + return None + + os.makedirs(save_dir, exist_ok=True) + local_path = os.path.join(save_dir, str(rel_path).rsplit("/", 1)[-1]) + with open(local_path, "wb") as f: + f.write(resp.content) + logger.info(f"[导出实验报告] 报告 Excel 已存: {local_path}") + return local_path + + def _parse_lims_report_excel(self, path: str) -> Dict[str, Any]: + """解析 LIMS 报告 Excel + + 表头两行合并在同一单元格(``中文\\n英文``),取中文首行定位列;第 2 行起是数据, + 前段字段只在首行有值,组分按行铺开在「组分ID/目标加样质量/真实加样质量/允差」。 + + Returns: + 含 prep_bottle_type / prep_bottle_barcode / vial_barcode / components 的字典, + 解析失败返回空字典 + """ + try: + df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl") + except Exception as e: + logger.warning(f"[导出实验报告] 报告 Excel 解析失败: {path}, 错误={e}") + return {} + if df.empty or len(df) < 2: + logger.warning(f"[导出实验报告] 报告 Excel 内容为空: {path}") + return {} + + header = [ + "" if value is None or pd.isna(value) else str(value).split("\n")[0].strip() + for value in df.iloc[0].tolist() + ] + + def cell(row_idx: int, col_name: str) -> Any: + if col_name not in header: + return None + value = df.iloc[row_idx, header.index(col_name)] + return None if pd.isna(value) else value + + result: Dict[str, Any] = { + "prep_bottle_type": cell(1, "配液瓶类型"), + "prep_bottle_barcode": cell(1, "配液瓶二维码"), + "tray_barcode": cell(1, "托盘二维码"), + "target_total_mass": cell(1, "目标加样总质量(g)"), + "actual_total_mass": cell(1, "真实加样总质量(g)"), + "vial_barcode": "", + "vial_barcode_column": "", + "components": [], + } + + for col_name in self._REPORT_VIAL_QR_COLUMNS: + value = cell(1, col_name) + if value not in (None, ""): + result["vial_barcode"] = str(value).strip() + result["vial_barcode_column"] = col_name + break + + for row_idx in range(1, len(df)): + name = cell(row_idx, "组分ID") + if name in (None, ""): + continue + result["components"].append({ + "name": str(name).strip(), + "target": cell(row_idx, "目标加样质量(g)"), + "actual": cell(row_idx, "真实加样质量(g)"), + }) + return result + + @staticmethod + def _mass_ratio_from_components(components: List[Dict[str, Any]], key: str) -> Dict[str, float]: + """按组分质量算质量比,口径与 _process_order_reagents 一致""" + values: Dict[str, float] = {} + for comp in components: + value = comp.get(key) + if isinstance(value, (int, float)): + values[comp["name"]] = float(value) + total = sum(values.values()) + if not total: + return {} + return {name: round(value / total, 4) for name, value in values.items()} + + @staticmethod + def _format_lims_time(value: Any) -> str: + """LIMS 的 ISO 时间转 CSV 展示格式""" + if not value: + return "" + try: + return datetime.fromisoformat(str(value)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError): + return str(value) + + def export_order_report(self, order_codes: str, csv_export_path: str) -> Dict[str, Any]: + """按 BSO 订单号导出配液实验报告 CSV + + 与 create_orders 结束时的导出格式完全一致,但数据全部现查 LIMS,不依赖 + order_finish 推送,因此可以事后补任意历史批次。取数链路: + + 1. 2.5 ``order-list`` 按 orderCode 查订单摘要(orderName / completeTime / + reportFile 相对路径); + 2. 2.6 ``order-report`` 按 orderId 查 sampleMaterials,取配液瓶/分液瓶的 + 规范类型名(CSV 要的是「配液瓶(小)」,报告 Excel 里写的是「15mL配液瓶」); + 3. 下载并解析报告 Excel,取配液瓶二维码、分液小瓶二维码,以及逐组分的目标/ + 真实加样质量,据此算质量比与允差。 + + 真实加样质量以报告 Excel 为准:已验证它与瓶身 material-info 的 feedingHistory + 逐位一致,比 order_finish 推送的 realQuantity 更可靠。 + + 分液瓶二维码只从报告 Excel 取,缺失时留空并告警——查接口拿到的是瓶位当前状态, + 会被后续批次扫码覆盖、串到别单。 + + Args: + order_codes: 订单号表达式,支持单个、区间(``BSO2026072800006-BSO2026072800029`` + 或尾号简写 ``BSO2026072800006-29``)、逗号跳选,可混写 + csv_export_path: CSV 导出目录(报告 Excel 存到其下 reports/ 子目录) + + Returns: + { + "csv_file": CSV 路径, + "total": 请求单数, + "resolved": 成功取到数据的单数, + "not_found": 未查到的 orderCode 列表, + "missing_vial_qr": 分液瓶二维码缺失的 orderCode 列表, + "rows": 每行数据 + } + """ + codes = self._expand_order_codes(order_codes) + if not csv_export_path or not str(csv_export_path).strip(): + raise BioyondException("[导出实验报告] csv_export_path 不能为空") + + csv_export_path = str(csv_export_path).strip() + os.makedirs(csv_export_path, exist_ok=True) + report_dir = os.path.join(csv_export_path, "reports") + + logger.info(f"[导出实验报告] 订单号展开为 {len(codes)} 个: {codes}") + + rows: List[Dict[str, str]] = [] + not_found: List[str] = [] + missing_vial_qr: List[str] = [] + + for idx, order_code in enumerate(codes, 1): + logger.info(f"[导出实验报告] ({idx}/{len(codes)}) 处理 orderCode={order_code}") + row = {key: "" for key in self._ORDER_REPORT_CSV_HEADER} + row["orderCode"] = order_code + + summary = self._resolve_order_by_code(order_code) + if not summary: + logger.warning(f"[导出实验报告] LIMS 未查到订单: orderCode={order_code},该行留空") + not_found.append(order_code) + rows.append(row) + continue + + row["orderName"] = summary.get("name") or "" + row["时间"] = self._format_lims_time(summary.get("completeTime")) + if str(summary.get("status")) != "80": + logger.warning( + f"[导出实验报告] 订单未成功完成: orderCode={order_code}, " + f"status={summary.get('status')}({summary.get('statusName')}),数据可能不完整" + ) + + # 瓶子规范类型名(配液瓶条码也可从这里兜底) + order_id = summary.get("id") or summary.get("orderId") or "" + report: Dict[str, Any] = {} + if order_id: + try: + report = (self.order_report_v2(order_id) or {}).get("data") or {} + except Exception as e: + logger.warning( + f"[导出实验报告] order-report 查询失败: orderCode={order_code}, 错误={e}" + ) + preps, vials = self._report_bottles_by_kind(report) + + # 报告 Excel + excel: Dict[str, Any] = {} + for pre_intake in summary.get("preIntakes") or []: + rel_files = ((pre_intake.get("extraProperties") or {}).get("reportFile")) or [] + for rel_path in rel_files: + local_path = self._fetch_lims_report_excel(rel_path, report_dir) + if local_path: + excel = self._parse_lims_report_excel(local_path) + break + if excel: + break + if not excel: + logger.warning( + f"[导出实验报告] 无报告 Excel: orderCode={order_code},质量比相关列留空" + ) + + row["配液瓶类型"] = ( + (preps[0]["typeName"] if preps else "") or str(excel.get("prep_bottle_type") or "") + ) + row["配液瓶二维码"] = ( + str(excel.get("prep_bottle_barcode") or "") or (preps[0]["barCode"] if preps else "") + ) + row["分液瓶类型"] = vials[0]["typeName"] if vials else "" + row["分液瓶二维码"] = excel.get("vial_barcode") or "" + if not row["分液瓶二维码"]: + missing_vial_qr.append(order_code) + logger.warning( + f"[导出实验报告] 分液瓶二维码缺失: orderCode={order_code}" + f"(报告 Excel 未写入,多为工站扫码失败);接口现值是瓶位当前状态、" + f"会串到别单,故留空" + ) + + components = excel.get("components") or [] + target_ratio = self._mass_ratio_from_components(components, "target") + real_ratio = self._mass_ratio_from_components(components, "actual") + mass_tolerance: Dict[str, Optional[float]] = {} + target_sum = 0.0 + real_sum = 0.0 + for comp in components: + target, actual = comp.get("target"), comp.get("actual") + if not isinstance(target, (int, float)) or not isinstance(actual, (int, float)): + continue + target_sum += float(target) + real_sum += float(actual) + mass_tolerance[comp["name"]] = ( + round((float(actual) - float(target)) / float(target), 6) if target else None + ) + total_mass_tolerance = ( + round((real_sum - target_sum) / target_sum, 6) if target_sum else None + ) + + row["目标配液质量比"] = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "" + row["真实配液质量比"] = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "" + row["各试剂允差"] = json.dumps(mass_tolerance, ensure_ascii=False) if mass_tolerance else "" + row["总质量允差"] = "" if total_mass_tolerance is None else str(total_mass_tolerance) + + rows.append(row) + logger.info( + f"[导出实验报告] orderCode={order_code}, orderName={row['orderName']}, " + f"配液瓶={row['配液瓶类型']}({row['配液瓶二维码']}), " + f"分液瓶={row['分液瓶类型']}({row['分液瓶二维码'] or '缺失'}), " + f"组分数={len(components)}" + ) + + time_date = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_file = os.path.join(csv_export_path, f"electrolyte_orders_bycode_{time_date}.csv") + with open(csv_file, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(list(self._ORDER_REPORT_CSV_HEADER)) + for row in rows: + writer.writerow([row.get(key, "") for key in self._ORDER_REPORT_CSV_HEADER]) + f.flush() + + resolved = len(codes) - len(not_found) + logger.info( + f"[导出实验报告] ✅ 导出完成: {csv_file}," + f"共 {len(codes)} 单,成功 {resolved} 单," + f"未查到 {len(not_found)} 单,分液瓶二维码缺失 {len(missing_vial_qr)} 单" + ) + if not_found: + logger.warning(f"[导出实验报告] 未查到的订单: {not_found}") + if missing_vial_qr: + logger.warning(f"[导出实验报告] 分液瓶二维码缺失的订单: {missing_vial_qr}") + + return { + "csv_file": csv_file, + "total": len(codes), + "resolved": resolved, + "not_found": not_found, + "missing_vial_qr": missing_vial_qr, + "rows": rows, + } + + def _export_conductivity_csv( + self, + results: List[Dict[str, Any]], + csv_export_path: str, + filename_prefix: str, + ) -> str: + """ + 导出电导-only CSV(external 与 inline 共用)。 + + Args: + results: _collect_conductivity_results 的返回(每瓶一条) + csv_export_path: 导出目录 + filename_prefix: 文件名前缀,如 "External_conductivity" / "Inline_conductivity" + + Returns: + 生成的 CSV 文件完整路径 + + 列:分液瓶板条码 | 分液瓶二维码 | 内部瓶位置X | 内部瓶位置Y | + 目标温度 | 实际温度 | 电导值 | 电导率单位 | 时间 + """ + os.makedirs(csv_export_path, exist_ok=True) + time_date = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_file = os.path.join(csv_export_path, f"{filename_prefix}_{time_date}.csv") + + logger.info( + f"[电导CSV导出] 开始导出, 行数={len(results)}, 路径={csv_file}" + ) + with open(csv_file, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow([ + "分液瓶板条码", "分液瓶二维码", + "内部瓶位置X", "内部瓶位置Y", + "目标温度", "实际温度", "电导值", "电导率单位", "时间", + ]) + for r in results: + writer.writerow([ + r.get("boardBarCode", ""), + r.get("bottleBarCode", ""), + r.get("bottleInnerX") if r.get("bottleInnerX") is not None else "", + r.get("bottleInnerY") if r.get("bottleInnerY") is not None else "", + r.get("targetTemperature") if r.get("targetTemperature") is not None else "", + r.get("temperature") if r.get("temperature") is not None else "", + r.get("conductivity") if r.get("conductivity") is not None else "", + r.get("conductivityUnit") if r.get("conductivityUnit") is not None else "", + r.get("report_time", ""), + ]) + f.flush() + + logger.info(f"[电导CSV导出] ✅ 导出完成: {csv_file}") + return csv_file + + def _export_conductivity_formulation_csv( + self, + results: List[Dict[str, Any]], + mass_ratios: List[Dict[str, Any]], + csv_export_path: str, + ) -> str: + """ + 导出配液+电导合并 CSV(inline 专用)。 + + 按"分液瓶二维码"把电导结果与配液配方信息合并,一瓶一行(不再把多瓶 + 条码合并成数组)。bottleBarCode 为空时回退用 (boardBarCode + X + Y) 匹配。 + + Args: + results: _collect_conductivity_results 的返回(每瓶一条电导结果) + mass_ratios: 配液配方信息列表(来自配液 handle),每项含 + orderCode/orderName/target_mass_ratio/real_mass_ratio/mass_tolerance/ + total_mass_tolerance/prep_bottle_barcode/prep_bottle_type/ + vial_bottle_barcodes/vial_bottle_types + csv_export_path: 导出目录 + + Returns: + 生成的 CSV 文件完整路径 + + 列:orderCode | orderName | 配液瓶类型 | 配液瓶二维码 | 分液瓶类型 | + 分液瓶二维码 | 目标配液质量比 | 真实配液质量比 | 各试剂允差 | + 总质量允差 | 电导值 | 电导率单位 | 目标温度 | 实际温度 | 时间 + """ + os.makedirs(csv_export_path, exist_ok=True) + time_date = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_file = os.path.join(csv_export_path, f"Formulation_Conductivity_{time_date}.csv") + + mass_ratios = mass_ratios or [] + + def _parse_barcode_list(raw: Any) -> List[str]: + """vial_bottle_barcodes 可能是单串或 JSON 数组串,统一成 list[str]。""" + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw if x] + s = str(raw).strip() + if not s: + return [] + if s.startswith("["): + try: + arr = json.loads(s) + if isinstance(arr, list): + return [str(x) for x in arr if x] + except Exception: + pass + return [s] + + # 建立 分液瓶二维码 → 配液配方项 的索引(按瓶条码逐个展开) + barcode_to_formula: Dict[str, Dict[str, Any]] = {} + for mr in mass_ratios: + if not isinstance(mr, dict): + continue + for bc in _parse_barcode_list(mr.get("vial_bottle_barcodes")): + barcode_to_formula[bc] = mr + + logger.info( + f"[配液电导CSV导出] 开始导出, 电导结果={len(results)} 条, " + f"配方索引瓶数={len(barcode_to_formula)}, 路径={csv_file}" + ) + + export_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + matched_cnt = 0 + with open(csv_file, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow([ + "orderCode", "orderName", + "配液瓶类型", "配液瓶二维码", + "分液瓶类型", "分液瓶二维码", + "目标配液质量比", "真实配液质量比", + "各试剂允差", "总质量允差", + "电导值", "电导率单位", "目标温度", "实际温度", "时间", + ]) + for r in results: + bottle_barcode = r.get("bottleBarCode", "") or "" + # 主匹配:分液瓶二维码 + mr = barcode_to_formula.get(bottle_barcode) if bottle_barcode else None + if mr is None: + # 回退:bottleBarCode 为空/未命中时,按 (板码 + X + Y) 反查 + mr = self._match_formula_by_position(r, mass_ratios) + if mr is not None: + matched_cnt += 1 + mr = mr or {} + + target_ratio = mr.get("target_mass_ratio", {}) + real_ratio = mr.get("real_mass_ratio", {}) + mass_tol = mr.get("mass_tolerance", {}) + total_tol = mr.get("total_mass_tolerance", None) + + writer.writerow([ + mr.get("orderCode", ""), + mr.get("orderName", ""), + mr.get("prep_bottle_type", ""), + mr.get("prep_bottle_barcode", ""), + self._pick_vial_type(mr), + bottle_barcode, + json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "", + json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "", + json.dumps(mass_tol, ensure_ascii=False) if mass_tol else "", + "" if total_tol is None else str(total_tol), + r.get("conductivity") if r.get("conductivity") is not None else "", + r.get("conductivityUnit") if r.get("conductivityUnit") is not None else "", + r.get("targetTemperature") if r.get("targetTemperature") is not None else "", + r.get("temperature") if r.get("temperature") is not None else "", + r.get("report_time", export_time), + ]) + f.flush() + + logger.info( + f"[配液电导CSV导出] ✅ 导出完成: {csv_file} " + f"(电导 {len(results)} 条, 命中配方 {matched_cnt} 条)" + ) + return csv_file + + def _push_csv_to_datacore(self, csv_file: str, tag: str) -> bool: + """ + 把 CSV 推送到 DataCore 大装置数据接入,凭据取自 bioyond_config.datacore_config。 + 未配置或推送失败都只记日志,不影响主流程。 + """ + return push_csv_by_config( + csv_file, self.bioyond_config.get("datacore_config"), tag=tag, log=logger.info + ) + + @staticmethod + def _pick_vial_type(mr: Dict[str, Any]) -> str: + """从配方项里取分液瓶类型;vial_bottle_types 可能是单串或 JSON 数组串。""" + raw = mr.get("vial_bottle_types") if isinstance(mr, dict) else None + if raw is None: + return "" + if isinstance(raw, list): + return str(raw[0]) if raw else "" + s = str(raw).strip() + if s.startswith("["): + try: + arr = json.loads(s) + if isinstance(arr, list) and arr: + return str(arr[0]) + except Exception: + pass + return s + + @staticmethod + def _match_formula_by_position( + result_row: Dict[str, Any], mass_ratios: List[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """ + 回退匹配:当电导结果的 bottleBarCode 为空/未命中时, + 按 (boardBarCode + bottleInnerX + bottleInnerY) 反查所属配方项。 + + 依赖配方项里的 vial_bottle_positions(建单阶段注入:板码 + X + Y → 瓶)。 + 没有该映射时返回 None。 + """ + board = result_row.get("boardBarCode", "") or "" + x = result_row.get("bottleInnerX") + y = result_row.get("bottleInnerY") + if not board or x is None or y is None: + return None + for mr in mass_ratios: + if not isinstance(mr, dict): + continue + for pos in (mr.get("vial_bottle_positions") or []): + if ( + str(pos.get("plateBarCode", "")) == str(board) + and pos.get("x") == x + and pos.get("y") == y + ): + return mr + return None + + def _query_material_info(self, material_id: str) -> Dict: + """ + 调用 LIMS API 2.4 查询物料详情 + + Args: + material_id: 物料ID (materialId) + + Returns: + { + "typeName": "5ml分液瓶板", + "barCode": "...", + "name": "...", + "detail": [...] + } + """ + # 从配置加载 api_key和api_host(用于日志) + api_key = self.bioyond_config.get("api_key", "8A819E5C") + api_host = self.bioyond_config.get("api_host", "UNKNOWN") + + # ========== 调试日志 ========== + logger.info( + f"[查询物料详情] 开始查询 materialId={material_id}, " + f"api_host={api_host}, api_key={api_key[:4]}****" + ) + + try: + # 直接传递 material_id,_post_lims 会自动包装为 {apiKey, requestTime, data} + response = self._post_lims("/api/lims/storage/material-info", material_id) + + if response.get("error"): + error_msg = f"查询物料详情失败: {response.get('error')}" + logger.warning(f"[查询物料详情] ❌ {error_msg}") + raise ValueError(error_msg) + + logger.debug(f"[查询物料详情] API响应: code={response.get('code')}, message={response.get('message')}") + + if response.get("code") == 1: + data = response.get("data", {}) + logger.info( + f"[查询物料详情] ✅ 成功: materialId={material_id}, " + f"typeName={data.get('typeName')}, barCode={data.get('barCode')}" + ) + return data + else: + error_msg = f"查询物料详情失败: {response.get('message')}" + logger.warning(f"[查询物料详情] ❌ {error_msg}") + raise ValueError(error_msg) + except Exception as e: + logger.error( + f"[查询物料详情] ❌ 异常: materialId={material_id}, " + f"错误类型={type(e).__name__}, 错误信息={str(e)}" + ) + raise + + def _query_plate_bottle_positions( + self, plate_material_id: str + ) -> List[Dict[str, Any]]: + """ + 查询分液瓶板上所有分液瓶的孔位 (X, Y) 坐标。 + + 通过调 LIMS API 2.4 (/api/lims/storage/material-info) 拿到板的 detail[*], + 每条 detail 即为一个孔位,按 (x, y) 取出所有有效孔位返回。 + + 2026-06-04 修:原先按 typeName 过滤"含'分液瓶'"在实际数据上失效—— + LIMS 端 detail.typeName 经常为空字符串/None,导致过滤后变 0 块。 + 现在与 `_populate_vial_bottles`(资源树创建用的同一份返回)保持一致, + 只要 detail 项有 x/y 即视为孔位;典型情况下板上每条 detail 就是一个瓶位, + 过滤 typeName 含"分液瓶板"的条目以防 detail 里掺了子板(极罕见)。 + + Args: + plate_material_id: 分液瓶板 materialId (GUID) + + Returns: + [{"x": int, "y": int, "typeName": str, "name": str, + "detailMaterialId": str, "code": str, "associateId": str}, ...] + associateId 为该瓶子关联的配液订单 GUID(跨 order 共用板时用于反查归属) + + Raises: + BioyondException: detail 为空 / 全部条目缺 x/y + """ + data = self._query_material_info(plate_material_id) + detail = data.get("detail") or [] + logger.info( + f"[查询瓶位] 板 {plate_material_id[:20]}... 的 detail 共 {len(detail)} 条原始记录" + ) + bottles: List[Dict[str, Any]] = [] + for idx, d in enumerate(detail): + if not isinstance(d, dict): + logger.debug(f"[查询瓶位] detail[{idx}] 非 dict,跳过: {d!r}") + continue + type_name = (d.get("typeName") or "").strip() + x_raw = d.get("x") + y_raw = d.get("y") + if x_raw is None or y_raw is None: + logger.debug( + f"[查询瓶位] detail[{idx}] 缺 x/y,跳过: typeName={type_name!r}, " + f"x={x_raw}, y={y_raw}, code={d.get('code')!r}" + ) + continue + # 几乎不可能,但保护一下:detail 里若混入子板,跳过 + if "分液瓶板" in type_name: + logger.debug( + f"[查询瓶位] detail[{idx}] typeName 含'分液瓶板',跳过(避免把子板当瓶): " + f"typeName={type_name!r}" + ) + continue + try: + x_int = int(x_raw) + y_int = int(y_raw) + except (TypeError, ValueError): + logger.warning( + f"[查询瓶位] detail[{idx}] x/y 不能转 int,跳过: x={x_raw!r}, y={y_raw!r}" + ) + continue + bottles.append({ + "x": x_int, + "y": y_int, + "typeName": type_name, + "name": d.get("name", "") or "", + "code": d.get("code", "") or "", + "detailMaterialId": d.get("detailMaterialId", "") or "", + "associateId": d.get("associateId", "") or "", + }) + + if not bottles: + raise BioyondException( + f"分液瓶板 {plate_material_id} 的 detail 中未找到任何有效孔位 " + f"(原始 detail 长度={len(detail)})" + ) + logger.info( + f"[查询瓶位] ✅ 板 {plate_material_id[:20]}... 共提取 {len(bottles)} 个孔位: " + f"{[(b['x'], b['y'], b['typeName'] or '', (b['associateId'] or '')[:8]) for b in bottles]}" + ) + return bottles + + def _create_vial_plate_resource(self, vial_plate_info: Dict) -> None: + """ + 创建分液瓶板资源对象并添加到资源树 + + Args: + vial_plate_info: 分液瓶板元数据 + { + "materialId": "3a1f3df9-ddce-f544-bd48-07077ad87bc5", + "locationId": "3a19debc-84b5-4c1c-d3a1-26830cf273ff", + "orderCode": "BSO2026020500002", + "typeName": "5ml分液瓶板" 或 "20ml分液瓶板" + } + """ + from unilabos.resources.bioyond.YB_bottle_carriers import ( + YB_Vial_5mL_Carrier, + YB_Vial_20mL_Carrier + ) + + material_id = vial_plate_info["materialId"] + location_id = vial_plate_info["locationId"] + order_code = vial_plate_info["orderCode"] + type_name = vial_plate_info["typeName"] + + logger.info( + f"[资源树] 开始创建分液瓶板: orderCode={order_code}, " + f"typeName={type_name}" + ) + + # 1. 根据类型创建Carrier对象 + # 命名必须含**完整** materialId:同订单多块物理板同前缀,重名会触发 + # deck 的 "already assigned to deck"(只挂第1块,其余槽位留空 holder)。 + # 奔曜 materialId 是顺序生成的,截断到前 8 位仍会撞 + # (3a22ac50-2daf / 3a22ac50-879e 前 8 位相同),故不做截断。 + plate_name = f"vial_plate_{order_code}_{material_id}" + if "5ml" in type_name.lower() or "5mL" in type_name: + vial_plate_obj = YB_Vial_5mL_Carrier(name=plate_name) + logger.debug(f"[资源树] 创建 YB_Vial_5mL_Carrier: {vial_plate_obj.name}") + elif "20ml" in type_name.lower() or "20mL" in type_name: + vial_plate_obj = YB_Vial_20mL_Carrier(name=plate_name) + logger.debug(f"[资源树] 创建 YB_Vial_20mL_Carrier: {vial_plate_obj.name}") + else: + logger.warning( + f"[资源树] ⚠️ 未知的分液瓶板类型: {type_name}, 跳过创建" + ) + return + + # ✅ 关键:分配 UUID(用于资源树转运) + # 使用 materialId 作为 UUID,确保与LIMS系统一致 + vial_plate_obj.unilabos_uuid = material_id + logger.debug(f"[资源树] 分配 UUID: {material_id[:30]}...") + + # ✅ 新增:查询并创建分液瓶板上的瓶子资源 + try: + self._populate_vial_bottles(vial_plate_obj, material_id, order_code) + except Exception as e: + logger.warning( + f"[资源树] ⚠️ 创建瓶子资源失败(继续创建瓶板): {e}" + ) + + # 2. 解析位置 (locationId → warehouse + slot) + wh_name, slot_name = self._get_warehouse_and_slot_from_location_id( + location_id + ) + + if not wh_name or not slot_name: + logger.warning( + f"[资源树] ⚠️ 无法解析位置: locationId={location_id}, " + f"wh_name={wh_name}, slot_name={slot_name}" + ) + return + + logger.debug( + f"[资源树] 解析位置: locationId={location_id[:20]}... → " + f"{wh_name}[{slot_name}]" + ) + + # 3. 添加到资源树 + try: + warehouse = self.deck.get_resource(wh_name) + if not warehouse: + logger.error(f"[资源树] ❌ 未找到仓库: {wh_name}") + return + + # 使用直接槽位赋值 + # warehouse 的 sites 是一个 dict: {"A01": ResourceHolder, "A02": ...} + # 直接通过 warehouse[slot_name] 访问槽位并赋值资源对象 + warehouse[slot_name] = vial_plate_obj + + logger.info( + f"[资源树] ✅ 创建成功: {wh_name}[{slot_name}] = " + f"{vial_plate_obj.name} (类型: {type_name})" + ) + except Exception as e: + logger.error( + f"[资源树] ❌ 添加到资源树失败: {wh_name}[{slot_name}], " + f"错误={e}" + ) + raise + + def _populate_vial_bottles( + self, + vial_plate_obj, + plate_material_id: str, + order_code: str + ) -> None: + """ + 查询分液瓶板的detail信息,创建瓶子资源并添加到瓶板 + + Args: + vial_plate_obj: 瓶板资源对象 + plate_material_id: 瓶板的materialId + order_code: 订单号 + """ + logger.info(f"[资源树] 查询瓶板子物料: materialId={plate_material_id[:20]}...") + + # 1. 调用LIMS接口查询瓶板详情 + try: + plate_detail = self.get_material_info(plate_material_id) + except Exception as e: + logger.error(f"[资源树] ❌ 查询瓶板详情失败: {e}") + return + + # 2. 提取detail字段(包含所有瓶子信息) + bottles_detail = plate_detail.get("detail", []) + if not bottles_detail: + logger.warning(f"[资源树] ⚠️ 瓶板无子物料信息") + return + + logger.info(f"[资源树] 瓶板包含 {len(bottles_detail)} 个瓶子") + + # 3. 为每个瓶子创建资源 + from unilabos.resources.bioyond.YB_bottles import YB_Vial_5mL + + created_count = 0 + skipped_no_code = 0 + for idx, bottle_info in enumerate(bottles_detail, 1): + try: + bottle_material_id = bottle_info.get("detailMaterialId") + # 用 or 而非 get 的默认值:LIMS 会把空瓶位的 code 显式返回成 null, + # 键存在时 get 的默认值不生效,直接拿到 None + bottle_code = bottle_info.get("code") or "" + bottle_x = bottle_info.get("x", 0) + bottle_y = bottle_info.get("y", 0) + associate_id = bottle_info.get("associateId") # 关联订单ID + + if not bottle_material_id: + logger.warning(f" 瓶子[{idx}]: 缺少materialId,跳过") + continue + + # 无编号的瓶位是本单没分液到的空位:LIMS 里仍有子物料记录,但没分配 + # 编号,而下游电导/扣电是靠 barCode 认瓶的,认不出这种瓶子。给它编个 + # 名字挂进资源树反而可能被下游取到空瓶,所以直接跳过。 + if not bottle_code: + skipped_no_code += 1 + logger.debug( + f" 瓶子[{idx}]: 位置=({bottle_x},{bottle_y}) 无编号(空瓶位),跳过" + ) + continue + + # ✅ 创建瓶子资源(使用工厂函数) + bottle_obj = YB_Vial_5mL( + name=f"{vial_plate_obj.name}_vial_{bottle_code.replace(' ', '_')}", + diameter=20.0, + height=50.0, + max_volume=5000.0, # 5mL + barcode=None + ) + + # ✅ 设置UUID(用于LIMS同步) + bottle_obj.unilabos_uuid = bottle_material_id + + # ✅ 存储元数据(供扣电使用) + bottle_obj._unilabos_state = { + "orderCode": order_code, + "materialId": bottle_material_id, + "code": bottle_code, + "position_x": bottle_x, + "position_y": bottle_y, + "associateId": associate_id + } + + # ✅ 添加到瓶板(根据xy坐标计算索引) + # 假设瓶板布局: x=1,2 y=1,2,3,4 (2x4布局) + bottle_index = (bottle_x - 1) * 4 + (bottle_y - 1) + + if 0 <= bottle_index < len(vial_plate_obj.children): + vial_plate_obj.children[bottle_index] = bottle_obj + created_count += 1 + logger.debug( + f" 瓶子[{idx}]: code={bottle_code}, " + f"位置=({bottle_x},{bottle_y}), 索引={bottle_index}" + ) + else: + logger.warning( + f" 瓶子[{idx}]: 索引超出范围 ({bottle_index} >= {len(vial_plate_obj.children)})" + ) + + except Exception as e: + logger.warning(f" 瓶子[{idx}]: 创建失败 - {e}") + continue + + logger.info( + f"[资源树] ✅ 已创建 {created_count}/{len(bottles_detail)} 个瓶子资源" + + (f",{skipped_no_code} 个空瓶位(无编号)已跳过" if skipped_no_code else "") + ) + + def transfer_3_to_2_to_1_auto( + self, + vial_plates: List[Dict], + target_device: str = "BatteryStation", + target_location: str = "bottle_rack_6x2", + mass_ratios: List[Dict] = None, + source_pos: Optional[Dict] = None, # 可选:统一 xyz 覆盖(当 vial_plate 内无预存坐标时使用) + **kwargs # 兼容性参数,捕获已废弃的 vial_plate_info 等参数 + ) -> Dict[str, Any]: + """ + 自动转运(从 create_orders 结果自动定位源位置) + + 本方法只负责在退出时释放 `stack_inquiry_2to1` 占下的交接堆栈预约,转运主体见 + `_transfer_3_to_2_to_1_auto_impl`。 + + 释放必须放在 `finally`:本 action 有两个出口——成功的 `return summary` 和有板搬 + 失败时的 `raise BioyondException`(2026-08-05 那次 8688 走的正是 raise)。只在 + return 前释放的话,失败路径上预约会一直挂到兜底作废时长(小时级)到期, + 期间每一轮的 `stack_inquiry_2to1` 都卡在开头等预约 —— 一次转运失败升级成全站停摆。 + + Args: + vial_plates: 分液瓶板列表 + 格式: [{"materialId": "...", "locationId": "...", "orderCode": "..."}, ...] + target_device: 目标设备ID + target_location: 目标资源名称 + mass_ratios: 配方信息列表(可选),用于确定瓶子在bottle_rack的位置 + 格式: [{"orderCode": "...", "real_mass_ratio": {...}, ...}, ...] + **kwargs: 兼容性参数,用于捕获已废弃的参数(如 vial_plate_info) + + Returns: + { + "total": 转运总数, + "success": 成功数量, + "failed": 失败数量, + "results": [每个转运的详细结果] + } + """ + try: + return self._transfer_3_to_2_to_1_auto_impl( + vial_plates=vial_plates, + target_device=target_device, + target_location=target_location, + mass_ratios=mass_ratios, + source_pos=source_pos, + **kwargs, + ) + finally: + # 到这里 321 已尘埃落定:搬成了则 0016 的占用已由 LIMS 库存如实反映, + # 搬失败则 0016 本就没被本轮占。两种情况都不该再挡着下一轮的门控。 + self._release_handoff_claim(reason="321 转运结束") + + def _transfer_3_to_2_to_1_auto_impl( + self, + vial_plates: List[Dict], + target_device: str = "BatteryStation", + target_location: str = "bottle_rack_6x2", + mass_ratios: List[Dict] = None, + source_pos: Optional[Dict] = None, + **kwargs + ) -> Dict[str, Any]: + """`transfer_3_to_2_to_1_auto` 的转运主体,参数含义见其 docstring。""" + # 检查是否传递了已废弃的参数 + if kwargs: + logger.warning( + f"[transfer_3_to_2_to_1_auto] ⚠️ 检测到已废弃的参数: {list(kwargs.keys())}, " + f"这些参数将被忽略" + ) + + # ========== 参数验证 ========== + if not vial_plates: + raise ValueError("vial_plates 参数不能为空") + + logger.info("=" * 80) + logger.info(f"[transfer_3_to_2_to_1_auto] 接收到 {len(vial_plates)} 个分液瓶板") + for idx, plate in enumerate(vial_plates, 1): + logger.info( + f" [{idx}] orderCode={plate.get('orderCode', 'N/A')}, " + f"materialId={plate.get('materialId', 'N/A')[:20]}..., " + f"typeName={plate.get('typeName', 'N/A')}" + ) + logger.info("=" * 80) + + # ========== 321 仅转运 5ml 分液瓶板(20ml 走 32 转运)========== + PLATE_TYPE_5ML = "5ml分液瓶板" + filtered_plates: List[Dict] = [] + skip_results: List[Dict] = [] + for idx, plate in enumerate(vial_plates, 1): + if not plate or not isinstance(plate, dict): + skip_results.append({ + "index": idx, + "orderCode": "N/A", + "materialId": "N/A", + "status": "failed", + "error": "分液瓶板信息无效或为空", + }) + continue + + type_name = (plate.get("typeName") or "").strip() + material_id = plate.get("materialId") or "" + order_code = plate.get("orderCode", "N/A") + + # typeName 缺失时兜底查一次物料详情 + if not type_name and material_id: + try: + info = self._query_material_info(material_id) + type_name = (info.get("typeName") or "").strip() + plate["typeName"] = type_name + logger.info( + f"[批量转运] typeName 缺失,已补查: materialId={material_id[:20]}..., " + f"typeName={type_name}" + ) + except Exception as e: + logger.warning( + f"[批量转运] typeName 缺失且补查失败: materialId={material_id[:20]}..., " + f"错误={e},将按非5ml跳过" + ) + + if PLATE_TYPE_5ML not in type_name: + logger.info( + f"[批量转运] ℹ️ [{idx}/{len(vial_plates)}] 非5ml板,跳过321转运 " + f"(orderCode={order_code}, typeName={type_name or '空'})" + ) + skip_results.append({ + "index": idx, + "orderCode": order_code, + "materialId": material_id, + "status": "skipped", + "message": f"非5ml分液瓶板(typeName={type_name or '空'}),321仅转运5ml板", + }) + continue + + filtered_plates.append(plate) + + if not filtered_plates: + logger.warning( + f"[transfer_3_to_2_to_1_auto] 过滤后无5ml分液瓶板可转运 " + f"(原始 {len(vial_plates)} 块,全部跳过)" + ) + return { + "total": len(vial_plates), + "success": 0, + "failed": sum(1 for r in skip_results if r.get("status") == "failed"), + "results": skip_results, + } + + logger.info( + f"[批量转运] 5ml过滤后保留 {len(filtered_plates)}/{len(vial_plates)} 块板" + ) + + # ========== 一轮只能转 1 块板:0016 只有 1 个库位 ========== + # 下面的 for 循环形式上支持多块,实际必然在第 2 块上失败——第 1 块放进 0016 后它 + # 就满了,LIMS 对「目标交接堆栈有物料」的 321 直接拒建(返回 data=None)。 + # 而 0016 要等整个扣电流程走完、transfer_1_to_2 把板送回 0017 才腾空, + # 这个循环内根本等不到。与其让第 2 块板报一句看不懂的 data=None,不如在这里拦掉。 + # 历史日志里 321 也确实从来只搬 1 块板(一轮 1–8 瓶正好装在同一块 8 位板上)。 + if len(filtered_plates) > 1: + raise BioyondException( + f"3-2-1 转运一次只能搬 1 块 5ml 分液瓶板,本次收到 {len(filtered_plates)} 块" + f"(orderCode={[p.get('orderCode') for p in filtered_plates]})。\n" + f"原因:1号2号手套箱交接堆栈只有 1 个库位,且要占用到本轮扣电结束、" + f"transfer_1_to_2 送回 2号手套箱内堆栈才腾空,所以第 2 块板必被 LIMS 拒建。\n" + f"处置:把这批拆成多轮实验提交,每轮 1 块板(≤8 瓶)。" + ) + + # ========== 步骤2:依次转运每个分液瓶板(去重,同一瓶板只转运一次)========== + results = list(skip_results) + success_count = 0 + failed_count = sum(1 for r in skip_results if r.get("status") == "failed") + transferred_material_ids = set() # ✅ 记录已转运的materialId + + logger.info( + f"[批量转运] 开始转运 {len(filtered_plates)} 块5ml分液瓶板 → " + f"{target_device}.{target_location}" + ) + + for idx, plate_info in enumerate(filtered_plates, 1): + try: + material_id = plate_info.get('materialId') + order_code = plate_info.get('orderCode', 'N/A') + + logger.info(f"\n{'='*60}") + logger.info(f"[批量转运] 处理 [{idx}/{len(filtered_plates)}]") + logger.info(f" orderCode: {order_code}") + logger.info(f" materialId: {material_id[:20] if material_id else 'N/A'}...") + logger.info(f" typeName: {plate_info.get('typeName', 'N/A')}") + + # ✅ 检查是否已转运(同一物理瓶板只转运一次) + if material_id in transferred_material_ids: + logger.info( + f" ℹ️ 该瓶板已转运,跳过 (多订单共用同一瓶板)" + ) + results.append({ + "index": idx, + "orderCode": order_code, + "materialId": material_id, + "status": "skipped", + "message": "该瓶板已转运(共用瓶板)" + }) + success_count += 1 # 视为成功 + logger.info(f"{'='*60}") + continue + + logger.info(f"{'='*60}") + + # 调用单个转运逻辑(source_pos 作为兜底坐标,低于 plate_info 内预存 xyz) + result = self._transfer_single_vial_plate( + vial_plate_info=plate_info, + target_device=target_device, + target_location=target_location, + source_pos_fallback=source_pos + ) + + transferred_material_ids.add(material_id) + results.append({ + "index": idx, + "orderCode": order_code, + "materialId": material_id, + "status": "success", + "result": result + }) + success_count += 1 + logger.info(f"[批量转运] ✅ [{idx}/{len(filtered_plates)}] 转运成功") + + except Exception as e: + logger.error( + f"[批量转运] ❌ [{idx}/{len(filtered_plates)}] 失败: {str(e)}" + ) + results.append({ + "index": idx, + "orderCode": plate_info.get("orderCode", "N/A") if plate_info else "N/A", + "materialId": plate_info.get("materialId", "N/A") if plate_info else "N/A", + "status": "failed", + "error": str(e) + }) + failed_count += 1 + + # ========== 步骤3:汇总结果 ========== + summary = { + "total": len(vial_plates), + "success": success_count, + "failed": failed_count, + "results": results + } + + logger.info(f"\n{'='*60}") + logger.info(f"[批量转运] 完成汇总:") + logger.info(f" 总数: {summary['total']}") + logger.info(f" 成功: {summary['success']} ✅") + logger.info(f" 失败: {summary['failed']} ❌") + logger.info(f"{'='*60}\n") + + # 有板没搬成功必须让本 action 失败:否则工作流会继续往下跑 + # transfer_1_to_2 / 扣电组装,而料根本没到位。 + if failed_count > 0: + fail_details = "; ".join( + f"{r.get('orderCode')}/{(r.get('materialId') or '')[:8]}: {r.get('error')}" + for r in results if r.get("status") == "failed" + ) + raise BioyondException( + f"3-2-1 批量转运有 {failed_count}/{len(filtered_plates)} 块板失败: {fail_details}" + ) + + return summary + + def _transfer_single_vial_plate( + self, + vial_plate_info: Dict, + target_device: str, + target_location: str, + source_pos_fallback: Optional[Dict] = None + ) -> Dict[str, Any]: + """ + 转运单个分液瓶板(内部方法) + + Args: + vial_plate_info: 单个分液瓶板信息 + target_device: 目标设备ID + target_location: 目标资源名称 + + Returns: + LIMS转运结果 + """ + location_id = vial_plate_info["locationId"] + material_id = vial_plate_info["materialId"] + + # 步骤1:locationId → warehouse名称 + 槽位名称 + wh_name, slot_name = self._get_warehouse_and_slot_from_location_id(location_id) + + if not wh_name or not slot_name: + raise ValueError(f"无法从 locationId 解析仓库和槽位: {location_id}") + + logger.info( + f"[自动转运] 分液瓶板资源树位置: {wh_name}[{slot_name}], " + f"materialId={material_id}" + ) + + # 步骤2:确定物理来源。321 的来源仓库只能是自动堆栈-左(WH3), + # 与 locationId 解析出的 wh_name 无关 —— 后者常是配液站内的临时槽位 + # (如 小分液瓶堆栈[C03]),拿它的名字去查 uuid + 按槽位标签硬算坐标 + # 会得到一个不存在的库位(C03→(3,3,1)),LIMS 直接不建单。 + warehouse_id = self._WH_ID_AUTO_STACK_LEFT + + # 步骤3:坐标优先实时查库定位(权威),失败再退到下单时的快照 + located = self._locate_material_in_warehouse(warehouse_id, material_id) + if located: + x, y, z = located + logger.info(f"[自动转运] 使用实时定位坐标: ({x}, {y}, {z})") + elif vial_plate_info.get("source_found"): + x = vial_plate_info["source_x"] + y = vial_plate_info["source_y"] + z = vial_plate_info["source_z"] + logger.warning( + f"[自动转运] 实时定位未命中,退用下单时快照坐标: ({x}, {y}, {z})" + ) + elif source_pos_fallback: + x = source_pos_fallback.get("x", 1) + y = source_pos_fallback.get("y", 1) + z = source_pos_fallback.get("z", 1) + logger.warning(f"[自动转运] 使用调用方指定的兜底坐标: ({x}, {y}, {z})") + else: + # 没有任何可信坐标时必须放弃:默认 (1,1,1) 指向 A01, + # 那里很可能是另一块板,搬过去就是转错板。 + raise ValueError( + f"分液瓶板不在自动堆栈-左,且无可信源坐标,321 无法转运: " + f"materialId={material_id}, 资源树位置={wh_name}[{slot_name}]。" + f"请确认配液已结束且该板已转运到自动堆栈-左" + ) + + # 步骤4:调用物理转运。失败会抛异常,由调用方记为 failed; + # 绝不能在物理没动的情况下往下走资源树同步。 + lims_result = self.transfer_3_to_2_to_1( + source_wh_id=warehouse_id, + source_x=x, + source_y=y, + source_z=z + ) + logger.info(f"[LIMS转运] 完成: {lims_result}") + + # 步骤5:资源树数字转运 + # warehouse[slot] 语义:满槽时 sites[idx] 已被替换为瓶板对象; + # 空槽时仍是原始 ResourceHolder。绝不能把 holder 当瓶板转运。 + try: + warehouse = self.deck.get_resource(wh_name) + if not warehouse: + raise ValueError(f"资源树中未找到仓库: {wh_name}") + + site_obj = warehouse[slot_name] + if isinstance(site_obj, ResourceHolder): + vial_plate = getattr(site_obj, "resource", None) + else: + vial_plate = site_obj + + if vial_plate is None or getattr(vial_plate, "unilabos_uuid", None) is None: + logger.warning( + f"[资源同步] 槽位 {wh_name}[{slot_name}] 无有效瓶板对象" + f"(site={getattr(site_obj, 'name', site_obj)}, " + f"plate={getattr(vial_plate, 'name', None)}),跳过资源树同步" + ) + else: + # ========== 获取目标资源对象 ========== + logger.info( + f"[资源同步] 准备目标资源: {target_device}.{target_location}" + ) + + # 从目标设备的资源树中获取真实的接驳槽对象(electrolyte_buffer) + target_resource_obj = self._get_resource_from_device( + device_id=target_device, + resource_name=target_location, + ) + if target_resource_obj is None: + raise RuntimeError( + f"[资源同步] 目标设备 '{target_device}' 中未找到资源 '{target_location}'。" + f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位," + f"且目标节点已启动并完成资源树初始化。" + ) + + logger.info( + f"[资源同步] 找到目标资源: {target_resource_obj.name}, " + f"UUID={getattr(target_resource_obj, 'unilabos_uuid', 'N/A')}" + ) + + # 执行资源树转移 + self.transfer_resource_to_another( + resource=[vial_plate], + mount_resource=[target_resource_obj], + sites=["electrolyte_buffer"], + mount_device_id=f"/devices/{target_device}" + ) + logger.info( + f"[资源同步] ✅ 成功: {vial_plate.name} → " + f"{target_device}.{target_location}" + ) + except Exception as e: + logger.error(f"[资源同步] ❌ 失败: {e}") + # 不中断流程,物理转运已完成 + + return lims_result + + def _get_resource_from_device( + self, + device_id: str, + resource_name: str, + ): + """ + 从指定设备的本地资源树中按名称查找 PLR 资源对象。 + + Args: + device_id: 目标设备 ID(如 "BatteryStation") + resource_name: 资源名称(如 "bottle_rack_6x2") + + Returns: + 找到的 PLR Resource 对象,未找到则返回 None + """ + # 优先:通过全局设备注册表直接访问目标设备的 deck + # DeviceInfoType 是 TypedDict(即普通 dict),必须用 dict.get() 而非 getattr() + try: + from unilabos.ros.nodes.base_device_node import registered_devices + device_info = registered_devices.get(device_id) + if device_info is not None: + driver = device_info.get("driver_instance") + if driver is not None: + deck = getattr(driver, "deck", None) + if deck is not None and hasattr(deck, "get_resource"): + try: + res = deck.get_resource(resource_name) + if res is not None: + return res + except Exception: + pass + except Exception: + pass + + # 降级:遍历 workstation 已注册的 plr_resources 列表(仅当前设备) + try: + for res in getattr(self, "_plr_resources", []): + if res.name == resource_name: + return res + found = res.get_resource(resource_name) if hasattr(res, "get_resource") else None + if found is not None: + return found + except Exception: + pass + + return None + + def _get_warehouse_and_slot_from_location_id( + self, + location_id: str + ) -> Tuple[Optional[str], Optional[str]]: + """ + 从 locationId 解析仓库名称和槽位名称 + + Args: + location_id: site_uuid, 例如 "3a19debc-84b5-4c1c-d3a1-26830cf273ff" + + Returns: + (warehouse_name, slot_name) + 例如:("自动堆栈-左", "A01") + """ + warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + + for wh_name, wh_data in warehouse_mapping.items(): + site_uuids = wh_data.get("site_uuids", {}) + for slot_name, site_uuid in site_uuids.items(): + if site_uuid == location_id: + return (wh_name, slot_name) + + logger.error(f"未找到 locationId: {location_id}") + return (None, None) + + def _get_warehouse_id(self, warehouse_name: str) -> str: + """ + 获取仓库的 warehouse_id (uuid) + + 配置缺失时返回空串。这里**不做**"回退到自动堆栈-左"的降级: + 拿别的仓库 uuid 配上本仓库的坐标去建单,等于让机械臂去另一个仓库的 + 同名坐标取板,要么建单失败,要么直接搬走那个坐标上的别的板。 + + Args: + warehouse_name: 仓库名称,例如 "自动堆栈-左" + + Returns: + warehouse_id;未配置时返回 "" + """ + warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + wh_data = warehouse_mapping.get(warehouse_name, {}) + warehouse_id = wh_data.get("uuid") or "" + + if not warehouse_id: + logger.error( + f"仓库 '{warehouse_name}' 的 uuid 未在 warehouse_mapping 中配置," + f"无法用它作为转运来源仓库" + ) + + return warehouse_id + + def _snapshot_warehouse(self, wh_id: str) -> Optional[Dict[str, Any]]: + """ + 查 warehouse-info(2.38),返回 data 段(含 name / locations)。 + + Args: + wh_id: 仓库 uuid + + Returns: + data 字典;查询失败返回 None + """ + resp = self._post_lims( + "/api/lims/storage/warehouse-info", + {"whId": wh_id, "includeDetail": True}, + ) + if not isinstance(resp, dict) or resp.get("code") != 1: + logger.warning(f"[库位定位] 查询仓库 {wh_id} 失败: {resp}") + return None + return resp.get("data") or {} + + def _locate_material_in_warehouse( + self, wh_id: str, material_id: str + ) -> Optional[Tuple[int, int, int]]: + """ + 按 holdMId 在指定仓库中定位物料,返回其 (x, y, z)。 + + 这是转运源坐标的权威来源:配液报告里的坐标是**下单时**的快照,板子在 + 配液结束后才被搬进自动堆栈-左,快照往往对不上或压根没有该仓库的记录。 + + Args: + wh_id: 仓库 uuid + material_id: 物料(瓶板)的 materialId + + Returns: + 命中则返回 (x, y, z);仓库里没有这块板或查询失败则返回 None + """ + data = self._snapshot_warehouse(wh_id) + if data is None: + return None + + wh_display = f"{data.get('name') or wh_id}" + for loc in (data.get("locations") or []): + if loc.get("holdMId") == material_id: + x = int(loc.get("x") or 1) + y = int(loc.get("y") or 1) + z = int(loc.get("z") or 1) + logger.info( + f"[库位定位] ✅ 在 {wh_display} 命中: 库位={loc.get('code')}, " + f"坐标=({x},{y},{z}), 物料={loc.get('holdMName')} " + f"({loc.get('holdMTypeName')})" + ) + return (x, y, z) + + occupied = [ + f"{loc.get('code')}={loc.get('holdMTypeName') or '空'}" + for loc in (data.get("locations") or []) + ] + logger.warning( + f"[库位定位] ❌ {wh_display} 中未找到 materialId={material_id};" + f"当前占用情况: {occupied}" + ) + return None + + def _slot_to_coordinates(self, slot_name: str) -> Tuple[int, int, int]: + """ + 槽位名称 → LIMS坐标 + + Args: + slot_name: 槽位名称,例如 "A01", "B02", "E03" + + Returns: + (x, y, z) 坐标元组 + + 转换规则: + - 字母 → x (A=1, B=2, C=3...) + - 数字 → y (01=1, 02=2, 03=3...) + - z 固定为 1 + + Examples: + >>> _slot_to_coordinates("A01") + (1, 1, 1) + >>> _slot_to_coordinates("B02") + (2, 2, 1) + >>> _slot_to_coordinates("E03") + (5, 3, 1) + """ + if not slot_name or len(slot_name) < 2: + raise ValueError(f"Invalid slot name: {slot_name}") + + letter = slot_name[0].upper() # 'A', 'B', 'C'... + number_str = slot_name[1:] # '01', '02', '03'... + + # 字母 → x + x = ord(letter) - ord('A') + 1 + + # 数字 → y + y = int(number_str) + + # z 固定为 1 + z = 1 + + return (x, y, z) + + + def _list_plates_in_warehouse( + self, wh_id: str, type_keyword: str = "分液瓶板" + ) -> Optional[List[Dict[str, Any]]]: + """列出仓库里被指定板型占用的库位。查询失败返回 None(供调用方容错重试)。 + + 判定口径与现有代码一致:holdMId 非空 + holdMTypeName 含 type_keyword。 + """ + data = self._snapshot_warehouse(wh_id) + if data is None: + return None + return [ + loc for loc in (data.get("locations") or []) + if isinstance(loc, dict) + and (loc.get("holdMId") or "").strip() + and type_keyword in (loc.get("holdMTypeName") or "") + ] + + def _wait_stack_condition( + self, + wh_id: str, + mode: str, + type_keyword: str = "分液瓶板", + material_ids: Optional[List[str]] = None, + min_count: int = 1, + poll_interval: float = 5.0, + timeout: int = 3600, + label: str = "库位门控", + ) -> Dict[str, Any]: + """轮询 warehouse-info(2.38),直到指定仓库满足条件;超时抛 BioyondException。 + + 各库位门控的共用底座,避免每个场景重复写轮询/容错/日志。 + + Args: + wh_id: 目标仓库 uuid(只用 whId 定位,code 会与 materialTypeCode 撞) + mode: "empty_of" 等到该板型清空;"occupied_by" 等到命中指定物料/数量 + type_keyword: 板型关键字 + material_ids: 给定则按 holdMId 精确匹配(occupied_by 时要求全部到位) + min_count: occupied_by 且未给 material_ids 时,要求命中的最少库位数 + poll_interval: 轮询间隔(秒) + timeout: 最长等待(秒) + label: 日志前缀 + + Returns: + {"status": "ok", "matched": [...库位摘要], "poll_count": int, "elapsed_seconds": float} + """ + want_ids = {mid for mid in (material_ids or []) if mid} + start = time.time() + poll_count = 0 + + while True: + plates = self._list_plates_in_warehouse(wh_id, type_keyword) + elapsed = time.time() - start + + if plates is None: + # 单次查询失败只告警重试,不中断轮询(对齐 monitor_manual_stack_3 的容错风格) + if elapsed > timeout: + raise BioyondException( + f"[{label}] 查询仓库 {wh_id} 持续失败且已超时({timeout}s)" + ) + logger.warning(f"[{label}] 查询仓库 {wh_id} 失败,{poll_interval}s 后重试…") + poll_count += 1 + time.sleep(poll_interval) + continue + + summary = [ + { + "code": loc.get("code"), + "holdMId": loc.get("holdMId"), + "holdMName": loc.get("holdMName"), + "holdMTypeName": loc.get("holdMTypeName"), + } + for loc in plates + ] + + if mode == "empty_of": + satisfied = not plates + elif mode == "occupied_by": + got_ids = {(loc.get("holdMId") or "").strip() for loc in plates} + satisfied = want_ids <= got_ids if want_ids else len(plates) >= min_count + else: + raise ValueError(f"[{label}] 未知 mode: {mode!r},只支持 empty_of / occupied_by") + + if satisfied: + logger.info( + f"[{label}] ✅ 条件满足(mode={mode},轮询 {poll_count} 次,耗时 {elapsed:.1f}s)" + ) + return { + "status": "ok", + "matched": summary, + "poll_count": poll_count, + "elapsed_seconds": round(elapsed, 1), + } + + if elapsed > timeout: + raise BioyondException( + f"[{label}] 等待超时({timeout}s,mode={mode})。" + f"当前占用: {summary or '空'}" + + (f";期望 materialId: {sorted(want_ids)}" if want_ids else "") + ) + + poll_count += 1 + logger.info( + f"[{label}] 条件未满足(mode={mode}),当前占用 {len(plates)} 个库位: " + f"{[loc.get('code') for loc in plates]}," + f"{poll_interval}s 后重试…(已等待 {elapsed:.1f}s)" + ) + time.sleep(poll_interval) + + def _acquire_handoff_claim( + self, task_hint: str, claim_timeout: float + ) -> Tuple[bool, Optional[Dict[str, Any]]]: + """尝试占据交接堆栈预约。已有未消费的预约则占据失败(调用方继续轮询)。 + + 预约语义 =「本轮已查到 1号2号交接堆栈空、但 321 还没把板放进去」。它填的是 + stack_inquiry_2to1 放行到 LIMS 库存显示占用之间的空窗(2026-08-05 实测 380ms: + 放行 17:09:40.868 → 321 开始放板 17:09:41.247),否则下一轮会同样看到「物理空」 + 而放行,其 321 必被 LIMS 拒(交接堆栈有物料时 321 直接拒建,已与 LIMS 开发者确认)。 + + claim_timeout 只兜进程崩溃这类未知情况;321 结束(成功或失败)由 + transfer_3_to_2_to_1_auto 的 finally 主动释放,不能靠超时干等。 + """ + now = time.time() + with self._interlock_lock: + claim = self._handoff_claim + if claim is not None: + # granted_at 缺失/非法时按「无限旧」处理,让它立刻作废。 + # 不能回落成 now——那样一张时间戳损坏的预约会永远不过期,把整站锁死。 + granted_at = claim.get("granted_at") + age = now - (float(granted_at) if isinstance(granted_at, (int, float)) else 0.0) + if age <= claim_timeout: + return False, claim + logger.error( + f"[交接堆栈预约] 超时作废(已持有 {age:.0f}s > {claim_timeout:.0f}s," + f"token={claim.get('token')}, 持有者={claim.get('task_hint')})。" + f"说明拿到放行权的任务在 321 转运前就异常退出了,请检查上一个任务的日志。" + ) + token = uuid.uuid4().hex + self._handoff_claim = { + "token": token, + "granted_at": now, + "task_hint": task_hint, + } + return True, self._handoff_claim + + def _release_handoff_claim(self, reason: str = "") -> None: + """释放交接堆栈预约,由 transfer_3_to_2_to_1_auto 的 finally 调用。 + + ⚠️ 与 `_release_interlock_claim` 不同,这里**不校验 token**:那边的占与放在同一个 + action 内、拿得到 token,而这里是 stack_inquiry_2to1 占、transfer_3_to_2_to_1_auto + 放,后者拿不到前者的 token(两个 action 参数分开,靠工作流传 token 太丑)。 + 无条件释放可接受:321 紧跟门控执行(实测相隔数秒),而作废时长是小时级, + 中间不可能过期后被别人重新占据。持有者打进日志,对不上便于事后对账。 + """ + with self._interlock_lock: + claim = self._handoff_claim + if claim is None: + logger.warning( + f"[交接堆栈预约] 释放时发现无预约在手({reason})——" + f"可能是工作流漏配 stack_inquiry_2to1,或预约已超时作废" + ) + return + self._handoff_claim = None + logger.info( + f"[交接堆栈预约] 已释放 token={claim.get('token')}, " + f"持有者={claim.get('task_hint')}({reason})" + ) + + def _peek_handoff_claim(self, claim_timeout: float) -> Optional[Dict[str, Any]]: + """只读查看当前是否有**未过期**的交接堆栈预约在手,不占据、不清除。 + + 与 `_acquire_handoff_claim` 的过期判定保持一致:granted_at 缺失/非法按「无限旧」 + 处理,视为已过期。过期的预约在这里只当作 None,实际作废由 acquire 负责并记日志。 + """ + now = time.time() + with self._interlock_lock: + claim = self._handoff_claim + if claim is None: + return None + granted_at = claim.get("granted_at") + age = now - (float(granted_at) if isinstance(granted_at, (int, float)) else 0.0) + return claim if age <= claim_timeout else None + + def _wait_handoff_claim_free( + self, poll_interval: float, timeout: int, claim_timeout: float + ) -> None: + """等上一轮的交接堆栈预约被消费掉(即它的 321 已放完板)。 + + 放在 stack_inquiry_2to1 查库存**之前**。因为 stack_inquiry_2to1 是单个 action、 + 单条 FIFO 队列,同时只有一个门控在跑(实测 8688 的门控 17:06:54 queued、 + 等 faef 的门控完成后的 17:09:41 才 started),所以此处只需防「上一轮门控已放行、 + 其 321 还没放板」这一种情况,不存在两个门控并发抢预约——也正因如此,这里 + 「先查空再占」不构成 TOCTOU:中间没有第二个门控能插进来抢。 + + 预约只从门控放行持有到 321 放完板(实测约 7s),故这里通常只等数秒。 + """ + start = time.time() + while True: + holder = self._peek_handoff_claim(claim_timeout) + if holder is None: + return + elapsed = time.time() - start + if elapsed > timeout: + granted_at = holder.get("granted_at") + held = time.time() - ( + float(granted_at) if isinstance(granted_at, (int, float)) else time.time() + ) + raise BioyondException( + f"[stack_inquiry_2to1] 等上一轮交接堆栈预约释放超时({timeout}s):" + f"持有者={holder.get('task_hint')},已持有 {held:.0f}s。" + f"说明上一轮门控已放行但 321 迟迟未完成," + f"请检查上一个任务的 transfer_3_to_2_to_1_auto 日志" + ) + logger.info( + f"[stack_inquiry_2to1] 上一轮({holder.get('task_hint')})已放行但 321 尚未放完板," + f"交接堆栈使用权未释放,{poll_interval}s 后重试…(已等待 {elapsed:.1f}s)" + ) + time.sleep(poll_interval) + + def _take_handoff_claim( + self, task_hint: str, poll_interval: float, timeout: int, claim_timeout: float + ) -> str: + """门控放行前占下交接堆栈预约,占不到就继续轮询。返回预约 token(仅供日志)。 + + 正常情况下开头的 `_wait_handoff_claim_free` 已确保无人持有、这里一次即中; + 循环只是防「查库存期间上一轮又抢到了」的理论情形,不能直接放行了事。 + """ + start = time.time() + while True: + ok, claim = self._acquire_handoff_claim(task_hint, claim_timeout) + if ok: + token = str((claim or {}).get("token")) + logger.info( + f"[交接堆栈预约] 已占据 token={token}, 持有者={task_hint}" + f"(放行 321,将由 transfer_3_to_2_to_1_auto 释放)" + ) + return token + elapsed = time.time() - start + if elapsed > timeout: + raise BioyondException( + f"[stack_inquiry_2to1] 交接堆栈物理上已可用,但占据使用权超时({timeout}s):" + f"被 {(claim or {}).get('task_hint')} 持有。" + f"请检查该任务的 transfer_3_to_2_to_1_auto 是否卡住" + ) + logger.info( + f"[stack_inquiry_2to1] 交接堆栈已空但使用权被 {(claim or {}).get('task_hint')} " + f"抢先占据,{poll_interval}s 后重试…(已等待 {elapsed:.1f}s)" + ) + time.sleep(poll_interval) + + def stack_inquiry_2to1( + self, + poll_interval: float = 5.0, + timeout: int = 3600, + type_keyword: str = "分液瓶板", + match_by_material_id: bool = True, + target_wh_id: str = "", + task_hint: str = "", + ) -> Dict[str, Any]: + """321 转运的**前置**门控:确认 1号2号交接堆栈(0016) 可以放新板,并占下使用权。 + + 必须放在 `transfer_3_to_2_to_1_auto` **之前**——321 会把新板放进 0016, + 而 0016 只有 1 个库位,所以放之前必须确认它是空的。放到 321 之后就退化成 + 一句无用查询,不会报错,只是保护静默失效。 + + 判定分两条路: + + 1. **0016 本就空 → 立即放行**(fast_path,不查 0017)。首轮 0016/0017 都空, + 若强行要求「0017 必须有板」会永久阻塞 + 2. **0016 有板** → 快照这些 holdMId(这就是上一轮待回流的板),等它们既 + 离开 0016、又出现在 2号内部堆栈(0017)。「0016 腾空」与「0017 到位」是 + 同一块板的两面,用 materialId 串起来才能确认板真的回流到位, + 而不是刚离开 0016 还在途中 + + ⚠️ **光查物理占用不够,必须同时占下预约**(2026-08-05 实测踩过):本函数放行到 + 321 真把板放进 0016 之间,LIMS 库存显示的还是「空」(实测 380ms:放行 + 17:09:40.868 → 321 开始放板 17:09:41.247 → 库存变占用 17:09:48.067)。那一瞬 + 下一轮的门控查到的是**过期的真话**——使用权其实已被上一轮拿走。而 321 是同一条 + `(device, action)` FIFO 队列,下一轮的 321 必然排在上一轮之后执行(实测 + 17:09:41.309 queued → 17:09:48.172 started),届时 0016 一定已被占, + LIMS 直接拒建(交接堆栈有物料则拒绝 321,已与 LIMS 开发者确认)。 + 所以失败是**确定的**而非概率性的。修法:开头先等上一轮预约被消费, + 放行时占下自己的预约,由 `transfer_3_to_2_to_1_auto` 的 finally 释放。 + + Args: + poll_interval: 轮询间隔(秒) + timeout: 最长等待(秒),超时抛 BioyondException。注意它**不是**交接堆栈预约的 + 作废时长,后者是固定的 `_HANDOFF_CLAIM_TTL_SECONDS` + type_keyword: 板型关键字。默认 `分液瓶板` 会**同时匹配 5ml 和 20ml**; + 扣电路应显式传 `5ml分液瓶板`。否则 0016 里若停着一块 20ml 板 + (它走 3→2,不经 1→2 回 0017),「到位 0017」这个判据永远不成立, + 只能等到超时 + match_by_material_id: 是否按 materialId 精确追踪回流。置 False 退化为 + 只要 0016 腾空即放行(旧行为) + target_wh_id: 回流目标仓库,默认 2号内部堆栈(0017) + task_hint: 调用方标识,仅用于日志和预约归属 + + Returns: + {"status": "clear", "fast_path": bool, "pending_material_ids": [...], + "arrived_material_ids": [...], "poll_count": int, "elapsed_seconds": float} + + Raises: + BioyondException: 查询失败 / 等待超时 + """ + target_wh_id = target_wh_id or self._WH_ID_STACK_2_INNER + hint = task_hint or "<未命名任务>" + start = time.time() + + # 先等上一轮的预约被消费(它的 321 已放完板),否则会查到过期的「物理空」 + self._wait_handoff_claim_free(poll_interval, timeout, self._HANDOFF_CLAIM_TTL_SECONDS) + + plates = self._list_plates_in_warehouse(self._WH_ID_TRANSFER_2TO1, type_keyword) + if plates is None: + raise BioyondException( + f"查询 1号2号手套箱交接堆栈失败(whId={self._WH_ID_TRANSFER_2TO1})" + ) + + # ---------- 快速路径:0016 本来就空 ---------- + if not plates: + remain = max(int(timeout - (time.time() - start)), 1) + claim_token = self._take_handoff_claim( + hint, poll_interval, remain, self._HANDOFF_CLAIM_TTL_SECONDS + ) + logger.info( + f"[stack_inquiry_2to1] ✅ 1号2号交接堆栈已无「{type_keyword}」,直接放行" + f"(快速路径,未查 0017)" + ) + return { + "status": "clear", + "fast_path": True, + "pending_material_ids": [], + "arrived_material_ids": [], + "poll_count": 0, + "elapsed_seconds": round(time.time() - start, 1), + "claim_token": claim_token, + } + + pending_ids = sorted({ + (loc.get("holdMId") or "").strip() + for loc in plates if (loc.get("holdMId") or "").strip() + }) + logger.info( + f"[stack_inquiry_2to1] 0016 仍有 {len(plates)} 块「{type_keyword}」" + f"(库位 {[loc.get('code') for loc in plates]})," + f"快照待回流 materialId: {pending_ids}" + ) + + # ---------- 步骤 1:等 0016 腾空 ---------- + remain = max(int(timeout - (time.time() - start)), 1) + try: + self._wait_stack_condition( + wh_id=self._WH_ID_TRANSFER_2TO1, + mode="empty_of", + type_keyword=type_keyword, + poll_interval=poll_interval, + timeout=remain, + label="stack_inquiry_2to1/等 0016 腾空", + ) + except BioyondException as exc: + # 只有 1 个库位的交接堆栈占住不动 = 全站阻塞:后续每一轮的 321 都进不来。 + # 底层报错只说「等待超时 + 当前占用」,这里补上「谁占的、占了多久、怎么解」, + # 否则值班的人拿到异常还得自己去翻 warehouse-info 才知道下一步做什么。 + stuck = [ + f"{loc.get('code')}(holdMId={loc.get('holdMId')}, " + f"名称={loc.get('holdMName')}, 类型={loc.get('holdMTypeName')})" + for loc in ( + self._list_plates_in_warehouse(self._WH_ID_TRANSFER_2TO1, type_keyword) or [] + ) + ] + raise BioyondException( + f"{exc}\n" + f"⚠️ 1号2号手套箱交接堆栈只有 1 个库位,被占住 {time.time() - start:.0f}s 未动," + f"后续所有轮次的 321 转运都会被 LIMS 拒建 → 全站阻塞。\n" + f"当前占用: {stuck or '空(占用刚好在报错前被清掉,可直接重跑本节点)'}\n" + f"处置:确认上一轮扣电已完成后,手工执行 transfer_1_to_2 把该板送回" + f"2号手套箱内堆栈;若该板本轮无需回流(如走 3→2 的 20ml 电导板)," + f"应改传 type_keyword='5ml分液瓶板' 把它排除在判据外。" + ) from exc + + if not match_by_material_id or not pending_ids: + remain = max(int(timeout - (time.time() - start)), 1) + claim_token = self._take_handoff_claim( + hint, poll_interval, remain, self._HANDOFF_CLAIM_TTL_SECONDS + ) + logger.info("[stack_inquiry_2to1] ✅ 0016 已腾空,未启用 materialId 追踪,放行") + return { + "status": "clear", + "fast_path": False, + "pending_material_ids": pending_ids, + "arrived_material_ids": [], + "poll_count": 0, + "elapsed_seconds": round(time.time() - start, 1), + "claim_token": claim_token, + } + + # ---------- 步骤 2:等快照的板到位 0017 ---------- + # 有的板可能被人工挪走/出库,既不在 0016 也不在 0017。这类视为已离场, + # 不再要求它到位,否则会白等到超时。 + arrived_locs = self._list_plates_in_warehouse(target_wh_id, type_keyword) or [] + arrived_ids = {(loc.get("holdMId") or "").strip() for loc in arrived_locs} + missing = [mid for mid in pending_ids if mid not in arrived_ids] + + if missing: + remain = max(int(timeout - (time.time() - start)), 1) + try: + self._wait_stack_condition( + wh_id=target_wh_id, + mode="occupied_by", + type_keyword=type_keyword, + material_ids=pending_ids, + poll_interval=poll_interval, + timeout=remain, + label="stack_inquiry_2to1/等回流到位 0017", + ) + except BioyondException: + # 超时前再确认一次:只要 0016 已腾空、且失联的板确实哪儿都不在, + # 就按「已离场」放行并告警,不把流程卡死在一块被人搬走的板上 + still_016 = self._list_plates_in_warehouse( + self._WH_ID_TRANSFER_2TO1, type_keyword + ) or [] + final_017 = self._list_plates_in_warehouse(target_wh_id, type_keyword) or [] + final_ids = {(loc.get("holdMId") or "").strip() for loc in final_017} + lost = [mid for mid in pending_ids if mid not in final_ids] + if still_016 or not lost: + raise + logger.warning( + f"[stack_inquiry_2to1] ⚠️ 0016 已腾空,但 materialId {lost} 既不在 0016 " + f"也不在 0017(疑似被人工挪走/出库),按已离场放行" + ) + + final_017 = self._list_plates_in_warehouse(target_wh_id, type_keyword) or [] + arrived = sorted({ + (loc.get("holdMId") or "").strip() for loc in final_017 + } & set(pending_ids)) + remain = max(int(timeout - (time.time() - start)), 1) + claim_token = self._take_handoff_claim( + hint, poll_interval, remain, self._HANDOFF_CLAIM_TTL_SECONDS + ) + logger.info( + f"[stack_inquiry_2to1] ✅ 0016 已腾空且 {len(arrived)}/{len(pending_ids)} 块板" + f"已回流到 0017,放行(耗时 {time.time() - start:.1f}s)" + ) + return { + "status": "clear", + "fast_path": False, + "pending_material_ids": pending_ids, + "arrived_material_ids": arrived, + "poll_count": 0, + "elapsed_seconds": round(time.time() - start, 1), + "claim_token": claim_token, + } + + def stack_inquiry_window_5_free( + self, + required_slots: int = 4, + poll_interval: float = 5.0, + timeout: int = 36000, + type_keyword: str = "", + ) -> Dict[str, Any]: + """建单**前置**门控:等 5号自动传递窗(0018) 的空闲库位数 ≥ required_slots。 + + 必须放在 `create_orders` **之前**:配液流程结束时电导板已经直接堆在 0018 上, + 等「到位」没有意义;真正的瓶颈是 0018 只有 4 个库位,而一块板对应一个温度点。 + 上一轮的板测完转去 0027、腾出空位后,下一轮才能建单。放到 create_orders + 之后不会报错,只是保护静默失效。 + + Args: + required_slots: 本轮需要的库位数 = 本轮温度点数(1–4),应与传给 + `conductivity_test_inline` 的 `temperature_points` 长度一致。 + 默认 4 是最保守取值(等 0018 全空),按实际温度点数传可少等 + poll_interval: 轮询间隔(秒) + timeout: 最长等待(秒),默认 10 小时,匹配电导测试时长 + type_keyword: 只统计含该关键字的板占用;留空则任何物料都算占位 + (0018 是限量通道,默认按最保守的口径算) + + Returns: + {"status": "ok", "free_slots": int, "required_slots": int, + "total_slots": int, "poll_count": int, "elapsed_seconds": float} + + Raises: + ValueError: required_slots 超出 1–4 + BioyondException: 查询持续失败 / 等待超时 + """ + if not 1 <= required_slots <= self._WINDOW_5_CAPACITY: + raise ValueError( + f"required_slots 必须在 1–{self._WINDOW_5_CAPACITY} 之间(当前 {required_slots})。" + f"5号自动传递窗只有 {self._WINDOW_5_CAPACITY} 个库位,一块板一个温度点," + f"超过就得拆轮做,否则会建出永远排不进传递窗的单。" + ) + + start = time.time() + poll_count = 0 + warned_misplaced = False + + while True: + data = self._snapshot_warehouse(self._WH_ID_TRANSFER_WINDOW_5) + elapsed = time.time() - start + + if data is None: + if elapsed > timeout: + raise BioyondException(f"查询 5 号自动传递窗持续失败且已超时({timeout}s)") + logger.warning(f"[stack_inquiry_window_5_free] 查询失败,{poll_interval}s 后重试…") + poll_count += 1 + time.sleep(poll_interval) + continue + + locations = data.get("locations") or [] + occupied = [ + loc for loc in locations + if isinstance(loc, dict) and (loc.get("holdMId") or "").strip() + and (not type_keyword or type_keyword in (loc.get("holdMTypeName") or "")) + ] + total_slots = len(locations) or self._WINDOW_5_CAPACITY + free_slots = total_slots - len(occupied) + + if free_slots >= required_slots: + logger.info( + f"[stack_inquiry_window_5_free] ✅ 5 号自动传递窗空闲 {free_slots}/{total_slots} 个库位," + f"满足本轮所需 {required_slots} 个,放行" + f"(轮询 {poll_count} 次,耗时 {elapsed:.1f}s)" + ) + return { + "status": "ok", + "free_slots": free_slots, + "required_slots": required_slots, + "total_slots": total_slots, + "poll_count": poll_count, + "elapsed_seconds": round(elapsed, 1), + } + + # 位置可疑告警:本门控是建单前置,进来时 0018 就满说明很可能被摆到了 + # create_orders 之后——那时板已经堆进去了,门控守了个寂寞 + if not warned_misplaced and poll_count == 0 and free_slots == 0: + warned_misplaced = True + logger.warning( + f"[stack_inquiry_window_5_free] ⚠️ 进入时 5 号自动传递窗已被占满" + f"({len(occupied)}/{total_slots})。若本轮的电导板已经在里面,说明本门控" + f"被放到了 create_orders **之后**,位置有误、保护已失效——它应当排在建单之前。" + ) + + if elapsed > timeout: + raise BioyondException( + f"等待 5 号自动传递窗空位超时({timeout}s):需要 {required_slots} 个空位," + f"当前只有 {free_slots} 个(占用 " + + ", ".join( + f"{loc.get('code')}={loc.get('holdMTypeName')}" for loc in occupied + ) + + ")。请确认上一轮电导板已测完并转去 5 号右侧手动堆栈。" + ) + + poll_count += 1 + logger.info( + f"[stack_inquiry_window_5_free] 5 号自动传递窗空闲 {free_slots}/{total_slots}," + f"不足本轮所需 {required_slots},{poll_interval}s 后重试…(已等待 {elapsed:.1f}s)" + ) + time.sleep(poll_interval) + + def monitor_manual_stack_3( + self, + poll_interval: float = 5.0, + max_duration: int = 3600, + ) -> Dict[str, Any]: + """ + 持续轮询监测 3 个堆栈的库位占用情况: + - 3号箱手动堆栈 (手动堆栈, code=0007) + - 3号箱自动堆栈-左 (自动堆栈-左, code=0008) + - 1号2号手套箱交接堆栈 (1号2号手套箱交接堆栈, code=0016) + + 每隔 poll_interval 秒分别查一次 warehouse-info(2.38),打印各堆栈当前各库位的占用物料, + 累计运行达到 max_duration 秒后自动停止并返回最后一次快照。 + + 用途:人工盯这三个堆栈的物料进出(调试 / 观察)。 + 说明:UniLab 的 action 不接受前端 cancel,故用 max_duration 到点自停; + 单次查询失败只告警重试,不中断监测。 + + Args: + poll_interval: 轮询间隔(秒),默认 5s(一轮内顺序查完 3 个堆栈后再 sleep) + max_duration: 最长监测时长(秒),默认 3600s(1h),到点自动停止 + + Returns: + { + "status": "stopped", + "poll_count": int, + "elapsed_seconds": float, + "last_snapshot": { + "3号箱手动堆栈": [ {"code", "holdMName", "holdMTypeName"}, ... ], + "3号箱自动堆栈-左": [...], + "1号2号手套箱交接堆栈": [...], + }, + } + """ + # whId 来源:包含库位的仓库信息0610.json + _STACKS = [ + ("3号箱手动堆栈", "3a19deae-2c79-05a3-9c76-8e6760424841"), # 手动堆栈 code=0007 + ("3号箱自动堆栈-左", self._WH_ID_AUTO_STACK_LEFT), # 自动堆栈-左 code=0008 + ("1号2号手套箱交接堆栈", self._WH_ID_TRANSFER_2TO1), # 交接堆栈 code=0016 + ] + + start = time.time() + poll_count = 0 + last_snapshot: Dict[str, Any] = {} + logger.info( + f"[monitor_3stacks] 开始监测 3 个堆栈(手动/自动堆栈-左/1-2交接)," + f"poll_interval={poll_interval}s, max_duration={max_duration}s" + ) + while True: + poll_count += 1 + snapshot: Dict[str, Any] = {} + for name, wh_id in _STACKS: + resp = self._post_lims( + "/api/lims/storage/warehouse-info", + {"whId": wh_id, "includeDetail": True}, + ) + if not isinstance(resp, dict) or resp.get("code") != 1: + logger.warning(f"[monitor_3stacks] ⚠️ [{name}] 查询失败(将重试): {resp}") + snapshot[name] = None + continue + data = resp.get("data") or {} + locations = data.get("locations") or [] + occupied = [ + loc for loc in locations + if isinstance(loc, dict) and (loc.get("holdMId") or "").strip() + ] + snap = [ + { + "code": loc.get("code"), + "holdMName": loc.get("holdMName"), + "holdMTypeName": loc.get("holdMTypeName"), + } + for loc in occupied + ] + snapshot[name] = snap + logger.info( + f"[monitor_3stacks] 第{poll_count}次 [{name}] 占用 " + f"{len(occupied)}/{len(locations)} 库位: " + f"{[(s['code'], s['holdMTypeName'] or s['holdMName']) for s in snap]}" + ) + last_snapshot = snapshot + + elapsed = time.time() - start + if elapsed >= max_duration: + logger.info( + f"[monitor_3stacks] 达到 max_duration={max_duration}s,停止监测" + f"(共轮询 {poll_count} 次,耗时 {elapsed:.1f}s)" + ) + return { + "status": "stopped", + "poll_count": poll_count, + "elapsed_seconds": round(elapsed, 1), + "last_snapshot": last_snapshot, + } + time.sleep(poll_interval) + + def multitask_probe(self, task_no: int = 1, hold_ms: int = 1000) -> Dict[str, Any]: + """ + 多任务调度探测动作。 + + 对同一动作提交多个任务、分别传入不同的 task_no,根据返回的 start_time_ms + 判断 UniLab 多任务是从前往后还是从后往前启动执行。 + + Args: + task_no: 任务编号(由调用方自定义,用于区分多任务实例) + hold_ms: 占用设备时长(毫秒),默认 1000,便于拉开各任务开始时间差 + + Returns: + 包含 task_no、开始/结束时间(毫秒精度)等字段的字典 + """ + start_dt = datetime.now() + start_time = ( + start_dt.strftime("%Y-%m-%dT%H:%M:%S.") + + f"{int(start_dt.microsecond / 1000):03d}" + ) + start_time_ms = int(start_dt.timestamp() * 1000) + + logger.info( + f"[multitask_probe] 开始执行 task_no={task_no}, " + f"start_time={start_time}, hold_ms={hold_ms}" + ) + + if hold_ms and hold_ms > 0: + time.sleep(hold_ms / 1000.0) + + end_dt = datetime.now() + end_time = ( + end_dt.strftime("%Y-%m-%dT%H:%M:%S.") + + f"{int(end_dt.microsecond / 1000):03d}" + ) + end_time_ms = int(end_dt.timestamp() * 1000) + + result = { + "success": True, + "task_no": task_no, + "start_time": start_time, + "start_time_ms": start_time_ms, + "end_time": end_time, + "end_time_ms": end_time_ms, + "hold_ms": hold_ms, + "message": f"task_no={task_no} 于 {start_time} 开始执行", } - - print(f"返回报文数量: {len(all_reports)}") - for i, report in enumerate(all_reports, 1): - print(f"报文 {i}: orderCode={report.get('orderCode', 'N/A')}, status={report.get('status', 'N/A')}") - print("========================") - - return final_result + logger.info(f"[multitask_probe] 结束执行 {result}") + return result # 2.7 启动调度 def scheduler_start(self) -> Dict[str, Any]: + # 人工重新起调度 = 已处置完上一批的建单失败,清掉标记, + # 否则门控会一直把 Pause 当成「建单失败自动暂停」而直接阻断。 + self.clear_interlock_failure() return self._post_lims("/api/lims/scheduler/start") # 3.10 停止调度 def scheduler_stop(self) -> Dict[str, Any]: @@ -1170,13 +7123,26 @@ def scheduler_stop(self) -> Dict[str, Any]: 请求体只包含 apiKey 和 requestTime """ return self._post_lims("/api/lims/scheduler/stop") - + + # 2.8 暂停调度 + def scheduler_pause(self) -> Dict[str, Any]: + """ + 暂停调度 (2.8) + + 电解液站只有这一个暂停接口,语义等同智能暂停:执行完当前动作再停, + 不会把机械臂冻在半空。恢复用 scheduler_continue。 + 请求体只包含 apiKey 和 requestTime + """ + return self._post_lims("/api/lims/scheduler/pause") + # 2.9 继续调度 def scheduler_continue(self) -> Dict[str, Any]: """ 继续调度 (2.9) 请求体只包含 apiKey 和 requestTime """ + # 人工恢复调度 = 已处置完上一批的建单失败,清掉标记(同 scheduler_start) + self.clear_interlock_failure() return self._post_lims("/api/lims/scheduler/continue") def scheduler_reset(self) -> Dict[str, Any]: """ @@ -1185,6 +7151,18 @@ def scheduler_reset(self) -> Dict[str, Any]: """ return self._post_lims("/api/lims/scheduler/reset") + # 查询调度状态 + def scheduler_status(self) -> Dict[str, Any]: + """ + 查询调度状态 + + 请求体只包含 apiKey 和 requestTime,返回原始响应,其中 data 为: + schedulerStatus(Init / Stop / Running / Pause / ErrorPause / ErrorStop)、 + hasTask(是否有任务)、creationTime。 + 互锁门控用的 `_query_scheduler_status` 只取 schedulerStatus 一个字段。 + """ + return self._post_lims("/api/lims/scheduler/scheduler-status") + def scheduler_start_and_auto_feeding( self, # ★ Excel路径参数 @@ -1326,160 +7304,6 @@ def scheduler_start_and_auto_feeding( } - def scheduler_start_and_auto_feeding_v2( - self, - # ★ Excel路径参数 - xlsx_path: Optional[str] = "D:\\UniLab\\Uni-Lab-OS\\unilabos\\devices\\workstation\\bioyond_studio\\bioyond_cell\\material_template.xlsx", - # ---------------- WH4 - 加样头面 (Z=1, 12个点位) ---------------- - WH4_x1_y1_z1_1_materialName: str = "", WH4_x1_y1_z1_1_quantity: float = 0.0, - WH4_x2_y1_z1_2_materialName: str = "", WH4_x2_y1_z1_2_quantity: float = 0.0, - WH4_x3_y1_z1_3_materialName: str = "", WH4_x3_y1_z1_3_quantity: float = 0.0, - WH4_x4_y1_z1_4_materialName: str = "", WH4_x4_y1_z1_4_quantity: float = 0.0, - WH4_x5_y1_z1_5_materialName: str = "", WH4_x5_y1_z1_5_quantity: float = 0.0, - WH4_x1_y2_z1_6_materialName: str = "", WH4_x1_y2_z1_6_quantity: float = 0.0, - WH4_x2_y2_z1_7_materialName: str = "", WH4_x2_y2_z1_7_quantity: float = 0.0, - WH4_x3_y2_z1_8_materialName: str = "", WH4_x3_y2_z1_8_quantity: float = 0.0, - WH4_x4_y2_z1_9_materialName: str = "", WH4_x4_y2_z1_9_quantity: float = 0.0, - WH4_x5_y2_z1_10_materialName: str = "", WH4_x5_y2_z1_10_quantity: float = 0.0, - WH4_x1_y3_z1_11_materialName: str = "", WH4_x1_y3_z1_11_quantity: float = 0.0, - WH4_x2_y3_z1_12_materialName: str = "", WH4_x2_y3_z1_12_quantity: float = 0.0, - - # ---------------- WH4 - 原液瓶面 (Z=2, 9个点位) ---------------- - WH4_x1_y1_z2_1_materialName: str = "", WH4_x1_y1_z2_1_quantity: float = 0.0, WH4_x1_y1_z2_1_materialType: str = "", WH4_x1_y1_z2_1_targetWH: str = "", - WH4_x2_y1_z2_2_materialName: str = "", WH4_x2_y1_z2_2_quantity: float = 0.0, WH4_x2_y1_z2_2_materialType: str = "", WH4_x2_y1_z2_2_targetWH: str = "", - WH4_x3_y1_z2_3_materialName: str = "", WH4_x3_y1_z2_3_quantity: float = 0.0, WH4_x3_y1_z2_3_materialType: str = "", WH4_x3_y1_z2_3_targetWH: str = "", - WH4_x1_y2_z2_4_materialName: str = "", WH4_x1_y2_z2_4_quantity: float = 0.0, WH4_x1_y2_z2_4_materialType: str = "", WH4_x1_y2_z2_4_targetWH: str = "", - WH4_x2_y2_z2_5_materialName: str = "", WH4_x2_y2_z2_5_quantity: float = 0.0, WH4_x2_y2_z2_5_materialType: str = "", WH4_x2_y2_z2_5_targetWH: str = "", - WH4_x3_y2_z2_6_materialName: str = "", WH4_x3_y2_z2_6_quantity: float = 0.0, WH4_x3_y2_z2_6_materialType: str = "", WH4_x3_y2_z2_6_targetWH: str = "", - WH4_x1_y3_z2_7_materialName: str = "", WH4_x1_y3_z2_7_quantity: float = 0.0, WH4_x1_y3_z2_7_materialType: str = "", WH4_x1_y3_z2_7_targetWH: str = "", - WH4_x2_y3_z2_8_materialName: str = "", WH4_x2_y3_z2_8_quantity: float = 0.0, WH4_x2_y3_z2_8_materialType: str = "", WH4_x2_y3_z2_8_targetWH: str = "", - WH4_x3_y3_z2_9_materialName: str = "", WH4_x3_y3_z2_9_quantity: float = 0.0, WH4_x3_y3_z2_9_materialType: str = "", WH4_x3_y3_z2_9_targetWH: str = "", - - # ---------------- WH3 - 人工堆栈 (Z=3, 15个点位) ---------------- - WH3_x1_y1_z3_1_materialType: str = "", WH3_x1_y1_z3_1_materialId: str = "", WH3_x1_y1_z3_1_quantity: float = 0, - WH3_x2_y1_z3_2_materialType: str = "", WH3_x2_y1_z3_2_materialId: str = "", WH3_x2_y1_z3_2_quantity: float = 0, - WH3_x3_y1_z3_3_materialType: str = "", WH3_x3_y1_z3_3_materialId: str = "", WH3_x3_y1_z3_3_quantity: float = 0, - WH3_x1_y2_z3_4_materialType: str = "", WH3_x1_y2_z3_4_materialId: str = "", WH3_x1_y2_z3_4_quantity: float = 0, - WH3_x2_y2_z3_5_materialType: str = "", WH3_x2_y2_z3_5_materialId: str = "", WH3_x2_y2_z3_5_quantity: float = 0, - WH3_x3_y2_z3_6_materialType: str = "", WH3_x3_y2_z3_6_materialId: str = "", WH3_x3_y2_z3_6_quantity: float = 0, - WH3_x1_y3_z3_7_materialType: str = "", WH3_x1_y3_z3_7_materialId: str = "", WH3_x1_y3_z3_7_quantity: float = 0, - WH3_x2_y3_z3_8_materialType: str = "", WH3_x2_y3_z3_8_materialId: str = "", WH3_x2_y3_z3_8_quantity: float = 0, - WH3_x3_y3_z3_9_materialType: str = "", WH3_x3_y3_z3_9_materialId: str = "", WH3_x3_y3_z3_9_quantity: float = 0, - WH3_x1_y4_z3_10_materialType: str = "", WH3_x1_y4_z3_10_materialId: str = "", WH3_x1_y4_z3_10_quantity: float = 0, - WH3_x2_y4_z3_11_materialType: str = "", WH3_x2_y4_z3_11_materialId: str = "", WH3_x2_y4_z3_11_quantity: float = 0, - WH3_x3_y4_z3_12_materialType: str = "", WH3_x3_y4_z3_12_materialId: str = "", WH3_x3_y4_z3_12_quantity: float = 0, - WH3_x1_y5_z3_13_materialType: str = "", WH3_x1_y5_z3_13_materialId: str = "", WH3_x1_y5_z3_13_quantity: float = 0, - WH3_x2_y5_z3_14_materialType: str = "", WH3_x2_y5_z3_14_materialId: str = "", WH3_x2_y5_z3_14_quantity: float = 0, - WH3_x3_y5_z3_15_materialType: str = "", WH3_x3_y5_z3_15_materialId: str = "", WH3_x3_y5_z3_15_quantity: float = 0, - ) -> Dict[str, Any]: - """ - 组合函数 V2 版本(测试版):先启动调度,然后执行自动化上料 - - ⚠️ 这是测试版本,使用非阻塞轮询等待方式,避免 ROS2 Action feedback publisher 失效 - - 与 V1 的区别: - - 使用 wait_for_order_finish_polling 替代原有的阻塞等待 - - 允许 ROS2 在等待期间正常发布 feedback 消息 - - 适用于长时间运行的任务 - - 参数与 scheduler_start_and_auto_feeding 完全相同 - - Returns: - 包含调度启动结果和上料结果的字典 - """ - logger.info("=" * 60) - logger.info("[V2测试版本] 开始执行组合操作:启动调度 + 自动化上料") - logger.info("=" * 60) - - # 步骤1: 启动调度 - logger.info("【步骤 1/2】启动调度...") - scheduler_result = self.scheduler_start() - logger.info(f"调度启动结果: {scheduler_result}") - - # 检查调度是否启动成功 - if scheduler_result.get("code") != 1: - logger.error(f"调度启动失败: {scheduler_result}") - return { - "success": False, - "step": "scheduler_start", - "scheduler_result": scheduler_result, - "error": "调度启动失败" - } - - logger.info("✓ 调度启动成功") - - # 步骤2: 执行自动化上料(这里会调用 auto_feeding4to3,内部使用轮询等待) - logger.info("【步骤 2/2】执行自动化上料...") - - # 临时替换 wait_for_order_finish 为轮询版本 - original_wait_func = self.wait_for_order_finish - self.wait_for_order_finish = self.wait_for_order_finish_polling - - try: - feeding_result = self.auto_feeding4to3( - xlsx_path=xlsx_path, - WH4_x1_y1_z1_1_materialName=WH4_x1_y1_z1_1_materialName, WH4_x1_y1_z1_1_quantity=WH4_x1_y1_z1_1_quantity, - WH4_x2_y1_z1_2_materialName=WH4_x2_y1_z1_2_materialName, WH4_x2_y1_z1_2_quantity=WH4_x2_y1_z1_2_quantity, - WH4_x3_y1_z1_3_materialName=WH4_x3_y1_z1_3_materialName, WH4_x3_y1_z1_3_quantity=WH4_x3_y1_z1_3_quantity, - WH4_x4_y1_z1_4_materialName=WH4_x4_y1_z1_4_materialName, WH4_x4_y1_z1_4_quantity=WH4_x4_y1_z1_4_quantity, - WH4_x5_y1_z1_5_materialName=WH4_x5_y1_z1_5_materialName, WH4_x5_y1_z1_5_quantity=WH4_x5_y1_z1_5_quantity, - WH4_x1_y2_z1_6_materialName=WH4_x1_y2_z1_6_materialName, WH4_x1_y2_z1_6_quantity=WH4_x1_y2_z1_6_quantity, - WH4_x2_y2_z1_7_materialName=WH4_x2_y2_z1_7_materialName, WH4_x2_y2_z1_7_quantity=WH4_x2_y2_z1_7_quantity, - WH4_x3_y2_z1_8_materialName=WH4_x3_y2_z1_8_materialName, WH4_x3_y2_z1_8_quantity=WH4_x3_y2_z1_8_quantity, - WH4_x4_y2_z1_9_materialName=WH4_x4_y2_z1_9_materialName, WH4_x4_y2_z1_9_quantity=WH4_x4_y2_z1_9_quantity, - WH4_x5_y2_z1_10_materialName=WH4_x5_y2_z1_10_materialName, WH4_x5_y2_z1_10_quantity=WH4_x5_y2_z1_10_quantity, - WH4_x1_y3_z1_11_materialName=WH4_x1_y3_z1_11_materialName, WH4_x1_y3_z1_11_quantity=WH4_x1_y3_z1_11_quantity, - WH4_x2_y3_z1_12_materialName=WH4_x2_y3_z1_12_materialName, WH4_x2_y3_z1_12_quantity=WH4_x2_y3_z1_12_quantity, - WH4_x1_y1_z2_1_materialName=WH4_x1_y1_z2_1_materialName, WH4_x1_y1_z2_1_quantity=WH4_x1_y1_z2_1_quantity, - WH4_x1_y1_z2_1_materialType=WH4_x1_y1_z2_1_materialType, WH4_x1_y1_z2_1_targetWH=WH4_x1_y1_z2_1_targetWH, - WH4_x2_y1_z2_2_materialName=WH4_x2_y1_z2_2_materialName, WH4_x2_y1_z2_2_quantity=WH4_x2_y1_z2_2_quantity, - WH4_x2_y1_z2_2_materialType=WH4_x2_y1_z2_2_materialType, WH4_x2_y1_z2_2_targetWH=WH4_x2_y1_z2_2_targetWH, - WH4_x3_y1_z2_3_materialName=WH4_x3_y1_z2_3_materialName, WH4_x3_y1_z2_3_quantity=WH4_x3_y1_z2_3_quantity, - WH4_x3_y1_z2_3_materialType=WH4_x3_y1_z2_3_materialType, WH4_x3_y1_z2_3_targetWH=WH4_x3_y1_z2_3_targetWH, - WH4_x1_y2_z2_4_materialName=WH4_x1_y2_z2_4_materialName, WH4_x1_y2_z2_4_quantity=WH4_x1_y2_z2_4_quantity, - WH4_x1_y2_z2_4_materialType=WH4_x1_y2_z2_4_materialType, WH4_x1_y2_z2_4_targetWH=WH4_x1_y2_z2_4_targetWH, - WH4_x2_y2_z2_5_materialName=WH4_x2_y2_z2_5_materialName, WH4_x2_y2_z2_5_quantity=WH4_x2_y2_z2_5_quantity, - WH4_x2_y2_z2_5_materialType=WH4_x2_y2_z2_5_materialType, WH4_x2_y2_z2_5_targetWH=WH4_x2_y2_z2_5_targetWH, - WH4_x3_y2_z2_6_materialName=WH4_x3_y2_z2_6_materialName, WH4_x3_y2_z2_6_quantity=WH4_x3_y2_z2_6_quantity, - WH4_x3_y2_z2_6_materialType=WH4_x3_y2_z2_6_materialType, WH4_x3_y2_z2_6_targetWH=WH4_x3_y2_z2_6_targetWH, - WH4_x1_y3_z2_7_materialName=WH4_x1_y3_z2_7_materialName, WH4_x1_y3_z2_7_quantity=WH4_x1_y3_z2_7_quantity, - WH4_x1_y3_z2_7_materialType=WH4_x1_y3_z2_7_materialType, WH4_x1_y3_z2_7_targetWH=WH4_x1_y3_z2_7_targetWH, - WH4_x2_y3_z2_8_materialName=WH4_x2_y3_z2_8_materialName, WH4_x2_y3_z2_8_quantity=WH4_x2_y3_z2_8_quantity, - WH4_x2_y3_z2_8_materialType=WH4_x2_y3_z2_8_materialType, WH4_x2_y3_z2_8_targetWH=WH4_x2_y3_z2_8_targetWH, - WH4_x3_y3_z2_9_materialName=WH4_x3_y3_z2_9_materialName, WH4_x3_y3_z2_9_quantity=WH4_x3_y3_z2_9_quantity, - WH4_x3_y3_z2_9_materialType=WH4_x3_y3_z2_9_materialType, WH4_x3_y3_z2_9_targetWH=WH4_x3_y3_z2_9_targetWH, - WH3_x1_y1_z3_1_materialType=WH3_x1_y1_z3_1_materialType, WH3_x1_y1_z3_1_materialId=WH3_x1_y1_z3_1_materialId, WH3_x1_y1_z3_1_quantity=WH3_x1_y1_z3_1_quantity, - WH3_x2_y1_z3_2_materialType=WH3_x2_y1_z3_2_materialType, WH3_x2_y1_z3_2_materialId=WH3_x2_y1_z3_2_materialId, WH3_x2_y1_z3_2_quantity=WH3_x2_y1_z3_2_quantity, - WH3_x3_y1_z3_3_materialType=WH3_x3_y1_z3_3_materialType, WH3_x3_y1_z3_3_materialId=WH3_x3_y1_z3_3_materialId, WH3_x3_y1_z3_3_quantity=WH3_x3_y1_z3_3_quantity, - WH3_x1_y2_z3_4_materialType=WH3_x1_y2_z3_4_materialType, WH3_x1_y2_z3_4_materialId=WH3_x1_y2_z3_4_materialId, WH3_x1_y2_z3_4_quantity=WH3_x1_y2_z3_4_quantity, - WH3_x2_y2_z3_5_materialType=WH3_x2_y2_z3_5_materialType, WH3_x2_y2_z3_5_materialId=WH3_x2_y2_z3_5_materialId, WH3_x2_y2_z3_5_quantity=WH3_x2_y2_z3_5_quantity, - WH3_x3_y2_z3_6_materialType=WH3_x3_y2_z3_6_materialType, WH3_x3_y2_z3_6_materialId=WH3_x3_y2_z3_6_materialId, WH3_x3_y2_z3_6_quantity=WH3_x3_y2_z3_6_quantity, - WH3_x1_y3_z3_7_materialType=WH3_x1_y3_z3_7_materialType, WH3_x1_y3_z3_7_materialId=WH3_x1_y3_z3_7_materialId, WH3_x1_y3_z3_7_quantity=WH3_x1_y3_z3_7_quantity, - WH3_x2_y3_z3_8_materialType=WH3_x2_y3_z3_8_materialType, WH3_x2_y3_z3_8_materialId=WH3_x2_y3_z3_8_materialId, WH3_x2_y3_z3_8_quantity=WH3_x2_y3_z3_8_quantity, - WH3_x3_y3_z3_9_materialType=WH3_x3_y3_z3_9_materialType, WH3_x3_y3_z3_9_materialId=WH3_x3_y3_z3_9_materialId, WH3_x3_y3_z3_9_quantity=WH3_x3_y3_z3_9_quantity, - WH3_x1_y4_z3_10_materialType=WH3_x1_y4_z3_10_materialType, WH3_x1_y4_z3_10_materialId=WH3_x1_y4_z3_10_materialId, WH3_x1_y4_z3_10_quantity=WH3_x1_y4_z3_10_quantity, - WH3_x2_y4_z3_11_materialType=WH3_x2_y4_z3_11_materialType, WH3_x2_y4_z3_11_materialId=WH3_x2_y4_z3_11_materialId, WH3_x2_y4_z3_11_quantity=WH3_x2_y4_z3_11_quantity, - WH3_x3_y4_z3_12_materialType=WH3_x3_y4_z3_12_materialType, WH3_x3_y4_z3_12_materialId=WH3_x3_y4_z3_12_materialId, WH3_x3_y4_z3_12_quantity=WH3_x3_y4_z3_12_quantity, - WH3_x1_y5_z3_13_materialType=WH3_x1_y5_z3_13_materialType, WH3_x1_y5_z3_13_materialId=WH3_x1_y5_z3_13_materialId, WH3_x1_y5_z3_13_quantity=WH3_x1_y5_z3_13_quantity, - WH3_x2_y5_z3_14_materialType=WH3_x2_y5_z3_14_materialType, WH3_x2_y5_z3_14_materialId=WH3_x2_y5_z3_14_materialId, WH3_x2_y5_z3_14_quantity=WH3_x2_y5_z3_14_quantity, - WH3_x3_y5_z3_15_materialType=WH3_x3_y5_z3_15_materialType, WH3_x3_y5_z3_15_materialId=WH3_x3_y5_z3_15_materialId, WH3_x3_y5_z3_15_quantity=WH3_x3_y5_z3_15_quantity, - ) - finally: - # 恢复原有函数 - self.wait_for_order_finish = original_wait_func - - logger.info("=" * 60) - logger.info("[V2测试版本] 组合操作完成") - logger.info("=" * 60) - - return { - "success": True, - "scheduler_result": scheduler_result, - "feeding_result": feeding_result, - "version": "v2_polling" - } - - # 2.24 物料变更推送 def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]: """ @@ -1491,7 +7315,12 @@ def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any] def transfer_3_to_2_to_1(self, # source_wh_id: Optional[str] = None, source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b', - source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]: + source_x: int = 1, source_y: int = 1, source_z: int = 1, + source_pos: Optional[Dict] = None) -> Dict[str, Any]: + if source_pos: + source_x = source_pos.get("x", source_x) + source_y = source_pos.get("y", source_y) + source_z = source_pos.get("z", source_z) payload: Dict[str, Any] = { "sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z } @@ -1500,19 +7329,40 @@ def transfer_3_to_2_to_1(self, response = self._post_lims("/api/lims/order/transfer-task3To2To1", payload) # 等待任务报送成功 - order_code = response.get("data", {}).get("orderCode") + order_data = response.get("data") or {} + order_code = order_data.get("orderCode") if not order_code: - logger.error("上料任务未返回有效 orderCode!") - return response - # 等待完成报送 - result = self.wait_for_order_finish(order_code) + # data=None 表示 LIMS 根本没建单,已知三种原因: + # 1) 该坐标在来源仓库不存在; + # 2) 该库位为空(没板可搬); + # 3) **目标的 1号2号手套箱交接堆栈已有物料**——它只有 1 个库位,占着时 + # LIMS 直接拒建(已与 LIMS 开发者确认)。2026-08-05 那次失败就是这条, + # 当时上一轮的板正等扣电结束后由 transfer_1_to_2 送回。 + # 绝不能当成功返回,否则调用方会继续做资源树同步 / 下一步 1→2 转运, + # 而板子其实一步都没动。 + raise BioyondException( + f"3-2-1 上料任务未创建(LIMS 未返回 orderCode): " + f"来源仓库={source_wh_id}, 坐标=({source_x},{source_y},{source_z}), " + f"响应={response}。" + f"可能原因:①该坐标在来源仓库不存在;②该库位为空;" + f"③1号2号手套箱交接堆栈已有物料(只有 1 个库位,占着则拒建)——" + f"请查 warehouse-info 确认,并检查是否漏配了前置的 stack_inquiry_2to1 门控" + ) + result = self.wait_for_order_finish( + order_code, expected_order_id=order_data.get("orderId") + ) + if result.get("status") != "success": + raise BioyondException( + f"3-2-1 上料任务未成功完成: orderCode={order_code}, 结果={result}" + ) return result def transfer_3_to_2(self, source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b', source_x: int = 1, source_y: int = 1, - source_z: int = 1) -> Dict[str, Any]: + source_z: int = 1, + source_pos: Optional[Dict] = None) -> Dict[str, Any]: """ 2.34 3-2 物料转运接口 @@ -1523,10 +7373,15 @@ def transfer_3_to_2(self, source_x: 来源位置 X 坐标 source_y: 来源位置 Y 坐标 source_z: 来源位置 Z 坐标 + source_pos: 整合 xyz 的字典(优先级高于单独的 x/y/z 参数) Returns: dict: 包含任务 orderId 和 orderCode 的响应 """ + if source_pos: + source_x = source_pos.get("x", source_x) + source_y = source_pos.get("y", source_y) + source_z = source_pos.get("z", source_z) payload: Dict[str, Any] = { "sourcePosX": source_x, "sourcePosY": source_y, @@ -1539,14 +7394,24 @@ def transfer_3_to_2(self, response = self._post_lims("/api/lims/order/transfer-task3To2", payload) # 等待任务报送成功 - order_code = response.get("data", {}).get("orderCode") + order_data = response.get("data") or {} + order_code = order_data.get("orderCode") if isinstance(order_data, dict) else None if not order_code: - logger.error("[transfer_3_to_2] 转运任务未返回有效 orderCode!") - return response + raise BioyondException( + f"[transfer_3_to_2] 转运任务未创建(LIMS 未返回 orderCode): " + f"来源仓库={source_wh_id}, 坐标=({source_x},{source_y},{source_z}), " + f"响应={response}" + ) logger.info(f"[transfer_3_to_2] 转运任务已创建: {order_code}") - # 等待完成报送 - result = self.wait_for_order_finish(order_code) + result = self.wait_for_order_finish( + order_code, + expected_order_id=order_data.get("orderId") if isinstance(order_data, dict) else None, + ) + if result.get("status") != "success": + raise BioyondException( + f"[transfer_3_to_2] 转运任务未成功完成: orderCode={order_code}, 结果={result}" + ) logger.info(f"[transfer_3_to_2] 转运任务完成: {order_code}") return result @@ -1572,15 +7437,229 @@ def transfer_1_to_2(self) -> Dict[str, Any]: order_code = data_field if not order_code: - logger.error(f"[transfer_1_to_2] 转运任务未返回有效 orderCode!响应: {response}") - return response + # 典型场景:上一步 321 其实没搬成功,1 号仓没有板可搬,LIMS 直接不建单。 + raise BioyondException( + f"[transfer_1_to_2] 转运任务未创建(LIMS 未返回 orderCode): 响应={response}。" + f"请确认 1 号仓已有待转运物料(上一步 3→2→1 是否真的成功)" + ) + expected_oid = data_field.get("orderId") if isinstance(data_field, dict) else None logger.info(f"[transfer_1_to_2] 转运任务已创建: {order_code}") - # 等待完成报送 - result = self.wait_for_order_finish(order_code) + result = self.wait_for_order_finish(order_code, expected_order_id=expected_oid) + if result.get("status") != "success": + raise BioyondException( + f"[transfer_1_to_2] 转运任务未成功完成: orderCode={order_code}, 结果={result}" + ) logger.info(f"[transfer_1_to_2] 转运任务完成: {order_code}") return result - + + def transfer_3_to_2_auto( + self, + vial_plates: List[Dict], + plate_type: str = "20ml分液瓶板", + **kwargs + ) -> Dict[str, Any]: + """ + 批量 3→2 转运:把**当前在自动堆栈-左**的分液瓶板逐块搬到 2 号位置。 + + 为什么不能只按 typeName 挑板:电导+软包混合单里两类板 typeName 都是 + "20ml分液瓶板",靠板型无法区分谁该走 32。真正的判据是**板在哪个仓库**: + - 软包板:配液结束后停在自动堆栈-左,等 32 转运 + - 电导板:配液结束后已搬到 5 号自动传递窗,等 conductivity_test_inline + 因此这里先查一次自动堆栈-左的实时占用,只搬命中的板;不在的板直接跳过 + (而非报错),与电导侧"按 5 号传递窗求交集过滤"正好互补。 + + 与 transfer_3_to_2_to_1_auto 的另一个区别:3→2 的落点在 2 号手套箱, + 当前没有对应设备节点承接,故只做物理转运,不做资源树同步。 + + Args: + vial_plates: 分液瓶板列表(上游配液输出,含全部板型) + plate_type: 参与 32 转运的板型关键字,默认 "20ml分液瓶板" + + Returns: + {"total": 入参板数, "success": 成功数, "failed": 失败数, "results": [...]} + """ + if kwargs: + logger.warning( + f"[transfer_3_to_2_auto] ⚠️ 检测到未识别的参数: {list(kwargs.keys())},已忽略" + ) + + if not vial_plates: + raise ValueError("vial_plates 参数不能为空") + + logger.info("=" * 80) + logger.info(f"[transfer_3_to_2_auto] 接收到 {len(vial_plates)} 个分液瓶板") + for idx, plate in enumerate(vial_plates, 1): + logger.info( + f" [{idx}] orderCode={(plate or {}).get('orderCode', 'N/A')}, " + f"materialId={((plate or {}).get('materialId') or 'N/A')[:20]}..., " + f"typeName={(plate or {}).get('typeName', 'N/A')}" + ) + logger.info("=" * 80) + + # ========== 步骤1:查一次自动堆栈-左的实时占用 ========== + # 只查一次而非逐板查:固定堆栈的库位坐标不因取走某块板而变化, + # 一次快照既省请求又保证同批板用的是一致的库存视图。 + wh_data = self._snapshot_warehouse(self._WH_ID_AUTO_STACK_LEFT) + if wh_data is None: + raise BioyondException( + "查询自动堆栈-左失败,无法确定 32 转运的源坐标" + ) + wh_display = wh_data.get("name") or self._WH_ID_AUTO_STACK_LEFT + stack_coords: Dict[str, Tuple[int, int, int]] = {} + occupied: List[str] = [] + for loc in (wh_data.get("locations") or []): + hold_mid = (loc.get("holdMId") or "").strip() + if not hold_mid: + continue + stack_coords[hold_mid] = ( + int(loc.get("x") or 1), + int(loc.get("y") or 1), + int(loc.get("z") or 1), + ) + occupied.append(f"{loc.get('code')}={loc.get('holdMTypeName')}") + logger.info( + f"[32批量转运] {wh_display} 当前有 {len(stack_coords)} 块物料: {occupied}" + ) + + # ========== 步骤2:按板型 + 是否在自动堆栈-左双重过滤 ========== + results: List[Dict] = [] + pending: List[Tuple[Dict, Tuple[int, int, int]]] = [] + seen_material_ids = set() + + for idx, plate in enumerate(vial_plates, 1): + if not plate or not isinstance(plate, dict): + results.append({ + "index": idx, "orderCode": "N/A", "materialId": "N/A", + "status": "failed", "error": "分液瓶板信息无效或为空", + }) + continue + + material_id = (plate.get("materialId") or "").strip() + order_code = plate.get("orderCode", "N/A") + type_name = (plate.get("typeName") or "").strip() + + if not material_id: + results.append({ + "index": idx, "orderCode": order_code, "materialId": "N/A", + "status": "failed", "error": "缺少 materialId", + }) + continue + + if plate_type not in type_name: + logger.info( + f"[32批量转运] ℹ️ [{idx}/{len(vial_plates)}] 板型不符,跳过 " + f"(orderCode={order_code}, typeName={type_name or '空'})" + ) + results.append({ + "index": idx, "orderCode": order_code, "materialId": material_id, + "status": "skipped", + "message": f"非{plate_type}(typeName={type_name or '空'})", + }) + continue + + if material_id in seen_material_ids: + logger.info( + f"[32批量转运] ℹ️ [{idx}/{len(vial_plates)}] 该瓶板已排入本批,跳过" + f"(多订单共用同一物理瓶板)" + ) + results.append({ + "index": idx, "orderCode": order_code, "materialId": material_id, + "status": "skipped", "message": "该瓶板已排入本批(共用瓶板)", + }) + continue + + coords = stack_coords.get(material_id) + if coords is None: + # 电导板停在 5 号自动传递窗,本就不该走 32;也可能是还没搬进来。 + logger.info( + f"[32批量转运] ℹ️ [{idx}/{len(vial_plates)}] 不在 {wh_display},跳过 " + f"(orderCode={order_code}, materialId={material_id[:20]}...)" + ) + results.append({ + "index": idx, "orderCode": order_code, "materialId": material_id, + "status": "skipped", + "message": f"不在{wh_display}(电导板停 5 号自动传递窗,或尚未搬入)", + }) + continue + + seen_material_ids.add(material_id) + pending.append((plate, coords)) + + if not pending: + logger.warning( + f"[transfer_3_to_2_auto] 过滤后无可转运的 {plate_type}" + f"(入参 {len(vial_plates)} 块,全部跳过)" + ) + return { + "total": len(vial_plates), + "success": 0, + "failed": sum(1 for r in results if r.get("status") == "failed"), + "results": results, + } + + logger.info( + f"[32批量转运] 过滤后保留 {len(pending)}/{len(vial_plates)} 块板待转运" + ) + + # ========== 步骤3:逐块物理转运 ========== + success_count = 0 + failed_count = sum(1 for r in results if r.get("status") == "failed") + + for seq, (plate, (x, y, z)) in enumerate(pending, 1): + material_id = (plate.get("materialId") or "").strip() + order_code = plate.get("orderCode", "N/A") + try: + logger.info(f"\n{'='*60}") + logger.info(f"[32批量转运] 处理 [{seq}/{len(pending)}]") + logger.info(f" orderCode: {order_code}") + logger.info(f" materialId: {material_id[:20]}...") + logger.info(f" 源坐标: {wh_display}({x},{y},{z})") + logger.info(f"{'='*60}") + + result = self.transfer_3_to_2( + source_wh_id=self._WH_ID_AUTO_STACK_LEFT, + source_x=x, source_y=y, source_z=z, + ) + results.append({ + "index": seq, "orderCode": order_code, "materialId": material_id, + "status": "success", "result": result, + }) + success_count += 1 + logger.info(f"[32批量转运] ✅ [{seq}/{len(pending)}] 转运成功") + except Exception as e: + logger.error(f"[32批量转运] ❌ [{seq}/{len(pending)}] 失败: {e}") + results.append({ + "index": seq, "orderCode": order_code, "materialId": material_id, + "status": "failed", "error": str(e), + }) + failed_count += 1 + + summary = { + "total": len(vial_plates), + "success": success_count, + "failed": failed_count, + "results": results, + } + + logger.info(f"\n{'='*60}") + logger.info(f"[32批量转运] 完成汇总:") + logger.info(f" 总数: {summary['total']}") + logger.info(f" 成功: {summary['success']} ✅") + logger.info(f" 失败: {summary['failed']} ❌") + logger.info(f"{'='*60}\n") + + if failed_count > 0: + fail_details = "; ".join( + f"{r.get('orderCode')}/{(r.get('materialId') or '')[:8]}: {r.get('error')}" + for r in results if r.get("status") == "failed" + ) + raise BioyondException( + f"3-2 批量转运有 {failed_count} 块板失败: {fail_details}" + ) + + return summary + # 2.5 批量查询实验报告(post过滤关键字查询) def order_list_v2(self, timeType: str = "", @@ -1589,12 +7668,19 @@ def order_list_v2(self, status: str = "", # 60表示正在运行,80表示完成,90表示失败 filter: str = "", skipCount: int = 0, - pageCount: int = 1, # 显示多少页数据 + pageCount: int = 1, # 每页条数(不是页数!实测 pageCount=10 配 totalCount=611 返回 10 条) sorting: str = "") -> Dict[str, Any]: """ 批量查询实验报告的详细信息 (2.5) URL: /api/lims/order/order-list 参数默认值和接口文档保持一致 + + 易错口径(写错**不报错**,只是静默查错范围,实测见 `_lims_time_window`): + - `timeType` 是「按哪个字段过滤」,要用时间窗只能传 `"requestTime"`。 + 传 `"beginTime"`(参数名本身)或空串都等于**不做时间过滤**,返回全部历史单 + - 时间窗只有「天」粒度,时分秒被丢弃;分钟级精度要靠调用方按 requestTime 复筛 + - `beginTime`/`endTime` 是**本地时间 + 假 Z 后缀**,LIMS 按裸本地时间比较, + 改成真 UTC 会让窗口偏 8 小时。构造窗口请用 `_lims_time_window` """ data: Dict[str, Any] = { "timeType": timeType, @@ -1608,6 +7694,115 @@ def order_list_v2(self, } return self._post_lims("/api/lims/order/order-list", data) + # 2.6 按 orderId 查询单个实验报告 + def order_report_v2(self, order_id: str) -> Dict[str, Any]: + """查询单个订单的实验报告详情 + + URL: /api/lims/order/order-report + + Args: + order_id: 订单 GUID。注意必须传 orderId,传 orderCode 接口会返回 400。 + + Returns: + 接口原始响应(data 内含 code/name/preIntakes[].sampleMaterials/usedMaterials 等) + """ + return self._post_lims("/api/lims/order/order-report", order_id) + + def _recover_timeout_order_report( + self, order_code: str, create_entry: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """timeout 兜底(LIMS 侧核对)。 + + 工作站等 order_finish 推送超时(如隔夜暂停第二天才完成),但 LIMS 侧可能 + 早已成功。此处用 2.5 `/api/lims/order/order-list` 按 orderCode 查真实状态; + 若 status==80(Succeed),就用建单返回 `create_entry.usedMaterials`(自带 + materialTypeName)合成一份 push 口径的 report,交给既有 prep/vial/plate 提取 + 逻辑补全条码与类型——不依赖迟到的 order_finish 推送。 + + Args: + order_code: 订单 orderCode(如 BSO2026071400001) + create_entry: 建单返回 `/api/lims/order/orders` 的 data[*] 条目, + 含 orderId / orderName / usedMaterials(materialTypeName/materialTypeMode) + + Returns: + 合成 report(含 usedMaterials / orderName / orderId), + 核对失败或 LIMS 未完成时返回 None。 + """ + if not order_code: + return None + try: + resp = self.order_list_v2(filter=order_code, pageCount=1) + except Exception as e: + logger.warning(f"[timeout兜底] order-list 查询失败: orderCode={order_code}, 错误={e}") + return None + + items = ((resp or {}).get("data") or {}).get("items") or [] + matched = None + expect_oid = self._normalize_order_id((create_entry or {}).get("orderId")) + expect_name = (create_entry or {}).get("orderName") + # 优先按 orderId 匹配,避免真机/仿真 orderCode 撞号时捞错单 + if self._is_usable_order_id(expect_oid): + for it in items: + if self._normalize_order_id(it.get("id") or it.get("orderId")) == expect_oid: + matched = it + break + if matched is None and expect_name: + for it in items: + if it.get("name") == expect_name: + matched = it + break + if matched is None and items and not self._is_usable_order_id(expect_oid): + matched = items[0] + if matched is None: + logger.warning(f"[timeout兜底] order-list 未返回订单: orderCode={order_code}") + return None + + status = matched.get("status") + if status != 80: + logger.warning( + f"[timeout兜底] LIMS 未完成,不恢复: orderCode={order_code}, " + f"status={status}({matched.get('statusName')})" + ) + return None + + if not create_entry: + logger.warning( + f"[timeout兜底] LIMS 显示完成但缺建单 usedMaterials,无法合成 report: {order_code}" + ) + return None + + # 建单返回 usedMaterials 字段口径与 push 报文不同(materialTypeMode 字符串、 + # 无 typemode/realQuantity/usedQuantity),这里转换成提取逻辑认得的字段。 + # 只取 Sample 模式物料(配液瓶/分液瓶/分液瓶板都是 Sample),排除耗材/试剂。 + synth_used: List[Dict[str, Any]] = [] + for m in create_entry.get("usedMaterials") or []: + if (m.get("materialTypeMode") or "") != "Sample": + continue + mid = m.get("materialId") or "" + if not mid: + continue + synth_used.append({ + "typemode": "1", + "realQuantity": 1, + "usedQuantity": 1, + "materialId": mid, + "locationId": m.get("locationId") or "", + "materialName": m.get("materialName") or "", + "materialTypeName": m.get("materialTypeName") or "", + }) + + logger.info( + f"[timeout兜底] ✓ LIMS status=80(Succeed),用建单 usedMaterials 合成 report: " + f"orderCode={order_code}, Sample物料={len(synth_used)}" + ) + return { + "orderCode": order_code, + "orderName": create_entry.get("orderName", "N/A"), + "orderId": create_entry.get("orderId", ""), + "usedMaterials": synth_used, + "_recovered_via_order_list": True, + } + # 一直post执行bioyond接口查询任务状态 def wait_for_transfer_task(self, timeout: int = 3000, interval: int = 5, filter_text: Optional[str] = None) -> bool: """ @@ -1617,6 +7812,9 @@ def wait_for_transfer_task(self, timeout: int = 3000, interval: int = 5, filter_ 返回 True 表示找到并成功完成,False 表示超时未找到 """ now = datetime.now() + # 注意:下面 timeType="" 等于不做时间过滤,这里的 begin/endTime 是死参数, + # 真正的筛选靠 filter=filter_text。不要「顺手」把 timeType 改成 requestTime—— + # 这个窗口是 [now, now+5min] 的未来区间,一旦生效会把所有单都过滤掉。 beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ") endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ") print(beginTime, endTime) @@ -1956,48 +8154,99 @@ def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: Resource if "update_resource_site" in plr_resource.unilabos_extra: site = plr_resource.unilabos_extra["update_resource_site"] plr_model = plr_resource.model - board_type = None - for key, (moudle_name,moudle_uuid) in self.bioyond_config['material_type_mappings'].items(): - if plr_model == moudle_name: - board_type = key - break + + # 直接用 plr_model 作为键查找(配置现在使用英文model名作为键) + board_type = plr_model if plr_model in self.bioyond_config['material_type_mappings'] else None + if board_type is None: - pass + logger.error(f"板类型 {plr_model} 不在 material_type_mappings 中") + return + bottle1 = plr_resource.children[0] - bottle_moudle = bottle1.model - bottle_type = None - for key, (moudle_name, moudle_uuid) in self.bioyond_config['material_type_mappings'].items(): - if bottle_moudle == moudle_name: - bottle_type = key - break + + # 直接用 bottle_moudle 作为键查找 + bottle_type = bottle_moudle if bottle_moudle in self.bioyond_config['material_type_mappings'] else None + + if bottle_type is None: + logger.error(f"瓶类型 {bottle_moudle} 不在 material_type_mappings 中") + return # 从 parent_resource 获取仓库名称 warehouse_name = parent_resource.name if parent_resource else "手动堆栈" - logger.info(f"拖拽上料: {plr_resource.name} -> {warehouse_name} / {site}") + barcode = plr_resource.unilabos_extra.get("barCode", "") + logger.info(f"拖拽上料: {plr_resource.name} -> {warehouse_name} / {site}, barCode={barcode!r}") - self.create_sample(plr_resource.name, board_type, bottle_type, site, warehouse_name) + self.create_sample(plr_resource.name, board_type, bottle_type, site, warehouse_name, barcode) return self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}") + def _get_type_id_by_name(self, type_name: str) -> Optional[str]: + """根据物料类型名称查找对应的 UUID。 + + 查找优先级: + 1. 直接以英文 model 名(如 "YB_Vial_5mL_Carrier")作为 key 查找; + 2. 按中文名称(value[0],如 "5ml分液瓶板")遍历查找。 + + Args: + type_name: 物料类型名称,可以是英文 model key 或中文名称 + + Returns: + 对应的 UUID,如果找不到则返回 None + """ + mappings = self.bioyond_config['material_type_mappings'] + + # 优先:直接 key 命中(英文 model 名) + if type_name in mappings: + value = mappings[type_name] + logger.debug(f"[类型映射] 直接 key 命中: {type_name} → {value[1][:8]}...") + return value[1] + + # 兜底:按中文名遍历(value 格式: [中文名称, UUID]) + for key, value in mappings.items(): + if value[0] == type_name: + logger.debug(f"[类型映射] 中文名匹配: {type_name} → {key} → {value[1][:8]}...") + return value[1] + + logger.error(f"[类型映射] 未找到类型: {type_name}") + logger.debug(f"[类型映射] 可用类型列表: {[v[0] for v in mappings.values()]}") + return None + + # 各板型对应的子位排列 (num_x, num_y),用于构建 details + # 同时支持中文板名与英文 model 名作为 key,避免不同调用方传入类型不一致时查表失败 + BOARD_GRID = { + "配液瓶(大)板": (2, 2), "YB_PrepBottle_60mL_Carrier": (2, 2), + "配液瓶(小)板": (2, 4), "YB_PrepBottle_15mL_Carrier": (2, 4), + "5ml分液瓶板": (2, 4), "YB_Vial_5mL_Carrier": (2, 4), + "20ml分液瓶板": (2, 4), "YB_Vial_20mL_Carrier": (2, 4), + } + def create_sample( self, name: str, board_type: str, bottle_type: str, location_code: str, - warehouse_name: str = "手动堆栈" + warehouse_name: str = "手动传递窗右", + barcode: str = "" ) -> Dict[str, Any]: """创建配液板物料并自动入库。 Args: - name: 物料名称 + name: 物料名称(必填) board_type: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" bottle_type: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" location_code: 库位编号,例如 "A01" - warehouse_name: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 + warehouse_name: 仓库名称,默认为 "手动传递窗右",支持 "自动堆栈-左"、"自动堆栈-右" 等 + barcode: 物料条码(可选),填写后发给奔曜;不填则为空字符串 """ - carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1] - bottle_type_id = self.bioyond_config['material_type_mappings'][bottle_type][1] + # 使用反向查找获取 type_id + carrier_type_id = self._get_type_id_by_name(board_type) + bottle_type_id = self._get_type_id_by_name(bottle_type) + + if not carrier_type_id: + raise ValueError(f"未找到板类型 '{board_type}' 的配置,请检查 material_type_mappings") + if not bottle_type_id: + raise ValueError(f"未找到瓶类型 '{bottle_type}' 的配置,请检查 material_type_mappings") # 从指定仓库获取库位UUID if warehouse_name not in self.bioyond_config['warehouse_mapping']: @@ -2011,10 +8260,18 @@ def create_sample( location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code] logger.info(f"创建样品入库: {name} -> {warehouse_name}/{location_code} (UUID: {location_id})") + # 根据板型获取子位网格尺寸;查不到时直接报错,避免静默按错误数量建料 + if board_type not in self.BOARD_GRID: + raise ValueError( + f"未找到板型 '{board_type}' 的子位网格配置,请在 BOARD_GRID 中补充该板型(中文板名或英文 model 名)" + ) + num_x, num_y = self.BOARD_GRID[board_type] + logger.debug(f"[create_sample] 板型 '{board_type}' 子位网格: {num_x}×{num_y}") + # 新建小瓶 details = [] - for y in range(1, 5): - for x in range(1, 3): + for y in range(1, num_y + 1): + for x in range(1, num_x + 1): details.append({ "typeId": bottle_type_id, "code": "", @@ -2030,7 +8287,7 @@ def create_sample( data = { "typeId": carrier_type_id, "code": "", - "barCode": "", + "barCode": barcode, "name": name, "unit": "块", "parameters": json.dumps({"unit": "块"}, ensure_ascii=False), @@ -2052,7 +8309,7 @@ def create_sample( if __name__ == "__main__": lab_registry.setup() - deck = BIOYOND_YB_Deck(setup=True) + deck = bioyond_electrolyte_deck(name="YB_Deck") ws = BioyondCellWorkstation(deck=deck) # ws.create_sample(name="test", board_type="配液瓶(小)板", bottle_type="配液瓶(小)", location_code="B01") # logger.info(ws.scheduler_stop()) @@ -2109,4 +8366,4 @@ def create_sample( # 3.31 下料:同理 # r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx") - # print(r2["payload"]["data"]) + # print(r2["payload"]["data"]) \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx deleted file mode 100644 index 88b233daa..000000000 Binary files a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx and /dev/null differ diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/outbound_template.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/outbound_template.xlsx deleted file mode 100644 index f2c42f25a..000000000 Binary files a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/outbound_template.xlsx and /dev/null differ diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index 327d8195c..6a493ced1 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -258,7 +258,7 @@ def sync_to_external(self, resource: Any) -> bool: logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") # 第1步:从配置中获取仓库配置 - warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) + warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {}) # 确定目标仓库名称 parent_name = None @@ -760,10 +760,9 @@ def __init__( except: pass - # 创建通信模块 + # 创建通信模块;同步器将在 post_init 中初始化并执行首次同步 self._create_communication_module(bioyond_config) - self.resource_synchronizer = BioyondResourceSynchronizer(self) - self.resource_synchronizer.sync_from_external() + self.resource_synchronizer = None # TODO: self._ros_node里面拿属性 @@ -802,6 +801,15 @@ def __del__(self): def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node + # Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库 + if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库") + self.deck.setup() + + # 初始化同步器并执行首次同步(需在仓库初始化之后) + self.resource_synchronizer = BioyondResourceSynchronizer(self) + self.resource_synchronizer.sync_from_external() + # 启动连接监控 try: self.connection_monitor = ConnectionMonitor(self) diff --git a/unilabos/devices/workstation/changelog_2026-03-12.md b/unilabos/devices/workstation/changelog_2026-03-12.md new file mode 100644 index 000000000..955954f61 --- /dev/null +++ b/unilabos/devices/workstation/changelog_2026-03-12.md @@ -0,0 +1,219 @@ +# 代码变更说明 — 2026-03-12 + +> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。 + +--- + +## 一、物料系统标准化重构(主线任务) + +### 1. `unilabos/resources/battery/magazine.py` + +**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。 + +**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。 + +**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。 + +--- + +### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题) + +**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写: +- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。 +- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。 + +**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。 + +--- + +### 3. `unilabos/resources/battery/bottle_carriers.py` + +**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。 + +**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。 + +--- + +### 4. `unilabos/resources/bioyond/decks.py` + +**改动**: +- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。 +- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。 +- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类: + - 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。 + - 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。 + +**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。 + +--- + +### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py` + +**改动**: +- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。 +- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。 +- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。 +- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。 +- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。 + +--- + +### 6. `unilabos/resources/resource_tracker.py` + +**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键: + +```python +state.setdefault("liquid_history", []) +state.setdefault("pending_liquids", {}) +``` + +**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。 + +**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。 + +**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。 + +--- + +### 7. `unilabos/resources/itemized_carrier.py` + +**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑: + +1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。 +2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。 + +**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。 + +--- + +### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` + +**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。 + +**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。 + +**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。 + +**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。 + +**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。 + +--- + +### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` + +**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。 + +**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读: + +| 属性名 | 寄存器地址 | 说明 | +|---|---|---| +| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 | +| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 | +| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 | +| `data_aluminum_foil_remaining` | 526 | 铝箔余量 | +| `data_positive_shell_remaining` | 528 | 正极壳余量 | +| `data_flat_washer_remaining` | 530 | 平垫余量 | +| `data_negative_shell_remaining` | 532 | 负极壳余量 | +| `data_spring_washer_remaining` | 534 | 弹垫余量 | +| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 | +| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 | + +**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。 + +--- + +## 二、配置与注册表更新 + +### 10. `yibin_electrolyte_config.json` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处) +- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处) +- 移除 `"setup": true` 字段 + +### 11. `yibin_coin_cell_only_config.json` +- `CoincellDeck` → `YihuaCoinCellDeck` +- 移除 `"setup": true` + +### 12. `yibin_electrolyte_only_config.json` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck` +- 移除 `"setup": true` + +### 13. `unilabos/registry/resources/bioyond/deck.yaml` +- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck` +- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck` + +--- + +## 三、独立 Bug 修复 + +### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` + +**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。 + +**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。 + +--- + +## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志) + +### 15. `unilabos/devices/workstation/bioyond_studio/station.py` + +**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。 + +**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。 + +--- + +### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` + +**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支: + +- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。 +- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。 + +**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。 + +--- + +## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志) + +### 17. `unilabos/resources/resource_tracker.py`(追加) + +**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。 + +**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。 + +**根本原因**: +1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。 +2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。 +3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。 + +**连锁错误(随根因修复自动消除)**: +- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误 +- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败 +- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站 + +--- + +> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。 + +--- + +## 六、变更文件汇总(最终) + +| 文件 | 变更类型 | 轮次 | +|---|---|---| +| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 | +| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 | +| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 | +| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 | +| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 | +| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 | +| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 | +| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 | +| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 | +| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 | +| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 | +| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 | diff --git a/unilabos/devices/workstation/coin_cell_assembly/20251230_Modbus_CSV_Mapping_Guide.md b/unilabos/devices/workstation/coin_cell_assembly/20251230_Modbus_CSV_Mapping_Guide.md index 8af63e7ab..8af120170 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/20251230_Modbus_CSV_Mapping_Guide.md +++ b/unilabos/devices/workstation/coin_cell_assembly/20251230_Modbus_CSV_Mapping_Guide.md @@ -51,14 +51,14 @@ self.client = modbus_client.register_node_list(self.nodes) ### 控制铝箔垫片 (COIL_ALUMINUM_FOIL) ```python -# 代码位置: qiming_coin_cell_code 函数 (L1048) -self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian) +# 代码位置: func_allpack_cmd_simp 函数(参数设置步骤) +self.client.use_node('COIL_ALUMINUM_FOIL').write(not aluminum_foil) ``` - **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。 - **写入 False**: 向地址 `8340` 写入 `0` (OFF)。 -> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。 +> **注意**: 代码中使用了 `not aluminum_foil`,这意味着逻辑是反转的。如果 `aluminum_foil` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。 --- diff --git "a/unilabos/devices/workstation/coin_cell_assembly/20260113_\347\211\251\346\226\231\346\220\234\345\257\273\347\241\256\350\256\244\345\274\271\347\252\227\350\207\252\345\212\250\345\244\204\347\220\206\345\212\237\350\203\275.md" "b/unilabos/devices/workstation/coin_cell_assembly/20260113_\347\211\251\346\226\231\346\220\234\345\257\273\347\241\256\350\256\244\345\274\271\347\252\227\350\207\252\345\212\250\345\244\204\347\220\206\345\212\237\350\203\275.md" index 96104b6f6..993bd877d 100644 --- "a/unilabos/devices/workstation/coin_cell_assembly/20260113_\347\211\251\346\226\231\346\220\234\345\257\273\347\241\256\350\256\244\345\274\271\347\252\227\350\207\252\345\212\250\345\244\204\347\220\206\345\212\237\350\203\275.md" +++ "b/unilabos/devices/workstation/coin_cell_assembly/20260113_\347\211\251\346\226\231\346\220\234\345\257\273\347\241\256\350\256\244\345\274\271\347\252\227\350\207\252\345\212\250\345\244\204\347\220\206\345\212\237\350\203\275.md" @@ -7,7 +7,7 @@ ## 背景问题 ### 原有流程 -1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备 +1. 调用 `coin_cell_init()` 初始化设备 2. PLC 在初始化过程中弹出物料搜寻确认对话框 3. **需要人工手动点击**"是"或"否"按钮 4. PLC 继续完成初始化并启动 @@ -65,7 +65,7 @@ - 验证状态 ``` -#### 1.2 修改 `func_pack_device_init_auto_start_combined()` +#### 1.2 修改 `coin_cell_init()` **位置:** 第 904-1115 行 @@ -73,7 +73,7 @@ 1. **添加新参数** ```python - def func_pack_device_init_auto_start_combined( + def coin_cell_init( self, material_search_enable: bool = False # 新增参数 ) -> bool: @@ -120,7 +120,7 @@ **修改内容:** ```yaml -auto-func_pack_device_init_auto_start_combined: +auto-coin_cell_init: goal_default: material_search_enable: false # 新增默认值 @@ -163,26 +163,26 @@ auto-func_pack_device_init_auto_start_combined: #### 1. 不启用物料搜寻(默认) ```python # 默认参数,点击"否"按钮 -await device.func_pack_device_init_auto_start_combined() +await device.coin_cell_init() ``` 或在 YAML workflow 中: ```yaml # 使用默认值 false,不启用物料搜寻 -- BatteryStation/auto-func_pack_device_init_auto_start_combined: {} +- BatteryStation/auto-coin_cell_init: {} ``` #### 2. 启用物料搜寻 ```python # 显式设置为 True,点击"是"按钮 -await device.func_pack_device_init_auto_start_combined( +await device.coin_cell_init( material_search_enable=True ) ``` 或在 YAML workflow 中: ```yaml -- BatteryStation/auto-func_pack_device_init_auto_start_combined: +- BatteryStation/auto-coin_cell_init: goal: material_search_enable: true # 启用物料搜寻 ``` @@ -228,7 +228,7 @@ await device.func_pack_device_init_auto_start_combined( ### 3. 超时保护 - 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中) -- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中) +- 初始化超时:120 秒(在 `coin_cell_init` 中) ### 4. PyModbus 3.x API 兼容 所有 Modbus 操作使用 keyword arguments: @@ -248,7 +248,7 @@ client.write_coil(address=5184, value=True) - 用于不需要自动处理弹窗的场景 ### 新增的功能 -- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理 +- 在 `coin_cell_init()` 中集成弹窗处理 - 通过参数控制,默认行为与之前兼容(点击"否") ## 验证测试 @@ -258,7 +258,7 @@ client.write_coil(address=5184, value=True) #### 场景 1:默认参数(不启用物料搜寻) ```bash # 调用时不传参数 -BatteryStation/auto-func_pack_device_init_auto_start_combined: {} +BatteryStation/auto-coin_cell_init: {} ``` **预期结果:** - ✅ 检测到弹窗 @@ -268,7 +268,7 @@ BatteryStation/auto-func_pack_device_init_auto_start_combined: {} #### 场景 2:启用物料搜寻 ```bash # 设置 material_search_enable=true -BatteryStation/auto-func_pack_device_init_auto_start_combined: +BatteryStation/auto-coin_cell_init: goal: material_search_enable: true ``` @@ -316,10 +316,10 @@ BatteryStation/auto-func_pack_device_init_auto_start_combined: ### 修改的文件 1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py` - 新增 `_handle_material_search_dialog()` 方法 - - 修改 `func_pack_device_init_auto_start_combined()` 函数 + - 修改 `coin_cell_init()` 函数 2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml` - - 更新 `auto-func_pack_device_init_auto_start_combined` 配置 + - 更新 `auto-coin_cell_init` 配置 - 添加 `material_search_enable` 参数说明 3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv` diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py index c9187e656..41ccd1f05 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -130,20 +130,14 @@ def __init__( ordering: Optional[OrderedDict[str, str]] = None, category: str = "material_plate", model: Optional[str] = None, - fill: bool = False ): - """初始化料板 + """初始化料板(不主动填充洞位,由工厂方法或反序列化恢复) Args: name: 料板名称 size_x: 长度 (mm) size_y: 宽度 (mm) size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing_x: X方向洞位间距 (mm) - hole_spacing_y: Y方向洞位间距 (mm) - number: 编号 category: 类别 model: 型号 """ @@ -153,42 +147,50 @@ def __init__( hole_diameter=20.0, info="", ) - # 创建4x4的洞位 - # TODO: 这里要改,对应不同形状 + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + ) + + @classmethod + def create_with_holes( + cls, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "material_plate", + model: Optional[str] = None, + ) -> "MaterialPlate": + """工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)""" + # 默认洞位间距(与 _unilabos_state 默认值保持一致) + hole_spacing_x = 24.0 + hole_spacing_y = 24.0 + # 先建洞位,再作为 ordered_items 传入构造函数 + # (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值) holes = create_ordered_items_2d( klass=MaterialHole, num_items_x=4, num_items_y=4, - dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 - dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 + dx=(size_x - 4 * hole_spacing_x) / 2, + dy=(size_y - 4 * hole_spacing_y) / 2, dz=size_z, - item_dx=self._unilabos_state["hole_spacing_x"], - item_dy=self._unilabos_state["hole_spacing_y"], - size_x = 16, - size_y = 16, - size_z = 16, + item_dx=hole_spacing_x, + item_dy=hole_spacing_y, + size_x=16, + size_y=16, + size_z=16, + ) + return cls( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, + ordered_items=holes, category=category, model=model, ) - if fill: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - else: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=ordered_items, - ordering=ordering, - category=category, - model=model, - ) def update_locations(self): # TODO:调多次相加 @@ -534,30 +536,19 @@ def serialize_state(self) -> Dict[str, Dict[str, Any]]: return data -class CoincellDeck(Deck): - """纽扣电池组装工作站台面类""" +class YihuaCoinCellDeck(Deck): + """依华纽扣电池组装工作站台面类""" def __init__( self, name: str = "coin_cell_deck", - size_x: float = 1450.0, # 1m - size_y: float = 1450.0, # 1m - size_z: float = 100.0, # 0.9m + size_x: float = 1450.0, + size_y: float = 1450.0, + size_z: float = 100.0, origin: Coordinate = Coordinate(-2200, 0, 0), category: str = "coin_cell_deck", - setup: bool = False, # 是否自动执行 setup + setup: bool = False, ): - """初始化纽扣电池组装工作站台面 - - Args: - name: 台面名称 - size_x: 长度 (mm) - 1m - size_y: 宽度 (mm) - 1m - size_z: 高度 (mm) - 0.9m - origin: 原点坐标 - category: 类别 - setup: 是否自动执行 setup 配置标准布局 - """ super().__init__( name=name, size_x=1450.0, @@ -591,14 +582,11 @@ def setup(self) -> None: # ====================================== 物料板 ============================================ # 创建物料板(料盘carrier)- 4x4布局 # 负极料盘 - fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0) self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0)) - # for i in range(16): - # fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - # fujiliaopan.children[i].assign_child_resource(fujipian, location=None) # 隔膜料盘 - gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0) self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0)) # for i in range(16): # gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) @@ -633,11 +621,27 @@ def setup(self) -> None: waste_tip_box = WasteTipBox(name="waste_tip_box") self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0)) + # 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板 + # 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应 + electrolyte_buffer = ResourceStack( + name="electrolyte_buffer", + direction="z", + resources=[], + ) + self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0)) + + +def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck: + deck = YihuaCoinCellDeck(name=name) + deck.setup() + return deck + + +# 向后兼容别名,日后废弃 +CoincellDeck = YihuaCoinCellDeck -def YH_Deck(name=""): - cd = CoincellDeck(name=name) - cd.setup() - return cd +def YH_Deck(name: str = "") -> YihuaCoinCellDeck: + return yihua_coin_cell_deck(name=name or "coin_cell_deck") if __name__ == "__main__": diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 91efd45fb..a0409ac0a 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -17,7 +17,7 @@ from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import * from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck +from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck from unilabos.resources.graphio import convert_resources_to_type from unilabos.utils.log import logger import struct @@ -161,7 +161,9 @@ def __init__(self, logger.info("没有传入依华deck,检查启动json文件") super().__init__(deck=deck, *args, **kwargs,) self.debug_mode = debug_mode - + self._modbus_address = address + self._modbus_port = port + """ 连接初始化 """ modbus_client = TCPClient(addr=address, port=port) logger.debug(f"创建 Modbus 客户端: {modbus_client}") @@ -178,9 +180,11 @@ def __init__(self, raise ValueError('modbus tcp connection failed') self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv')) self.client = modbus_client.register_node_list(self.nodes) + self._modbus_client_raw = modbus_client else: print("测试模式,跳过连接") self.nodes, self.client = None, None + self._modbus_client_raw = None """ 工站的配置 """ @@ -190,10 +194,43 @@ def __init__(self, self.csv_export_running = False self.csv_export_file = None self.coin_num_N = 0 #已组装电池数量 + self._elec_use_num = 0 # 每瓶电池数(下单后由 coin_cell_start 更新) + self._elec_bottle_num = 0 # 电解液瓶数(下单后由 coin_cell_start 更新) + + def _ensure_modbus_connected(self) -> None: + """检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)""" + if self.debug_mode or self._modbus_client_raw is None: + return + raw_client = self._modbus_client_raw.client + if raw_client.is_socket_open(): + return + logger.warning("[Modbus] 检测到连接已断开,尝试重连...") + try: + raw_client.close() + except Exception: + pass + count = 10 + while count > 0: + count -= 1 + try: + raw_client.connect() + except Exception: + pass + if raw_client.is_socket_open(): + break + time.sleep(2) + if not raw_client.is_socket_open(): + raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接") + logger.info("[Modbus] 重连成功") def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node - #self.deck = create_a_coin_cell_deck() + + # Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料 + if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup): + logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化") + self.deck.setup() + ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) @@ -619,16 +656,59 @@ def data_electrolyte_volume(self) -> int: """当前电解液加注量 (INT16)""" if self.debug_mode: return 0 - vol, read_err = self.client.use_node('REG_DATA_ELECTROLYTE_VOLUME').read(1) - return vol + try: + vol, read_err = self.client.use_node('REG_DATA_ELECTROLYTE_VOLUME').read(1) + return vol or 0 + except Exception as e: + logger.warning(f"读取电解液加注量失败,返回 0: {e}") + return 0 @property - def data_coin_num(self) -> int: - """当前电池数量 (INT16)""" + def data_coin_type(self) -> int: + """电池类型 - 7种或8种组装物料 (INT16)""" + if self.debug_mode: + return 7 + coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1) + return coin_type + + @property + def data_current_assembling_count(self) -> int: + """当前进行组装的电池数量 - Current assembling battery count (INT16)""" if self.debug_mode: return 0 - num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1) - return num + count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1) + return count + + @property + def data_current_completed_count(self) -> int: + """当前完成组装的电池数量 - Current completed battery count (INT16)""" + if self.debug_mode: + return 0 + count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1) + return count + + @property + def data_order_completion_percentage(self) -> float: + """电池订单任务完成百分比 (%) + + 分子: 当前完成组装的电池数量 (data_current_completed_count) + 分母: 电池总数 = 电解液瓶数 (REG_MSG_ELECTROLYTE_NUM) + × 每瓶电池数 (REG_MSG_ELECTROLYTE_USE_NUM) + 订单未下发(分母为 0)时返回 0.0 + """ + if self.debug_mode: + return 0.0 + try: + completed = self.data_current_completed_count + bottle_num, _ = self.client.use_node('REG_MSG_ELECTROLYTE_NUM').read(1) + use_num, _ = self.client.use_node('REG_MSG_ELECTROLYTE_USE_NUM').read(1) + total = (bottle_num or 0) * (use_num or 0) + if total <= 0: + return 0.0 + return round((completed or 0) / total * 100.0, 2) + except Exception as e: + logger.warning(f"读取订单完成进度失败,返回 0.0: {e}") + return 0.0 @property def data_coin_cell_code(self) -> str: @@ -726,6 +806,116 @@ def data_glove_box_water_content(self) -> float: return 0.0 return _decode_float32_correct(result.registers) + @property + def data_10mm_positive_plate_remaining(self) -> float: + """10mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取10mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_12mm_positive_plate_remaining(self) -> float: + """12mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取12mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_16mm_positive_plate_remaining(self) -> float: + """16mm正极片剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取16mm正极片余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_aluminum_foil_remaining(self) -> float: + """铝箔剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取铝箔余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_positive_shell_remaining(self) -> float: + """正极壳剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取正极壳余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_flat_washer_remaining(self) -> float: + """平垫剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取平垫余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_negative_shell_remaining(self) -> float: + """负极壳剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取负极壳余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_spring_washer_remaining(self) -> float: + """弹垫剩余物料数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2) + if result.isError(): + logger.error("读取弹垫余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_finished_battery_remaining_capacity(self) -> float: + """成品电池剩余可容纳数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2) + if result.isError(): + logger.error("读取成品电池余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + + @property + def data_finished_battery_ng_remaining_capacity(self) -> float: + """成品电池NG槽剩余可容纳数量 (FLOAT32)""" + if self.debug_mode: + return 0.0 + result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2) + if result.isError(): + logger.error("读取成品电池NG槽余量失败") + return 0.0 + return _decode_float32_correct(result.registers) + # @property # def data_stack_vision_code(self) -> int: # """物料堆叠复检图片编码 (INT16)""" @@ -901,7 +1091,88 @@ def _handle_material_search_dialog(self, enable_search: bool, timeout: int = 30) raise RuntimeError(error_msg) - def func_pack_device_init_auto_start_combined(self, material_search_enable: bool = False) -> bool: + # 长时延模拟的推荐档位(对应测试方案要跑的三种时长),不是硬约束 + SIM_DELAY_PRESETS = (3, 5, 10) + # 上限不是「只能这几档」,而是拦住打错字:delay_minutes=1000 会让工站空睡 16 小时, + # 而这个动作除了睡觉什么都不做,堵住纯属浪费 + SIM_DELAY_MAX_MINUTES = 60 + + def coin_cell_sim_delay( + self, + delay_minutes: float = 3, + report_interval: float = 15.0, + ) -> Dict[str, Any]: + """长时延模拟:空转指定时长,用来模拟扣电组装的完整流程耗时。 + + 用于配液↔扣电联动的时序验证:把它替换掉真实的 + `coin_cell_init` + `coin_cell_start`,就能在不动硬件的情况下 + 观察配液工站的互锁门控是否按预期阻塞、放行。 + + Args: + delay_minutes: 模拟耗时(分钟)。推荐档位 3 / 5 / 10,但接受任意正值, + 上限 60 分钟。不做白名单是因为快速联调时常需要 30 秒级的时长 + report_interval: 进度上报间隔(秒)。分段 sleep 是必要的——一句 + time.sleep(600) 会让前端十分钟毫无变化、看起来像卡死 + + Returns: + {"status": "ok", "delay_minutes": float, "delay_seconds": float, + "start_time": str, "start_time_ms": int, + "end_time": str, "end_time_ms": int, "actual_seconds": float} + + Raises: + ValueError: delay_minutes 非正或超过 60 分钟 + """ + try: + minutes = float(delay_minutes) + except (TypeError, ValueError): + raise ValueError(f"delay_minutes 必须是数字,收到: {delay_minutes!r}") + + if minutes <= 0: + raise ValueError(f"delay_minutes 必须为正值,收到: {minutes}") + if minutes > self.SIM_DELAY_MAX_MINUTES: + raise ValueError( + f"delay_minutes={minutes} 超过上限 {self.SIM_DELAY_MAX_MINUTES} 分钟。" + f"这个动作只是空转,睡这么久多半是填错了;" + f"推荐档位 {'/'.join(map(str, self.SIM_DELAY_PRESETS))} 分钟。" + ) + + total_seconds = minutes * 60.0 + start_dt = datetime.now() + start_ms = int(start_dt.timestamp() * 1000) + logger.info( + f"[coin_cell_sim_delay] 开始模拟扣电组装耗时 {minutes} 分钟" + f"({total_seconds:.0f}s),起始 {start_dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}" + ) + + deadline = time.monotonic() + total_seconds + step = max(float(report_interval), 1.0) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(step, remaining)) + remaining = max(deadline - time.monotonic(), 0.0) + logger.info( + f"[coin_cell_sim_delay] 模拟中… 剩余 {remaining:.0f}s / 共 {total_seconds:.0f}s" + ) + + end_dt = datetime.now() + end_ms = int(end_dt.timestamp() * 1000) + actual = (end_ms - start_ms) / 1000.0 + result = { + "status": "ok", + "delay_minutes": minutes, + "delay_seconds": round(total_seconds, 3), + "start_time": start_dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + "start_time_ms": start_ms, + "end_time": end_dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + "end_time_ms": end_ms, + "actual_seconds": round(actual, 3), + } + logger.info(f"[coin_cell_sim_delay] ✅ 模拟结束,实际耗时 {actual:.1f}s: {result}") + return result + + def coin_cell_init(self, material_search_enable: bool = False) -> bool: """ 组合函数:设备初始化 + 切换自动模式 + 启动 @@ -925,6 +1196,7 @@ def func_pack_device_init_auto_start_combined(self, material_search_enable: bool # 步骤0: 前置条件检查 logger.info("\n【步骤 0/4】前置条件检查...") + self._ensure_modbus_connected() try: # 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互) unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT') @@ -985,6 +1257,53 @@ def func_pack_device_init_auto_start_combined(self, material_search_enable: bool raise RuntimeError(error_msg) logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)") + + # 握手/命令线圈残留自动复位(init 前正常状态均应为 False) + # 说明:以下线圈物理上均可写,若上次运行意外中断残留为 True 会导致流程卡死; + # 此处统一尽力写回 False(best-effort,不阻断初始化)。 + # 注意方向:8700/8720/8730/8060/8050/8040 为 PC→PLC(PC 写,复位安全); + # 8500/8510/8710 为 PLC→PC(PLC 写),若 PLC 仍在主动驱动,写 False 可能被覆盖,仅告警继续。 + coils_to_reset = [ + ("COIL_UNILAB_SEND_MSG_SUCC_CMD", "PC→PLC 配方发送完毕 (8700)"), + ("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "PC→PLC 瓶数发送完毕 (8720)"), + ("UNILAB_SEND_FINISHED_CMD", "PC→PLC 一组完成确认 (8730)"), + ("COIL_SYS_INIT_CMD", "PC→PLC 系统初始化命令 (8060)"), + ("COIL_SYS_AUTO_CMD", "PC→PLC 系统自动模式命令 (8050)"), + ("COIL_SYS_HAND_CMD", "PC→PLC 系统手动模式命令 (8040)"), + ("COIL_REQUEST_REC_MSG_STATUS", "PLC→PC 请求接收配方 (8500)"), + ("COIL_REQUEST_SEND_MSG_STATUS", "PLC→PC 请求发送测试数据 (8510)"), + ("COIL_UNILAB_REC_MSG_SUCC_CMD", "PLC→PC 数据接收完毕 (8710)"), + ] + for coil_name, coil_desc in coils_to_reset: + try: + cmd_node = self.client.use_node(coil_name) + cmd_value, cmd_err = cmd_node.read(1) + if cmd_err: + logger.warning(f" ⚠ 无法读取 {coil_name}({coil_desc}),跳过此项") + continue + cmd_actual = cmd_value[0] if isinstance(cmd_value, (list, tuple)) else cmd_value + logger.info(f" {coil_name} 当前值: {cmd_actual}") + if cmd_actual: + logger.warning(f" ⚠ {coil_name}({coil_desc}) = True,期望 False,正在自动复位...") + cmd_node.write(False) + time.sleep(0.2) + # 回读确认是否复位成功 + verify_value, verify_err = cmd_node.read(1) + verify_actual = verify_value[0] if isinstance(verify_value, (list, tuple)) else verify_value + if verify_err or verify_actual: + # 写不进去(多为 PLC 仍在主动驱动该状态位):仅告警,不阻断初始化 + logger.warning( + f" ⚠ {coil_name} 自动复位未生效,当前值仍为 {verify_actual}" + f"({coil_desc})。若为 PLC 主动驱动属正常,继续初始化;" + "如初始化异常请检查上次运行是否意外中断或在HMI手动复位。" + ) + else: + logger.info(f" ✓ {coil_name} 已自动复位为 False") + else: + logger.info(f" ✓ {coil_name} 检查通过 (值为False)") + except Exception as cmd_e: + logger.warning(f" ⚠ 处理 {coil_name} 时发生异常: {cmd_e},跳过此项") + logger.info("✓ 所有前置条件检查通过!") except ValueError as e: @@ -1137,7 +1456,7 @@ def func_pack_send_bottle_num(self, bottle_num): time.sleep(1) #自动按钮置False - def func_sendbottle_allpack_multi( + def coin_cell_start( self, elec_num, elec_use_num, @@ -1149,16 +1468,22 @@ def func_sendbottle_allpack_multi( dual_drop_start_timing: bool = False, assembly_type: int = 7, assembly_pressure: int = 4200, - # 来自原 qiming_coin_cell_code 的参数 - fujipian_panshu: int = 0, - fujipian_juzhendianwei: int = 0, - gemopanshu: int = 0, - gemo_juzhendianwei: int = 0, - qiangtou_juzhendianwei: int = 0, - lvbodian: bool = True, + # 装配参数(负极片/隔膜/枪头盒/铝箔垫) + ne_plate_num: int = 0, + ne_plate_matrix: int = 0, + sep_plate_num: int = 0, + sep_plate_matrix: int = 0, + tip_box_matrix: int = 0, + aluminum_foil: bool = True, battery_pressure_mode: bool = True, battery_clean_ignore: bool = False, - file_path: str = "/Users/sml/work" + # 正负极片取料/负极片吹气参数 + positive_plate_pick_mode: bool = False, + negative_plate_thickness_mode: bool = False, + thick_negative_plate_blow_time: int = 0, + thin_negative_plate_blow_time: int = 0, + file_path: str = "/Users/sml/work", + formulations: List[Dict] = None ) -> Dict[str, Any]: """ 发送瓶数+简化组装函数(适用于第二批次及后续批次) @@ -1176,26 +1501,63 @@ def func_sendbottle_allpack_multi( dual_drop_start_timing: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) assembly_type: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) assembly_pressure: 电池压制力 (N) - fujipian_panshu: 负极片盘数 - fujipian_juzhendianwei: 负极片矩阵点位 - gemopanshu: 隔膜盘数 - gemo_juzhendianwei: 隔膜矩阵点位 - qiangtou_juzhendianwei: 枪头盒矩阵点位 - lvbodian: 是否使用铝箔垫片 + ne_plate_num: 负极片盘数 + ne_plate_matrix: 负极片矩阵点位 + sep_plate_num: 隔膜盘数 + sep_plate_matrix: 隔膜矩阵点位 + tip_box_matrix: 枪头盒矩阵点位 + aluminum_foil: 是否使用铝箔垫片 battery_pressure_mode: 是否启用压力模式 battery_clean_ignore: 是否忽略电池清洁 + positive_plate_pick_mode: 正极片取料模式 (False=弹夹取料, True=水平取料) + negative_plate_thickness_mode: 负极片厚度模式 (False=厚负极片, True=薄负极片) + thick_negative_plate_blow_time: 厚负极片吹气时间 + thin_negative_plate_blow_time: 薄负极片吹气时间 file_path: 实验记录保存路径 + formulations: 配方信息列表(从 create_orders.mass_ratios 获取) + 包含 orderCode, target_mass_ratio, real_mass_ratio 等 + 用于CSV数据追溯,可选参数 Returns: dict: 包含组装结果的字典 - 注意: - - 第一次启动需先调用 func_pack_device_init_auto_start_combined() + 注意: + - 第一次启动需先调用 coin_cell_init() - 后续批次直接调用此函数即可 """ logger.info("=" * 60) logger.info("开始发送瓶数+简化组装流程...") logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}") + + # 本次组装运行的时间戳(用于 CSV 文件命名,整批电池共用同一个文件) + self._csv_export_run_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + logger.info(f"本次组装 CSV 文件时间戳: {self._csv_export_run_timestamp}") + + # 存储配方信息到设备状态(供 CSV 写入使用) + if formulations: + logger.info(f"接收到配方信息: {len(formulations)} 条") + # 将配方信息按 orderCode 索引,方便后续查找 + self._formulations_map = { + f["orderCode"]: f for f in formulations + } if formulations else {} + # ✅ 新增:存储配方列表(按接收顺序),用于索引访问 + self._formulations_list = formulations + else: + logger.warning("未接收到配方信息,CSV将不包含配方字段") + self._formulations_map = {} + self._formulations_list = [] + + # ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号 + # ⚠️ 确保转换为整数(前端可能传递字符串) + self._elec_use_num = int(elec_use_num) if elec_use_num else 0 + # ✅ 新增:存储电解液瓶数,用于计算订单完成百分比(分母 = 瓶数 × 每瓶电池数) + self._elec_bottle_num = int(elec_num) if elec_num else 0 + logger.info(f"已存储参数: 电解液瓶数={self._elec_bottle_num}, 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}") + + # ✅ 新增:软件层电池计数器(防止硬件计数器不准确) + self._software_battery_counter = 0 # 从0开始,每写入一次CSV递增 + logger.info("软件层电池计数器已初始化") + logger.info("=" * 60) # 步骤1: 发送电解液瓶数(触发物料搬运) @@ -1224,14 +1586,18 @@ def func_sendbottle_allpack_multi( dual_drop_start_timing=dual_drop_start_timing, assembly_type=assembly_type, assembly_pressure=assembly_pressure, - fujipian_panshu=fujipian_panshu, - fujipian_juzhendianwei=fujipian_juzhendianwei, - gemopanshu=gemopanshu, - gemo_juzhendianwei=gemo_juzhendianwei, - qiangtou_juzhendianwei=qiangtou_juzhendianwei, - lvbodian=lvbodian, + ne_plate_num=ne_plate_num, + ne_plate_matrix=ne_plate_matrix, + sep_plate_num=sep_plate_num, + sep_plate_matrix=sep_plate_matrix, + tip_box_matrix=tip_box_matrix, + aluminum_foil=aluminum_foil, battery_pressure_mode=battery_pressure_mode, battery_clean_ignore=battery_clean_ignore, + positive_plate_pick_mode=positive_plate_pick_mode, + negative_plate_thickness_mode=negative_plate_thickness_mode, + thick_negative_plate_blow_time=thick_negative_plate_blow_time, + thin_negative_plate_blow_time=thin_negative_plate_blow_time, file_path=file_path ) @@ -1285,6 +1651,7 @@ def func_pack_send_msg_cmd(self, elec_use_num, elec_vol, assembly_type, assembly self._unilab_send_msg_assembly_type(assembly_type) time.sleep(1) #发送电池压制力 + self._target_assembly_pressure = assembly_pressure self._unilab_send_msg_assembly_pressure(assembly_pressure) time.sleep(1) self._unilab_send_msg_succ_cmd(True) @@ -1331,7 +1698,8 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: data_assembly_time = self.data_assembly_time data_assembly_pressure = self.data_assembly_pressure data_electrolyte_volume = self.data_electrolyte_volume - data_coin_num = self.data_coin_num + data_coin_type = self.data_coin_type # 电池类型(7或8种物料) + data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号 # 处理电解液二维码 - 确保是字符串类型 try: @@ -1361,28 +1729,32 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: logger.debug(f"data_assembly_time: {data_assembly_time}") logger.debug(f"data_assembly_pressure: {data_assembly_pressure}") logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}") - logger.debug(f"data_coin_num: {data_coin_num}") + logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型 + logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号 logger.debug(f"data_electrolyte_code: {data_electrolyte_code}") logger.debug(f"data_coin_cell_code: {data_coin_cell_code}") #接收完信息后,读取完毕标志位置True - liaopan3 = self.deck.get_resource("成品弹夹") + finished_battery_magazine = self.deck.get_resource("成品弹夹") + + # 计算电池应该放在哪个洞,以及洞内的堆叠位置 + # 成品弹夹有6个洞,每个洞可堆叠20颗电池 + # 前5个洞(索引0-4)放正常电池,第6个洞(索引5)放NG电池 + BATTERIES_PER_HOLE = 20 + MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞 + + hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞(0-4为正常电池) + in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号 + + if hole_index >= 5: + logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}") + raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}颗") + + target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞 # 生成唯一的电池名称(使用时间戳确保唯一性) timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f") battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}" - # 检查目标位置是否已有资源,如果有则先卸载 - target_slot = liaopan3.children[self.coin_num_N] - if target_slot.children: - logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源") - try: - # 卸载所有现有子资源 - for child in list(target_slot.children): - target_slot.unassign_child_resource(child) - logger.info(f"已卸载旧资源: {child.name}") - except Exception as e: - logger.error(f"卸载旧资源时出错: {e}") - # 创建新的电池资源 battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2) battery._unilabos_state = { @@ -1393,13 +1765,12 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: "electrolyte_volume": data_electrolyte_volume } - # 分配新资源到目标位置 + # 将电池堆叠到目标洞中 try: - target_slot.assign_child_resource(battery, location=None) - logger.info(f"成功分配电池 {battery_name} 到位置 {self.coin_num_N}") + target_hole.assign_child_resource(battery, location=None) + logger.info(f"成功放置电池 {battery_name} 到弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)") except Exception as e: - logger.error(f"分配电池资源失败: {e}") - # 如果分配失败,尝试使用更简单的方法 + logger.error(f"放置电池资源失败: {e}") raise #print(jipian2.parent) @@ -1417,11 +1788,18 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: self._unilab_rec_msg_succ_cmd(False) time.sleep(1) #将允许读取标志位置True - time_date = datetime.now().strftime("%Y%m%d") #秒级时间戳用于标记每一行电池数据 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - #生成输出文件的变量 - self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv") + self._last_assembly_timestamp = timestamp + #生成输出文件的变量(整批组装共用同一文件,文件名带运行时间戳) + #若未经 coin_cell_start 设置运行时间戳则回退到当前时间 + run_timestamp = getattr(self, "_csv_export_run_timestamp", None) or datetime.now().strftime("%Y%m%d_%H%M%S") + self.csv_export_file = os.path.join(file_path, f"coin_cell_assembly_{run_timestamp}.csv") + # 导出目录不存在则跳过 CSV 记录:仅告警,不创建目录、不中断组装任务 + if not os.path.isdir(file_path): + logger.warning(f"[CSV写入] 导出目录不存在,跳过本次CSV记录(不中断任务): {file_path}") + self.success = True + return self.success #将数据存入csv文件 if not os.path.exists(self.csv_export_file): #创建一个表头 @@ -1429,18 +1807,81 @@ def func_pack_get_msg_cmd(self, file_path: str="D:\\coin_cell_data") -> bool: writer = csv.writer(csvfile) writer.writerow([ 'Time', 'open_circuit_voltage', 'pole_weight', - 'assembly_time', 'assembly_pressure', 'electrolyte_volume', - 'coin_num', 'electrolyte_code', 'coin_cell_code' + 'assembly_time', 'target_assembly_pressure', 'real_assembly_pressure', 'electrolyte_volume', + 'data_coin_type', 'electrolyte_code', 'coin_cell_code', + 'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes', + 'target_mass_ratio', 'real_mass_ratio' ]) #立刻写入磁盘 csvfile.flush() #开始追加电池信息 with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile: writer = csv.writer(csvfile) + + # ========== 提取配方信息 ========== + formulation_order_name = "" + prep_bottle_barcode = "" + vial_bottle_barcodes = "" + target_ratio_str = "" + real_ratio_str = "" + + # 从 self._formulations_list 获取配方信息 + if hasattr(self, '_formulations_list') and self._formulations_list: + # ✅ 新方案:根据电池编号和每瓶电池数计算当前瓶号 + # 例如:elec_use_num=2时,电池1-2用瓶0,电池3-4用瓶1 + if hasattr(self, '_elec_use_num') and self._elec_use_num: + # ⚠️ 确保转换为整数(防御性编程) + elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1 + if elec_use_num_int > 0: + current_bottle_index = (data_battery_number - 1) // elec_use_num_int + else: + current_bottle_index = 0 + + logger.debug( + f"[CSV写入] 电池 {data_battery_number}: 计算瓶号索引={current_bottle_index} " + f"(每瓶{self._elec_use_num}颗电池)" + ) + else: + # 降级方案:尝试从二维码解析(仅当参数未设置时) + current_bottle_index = int(data_electrolyte_code.split('-')[-1]) if '-' in str(data_electrolyte_code) else 0 + logger.debug( + f"[CSV写入] 电池 {data_battery_number}: 从二维码解析瓶号索引={current_bottle_index}" + ) + + # 从配方列表中获取对应配方 + if 0 <= current_bottle_index < len(self._formulations_list): + formulation = self._formulations_list[current_bottle_index] + formulation_order_name = formulation.get("orderName", "") + prep_bottle_barcode = formulation.get("prep_bottle_barcode", "") + vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "") + + real_ratio = formulation.get("real_mass_ratio", {}) + target_ratio = formulation.get("target_mass_ratio", {}) + + # 将配方比例转为JSON字符串 + import json + target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else "" + real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else "" + + logger.info( + f"[CSV写入] 电池 {data_battery_number}: 使用配方[{current_bottle_index}] " + f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, 分液瓶={vial_bottle_barcodes}" + ) + else: + logger.warning( + f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} " + f"超出配方列表范围 (共{len(self._formulations_list)}个配方)" + ) + else: + logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据") + + target_assembly_pressure = getattr(self, '_target_assembly_pressure', '') writer.writerow([ timestamp, data_open_circuit_voltage, data_pole_weight, - data_assembly_time, data_assembly_pressure, data_electrolyte_volume, - data_coin_num, data_electrolyte_code, data_coin_cell_code + data_assembly_time, target_assembly_pressure, data_assembly_pressure, data_electrolyte_volume, + data_coin_type, data_electrolyte_code, data_coin_cell_code, + formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes, + target_ratio_str, real_ratio_str ]) #立刻写入磁盘 csvfile.flush() @@ -1465,184 +1906,6 @@ def func_pack_send_finished_cmd(self) -> bool: self.success = True return self.success - def qiming_coin_cell_code(self, fujipian_panshu:int, fujipian_juzhendianwei:int=0, gemopanshu:int=0, gemo_juzhendianwei:int=0, lvbodian:bool=True, battery_pressure_mode:bool=True, battery_pressure:int=4000, battery_clean_ignore:bool=False) -> bool: - self.success = False - self.client.use_node('REG_MSG_NE_PLATE_NUM').write(fujipian_panshu) - self.client.use_node('REG_MSG_NE_PLATE_MATRIX').write(fujipian_juzhendianwei) - self.client.use_node('REG_MSG_SEPARATOR_PLATE_NUM').write(gemopanshu) - self.client.use_node('REG_MSG_SEPARATOR_PLATE_MATRIX').write(gemo_juzhendianwei) - self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian) - self.client.use_node('REG_MSG_PRESS_MODE').write(not battery_pressure_mode) - # self.client.use_node('REG_MSG_ASSEMBLY_PRESSURE').write(battery_pressure) - self.client.use_node('REG_MSG_BATTERY_CLEAN_IGNORE').write(battery_clean_ignore) - self.success = True - - return self.success - - def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="/Users/sml/work") -> Dict[str, Any]: - elec_num, elec_use_num, elec_vol, assembly_type, assembly_pressure = int(elec_num), int(elec_use_num), int(elec_vol), int(assembly_type), int(assembly_pressure) - summary_csv_file = os.path.join(file_path, "duandian.csv") - - # 用于收集所有电池的数据 - battery_data_list = [] - - # 如果断点文件存在,先读取之前的进度 - if os.path.exists(summary_csv_file): - read_status_flag = True - with open(summary_csv_file, 'r', newline='', encoding='utf-8') as csvfile: - reader = csv.reader(csvfile) - header = next(reader) # 跳过标题行 - data_row = next(reader) # 读取数据行 - if len(data_row) >= 2: - elec_num_r = int(data_row[0]) - elec_use_num_r = int(data_row[1]) - elec_num_N = int(data_row[2]) - elec_use_num_N = int(data_row[3]) - coin_num_N = int(data_row[4]) - if elec_num_r == elec_num and elec_use_num_r == elec_use_num: - print("断点文件与当前任务匹配,继续") - else: - print("断点文件中elec_num、elec_use_num与当前任务不匹配,请检查任务下发参数或修改断点文件") - return { - "success": False, - "error": "断点文件参数不匹配", - "total_batteries": 0, - "batteries": [] - } - print(f"从断点文件读取进度: elec_num_N={elec_num_N}, elec_use_num_N={elec_use_num_N}, coin_num_N={coin_num_N}") - - else: - read_status_flag = False - print("未找到断点文件,从头开始") - elec_num_N = 0 - elec_use_num_N = 0 - coin_num_N = 0 - for i in range(20): - print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}") - print(f"剩余电解液瓶数: {type(elec_num)}, 已组装电池数: {type(elec_use_num)}") - print(f"剩余电解液瓶数: {type(int(elec_num))}, 已组装电池数: {type(int(elec_use_num))}") - - #如果是第一次运行,则进行初始化、切换自动、启动, 如果是断点重启则跳过。 - if read_status_flag == False: - pass - #初始化 - #self.func_pack_device_init() - #切换自动 - #self.func_pack_device_auto() - #启动,小车收回 - #self.func_pack_device_start() - #发送电解液瓶数量,启动搬运,多搬运没事 - #self.func_pack_send_bottle_num(elec_num) - last_i = elec_num_N - last_j = elec_use_num_N - for i in range(last_i, elec_num): - print(f"开始第{last_i+i+1}瓶电解液的组装") - #第一个循环从上次断点继续,后续循环从0开始 - j_start = last_j if i == last_i else 0 - self.func_pack_send_msg_cmd(elec_use_num-j_start, elec_vol, assembly_type, assembly_pressure) - - for j in range(j_start, elec_use_num): - print(f"开始第{last_i+i+1}瓶电解液的第{j+j_start+1}个电池组装") - - #读取电池组装数据并存入csv - self.func_pack_get_msg_cmd(file_path) - - # 收集当前电池的数据 - # 处理电池二维码 - try: - battery_qr_code = self.data_coin_cell_code - except Exception as e: - print(f"读取电池二维码失败: {e}") - battery_qr_code = "N/A" - - # 处理电解液二维码 - try: - electrolyte_qr_code = self.data_electrolyte_code - except Exception as e: - print(f"读取电解液二维码失败: {e}") - electrolyte_qr_code = "N/A" - - # 处理开路电压 - 确保是数值类型 - try: - open_circuit_voltage = self.data_open_circuit_voltage - if isinstance(open_circuit_voltage, (list, tuple)) and len(open_circuit_voltage) > 0: - open_circuit_voltage = float(open_circuit_voltage[0]) - else: - open_circuit_voltage = float(open_circuit_voltage) - except Exception as e: - print(f"读取开路电压失败: {e}") - open_circuit_voltage = 0.0 - - # 处理极片质量 - 确保是数值类型 - try: - pole_weight = self.data_pole_weight - if isinstance(pole_weight, (list, tuple)) and len(pole_weight) > 0: - pole_weight = float(pole_weight[0]) - else: - pole_weight = float(pole_weight) - except Exception as e: - print(f"读取正极片重量失败: {e}") - pole_weight = 0.0 - - battery_info = { - "battery_index": coin_num_N + 1, - "battery_barcode": battery_qr_code, - "electrolyte_barcode": electrolyte_qr_code, - "open_circuit_voltage": open_circuit_voltage, - "pole_weight": pole_weight, - "assembly_time": self.data_assembly_time, - "assembly_pressure": self.data_assembly_pressure, - "electrolyte_volume": self.data_electrolyte_volume - } - battery_data_list.append(battery_info) - print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}") - - time.sleep(1) - # TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑 - - # 生成断点文件 - # 生成包含elec_num_N、coin_num_N、timestamp的CSV文件 - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - with open(summary_csv_file, 'w', newline='', encoding='utf-8') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(['elec_num','elec_use_num', 'elec_num_N', 'elec_use_num_N', 'coin_num_N', 'timestamp']) - writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp]) - csvfile.flush() - coin_num_N += 1 - self.coin_num_N = coin_num_N - elec_use_num_N += 1 - elec_num_N += 1 - elec_use_num_N = 0 - - #循环正常结束,则删除断点文件 - os.remove(summary_csv_file) - #全部完成后等待依华发送完成信号 - self.func_pack_send_finished_cmd() - - # 返回JSON格式数据 - result = { - "success": True, - "total_batteries": len(battery_data_list), - "batteries": battery_data_list, - "summary": { - "electrolyte_bottles_used": elec_num, - "batteries_per_bottle": elec_use_num, - "electrolyte_volume": elec_vol, - "assembly_type": assembly_type, - "assembly_pressure": assembly_pressure - } - } - - print(f"\n{'='*60}") - print(f"组装完成统计:") - print(f" 总组装电池数: {result['total_batteries']}") - print(f" 使用电解液瓶数: {elec_num}") - print(f" 每瓶电池数: {elec_use_num}") - print(f"{'='*60}\n") - - return result - - def func_allpack_cmd_simp( self, elec_num, @@ -1655,21 +1918,25 @@ def func_allpack_cmd_simp( dual_drop_start_timing: bool = False, assembly_type: int = 7, assembly_pressure: int = 4200, - # 来自原 qiming_coin_cell_code 的参数 - fujipian_panshu: int = 0, - fujipian_juzhendianwei: int = 0, - gemopanshu: int = 0, - gemo_juzhendianwei: int = 0, - qiangtou_juzhendianwei: int = 0, - lvbodian: bool = True, + # 装配参数(负极片/隔膜/枪头盒/铝箔垫) + ne_plate_num: int = 0, + ne_plate_matrix: int = 0, + sep_plate_num: int = 0, + sep_plate_matrix: int = 0, + tip_box_matrix: int = 0, + aluminum_foil: bool = True, battery_pressure_mode: bool = True, battery_clean_ignore: bool = False, + # 正负极片取料/负极片吹气参数 + positive_plate_pick_mode: bool = False, + negative_plate_thickness_mode: bool = False, + thick_negative_plate_blow_time: int = 0, + thin_negative_plate_blow_time: int = 0, file_path: str = "/Users/sml/work" ) -> Dict[str, Any]: """ - 简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式 - - 此函数是 func_allpack_cmd 的增强版本,自动处理以下配置: + + 简化版组装函数,自动处理以下配置: - 负极片和隔膜的盘数及矩阵点位 - 枪头盒矩阵点位 - 铝箔垫片使用设置 @@ -1686,14 +1953,18 @@ def func_allpack_cmd_simp( dual_drop_start_timing: 二次滴液开始滴液时机 (False=正极片前, True=正极片后) assembly_type: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) assembly_pressure: 电池压制力 (N) - fujipian_panshu: 负极片盘数 - fujipian_juzhendianwei: 负极片矩阵点位 - gemopanshu: 隔膜盘数 - gemo_juzhendianwei: 隔膜矩阵点位 - qiangtou_juzhendianwei: 枪头盒矩阵点位 - lvbodian: 是否使用铝箔垫片 + ne_plate_num: 负极片盘数 + ne_plate_matrix: 负极片矩阵点位 + sep_plate_num: 隔膜盘数 + sep_plate_matrix: 隔膜矩阵点位 + tip_box_matrix: 枪头盒矩阵点位 + aluminum_foil: 是否使用铝箔垫片 battery_pressure_mode: 是否启用压力模式 battery_clean_ignore: 是否忽略电池清洁 + positive_plate_pick_mode: 正极片取料模式 (False=弹夹取料, True=水平取料) + negative_plate_thickness_mode: 负极片厚度模式 (False=厚负极片, True=薄负极片) + thick_negative_plate_blow_time: 厚负极片吹气时间 + thin_negative_plate_blow_time: 薄负极片吹气时间 file_path: 实验记录保存路径 Returns: @@ -1706,33 +1977,44 @@ def func_allpack_cmd_simp( dual_drop_first_volume = int(dual_drop_first_volume) assembly_type = int(assembly_type) assembly_pressure = int(assembly_pressure) - fujipian_panshu = int(fujipian_panshu) - fujipian_juzhendianwei = int(fujipian_juzhendianwei) - gemopanshu = int(gemopanshu) - gemo_juzhendianwei = int(gemo_juzhendianwei) - qiangtou_juzhendianwei = int(qiangtou_juzhendianwei) - - # 步骤1: 设置设备参数(原 qiming_coin_cell_code 的功能) + ne_plate_num = int(ne_plate_num) + ne_plate_matrix = int(ne_plate_matrix) + sep_plate_num = int(sep_plate_num) + sep_plate_matrix = int(sep_plate_matrix) + tip_box_matrix = int(tip_box_matrix) + thick_negative_plate_blow_time = int(thick_negative_plate_blow_time) + thin_negative_plate_blow_time = int(thin_negative_plate_blow_time) + + # 步骤1: 设置设备参数(负极片/隔膜/枪头盒/铝箔垫/压力模式等) logger.info("=" * 60) logger.info("设置设备参数...") - logger.info(f" 负极片盘数: {fujipian_panshu}, 矩阵点位: {fujipian_juzhendianwei}") - logger.info(f" 隔膜盘数: {gemopanshu}, 矩阵点位: {gemo_juzhendianwei}") - logger.info(f" 枪头盒矩阵点位: {qiangtou_juzhendianwei}") - logger.info(f" 铝箔垫片: {lvbodian}, 压力模式: {battery_pressure_mode}") + logger.info(f" 负极片盘数: {ne_plate_num}, 矩阵点位: {ne_plate_matrix}") + logger.info(f" 隔膜盘数: {sep_plate_num}, 矩阵点位: {sep_plate_matrix}") + logger.info(f" 枪头盒矩阵点位: {tip_box_matrix}") + logger.info(f" 铝箔垫片: {aluminum_foil}, 压力模式: {battery_pressure_mode}") logger.info(f" 压制力: {assembly_pressure}") logger.info(f" 忽略电池清洁: {battery_clean_ignore}") + logger.info(f" 正极片取料模式: {'水平取料' if positive_plate_pick_mode else '弹夹取料'}") + logger.info(f" 负极片厚度模式: {'薄负极片' if negative_plate_thickness_mode else '厚负极片'}") + logger.info(f" 厚负极片吹气时间: {thick_negative_plate_blow_time}, 薄负极片吹气时间: {thin_negative_plate_blow_time}") logger.info("=" * 60) # 写入基础参数到PLC - self.client.use_node('REG_MSG_NE_PLATE_NUM').write(fujipian_panshu) - self.client.use_node('REG_MSG_NE_PLATE_MATRIX').write(fujipian_juzhendianwei) - self.client.use_node('REG_MSG_SEPARATOR_PLATE_NUM').write(gemopanshu) - self.client.use_node('REG_MSG_SEPARATOR_PLATE_MATRIX').write(gemo_juzhendianwei) - self.client.use_node('REG_MSG_TIP_BOX_MATRIX').write(qiangtou_juzhendianwei) - self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian) + self.client.use_node('REG_MSG_NE_PLATE_NUM').write(ne_plate_num) + self.client.use_node('REG_MSG_NE_PLATE_MATRIX').write(ne_plate_matrix) + self.client.use_node('REG_MSG_SEPARATOR_PLATE_NUM').write(sep_plate_num) + self.client.use_node('REG_MSG_SEPARATOR_PLATE_MATRIX').write(sep_plate_matrix) + self.client.use_node('REG_MSG_TIP_BOX_MATRIX').write(tip_box_matrix) + self.client.use_node('COIL_ALUMINUM_FOIL').write(not aluminum_foil) self.client.use_node('REG_MSG_PRESS_MODE').write(not battery_pressure_mode) self.client.use_node('REG_MSG_BATTERY_CLEAN_IGNORE').write(battery_clean_ignore) + # 正极片取料模式 / 负极片厚度模式 / 负极片吹气时间 + self.client.use_node('COIL_POSITIVE_PLATE_PICK_MODE').write(positive_plate_pick_mode) + self.client.use_node('COIL_NEGATIVE_PLATE_THICKNESS_MODE').write(negative_plate_thickness_mode) + self.client.use_node('REG_MSG_THICK_NEGATIVE_PLATE_BLOW_TIME').write(thick_negative_plate_blow_time) + self.client.use_node('REG_MSG_THIN_NEGATIVE_PLATE_BLOW_TIME').write(thin_negative_plate_blow_time) + # 设置电解液双滴模式参数 self.client.use_node('COIL_ELECTROLYTE_DUAL_DROP_MODE').write(dual_drop_mode) self.client.use_node('REG_MSG_DUAL_DROP_FIRST_VOLUME').write(dual_drop_first_volume) @@ -1748,7 +2030,7 @@ def func_allpack_cmd_simp( logger.info("✓ 设备参数设置完成") - # 步骤2: 执行组装流程(复用 func_allpack_cmd 的主体逻辑) + # 步骤2: 执行组装流程(含断点续传) summary_csv_file = os.path.join(file_path, "duandian.csv") # 用于收集所有电池的数据 @@ -1839,27 +2121,32 @@ def func_allpack_cmd_simp( pole_weight = 0.0 battery_info = { - "battery_index": coin_num_N + 1, - "battery_barcode": battery_qr_code, - "electrolyte_barcode": electrolyte_qr_code, + "Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")), "open_circuit_voltage": open_circuit_voltage, "pole_weight": pole_weight, "assembly_time": self.data_assembly_time, "assembly_pressure": self.data_assembly_pressure, - "electrolyte_volume": self.data_electrolyte_volume + "target_assembly_pressure": getattr(self, "_target_assembly_pressure", ""), + "electrolyte_volume": self.data_electrolyte_volume, + "data_coin_type": getattr(self, "data_coin_type", 0), + "electrolyte_code": electrolyte_qr_code, + "coin_cell_code": battery_qr_code, } battery_data_list.append(battery_info) - print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}") + print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}") time.sleep(1) # 生成断点文件 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - with open(summary_csv_file, 'w', newline='', encoding='utf-8') as csvfile: - writer = csv.writer(csvfile) - writer.writerow(['elec_num','elec_use_num', 'elec_num_N', 'elec_use_num_N', 'coin_num_N', 'timestamp']) - writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp]) - csvfile.flush() + try: + with open(summary_csv_file, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile) + writer.writerow(['elec_num','elec_use_num', 'elec_num_N', 'elec_use_num_N', 'coin_num_N', 'timestamp']) + writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp]) + csvfile.flush() + except Exception as e: + logger.warning(f"[断点文件] 写入失败,跳过(不中断任务): {e}") coin_num_N += 1 self.coin_num_N = coin_num_N elec_use_num_N += 1 @@ -1867,7 +2154,8 @@ def func_allpack_cmd_simp( elec_use_num_N = 0 # 循环正常结束,则删除断点文件 - os.remove(summary_csv_file) + if os.path.exists(summary_csv_file): + os.remove(summary_csv_file) # 全部完成后等待依华发送完成信号 self.func_pack_send_finished_cmd() @@ -1876,6 +2164,7 @@ def func_allpack_cmd_simp( "success": True, "total_batteries": len(battery_data_list), "batteries": battery_data_list, + "assembly_data": battery_data_list, "summary": { "electrolyte_bottles_used": elec_num, "batteries_per_bottle": elec_use_num, @@ -1922,7 +2211,7 @@ def func_pack_device_stop(self) -> bool: def fun_wuliao_test(self) -> bool: #找到data_init中构建的2个物料盘 - liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8") + test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8") for i in range(16): battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2) battery._unilabos_state = { @@ -1932,7 +2221,7 @@ def fun_wuliao_test(self) -> bool: "electrolyte_volume": 20.0, "electrolyte_name": f"DP{i}" } - liaopan3.children[i].assign_child_resource(battery, location=None) + test_battery_plate.children[i].assign_child_resource(battery, location=None) ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] @@ -1952,6 +2241,9 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): os.makedirs(file_path) print(f"创建目录: {file_path}") + #本次读取会话的时间戳(用于 CSV 文件命名,整个会话共用同一个文件) + run_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + # 只要允许读取标志位为true,就持续运行该函数,直到触发停止条件 while self.allow_data_read: @@ -1962,12 +2254,10 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): while self.request_send_msg_status == False: print("waiting for send_msg_status to True") time.sleep(1) - #日期时间戳用于按天存放csv文件 - time_date = datetime.now().strftime("%Y%m%d") #秒级时间戳用于标记每一行电池数据 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - #生成输出文件的变量 - self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv") + #生成输出文件的变量(整个读取会话共用同一文件,文件名带运行时间戳) + self.csv_export_file = os.path.join(file_path, f"coin_cell_assembly_{run_timestamp}.csv") #接收信息 data_open_circuit_voltage = self.data_open_circuit_voltage @@ -1975,7 +2265,7 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): data_assembly_time = self.data_assembly_time data_assembly_pressure = self.data_assembly_pressure data_electrolyte_volume = self.data_electrolyte_volume - data_coin_num = self.data_coin_num + data_coin_type = self.data_coin_type # 电池类型(7或8种物料) data_electrolyte_code = self.data_electrolyte_code data_coin_cell_code = self.data_coin_cell_code # 电解液瓶位置 @@ -2089,7 +2379,7 @@ def func_read_data_and_output(self, file_path: str="/Users/sml/work"): writer.writerow([ timestamp, data_open_circuit_voltage, data_pole_weight, data_assembly_time, data_assembly_pressure, data_electrolyte_volume, - data_coin_num, data_electrolyte_code, data_coin_cell_code + data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正 ]) #立刻写入磁盘 csvfile.flush() @@ -2140,13 +2430,9 @@ def data_tips_inventory(self) -> int: if __name__ == "__main__": # 简单测试 - workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck")) - # workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False) + workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck")) # print(f"工作站创建成功: {workstation.deck.name}") # print(f"料盘数量: {len(workstation.deck.children)}") - workstation.func_pack_device_init() - workstation.func_pack_device_auto() - workstation.func_pack_device_start() - workstation.func_pack_send_bottle_num(16) - workstation.func_allpack_cmd(elec_num=16, elec_use_num=16, elec_vol=50, assembly_type=7, assembly_pressure=4200, file_path="/Users/calvincao/Desktop/work/Uni-Lab-OS-hhm") + workstation.coin_cell_init() + workstation.coin_cell_start(elec_num=16, elec_use_num=16, elec_vol=50, assembly_type=7, assembly_pressure=4200, file_path="/Users/calvincao/Desktop/work/Uni-Lab-OS-hhm") \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv index e46d1de5f..bdd6ed2d1 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv @@ -1,133 +1,147 @@ -Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, -COIL_SYS_START_CMD,BOOL,,,,coil,8010, -COIL_SYS_STOP_CMD,BOOL,,,,coil,8020, -COIL_SYS_RESET_CMD,BOOL,,,,coil,8030, -COIL_SYS_HAND_CMD,BOOL,,,,coil,8040, -COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050, -COIL_SYS_INIT_CMD,BOOL,,,,coil,8060, -COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700, -COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd -COIL_SYS_START_STATUS,BOOL,,,,coil,8210, -COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220, -COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230, -COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240, -COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250, -COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260, -COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500, -COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status -REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000, -REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num -REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol -REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type -REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure -REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num -REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage -REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004, -REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006, -REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008, -REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight -REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time -REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure -REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume -REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num -REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code() -REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code() -REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code() -REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure -REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content -REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content -UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720, -UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520, -REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496, -REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000, -UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730, -UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530, -REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8 -REG_UNILAB_INTERACT,BOOL,,,,coil,8450, -,,,,,coil,8320, -COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340, -REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440, -REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450, -REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480, -REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443, -REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453, -REG_MSG_PRESS_MODE,BOOL,,,,coil,8360, -,BOOL,,,,coil,8300, -,BOOL,,,,coil,8310, -COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320, -COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420, -,BOOL,,,,coil,8350, -COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370, -,BOOL,,,,coil,8380, -,BOOL,,,,coil,8390, -,BOOL,,,,coil,8400, -,BOOL,,,,coil,8410, -REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001, -COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430, -COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470, -REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460, -COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470, -COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480, -COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490, -COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常 -COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停 -COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停 -COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡 -COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料 -COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料 -COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料 -COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料 -COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料 -COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料 -COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料 -COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料 -COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料 -COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料 -COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常 -COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常 -COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常 -COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常 -COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常 -COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常 -COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常 -COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常 -COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常 -COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常 -COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常 -COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常 -COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常 -COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常 -COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常 -COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常 -COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警 -COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常 -COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误 -COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制 -COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大 -COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败 -COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败 -COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败 -COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败 -COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败 -COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常 -COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常 -COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常 -COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常 -COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常 -COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常 -COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常 -COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常 -COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常 -COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常 -COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常 -COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常 -COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常 -COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常 -COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常 -COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常 -COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常 -COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常 -COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常 -COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常 -COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常 -COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常 +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,,,coil,8010,Device start command +COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,Device stop command +COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,Device reset command +COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,Device manual-mode command +COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,Device auto-mode command +COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,Device initialization-mode command +COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,UNILAB recipe send-complete command +COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,UNILAB test-data receive-complete command +COIL_SYS_START_STATUS,BOOL,,,,coil,8210,Device running status +COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,Device stopped/paused status +COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,Device fault-reset status +COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,Device manual-mode status +COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,Device auto-mode status +COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,Device initialization-complete status +COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,Device request to receive recipe +COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,Device request to send test data +REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,Electrolyte use count per bottle +REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,UNILAB sent electrolyte bottle number +REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,Electrolyte aspiration volume +REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,Message assembly type +REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,Coin-cell assembly pressing force +REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,Current assembled coin-cell count +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,Current coin-cell voltage data +REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,Data axis X position +REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,Data axis Y position +REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,Data axis Z position +REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,Current coin-cell positive-electrode weight +REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,Current single-cell assembly time +REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,Current coin-cell assembly pressing force +REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,Current electrolyte filling volume +REG_DATA_COIN_TYPE,INT16,,,,hold_register,10018,Assembly parameter: electrode stacking mode (7/8) +REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,Current in-progress assembly cell count (R) +REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,Current completed assembly cell count (R) +REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,Electrolyte QR-code serial number +REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,Coin-cell QR-code serial number +REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,Material-stack re-inspection image code +REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,Glove-box pressure +REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,Glove-box water content +REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,Glove-box oxygen content +UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,UNILAB electrolyte-bottle-number send-complete command +UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,Device request to receive electrolyte bottle number +REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,Number of electrolyte bottles used +REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,Current assembled-cell count for electrolyte (R) +UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,UNILAB finished-data send-complete command +UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,Device request to receive finished data +REG_UNILAB_INTERACT,BOOL,,,,coil,8450,"UNILAB interaction ignore flag (false=use, true=ignore)" +COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,"Aluminum-foil material ignore flag (false=use, true=ignore)" +REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,Negative-electrode tray matrix position (initial position 0) +REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,Separator tray matrix position (initial position 0) +REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,Pipette-tip box matrix position (initial position 0) +REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,Set negative-electrode tray count +REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,Set separator tray count +REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,"Pressing mode (false=force-detection mode, true=distance mode)" +COIL_VISION_ALIGNMENT_IGNORE,BOOL,,,,coil,8300,"Vision alignment ignore flag (false=use, true=ignore)" +COIL_REINSPECTION_IGNORE,BOOL,,,,coil,8310,"Re-inspection ignore flag (false=use, true=ignore)" +COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,"Left glove-box chamber ignore flag (false=use, true=ignore)" +COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,"Right glove-box chamber ignore flag (false=use, true=ignore)" +COIL_VACUUM_DETECTION_IGNORE,BOOL,,,,coil,8350,"Vacuum detection ignore flag (false=use, true=ignore)" +COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,"Electrolyte dispensing mode (false=single-drop, true=dual-drop)" +COIL_POSITIVE_PLATE_WEIGHING_IGNORE,BOOL,,,,coil,8380,"Positive-electrode weighing ignore flag (false=use, true=ignore)" +COIL_ELECTRODE_ASSEMBLY_ORIENTATION,BOOL,,,,coil,8390,"Electrode assembly orientation (false=normal, true=inverted)" +COIL_PRESSING_CLEANING_IGNORE,BOOL,,,,coil,8400,"Pressing-cleaning ignore flag (false=use, true=ignore)" +COIL_MATERIAL_TRAY_LAYOUT_MODE,BOOL,,,,coil,8410,"Material-tray layout mode (false=horizontal, true=stacked)" +REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,Pipette discharge volume +COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,"Dual-drop suction timing (false=normal suction, true=suction first)" +COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,"Dual-drop start timing (false=before positive electrode, true=after positive electrode)" +REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,"Battery-cleaning ignore flag (false=use, true=ignore)" +COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,Material-search confirmation dialog displayed +COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,Initialization material-search confirmation button: Yes +COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,Initialization material-search confirmation button: No +COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,Alarm 100 - System error +COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,Alarm 101 - Emergency stop +COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,Alarm 111 - Glove-box emergency stop +COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,Alarm 112 - Glove-box grating blocked +COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,Alarm 160 - Pipette tip shortage +COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,Alarm 161 - Positive shell shortage +COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,Alarm 162 - Aluminum-foil spacer shortage +COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,Alarm 163 - Positive-electrode shortage +COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,Alarm 164 - Separator shortage +COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,Alarm 165 - Negative-electrode shortage +COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,Alarm 166 - Flat washer shortage +COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,Alarm 167 - Spring washer shortage +COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,Alarm 168 - Negative shell shortage +COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,Alarm 169 - Finished-cell bin full +COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,Alarm 201 - Servo axis 01 error +COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,Alarm 202 - Servo axis 02 error +COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,Alarm 203 - Servo axis 03 error +COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,Alarm 204 - Servo axis 04 error +COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,Alarm 205 - Servo axis 05 error +COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,Alarm 206 - Servo axis 06 error +COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,Alarm 207 - Servo axis 07 error +COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,Alarm 208 - Servo axis 08 error +COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,Alarm 209 - Servo axis 09 error +COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,Alarm 210 - Servo axis 10 error +COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,Alarm 211 - Servo axis 11 error +COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,Alarm 212 - Servo axis 12 error +COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,Alarm 213 - Servo axis 13 error +COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,Alarm 214 - Servo axis 14 error +COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,Alarm 250 - Other component error +COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,Alarm 251 - Pipette communication error +COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,Alarm 252 - Pipette alarm +COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,Alarm 256 - Electric gripper error +COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,Alarm 262 - RB alarm: unknown position error +COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,Alarm 263 - RB alarm: X/Y/Z parameter limit exceeded +COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,Alarm 264 - RB alarm: vision parameter error too large +COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,Alarm 265 - RB alarm: nozzle 1 pick failed +COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,Alarm 266 - RB alarm: nozzle 2 pick failed +COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,Alarm 267 - RB alarm: nozzle 3 pick failed +COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,Alarm 268 - RB alarm: nozzle 4 pick failed +COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,Alarm 269 - RB alarm: material tray pick failed +COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,Alarm 280 - RB collision error +COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,Alarm 290 - Vision-system communication error +COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,Alarm 291 - Vision alignment NG error +COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,Alarm 292 - Barcode-scanner communication error +COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,Alarm 310 - OCV transfer nozzle suction-vacuum error +COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,Alarm 311 - OCV transfer nozzle vacuum-release error +COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,Alarm 312 - Weighing transfer nozzle suction-vacuum error +COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,Alarm 313 - Weighing transfer nozzle vacuum-release error +COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,Alarm 340 - OCV nozzle transfer cylinder error +COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,Alarm 342 - OCV nozzle lift cylinder error +COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,Alarm 344 - OCV crimping cylinder error +COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,Alarm 350 - Weighing nozzle transfer cylinder error +COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,Alarm 352 - Weighing nozzle lift cylinder error +COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,Alarm 354 - Cleaning-cloth transfer cylinder error +COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,Alarm 356 - Cleaning-cloth press cylinder error +COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,Alarm 360 - Electrolyte bottle positioning cylinder error +COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,Alarm 362 - Pipette-tip box positioning cylinder error +COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,Alarm 364 - Reagent bottle gripper lift cylinder error +COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,Alarm 366 - Reagent bottle gripper cylinder error +COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,Alarm 370 - Press module blow cylinder error +COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,Alarm 151 - Electrolyte bottle position presence error +COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,Alarm 152 - Electrolyte bottle cap presence error +REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10 mm positive-electrode remaining material count (R) +REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12 mm positive-electrode remaining material count (R) +REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16 mm positive-electrode remaining material count (R) +REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,Aluminum-foil remaining material count (R) +REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,Positive shell remaining material count (R) +REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,Flat washer remaining material count (R) +REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,Negative shell remaining material count (R) +REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,Spring washer remaining material count (R) +REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,Finished-cell remaining capacity (R) +REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,Finished-cell NG-slot remaining capacity (R) +COIL_POSITIVE_PLATE_PICK_MODE,BOOL,,,,coil,8490,"Positive-electrode pick-up mode (true=horizontal pick-up, false=magazine pick-up)" +COIL_NEGATIVE_PLATE_THICKNESS_MODE,BOOL,,,,coil,8440,"Negative-electrode thickness mode (true=thin negative electrode, false=thick negative electrode)" +REG_MSG_THICK_NEGATIVE_PLATE_BLOW_TIME,INT16,,,,hold_register,514,Thick negative-electrode air-blow time +REG_MSG_THIN_NEGATIVE_PLATE_BLOW_TIME,INT16,,,,hold_register,516,Thin negative-electrode air-blow time diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b_old.csv b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b_old.csv new file mode 100644 index 000000000..d28b1b6d1 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b_old.csv @@ -0,0 +1,145 @@ +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,,,coil,8010, +COIL_SYS_STOP_CMD,BOOL,,,,coil,8020, +COIL_SYS_RESET_CMD,BOOL,,,,coil,8030, +COIL_SYS_HAND_CMD,BOOL,,,,coil,8040, +COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050, +COIL_SYS_INIT_CMD,BOOL,,,,coil,8060, +COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700, +COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd +COIL_SYS_START_STATUS,BOOL,,,,coil,8210, +COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220, +COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230, +COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240, +COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250, +COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260, +COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500, +COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status +REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000, +REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num +REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol +REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type +REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure +REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage +REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004, +REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006, +REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008, +REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight +REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time +REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure +REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume +REG_DATA_COIN_TYPE,INT16,,,,hold_register,10018,data_coin_type +REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,data_current_assembling_count +REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,data_current_completed_count +REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code() +REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code() +REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code() +REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure +REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content +REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content +UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720, +UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520, +REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496, +REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000, +UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730, +UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530, +REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8 +REG_UNILAB_INTERACT,BOOL,,,,coil,8450, +,,,,,coil,8320, +COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340, +REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440, +REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450, +REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480, +REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443, +REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453, +REG_MSG_PRESS_MODE,BOOL,,,,coil,8360, +,BOOL,,,,coil,8300, +,BOOL,,,,coil,8310, +COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320, +COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420, +,BOOL,,,,coil,8350, +COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370, +,BOOL,,,,coil,8380, +,BOOL,,,,coil,8390, +,BOOL,,,,coil,8400, +,BOOL,,,,coil,8410, +REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001, +COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430, +COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470, +REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460, +COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470, +COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480, +COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490, +COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,??100-???? +COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,??101-?? +COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,??111-????? +COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,??112-???????? +COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,??160-?????? +COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,??161-????? +COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,??162-????? +COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,??163-????? +COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,??164-???? +COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,??165-????? +COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,??166-???? +COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,??167-???? +COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,??168-????? +COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,??169-?????? +COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,??201-???01?? +COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,??202-???02?? +COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,??203-???03?? +COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,??204-???04?? +COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,??205-???05?? +COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,??206-???06?? +COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,??207-???07?? +COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,??208-???08?? +COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,??209-???09?? +COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,??210-???10?? +COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,??211-???11?? +COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,??212-???12?? +COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,??213-???13?? +COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,??214-???14?? +COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,??250-?????? +COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,??251-??????? +COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,??252-????? +COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,??256-???? +COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,??262-RB????????? +COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,??263-RB???X?Y?Z????? +COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,??264-RB??????????? +COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,??265-RB???1#?????? +COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,??266-RB???2#?????? +COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,??267-RB???3#?????? +COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,??268-RB???4#?????? +COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,??269-RB????????? +COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,??280-RB???? +COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,??290-???????? +COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,??291-????NG?? +COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,??292-??????? +COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,??310-??????????? +COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,??311-??????????? +COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,??312-??????????? +COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,??313-??????????? +COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,??340-???????????? +COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,??342-???????????? +COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,??344-?????????? +COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,??350-?????????? +COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,??352-?????????? +COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,??354-??????????? +COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,??356-??????????? +COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,??360-?????????? +COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,??362-??????????? +COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,??364-??????????? +COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,??366-????????? +COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,??370-?????????? +COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,??151-?????????? +COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,??152-????????? +REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10mm??????????R? +REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12mm??????????R? +REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16mm??????????R? +REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,?????????R? +REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,??????????R? +REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,?????????R? +REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,??????????R? +REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,?????????R? +REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,????????????R? +REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,????NG?????????R? diff --git a/unilabos/devices/workstation/implementation_plan.md b/unilabos/devices/workstation/implementation_plan.md new file mode 100644 index 000000000..86f2ee322 --- /dev/null +++ b/unilabos/devices/workstation/implementation_plan.md @@ -0,0 +1,88 @@ +# 物料系统标准化重构方案 + +根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。 + +## 拟议变更 + +### [参考] PRCXI9300 标准化模式 +#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py) +* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。 +* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。 + +### [组件] 台面 (Decks) +#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py) +* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。 +* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。 +* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。 + +#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py` +* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。 +* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。 + +### [组件] 容器类与弹夹 (Itemized Carriers & Magazines) +#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py) +* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。 +* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。 + +#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py) +* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。 + +#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py) +* 移除之前添加的 `idx is None` 兜底补丁。 +* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。 + +### [组件] 状态兼容性 (State Compatibility) +#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py) +* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。 +* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。 + +### [组件] 料盘 (Material Plates) +#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) +* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。 +* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。 + +### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer) +#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py] +* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。 +* **修复方案**: + 1. **目标端 (Yihua 侧)**: + * 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。 + * 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。 + 2. **转运端 (Bioyond 侧)**: + * 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。 + +### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring) +#### [修改] 寄存器直读与前端集成 +* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。 +* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。 +* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。 +* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。 +* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`): + * `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL) + * `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL) + * `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL) + * `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL) + * `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL) + * `平垫剩余物料数量(R)`:`read hold_register 530` (REAL) + * `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL) + * `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL) + * `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL) + * `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL) + +### [配置] JSON 配置文件 (Configuration Files) +#### [修改] 资源类型名称更新 +* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**: + * `yibin_electrolyte_config.json` + * `yibin_coin_cell_only_config.json` + * `yibin_electrolyte_only_config.json` + +## 验证计划 + +### 自动化测试 +* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。 +* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。 +* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。 + +### 手动验证 +* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。 +* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。 diff --git a/unilabos/devices/workstation/implementation_plan_v2.md b/unilabos/devices/workstation/implementation_plan_v2.md new file mode 100644 index 000000000..7e2233ef9 --- /dev/null +++ b/unilabos/devices/workstation/implementation_plan_v2.md @@ -0,0 +1,388 @@ +# 物料系统标准化重构方案 v2(增强版) + +> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。 +> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。 + +--- + +## 0. 核心原则(保持不变) + +"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。 + +--- + +## 1. 当前代码现状核查(2026-03-12) + +| 文件 | 计划要求 | 当前状态 | 是否完成 | +|---|---|---|---| +| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ | +| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ | +| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ | +| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ | +| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) | +| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ | +| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ | +| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ | +| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) | + +--- + +## 2. 执行顺序(含依赖关系) + +``` +阶段 A(底层资源类) + A1. magazine.py — 移除 klasses 填充 + A2. bottle_carriers.py — 移除瓶子填充 + +阶段 B(Deck 层) + B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名 + B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁 + +阶段 C(状态兼容) + C1. resource_tracker.py — 预填 Container 缺失键 + C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后) + +阶段 D(跨站转运修复) + D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽) + D2. bioyond_cell_workstation.py 修正 transfer 目标 + +阶段 E(配置与注册表) + E1. yibin_*.json 更新类名 + E2. registry/resources/bioyond/deck.yaml 更新类名 + E3. coin_cell_assembly.py 更新导入路径(若文件重命名) +``` + +--- + +## 3. 分阶段详细说明 + +--- + +### 阶段 A — 底层资源类 + +#### A1. `unilabos/resources/battery/magazine.py` + +**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。 + +**修改**: + +- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。 +- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。 + +```python +# 修改前(MagazineHolder_6_Cathode 举例) +klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], + +# 修改后 +klasses=None, +``` + +> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。 + +--- + +#### A2. `unilabos/resources/battery/bottle_carriers.py` + +**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。 + +**修改**:删除以下两行: + +```python +# 删除 +for i in range(12): + carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") +``` + +**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。 + +--- + +### 阶段 B — Deck 层重构 + +#### B1. `unilabos/resources/bioyond/decks.py` + +**改动列表**: + +1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck` +2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()` +3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用 +4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题) +5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步 + +**重构后初始化模式**: + +```python +class BioyondElectrolyteDeck(Deck): + def __init__(self, name: str = "YB_Deck", ...): + super().__init__(name=name, ...) + # ❌ 不调用 self.setup() + # PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源 + + def setup(self) -> None: + # 完整的子资源初始化逻辑保留在这里,只由工厂函数调用 + ... + +def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck: + deck = BioyondElectrolyteDeck(name=name) + deck.setup() # ✅ 工厂函数负责填充 + return deck +``` + +**同步修改**: +- `bioyond_cell_workstation.py` 第 20 行: + ```python + # 修改前 + from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck + # 修改后 + from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck + ``` +- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")` + +--- + +#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py` + +**改动列表**: + +1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck` +2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃) +3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用 +4. **删除** `CoincellDeck.deserialize` 重写方法 +5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支) + +```python +# 修改前(MaterialPlate.__init__ 片段) +if fill: + super().__init__(..., ordered_items=holes, ...) +else: + super().__init__(..., ordered_items=ordered_items, ...) + +# 修改后(始终走 "不填充" 路径) +super().__init__(..., ordered_items=ordered_items, ...) +# holes 的创建代码整体移入独立工厂方法 +``` + +**同步修改**: +- `coin_cell_assembly.py` 第 20 行导入: + ```python + # 修改前 + from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck + # 修改后 + from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck + ``` +- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")` +- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换) + +--- + +### 阶段 C — 状态兼容 + +#### C1. `unilabos/resources/resource_tracker.py` + +**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。 + +**修改**:在第 616 行前插入预处理: + +```python +# 在 load_all_state 调用前预填缺失键 +from pylabrobot.resources.container import Container as PLRContainer +for res_name, state in all_states.items(): + if state and isinstance(state, dict): + # Container 类型要求这两个键存在 + state.setdefault("liquid_history", []) + state.setdefault("pending_liquids", {}) + +plr_resource.load_all_state(all_states) +``` + +--- + +#### C2. `unilabos/resources/itemized_carrier.py` + +**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。 + +**修改**:删除第 182-190 行的兜底补丁: + +```python +# 删除以下整个 if 块 +if idx is None: + fallback_location = location if location is not None else Coordinate.zero() + super().assign_child_resource(resource, location=fallback_location, reassign=reassign) + return +``` + +**替代**:改为抛出带诊断信息的异常,便于后续问题排查: + +```python +if idx is None: + raise ValueError( + f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。" + f"已知槽位:{list(self.child_locations.keys())}," + f"传入坐标:{location}" + ) +``` + +--- + +### 阶段 D — 跨站转运修复 + +#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽 + +在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`): + +```python +# 在 setup() 末尾追加 +from pylabrobot.resources.resource_stack import ResourceStack + +vial_plate_dock = ResourceStack( + name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致 + direction="z", + resources=[], +) +self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0)) +``` + +> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。 + +--- + +#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标 + +**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。 + +**修改**: + +```python +# 修改前:创建虚拟目标资源 +target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...) +target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码 + +# 修改后:通过 ROS2/设备注册表查询真实资源 +# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象) +target_resource_obj = self._get_resource_from_device( + device_id=target_device, + resource_name=target_location +) +if target_resource_obj is None: + raise RuntimeError( + f"目标设备 {target_device} 中未找到资源 '{target_location}'," + f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位" + ) +``` + +> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。 + +--- + +### 阶段 E — 配置与注册表 + +#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json` + +全局替换以下字符串: + +| 旧值 | 新值 | +|---|---| +| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` | +| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` | +| `CoincellDeck` | `YihuaCoinCellDeck` | +| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` | + +--- + +#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**) + +当前第 25 行和第 37 行仍使用旧类名,需同步更新: + +```yaml +# 修改前 +BIOYOND_YB_Deck: + ... +CoincellDeck: + ... + +# 修改后 +BioyondElectrolyteDeck: + ... +YihuaCoinCellDeck: + ... +``` + +--- + +### 阶段 F — 物料余量监控集成(原计划第5节细化) + +**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。 + +#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法 + +参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法: + +```python +MATERIAL_REGISTER_MAP = { + "10mm正极片": (520, "REAL"), + "12mm正极片": (522, "REAL"), + "16mm正极片": (524, "REAL"), + "铝箔": (526, "REAL"), + "正极壳": (528, "REAL"), + "平垫": (530, "REAL"), + "负极壳": (532, "REAL"), + "弹垫": (534, "REAL"), + "成品容量": (536, "REAL"), + "成品NG容量": (538, "REAL"), +} + +def get_material_remaining(self, material_name: str) -> float: + """通过寄存器直读指定物料的剩余数量""" + if material_name not in MATERIAL_REGISTER_MAP: + raise KeyError(f"未知物料名称: {material_name}") + address, dtype = MATERIAL_REGISTER_MAP[material_name] + return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法 +``` + +#### F2. 前端 data view 集成 + +- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。 +- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。 + +--- + +## 4. 验证计划(细化) + +### 4.1 单元测试(自动化) + +```bash +# 序列化/反序列化往返测试 +python -m pytest unilabos/test/ -k "serial" -v + +# 特别检查以下错误消失: +# - ValueError: Resource '...' already assigned to deck +# - KeyError: 'liquid_history' +# - 重复 UUID 报错 +``` + +### 4.2 集成测试(手动) + +按以下顺序逐步验证,确保每步正常后再进行下一步: + +1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。 +2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。 +3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认: + - `electrolyte_buffer` 槽位正确接收分液瓶板。 + - `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。 +4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。 +5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。 + +--- + +## 5. 与原计划的差异对照 + +| 维度 | 原计划 | 本文档新增/修订 | +|---|---|---| +| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 | +| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 | +| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 | +| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 | +| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 | +| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 | +| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 | +| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 | diff --git a/unilabos/registry/ast_registry_scanner.py b/unilabos/registry/ast_registry_scanner.py index 80aba3e2c..eb4b5bf9b 100644 --- a/unilabos/registry/ast_registry_scanner.py +++ b/unilabos/registry/ast_registry_scanner.py @@ -20,6 +20,7 @@ import ast import hashlib import json +import re import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -32,12 +33,22 @@ MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_FILES = 1000 # 最大扫描文件数量 -_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增 +_CACHE_VERSION = 3 # 缓存格式版本号,格式变更时递增 +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # 合法的装饰器来源模块 _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" +def _validate_device_ids(device_ids: List[str]) -> None: + invalid_ids = [device_id for device_id in device_ids if not _DEVICE_ID_RE.fullmatch(device_id)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(device_id) for device_id in invalid_ids) + ) + + # --------------------------------------------------------------------------- # File-level cache helpers # --------------------------------------------------------------------------- @@ -258,8 +269,6 @@ def _collect_results(futures_dict: Dict): } - - # --------------------------------------------------------------------------- # File-level parsing # --------------------------------------------------------------------------- @@ -344,14 +353,16 @@ def _parse_file( did = device_args.get("id") or device_args.get("device_id") device_ids = [did] if did else [f"{module_path}:{node.name}"] + _validate_device_ids(device_ids) id_meta = device_args.get("id_meta") or {} + display_name = device_args.get("displayname") or device_args.get("display_name", "") base_meta = { "class_name": node.name, "module": f"{module_path}:{node.name}", "file_path": str(filepath).replace("\\", "/"), "category": device_args.get("category", []), "description": device_args.get("description", ""), - "display_name": device_args.get("display_name", ""), + "display_name": display_name, "icon": device_args.get("icon", ""), "version": device_args.get("version", "1.0.0"), "device_type": _detect_class_type(node, import_map), @@ -361,6 +372,7 @@ def _parse_file( "actions": class_body.get("actions", {}), "status_properties": class_body.get("status_properties", {}), "init_params": class_body.get("init_params", []), + "init_docstring": class_body.get("init_docstring"), "auto_methods": class_body.get("auto_methods", {}), "import_map": import_map, } @@ -368,9 +380,12 @@ def _parse_file( meta = dict(base_meta) meta["device_id"] = did overrides = id_meta.get(did, {}) - for key in ("handles", "description", "icon", "model", "hardware_interface"): + for key in ("handles", "description", "display_name", "displayname", "icon", "model", "hardware_interface"): if key in overrides: - meta[key] = overrides[key] + if key == "displayname": + meta["display_name"] = overrides[key] + else: + meta[key] = overrides[key] devices.append(meta) # --- @resource on classes --- @@ -497,7 +512,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]: return import_map - # --------------------------------------------------------------------------- # Decorator finding & argument extraction # --------------------------------------------------------------------------- @@ -768,6 +782,7 @@ def _extract_class_body( "actions": {}, # method_name -> action_info "status_properties": {}, # prop_name -> status_info "init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...] + "init_docstring": None, "auto_methods": {}, # method_name -> method_info (no @action decorator) } @@ -780,6 +795,7 @@ def _extract_class_body( # --- __init__ --- if method_name == "__init__": result["init_params"] = _extract_method_params(item, import_map) + result["init_docstring"] = ast.get_docstring(item) continue # --- Skip private/dunder --- @@ -825,6 +841,7 @@ def _extract_class_body( action_args.setdefault("placeholder_keys", {}) action_args.setdefault("always_free", False) action_args.setdefault("is_protocol", False) + action_args.setdefault("feedback_interval", 1.0) action_args.setdefault("description", "") action_args.setdefault("auto_prefix", False) action_args.setdefault("parent", False) diff --git a/unilabos/registry/decorators.py b/unilabos/registry/decorators.py index 25a2e57f8..927376e66 100644 --- a/unilabos/registry/decorators.py +++ b/unilabos/registry/decorators.py @@ -12,9 +12,10 @@ ) @device( - id="solenoid_valve.mock", + id="solenoid_valve_mock", category=["pump_and_valve"], description="模拟电磁阀设备", + displayname="模拟电磁阀", handles=[ InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH), OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH), @@ -46,11 +47,13 @@ def is_open(self): from enum import Enum from functools import wraps +import re from typing import Any, Callable, Dict, List, Optional, TypeVar from pydantic import BaseModel, ConfigDict, Field F = TypeVar("F", bound=Callable[..., Any]) +_DEVICE_ID_RE = re.compile(r"^[A-Za-z0-9_]+$") # --------------------------------------------------------------------------- # 枚举 @@ -248,6 +251,7 @@ def device( category: Optional[List[str]] = None, description: str = "", display_name: str = "", + displayname: str = "", icon: str = "", version: str = "1.0.0", handles: Optional[List[_DeviceHandleBase]] = None, @@ -270,7 +274,8 @@ def device( id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model) category: 设备分类标签列表 (必填) description: 设备描述 - display_name: 人类可读的设备显示名称,缺失时默认使用 id + displayname: 人类可读的设备显示名称,缺失时默认使用 id + display_name: 兼容旧代码的显示名称参数;新代码优先使用 displayname icon: 图标路径 version: 版本号 handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用) @@ -290,13 +295,21 @@ def device( else: raise ValueError("@device 必须提供 id 或 ids") + invalid_ids = [did for did in device_ids if not _DEVICE_ID_RE.fullmatch(did)] + if invalid_ids: + raise ValueError( + "@device id 只能包含英文、数字、下划线: " + + ", ".join(repr(did) for did in invalid_ids) + ) + if category is None: raise ValueError("@device category 必填") + resolved_display_name = displayname or display_name base_meta = { "category": category, "description": description, - "display_name": display_name, + "display_name": resolved_display_name, "icon": icon, "version": version, "handles": _device_handles_to_list(handles), @@ -343,6 +356,7 @@ def action( auto_prefix: bool = False, parent: bool = False, node_type: Optional["NodeType"] = None, + feedback_interval: Optional[float] = None, ): """ 动作方法装饰器 @@ -378,9 +392,16 @@ def AddProtocol(self): ... """ def decorator(func: F) -> F: - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) + import asyncio as _asyncio + + if _asyncio.iscoroutinefunction(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type @@ -399,6 +420,8 @@ def wrapper(*args, **kwargs): "auto_prefix": auto_prefix, "parent": parent, } + if feedback_interval is not None: + meta["feedback_interval"] = feedback_interval if node_type is not None: meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type) wrapper._action_registry_meta = meta # type: ignore[attr-defined] @@ -495,12 +518,14 @@ def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, overrides = id_meta[device_id] result = dict(base) result["device_id"] = device_id - for key in ["handles", "description", "icon", "model"]: + for key in ["handles", "description", "display_name", "displayname", "icon", "model"]: if key in overrides: val = overrides[key] if key == "handles" and isinstance(val, list): # handles 必须是 Handle 对象列表 result[key] = [h.to_registry_dict() for h in val] + elif key == "displayname": + result["display_name"] = val else: result[key] = val return result diff --git a/unilabos/registry/devices/Qone_nmr.yaml b/unilabos/registry/devices/Qone_nmr.yaml index 5c5f1f8a9..fd2761e48 100644 --- a/unilabos/registry/devices/Qone_nmr.yaml +++ b/unilabos/registry/devices/Qone_nmr.yaml @@ -51,14 +51,18 @@ Qone_nmr: properties: check_interval: default: 60 + description: 检查间隔时间(秒),默认60秒 type: string expected_count: default: 1 + description: 期望生成的.nmr文件数量,默认1个 type: string monitor_dir: + description: 要监督的目录路径,如果未指定则使用self.monitor_directory type: string stability_checks: default: 3 + description: 文件大小稳定性检查次数,默认3次 type: string required: [] type: object @@ -85,11 +89,14 @@ Qone_nmr: goal: properties: output_dir: + description: 输出目录(如果未指定,使用self.output_directory) type: string string_list: + description: 字符串列表 type: string txt_encoding: default: utf-8 + description: 文件编码 type: string required: - string_list @@ -151,6 +158,13 @@ Qone_nmr: additionalProperties: false properties: string: + description: '包含多个字符串的输入数据,支持两种格式: + + 1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30" + + 2. 换行分隔:如 "A 1 B 2 C 3 + + X 10 Y 20 Z 30"' type: string title: StrSingleInput_Goal type: object diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml index f57cd35c5..d4622388a 100644 --- a/unilabos/registry/devices/bioyond_cell.yaml +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -22,8 +22,7 @@ bioyond_cell: required: - xlsx_path type: object - result: - type: object + result: {} required: - goal title: auto_batch_outbound_from_xlsx参数 @@ -33,112 +32,7 @@ bioyond_cell: feedback: {} goal: {} goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 - xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + xlsx_path: D:\Uni-Lab-OS-Data\date\material_template.xlsx handles: {} placeholder_keys: {} result: {} @@ -148,323 +42,8 @@ bioyond_cell: feedback: {} goal: properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' - type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' - type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: - default: '' - type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: - default: '' - type: string - WH3_x3_y5_z3_15_materialType: - default: '' - type: string - WH3_x3_y5_z3_15_quantity: - default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' - type: string - WH4_x3_y3_z2_9_materialName: - default: '' - type: string - WH4_x3_y3_z2_9_materialType: - default: '' - type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: - default: '' - type: string - WH4_x4_y1_z1_4_materialName: - default: '' - type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 - type: number - WH4_x4_y2_z1_9_materialName: - default: '' - type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: - default: '' - type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 - type: number - WH4_x5_y2_z1_10_materialName: - default: '' - type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 - type: number xlsx_path: - default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + default: D:\Uni-Lab-OS-Data\date\material_template.xlsx type: string required: [] type: object @@ -491,19 +70,21 @@ bioyond_cell: goal: properties: material_names: + description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2] items: type: string type: array type_id: default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + description: 物料类型ID type: string warehouse_name: default: 粉末加样头堆栈 + description: 目标仓库名(用于取位置信息) type: string required: [] type: object - result: - type: object + result: {} required: - goal title: create_and_inbound_materials参数 @@ -527,20 +108,23 @@ bioyond_cell: goal: properties: location_name_or_id: + description: 具体库位名称(如 A01)或库位 UUID,由用户指定。 type: string material_name: + description: 物料名称(会优先匹配配置模板)。 type: string type_id: + description: 物料类型 ID(若为空则尝试从配置推断)。 type: string warehouse_name: + description: 需要入库的仓库名称;若为空则仅创建不入库。 type: string required: - material_name - type_id - warehouse_name type: object - result: - type: object + result: {} required: - goal title: create_material参数 @@ -561,34 +145,55 @@ bioyond_cell: goal: properties: mappings: - additionalProperties: - type: object type: object required: - mappings type: object - result: - items: - type: object - type: array + result: {} required: - goal title: create_materials参数 type: object type: UniLabJsonCommand auto-create_orders: + always_free: true feedback: {} goal: {} goal_default: xlsx_path: null + csv_export_path: '' handles: output: - data_key: total_orders data_source: executor data_type: integer handler_key: bottle_count - io_type: sink label: 配液瓶数 + - data_key: vial_plates + data_source: executor + data_type: array + handler_key: vial_plates_output + label: 分液瓶板列表 + - data_key: mass_ratios + data_source: executor + data_type: array + handler_key: mass_ratios_output + label: 配方信息列表 + - data_key: vial_bottle_positions + data_source: executor + data_type: array + handler_key: vial_bottle_positions_output + label: 分液瓶孔位映射(板/XY/归属订单) + - data_key: vial_321_source_pos + data_source: executor + data_type: object + handler_key: vial_321_source_pos_output + label: 5ml瓶板源坐标(321任务) + - data_key: vial_32_source_pos + data_source: executor + data_type: object + handler_key: vial_32_source_pos_output + label: 20ml瓶板源坐标(32任务) placeholder_keys: {} result: {} schema: @@ -598,204 +203,394 @@ bioyond_cell: goal: properties: xlsx_path: + title: 新建实验文件路径 + type: string + csv_export_path: + default: '' + title: "配液实验报告文件路径\n(需要主控电脑的路径)" + description: CSV导出目录路径,为空则不导出 type: string required: - xlsx_path type: object - result: - type: object + result: {} required: - goal title: create_orders参数 type: object type: UniLabJsonCommand - auto-create_orders_v2: + auto-create_orders_formulation: + always_free: true feedback: {} goal: {} goal_default: - xlsx_path: null + batch_id: '' + order_names: [] + bottle_type: 配液小瓶 + conductivity_bottle_count: 0 + conductivity_volume: 0.0 + formulation: null + coin_cell_volume: 0.0 + mix_time: [] + pouch_cell_volume: 0.0 + csv_export_path: '' handles: output: - data_key: total_orders data_source: executor data_type: integer handler_key: bottle_count - io_type: sink label: 配液瓶数 - placeholder_keys: {} + - data_key: vial_plates + data_source: executor + data_type: array + handler_key: vial_plates_output + label: 分液瓶板列表 + - data_key: mass_ratios + data_source: executor + data_type: array + handler_key: mass_ratios_output + label: 配方信息列表 + - data_key: vial_bottle_positions + data_source: executor + data_type: array + handler_key: vial_bottle_positions_output + label: 分液瓶孔位映射(板/XY/归属订单) + - data_key: vial_321_source_pos + data_source: executor + data_type: object + handler_key: vial_321_source_pos_output + label: 5ml瓶板源坐标(321任务) + - data_key: vial_32_source_pos + data_source: executor + data_type: object + handler_key: vial_32_source_pos_output + label: 20ml瓶板源坐标(32任务) + placeholder_keys: + formulation: unilabos_formulation result: {} schema: - description: 从Excel解析并创建实验(V2版本) + description: 配方批量输入版本的创建实验——通过前端配方组件输入物料配比,替代Excel导入 properties: feedback: {} goal: properties: - xlsx_path: - type: string - required: - - xlsx_path - type: object - result: - type: object - required: - - goal - title: create_orders_v2参数 - type: object - type: UniLabJsonCommand - auto-create_sample: - feedback: {} - goal: {} - goal_default: - board_type: null - bottle_type: null - location_code: null - name: null - warehouse_name: 手动堆栈 - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: - board_type: + batch_id: + default: '' + title: 批次 + description: 批次ID,为空则自动生成时间戳,每一个批次需要修改 type: string + order_names: + default: [] + title: 订单名称 + description: 配方ID/订单编号列表,与formulation一一对应,用于填写DoE撒点编号等自定义标识,便于后续追溯。未填则自动生成。 + items: + type: string + type: array bottle_type: + default: 配液小瓶 + title: 配液瓶类型 + description: 填写配液小瓶/配液大瓶 type: string - location_code: - type: string - name: - type: string - warehouse_name: - default: 手动堆栈 + conductivity_bottle_count: + default: 0 + title: 电导测试分液瓶数 + description: 电导测试可选择1-4瓶 + type: integer + conductivity_volume: + default: 0.0 + title: 电导测试分液体积(mL) + description: 建议分8ml,匹配电导站电极插入深度 + type: number + formulation: + description: 配方列表,每个元素代表一个订单(一瓶) + items: + properties: + materials: + description: 物料列表 + items: + properties: + mass: + description: 质量(g) + type: number + name: + description: 物料名称 + type: string + required: + - name + - mass + type: object + type: array + order_name: + description: 配方名称(可选) + type: string + required: + - materials + type: object + type: array + coin_cell_volume: + default: 0.0 + title: 扣电组装分液体积(mL) + description: 扣电组装选择5mL分液瓶进行分液 + type: number + mix_time: + default: [] + title: 混合时间(s) + description: 混匀时间列表(秒),与formulation一一对应 + items: + type: integer + type: array + pouch_cell_volume: + default: 0.0 + title: 软包组装分液体积(mL) + description: 软包电池注液组装分液体积 + type: number + csv_export_path: + default: '' + title: "配液实验报告文件路径\n(需要主控电脑的路径)" + description: CSV导出目录路径,为空则不导出 type: string required: - - name - - board_type - - bottle_type - - location_code - type: object - result: + - formulation type: object + result: {} required: - goal - title: create_sample参数 + title: create_orders_formulation参数 type: object type: UniLabJsonCommand - auto-order_list_v2: + auto-export_order_report: + always_free: true feedback: {} goal: {} goal_default: - beginTime: '' - endTime: '' - filter: '' - pageCount: 1 - skipCount: 0 - sorting: '' - status: '' - timeType: '' + order_codes: '' + csv_export_path: '' handles: {} placeholder_keys: {} result: {} schema: - description: '' + description: 按 BSO 订单号现查 LIMS 导出配液实验报告 CSV,不依赖 order_finish 推送,可补历史批次 properties: feedback: {} goal: properties: - beginTime: - default: '' - type: string - endTime: - default: '' - type: string - filter: - default: '' + order_codes: + title: BSO订单号 + description: "支持单个 BSO2026072800006;区间 BSO2026072800006-BSO2026072800029\ + \ 或尾号简写 BSO2026072800006-29;逗号跳选 BSO2026072800006,BSO2026072800012-15;跨批次请用逗号分开写" type: string - pageCount: - default: 1 - type: integer - skipCount: - default: 0 - type: integer - sorting: - default: '' + csv_export_path: + title: "实验报告导出目录\n(需要主控电脑的路径)" + description: CSV 导出目录,报告 Excel 存到其下 reports/ 子目录 type: string - status: - default: '' - type: string - timeType: - default: '' - type: string - required: [] - type: object - result: + required: + - order_codes + - csv_export_path type: object + result: {} required: - goal - title: order_list_v2参数 + title: export_order_report参数 type: object type: UniLabJsonCommand - auto-process_order_finish_report: + auto-conductivity_test_external: + always_free: true feedback: {} goal: {} goal_default: - report_request: null - used_materials: null - handles: {} + xlsx_path: null + validate_barcode: true + start_scheduler: true + wait_for_finish: true + wait_timeout_seconds: 36000 + csv_export_path: '' + handles: + output: + - data_key: total_entries + data_source: executor + data_type: integer + handler_key: conductivity_total_entries + label: 电导实验条目数 + - data_key: validated_barcodes + data_source: executor + data_type: array + handler_key: conductivity_validated_barcodes + label: 已校验通过的板条码列表 + - data_key: conductivity_results + data_source: executor + data_type: array + handler_key: conductivity_results_output + label: 电导结果列表 placeholder_keys: {} result: {} schema: - description: '' + description: 5号电导工作站外部进样新建实验(手动填 Excel 模板批量提交)。Excel 列:算法批次ID/配方ID/创建日期/板BarCode/内部瓶位置X/内部瓶位置Y/温控点。提交前会按板条码批量校验物料系统是否已建对应分液板。默认 wait_for_finish=true:建单后阻塞等所有电导单完成,再按 orderId 调 conductivity-order-result 拉取电导率/温度,导出 External_conductivity_*.csv。 properties: feedback: {} goal: properties: - report_request: - type: string - used_materials: + xlsx_path: + title: 电导实验模板路径 + description: 电导实验模板 xlsx 路径 + type: string + validate_barcode: + default: true + title: 条码验证 + description: 是否在提交前校验 plateBarCode 在物料系统中存在 + type: boolean + start_scheduler: + default: true + title: 启动调度 + description: 提交订单前是否先启动调度(外部进样路径没有上游管调度,默认 true 确保订单进队列后立即消费) + type: boolean + wait_for_finish: + default: true + title: 等待完成 + description: 是否阻塞等所有电导单完成并拉取结果。true=节点运行到测试完成再导出 CSV;false=fire-and-forget,POST 完即返回。 + type: boolean + wait_timeout_seconds: + default: 36000 + title: 等待超时时间(秒) + description: 单个电导单 wait 超时秒数,仅 wait_for_finish=true 时生效。默认 36000 秒(10 小时)。 + type: integer + csv_export_path: + default: '' + title: 电导结果CSV导出路径 + description: 电导结果 CSV 导出目录(External_conductivity_年月日时分秒.csv),为空则不导出。 type: string required: - - report_request + - xlsx_path type: object result: {} required: - goal - title: process_order_finish_report参数 + title: conductivity_test_external参数 type: object type: UniLabJsonCommand - auto-process_sample_finish_report: + auto-conductivity_test_inline: + always_free: true feedback: {} goal: {} goal_default: - report_request: null - handles: {} + vial_plates: null + temperature_points: null + vial_bottle_positions: null + mass_ratios: null + validate_barcode: true + wait_for_finish: true + wait_timeout_seconds: 36000 + csv_export_path: '' + handles: + input: + - data_key: '@this@@@vial_plates' + data_source: handle + data_type: array + handler_key: vial_plates_input + label: 分液瓶板列表 + - data_key: '@this@@@vial_bottle_positions' + data_source: handle + data_type: array + handler_key: vial_bottle_positions_input + label: 分液瓶孔位映射(板/XY/归属订单) + - data_key: '@this@@@mass_ratios' + data_source: handle + data_type: array + handler_key: mass_ratios_input + label: 配方信息列表 + output: + - data_key: conductivity_results + data_source: executor + data_type: array + handler_key: conductivity_results_output + label: 电导结果列表 placeholder_keys: {} result: {} schema: - description: '' + description: 5号电导工作站自动新建实验(配液站传过来)。直接接收上游配液 handle 输出的 vial_plates(含 batch_id/orderCode/barCode/materialId)、vial_bottle_positions(分液瓶孔位映射,板/XY/归属订单)与 mass_ratios(配方信息)。上游会把订单产出的全部20ml分液瓶板都传过来,本 action 提交前会查 warehouse-info(5号自动传递窗) 按 materialId 取交集,只对已转运到5号站的板建电导单(需在转运后调用)。每瓶的归属订单与瓶位(X,Y)直接取自 vial_bottle_positions(按 detailMaterialId→orderCode 权威映射,已过滤空占位孔),不再现场按 associateId 反查。同一块板上的所有分液瓶共享同一温度点。默认 wait_for_finish=true:节点会阻塞到所有 LIMS 新建电导单收到 /report/order_finish 推送(数小时级),完成后按 orderId 调 conductivity-order-result 拉取结果,并在同一目录导出两份 CSV:Inline_conductivity_*.csv(电导-only)与 Formulation_Conductivity_*.csv(按分液瓶二维码合并配方信息)。 properties: feedback: {} goal: properties: - report_request: + vial_plates: + title: 分液瓶板 + description: 无需填写 + items: + properties: + barCode: + type: string + batch_id: + type: string + materialId: + type: string + orderCode: + type: string + required: + - batch_id + - materialId + - barCode + - orderCode + type: object + type: array + temperature_points: + title: 温度点 + description: "温度点列表(℃)。\n需打开温控模块提前预热。\n业务规则:一块板共享一个温度,板上所有分液瓶都用该温度。最高设置4个温度点:温控模块ABC只能做5℃以上温控,-10℃至5℃需要用温控模块D" + items: + type: number + type: array + vial_bottle_positions: + title: 分液瓶位置 + description: 无需填写 + items: + type: object + type: array + mass_ratios: + title: 质量比 + description: 无需填写 + items: + type: object + type: array + validate_barcode: + default: true + title: 条码验证 + description: 无需填写 + type: boolean + wait_for_finish: + default: true + title: 等待完成 + description: 是否阻塞等所有LIMS新建电导单收到/report/order_finish 推送。true=与配液一致,节点会一直运行到测试完成;false=fire-and-forget,POST完即返回,剩下交给奔曜软件 + type: boolean + wait_timeout_seconds: + default: 36000 + title: 等待超时时间(秒) + description: 单个电导wait超时秒数,仅 wait_for_finish=true时生效。默认36000秒(10小时),与配液一致 + type: integer + csv_export_path: + default: '' + title: 电导结果CSV导出路径 + description: 电导结果CSV导出目录 type: string required: - - report_request + - vial_plates + - temperature_points + - vial_bottle_positions type: object result: {} required: - goal - title: process_sample_finish_report参数 + title: conductivity_test_inline参数 type: object type: UniLabJsonCommand - auto-process_step_finish_report: + auto-create_sample: feedback: {} goal: {} goal_default: - report_request: null + board_type: null + bottle_type: null + location_code: null + name: null + warehouse_name: 手动传递窗右 + barcode: '' handles: {} placeholder_keys: {} result: {} @@ -805,99 +600,265 @@ bioyond_cell: feedback: {} goal: properties: - report_request: + board_type: + description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" + type: string + bottle_type: + description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" + type: string + location_code: + description: 库位编号,例如 "A01" + type: string + name: + description: 物料名称 + type: string + warehouse_name: + default: 手动传递窗右 + description: 仓库名称,默认为 "手动传递窗右",支持 "自动堆栈-左"、"自动堆栈-右" 等 + type: string + barcode: + default: '' + description: 物料条码(可选),填写后发给奔曜;不填则为空字符串 type: string required: - - report_request + - name + - board_type + - bottle_type + - location_code type: object result: {} required: - goal - title: process_step_finish_report参数 + title: create_sample参数 type: object type: UniLabJsonCommand - auto-report_material_change: + auto-wait_orderlist_idle: feedback: {} goal: {} goal_default: - material_obj: null + lookback_minutes: 3600 + poll_interval: 10.0 + timeout: 86400 + scenario: all + workflow_name_keyword: '' + page_size: 50 + max_pages: 40 + check_scheduler: true + claim_timeout: 7200.0 + xlsx_path: '' + task_hint: '' handles: {} placeholder_keys: {} result: {} schema: - description: '' + description: 多任务互锁门控:阻塞到「本工站可以安全发起下一批配液」为止。放在 create_orders 之前,整条链路只调一次(建单后的完成等待由 create_orders 内部负责)。放行需同时满足:调度处于 Running;窗口内无失败(90)配液单;窗口内无执行中(60)配液单;上一批缓存为空或已全部到 80/100;能占据放行凭证(上一个放行者已完成建单交接)。凭证填的是门控返回到 LIMS 把订单登记成 60 之间的空窗,没有它两批会同时建单击穿 0016(1位)/0018(4位) 容量。 properties: feedback: {} goal: properties: - material_obj: - type: object - required: - - material_obj + lookback_minutes: + default: 3600 + title: 回溯窗口(分钟) + description: 默认 3600(60 小时),覆盖多批 1–8 瓶的长周期与隔夜任务。现场遇到旧失败单锁站时可临时调小跨过去——没有失败确认机制,一次失败会锁到该单滑出窗口为止,门控 blocked 时会打印具体滑出钟点。 + type: integer + poll_interval: + default: 10.0 + description: 未空闲时的轮询间隔(秒) + type: number + timeout: + default: 86400 + description: 整次门控最长等待(秒),默认 86400(24 小时) + type: integer + scenario: + default: all + title: 场景键 + description: "可选 all/coin/pouch/cond。⚠️ 当前只影响过滤范围与分场景统计,不收窄阻断作用域——配液共用同一台奔曜分液硬件,两批物理上不可能并行,收窄会把第二批放进去。命名陷阱:配液分液(小瓶)(电导) 其实是扣电路,名字里的「电导」是 LIMS 历史遗留。" + enum: + - all + - coin + - pouch + - cond + type: string + workflow_name_keyword: + default: '' + title: 显式过滤关键字 + description: 非空时优先于 scenario。留空则按 scenario 查表,默认「配液」。 + type: string + page_size: + default: 50 + description: order-list 单页条数(映射到接口的 pageCount,注意它是每页条数不是页数) + type: integer + max_pages: + default: 40 + description: 翻页上限,防异常数据把门控拖死。命中即短路,一般翻不到上限。 + type: integer + check_scheduler: + default: true + title: 检查调度状态 + description: 进入时先查 scheduler-status。Init/Stop 与 ErrorPause/ErrorStop 直接阻断;Pause 若带建单失败标记也直接阻断(那是自动暂停的,等下去不会好),否则轮询等待。不检查的话调度没起来时建单会成功但没人消费,最后表现成「配液超时失败」,极难定位。 + type: boolean + claim_timeout: + default: 7200.0 + title: 放行凭证兜底作废时长(秒) + description: 只用于兜进程崩溃这类未知情况;预检抛错、建单失败这两条已知路径由 create_orders 主动释放。必须大于后续 stack_inquiry_window_5_free 的等待时长,因为门控排在它前面。 + type: number + xlsx_path: + default: '' + title: 本轮配方 Excel 路径(可选) + description: 给了就顺带做一次「绝对不够」预检——只拦独占也不够的情况,不做在飞批次扣减因此不可能误拦。作用是把缺料从排队 40 分钟之后提前到排队第一分钟。主机制仍是提交前的 precheck_reagents_total。 + type: string + formulation: + title: 本轮配方(可选) + description: 同 xlsx_path,二选一。形状同 create_orders_formulation 的 formulation 入参。 + items: + type: object + type: array + task_hint: + default: '' + title: 任务标识 + description: 仅用于日志与凭证归属,便于多任务时看清是谁在持有放行权 + type: string + required: [] type: object result: + properties: + batch: + description: 放行前的互锁缓存快照(上一批 orderCode) + items: + type: string + type: array + elapsed_seconds: + type: number + failed: + description: 窗口内失败(90)配液单摘要 + items: + type: object + type: array + lookback_minutes: + type: integer + matched_keywords: + description: 实际用于过滤的关键字 + items: + type: string + type: array + poll_count: + type: integer + reason: + type: string + running: + description: 窗口内执行中(60)配液单摘要 + items: + type: object + type: array + scenario: + type: string + scheduler_status: + description: 本次查到的调度状态 + type: string + status: + description: idle / blocked / timeout;blocked 与 timeout 会抛异常阻止后续节点 + type: string type: object required: - goal - title: report_material_change参数 + title: wait_orderlist_idle参数 type: object type: UniLabJsonCommand - auto-resource_tree_transfer: + auto-order_report_v2: feedback: {} goal: {} goal_default: - old_parent: null - parent_resource: null - plr_resource: null + order_id: null handles: {} placeholder_keys: {} result: {} schema: - description: '' + description: 查询单个订单的实验报告详情 properties: feedback: {} goal: properties: - old_parent: - type: object - parent_resource: - type: object - plr_resource: - type: object + order_id: + description: 订单 GUID。注意必须传 orderId,传 orderCode 接口会返回 400。 + type: string required: - - old_parent - - plr_resource - - parent_resource + - order_id + type: object + result: type: object - result: {} required: - goal - title: resource_tree_transfer参数 + title: order_report_v2参数 type: object type: UniLabJsonCommand - auto-scheduler_continue: + auto-precheck_reagents_total: feedback: {} goal: {} - goal_default: {} + goal_default: + raise_on_shortage: false handles: {} placeholder_keys: {} result: {} schema: - description: '' + description: 多批次总量预检(只读,不建单、不改调度状态):把所有待提交轮次的试剂需求汇总一次,与在库余量比对。放在提交任何 notebook 之前——此刻没有任何在飞批次,库存不含未决占用,因此不需要扣减、也不用判断 LIMS 是建单即锁还是消耗才扣,一次「在库 ≥ 总需求」就定论。它是建议性的而非保证(从检查到最后一轮跑完,中间可能有别人提交的任务或手工取用),所以 create_orders 内的建单前预检仍是最终权威判据。多 notebook 时必须在提交第一个之前跨全部 notebook 汇总一次,每个各算各的等于没做。 properties: feedback: {} goal: - properties: {} + properties: + formulations: + title: 多轮 formulation + description: 每个元素是一轮的 formulation(形状同 create_orders_formulation 的入参) + items: + items: + type: object + type: array + type: array + xlsx_paths: + title: 多轮 Excel 路径 + description: 每个元素是一轮的 Excel 路径(形状同 create_orders 的入参)。与 formulations 可同时给,会一并汇总。 + items: + type: string + type: array + raise_on_shortage: + default: false + title: 缺料时抛错 + description: 默认 false——它是提交侧的查询,由调用方决定要不要提交。置 true 可当工作流节点用。 + type: boolean required: [] type: object result: + properties: + items: + description: 逐试剂的 name/required/available/gap/status(status 为 ok/short/not_found/skipped_unit) + items: + type: object + type: array + ok: + description: 总需求是否都够 + type: boolean + rounds: + description: 汇总了几轮 + type: integer + shortages: + description: 缺口描述,缺料时即补料清单 + items: + type: string + type: array + skipped: + description: 因单位非质量单位而跳过比对的项 + items: + type: string + type: array + stock_query_ok: + description: 库存查询是否成功。false 时结论不可用,交由建单前预检兜底 + type: boolean type: object required: - goal - title: scheduler_continue参数 + title: precheck_reagents_total参数 type: object type: UniLabJsonCommand - auto-scheduler_reset: + auto-clear_interlock_failure: feedback: {} goal: {} goal_default: {} @@ -905,7 +866,7 @@ bioyond_cell: placeholder_keys: {} result: {} schema: - description: '' + description: 清除建单失败标记。建单未全部成功时会自动 scheduler_pause() 并记下标记,门控见到「Pause + 标记」会直接阻断并报真实原因(而不是当成人工临时暂停一路轮询到 24 小时超时)。scheduler_continue / scheduler_start 内部已自动清除,这里单独暴露一个入口供调试,或人工在别处恢复调度后手动清除。 properties: feedback: {} goal: @@ -913,927 +874,583 @@ bioyond_cell: required: [] type: object result: + properties: + cleared: + description: 被清除的失败标记内容 + type: object + status: + description: cleared / noop + type: string type: object required: - goal - title: scheduler_reset参数 + title: clear_interlock_failure参数 type: object type: UniLabJsonCommand - auto-scheduler_start: + auto-multitask_probe: feedback: {} goal: {} - goal_default: {} + goal_default: + hold_ms: 1000 + task_no: 1 handles: {} placeholder_keys: {} result: {} schema: - description: '' + description: 多任务调度探测。同一动作提交多个任务并填不同 task_no,用返回的 start_time_ms 判断从前往后还是从后往前执行。 properties: feedback: {} goal: - properties: {} - required: [] + properties: + hold_ms: + default: 1000 + description: 占用时长(毫秒),便于拉开各任务开始时间差 + type: integer + task_no: + default: 1 + description: 任务编号,多任务时为每个实例填不同编号 + type: integer + required: + - task_no type: object result: + properties: + end_time: + description: 结束时间(本地,毫秒精度) + type: string + end_time_ms: + description: 结束时间 Unix 毫秒时间戳 + type: integer + hold_ms: + type: integer + message: + type: string + start_time: + description: 实际开始执行时间(本地,毫秒精度) + type: string + start_time_ms: + description: 开始时间 Unix 毫秒时间戳,用于比较执行顺序 + type: integer + success: + type: boolean + task_no: + type: integer type: object required: - goal - title: scheduler_start参数 + title: multitask_probe参数 type: object type: UniLabJsonCommand - auto-scheduler_start_and_auto_feeding: + auto-order_list_v2: feedback: {} goal: {} goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 - xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + beginTime: '' + endTime: '' + filter: '' + pageCount: 1 + skipCount: 0 + sorting: '' + status: '' + timeType: '' handles: {} placeholder_keys: {} result: {} schema: - description: 组合函数:先启动调度,然后执行自动化上料 + description: '' properties: feedback: {} goal: properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' - type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' - type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: + beginTime: default: '' type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: + endTime: default: '' type: string - WH3_x3_y5_z3_15_materialType: + filter: default: '' type: string - WH3_x3_y5_z3_15_quantity: + pageCount: + default: 1 + type: integer + skipCount: default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' - type: string - WH4_x3_y3_z2_9_materialName: - default: '' - type: string - WH4_x3_y3_z2_9_materialType: - default: '' - type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: - default: '' - type: string - WH4_x4_y1_z1_4_materialName: - default: '' - type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 - type: number - WH4_x4_y2_z1_9_materialName: + type: integer + sorting: default: '' type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: + status: default: '' type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 - type: number - WH4_x5_y2_z1_10_materialName: + timeType: default: '' type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 - type: number - xlsx_path: - default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx - type: string required: [] type: object - result: - type: object + result: {} required: - goal - title: scheduler_start_and_auto_feeding参数 + title: order_list_v2参数 type: object type: UniLabJsonCommand - auto-scheduler_start_and_auto_feeding_v2: + auto-process_order_finish_report: feedback: {} goal: {} goal_default: - WH3_x1_y1_z3_1_materialId: '' - WH3_x1_y1_z3_1_materialType: '' - WH3_x1_y1_z3_1_quantity: 0 - WH3_x1_y2_z3_4_materialId: '' - WH3_x1_y2_z3_4_materialType: '' - WH3_x1_y2_z3_4_quantity: 0 - WH3_x1_y3_z3_7_materialId: '' - WH3_x1_y3_z3_7_materialType: '' - WH3_x1_y3_z3_7_quantity: 0 - WH3_x1_y4_z3_10_materialId: '' - WH3_x1_y4_z3_10_materialType: '' - WH3_x1_y4_z3_10_quantity: 0 - WH3_x1_y5_z3_13_materialId: '' - WH3_x1_y5_z3_13_materialType: '' - WH3_x1_y5_z3_13_quantity: 0 - WH3_x2_y1_z3_2_materialId: '' - WH3_x2_y1_z3_2_materialType: '' - WH3_x2_y1_z3_2_quantity: 0 - WH3_x2_y2_z3_5_materialId: '' - WH3_x2_y2_z3_5_materialType: '' - WH3_x2_y2_z3_5_quantity: 0 - WH3_x2_y3_z3_8_materialId: '' - WH3_x2_y3_z3_8_materialType: '' - WH3_x2_y3_z3_8_quantity: 0 - WH3_x2_y4_z3_11_materialId: '' - WH3_x2_y4_z3_11_materialType: '' - WH3_x2_y4_z3_11_quantity: 0 - WH3_x2_y5_z3_14_materialId: '' - WH3_x2_y5_z3_14_materialType: '' - WH3_x2_y5_z3_14_quantity: 0 - WH3_x3_y1_z3_3_materialId: '' - WH3_x3_y1_z3_3_materialType: '' - WH3_x3_y1_z3_3_quantity: 0 - WH3_x3_y2_z3_6_materialId: '' - WH3_x3_y2_z3_6_materialType: '' - WH3_x3_y2_z3_6_quantity: 0 - WH3_x3_y3_z3_9_materialId: '' - WH3_x3_y3_z3_9_materialType: '' - WH3_x3_y3_z3_9_quantity: 0 - WH3_x3_y4_z3_12_materialId: '' - WH3_x3_y4_z3_12_materialType: '' - WH3_x3_y4_z3_12_quantity: 0 - WH3_x3_y5_z3_15_materialId: '' - WH3_x3_y5_z3_15_materialType: '' - WH3_x3_y5_z3_15_quantity: 0 - WH4_x1_y1_z1_1_materialName: '' - WH4_x1_y1_z1_1_quantity: 0.0 - WH4_x1_y1_z2_1_materialName: '' - WH4_x1_y1_z2_1_materialType: '' - WH4_x1_y1_z2_1_quantity: 0.0 - WH4_x1_y1_z2_1_targetWH: '' - WH4_x1_y2_z1_6_materialName: '' - WH4_x1_y2_z1_6_quantity: 0.0 - WH4_x1_y2_z2_4_materialName: '' - WH4_x1_y2_z2_4_materialType: '' - WH4_x1_y2_z2_4_quantity: 0.0 - WH4_x1_y2_z2_4_targetWH: '' - WH4_x1_y3_z1_11_materialName: '' - WH4_x1_y3_z1_11_quantity: 0.0 - WH4_x1_y3_z2_7_materialName: '' - WH4_x1_y3_z2_7_materialType: '' - WH4_x1_y3_z2_7_quantity: 0.0 - WH4_x1_y3_z2_7_targetWH: '' - WH4_x2_y1_z1_2_materialName: '' - WH4_x2_y1_z1_2_quantity: 0.0 - WH4_x2_y1_z2_2_materialName: '' - WH4_x2_y1_z2_2_materialType: '' - WH4_x2_y1_z2_2_quantity: 0.0 - WH4_x2_y1_z2_2_targetWH: '' - WH4_x2_y2_z1_7_materialName: '' - WH4_x2_y2_z1_7_quantity: 0.0 - WH4_x2_y2_z2_5_materialName: '' - WH4_x2_y2_z2_5_materialType: '' - WH4_x2_y2_z2_5_quantity: 0.0 - WH4_x2_y2_z2_5_targetWH: '' - WH4_x2_y3_z1_12_materialName: '' - WH4_x2_y3_z1_12_quantity: 0.0 - WH4_x2_y3_z2_8_materialName: '' - WH4_x2_y3_z2_8_materialType: '' - WH4_x2_y3_z2_8_quantity: 0.0 - WH4_x2_y3_z2_8_targetWH: '' - WH4_x3_y1_z1_3_materialName: '' - WH4_x3_y1_z1_3_quantity: 0.0 - WH4_x3_y1_z2_3_materialName: '' - WH4_x3_y1_z2_3_materialType: '' - WH4_x3_y1_z2_3_quantity: 0.0 - WH4_x3_y1_z2_3_targetWH: '' - WH4_x3_y2_z1_8_materialName: '' - WH4_x3_y2_z1_8_quantity: 0.0 - WH4_x3_y2_z2_6_materialName: '' - WH4_x3_y2_z2_6_materialType: '' - WH4_x3_y2_z2_6_quantity: 0.0 - WH4_x3_y2_z2_6_targetWH: '' - WH4_x3_y3_z2_9_materialName: '' - WH4_x3_y3_z2_9_materialType: '' - WH4_x3_y3_z2_9_quantity: 0.0 - WH4_x3_y3_z2_9_targetWH: '' - WH4_x4_y1_z1_4_materialName: '' - WH4_x4_y1_z1_4_quantity: 0.0 - WH4_x4_y2_z1_9_materialName: '' - WH4_x4_y2_z1_9_quantity: 0.0 - WH4_x5_y1_z1_5_materialName: '' - WH4_x5_y1_z1_5_quantity: 0.0 - WH4_x5_y2_z1_10_materialName: '' - WH4_x5_y2_z1_10_quantity: 0.0 - xlsx_path: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + report_request: null + used_materials: null handles: {} placeholder_keys: {} result: {} schema: - description: 组合函数V2版本(测试):先启动调度,然后执行自动化上料(使用非阻塞轮询等待) + description: '' properties: feedback: {} goal: properties: - WH3_x1_y1_z3_1_materialId: - default: '' - type: string - WH3_x1_y1_z3_1_materialType: - default: '' + report_request: type: string - WH3_x1_y1_z3_1_quantity: - default: 0 - type: number - WH3_x1_y2_z3_4_materialId: - default: '' + used_materials: type: string - WH3_x1_y2_z3_4_materialType: - default: '' - type: string - WH3_x1_y2_z3_4_quantity: - default: 0 - type: number - WH3_x1_y3_z3_7_materialId: - default: '' - type: string - WH3_x1_y3_z3_7_materialType: - default: '' - type: string - WH3_x1_y3_z3_7_quantity: - default: 0 - type: number - WH3_x1_y4_z3_10_materialId: - default: '' - type: string - WH3_x1_y4_z3_10_materialType: - default: '' - type: string - WH3_x1_y4_z3_10_quantity: - default: 0 - type: number - WH3_x1_y5_z3_13_materialId: - default: '' - type: string - WH3_x1_y5_z3_13_materialType: - default: '' - type: string - WH3_x1_y5_z3_13_quantity: - default: 0 - type: number - WH3_x2_y1_z3_2_materialId: - default: '' - type: string - WH3_x2_y1_z3_2_materialType: - default: '' - type: string - WH3_x2_y1_z3_2_quantity: - default: 0 - type: number - WH3_x2_y2_z3_5_materialId: - default: '' - type: string - WH3_x2_y2_z3_5_materialType: - default: '' - type: string - WH3_x2_y2_z3_5_quantity: - default: 0 - type: number - WH3_x2_y3_z3_8_materialId: - default: '' - type: string - WH3_x2_y3_z3_8_materialType: - default: '' - type: string - WH3_x2_y3_z3_8_quantity: - default: 0 - type: number - WH3_x2_y4_z3_11_materialId: - default: '' - type: string - WH3_x2_y4_z3_11_materialType: - default: '' - type: string - WH3_x2_y4_z3_11_quantity: - default: 0 - type: number - WH3_x2_y5_z3_14_materialId: - default: '' - type: string - WH3_x2_y5_z3_14_materialType: - default: '' - type: string - WH3_x2_y5_z3_14_quantity: - default: 0 - type: number - WH3_x3_y1_z3_3_materialId: - default: '' - type: string - WH3_x3_y1_z3_3_materialType: - default: '' - type: string - WH3_x3_y1_z3_3_quantity: - default: 0 - type: number - WH3_x3_y2_z3_6_materialId: - default: '' - type: string - WH3_x3_y2_z3_6_materialType: - default: '' - type: string - WH3_x3_y2_z3_6_quantity: - default: 0 - type: number - WH3_x3_y3_z3_9_materialId: - default: '' - type: string - WH3_x3_y3_z3_9_materialType: - default: '' - type: string - WH3_x3_y3_z3_9_quantity: - default: 0 - type: number - WH3_x3_y4_z3_12_materialId: - default: '' - type: string - WH3_x3_y4_z3_12_materialType: - default: '' - type: string - WH3_x3_y4_z3_12_quantity: - default: 0 - type: number - WH3_x3_y5_z3_15_materialId: - default: '' - type: string - WH3_x3_y5_z3_15_materialType: - default: '' - type: string - WH3_x3_y5_z3_15_quantity: - default: 0 - type: number - WH4_x1_y1_z1_1_materialName: - default: '' - type: string - WH4_x1_y1_z1_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_materialName: - default: '' - type: string - WH4_x1_y1_z2_1_materialType: - default: '' - type: string - WH4_x1_y1_z2_1_quantity: - default: 0.0 - type: number - WH4_x1_y1_z2_1_targetWH: - default: '' - type: string - WH4_x1_y2_z1_6_materialName: - default: '' - type: string - WH4_x1_y2_z1_6_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_materialName: - default: '' - type: string - WH4_x1_y2_z2_4_materialType: - default: '' - type: string - WH4_x1_y2_z2_4_quantity: - default: 0.0 - type: number - WH4_x1_y2_z2_4_targetWH: - default: '' - type: string - WH4_x1_y3_z1_11_materialName: - default: '' - type: string - WH4_x1_y3_z1_11_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_materialName: - default: '' - type: string - WH4_x1_y3_z2_7_materialType: - default: '' - type: string - WH4_x1_y3_z2_7_quantity: - default: 0.0 - type: number - WH4_x1_y3_z2_7_targetWH: - default: '' - type: string - WH4_x2_y1_z1_2_materialName: - default: '' - type: string - WH4_x2_y1_z1_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_materialName: - default: '' - type: string - WH4_x2_y1_z2_2_materialType: - default: '' - type: string - WH4_x2_y1_z2_2_quantity: - default: 0.0 - type: number - WH4_x2_y1_z2_2_targetWH: - default: '' - type: string - WH4_x2_y2_z1_7_materialName: - default: '' - type: string - WH4_x2_y2_z1_7_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_materialName: - default: '' - type: string - WH4_x2_y2_z2_5_materialType: - default: '' - type: string - WH4_x2_y2_z2_5_quantity: - default: 0.0 - type: number - WH4_x2_y2_z2_5_targetWH: - default: '' - type: string - WH4_x2_y3_z1_12_materialName: - default: '' - type: string - WH4_x2_y3_z1_12_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_materialName: - default: '' - type: string - WH4_x2_y3_z2_8_materialType: - default: '' - type: string - WH4_x2_y3_z2_8_quantity: - default: 0.0 - type: number - WH4_x2_y3_z2_8_targetWH: - default: '' - type: string - WH4_x3_y1_z1_3_materialName: - default: '' - type: string - WH4_x3_y1_z1_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_materialName: - default: '' - type: string - WH4_x3_y1_z2_3_materialType: - default: '' - type: string - WH4_x3_y1_z2_3_quantity: - default: 0.0 - type: number - WH4_x3_y1_z2_3_targetWH: - default: '' - type: string - WH4_x3_y2_z1_8_materialName: - default: '' - type: string - WH4_x3_y2_z1_8_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_materialName: - default: '' - type: string - WH4_x3_y2_z2_6_materialType: - default: '' - type: string - WH4_x3_y2_z2_6_quantity: - default: 0.0 - type: number - WH4_x3_y2_z2_6_targetWH: - default: '' + required: + - report_request + type: object + result: {} + required: + - goal + title: process_order_finish_report参数 + type: object + type: UniLabJsonCommand + auto-process_sample_finish_report: + feedback: {} + goal: {} + goal_default: + report_request: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + report_request: type: string - WH4_x3_y3_z2_9_materialName: - default: '' + required: + - report_request + type: object + result: {} + required: + - goal + title: process_sample_finish_report参数 + type: object + type: UniLabJsonCommand + auto-process_step_finish_report: + feedback: {} + goal: {} + goal_default: + report_request: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + report_request: type: string - WH4_x3_y3_z2_9_materialType: - default: '' + required: + - report_request + type: object + result: {} + required: + - goal + title: process_step_finish_report参数 + type: object + type: UniLabJsonCommand + auto-register_interlock_batch: + feedback: {} + goal: {} + goal_default: + data_list: null + scenario: all + tag: '' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 建单成功后写入互锁缓存,并接管(清除)放行凭证,完成「凭证 → 批次」交接。 + properties: + feedback: {} + goal: + properties: + data_list: + items: + type: object + type: array + scenario: + default: all type: string - WH4_x3_y3_z2_9_quantity: - default: 0.0 - type: number - WH4_x3_y3_z2_9_targetWH: + tag: default: '' type: string - WH4_x4_y1_z1_4_materialName: - default: '' + required: + - data_list + type: object + result: {} + required: + - goal + title: register_interlock_batch参数 + type: object + type: UniLabJsonCommand + auto-report_material_change: + feedback: {} + goal: {} + goal_default: + material_obj: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + material_obj: + type: object + required: + - material_obj + type: object + result: {} + required: + - goal + title: report_material_change参数 + type: object + type: UniLabJsonCommand + auto-resource_tree_transfer: + feedback: {} + goal: {} + goal_default: + old_parent: null + parent_resource: null + plr_resource: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + old_parent: + type: object + parent_resource: + type: object + plr_resource: + type: object + required: + - old_parent + - plr_resource + - parent_resource + type: object + result: {} + required: + - goal + title: resource_tree_transfer参数 + type: object + type: UniLabJsonCommand + auto-scheduler_continue: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_continue参数 + type: object + type: UniLabJsonCommand + auto-scheduler_pause: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 暂停调度 (2.8) + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + type: object + required: + - goal + title: scheduler_pause参数 + type: object + type: UniLabJsonCommand + auto-scheduler_reset: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_reset参数 + type: object + type: UniLabJsonCommand + auto-scheduler_start: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_start参数 + type: object + type: UniLabJsonCommand + auto-scheduler_start_and_auto_feeding: + feedback: {} + goal: {} + goal_default: + xlsx_path: D:\Uni-Lab-OS-Data\date\material_template.xlsx + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 组合函数:先启动调度,然后执行自动化上料 + properties: + feedback: {} + goal: + properties: + xlsx_path: + default: D:\Uni-Lab-OS-Data\date\material_template.xlsx + title: 入库配置文件路径 type: string - WH4_x4_y1_z1_4_quantity: - default: 0.0 + required: [] + type: object + result: {} + required: + - goal + title: scheduler_start_and_auto_feeding参数 + type: object + type: UniLabJsonCommand + auto-scheduler_status: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 查询调度状态。返回 data.schedulerStatus(Init / Stop / Running / Pause / ErrorPause + / ErrorStop)、hasTask(是否有任务)、creationTime。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_status参数 + type: object + type: UniLabJsonCommand + auto-scheduler_stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_stop参数 + type: object + type: UniLabJsonCommand + auto-stack_inquiry_2to1: + feedback: {} + goal: {} + goal_default: + poll_interval: 5.0 + timeout: 3600 + type_keyword: 分液瓶板 + match_by_material_id: true + target_wh_id: '' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 321 转运的【前置】门控:确认 1号2号交接堆栈(0016) 可以放新板。必须排在 transfer_3_to_2_to_1_auto 之前——321 会把新板放进 0016,而 0016 只有 1 个库位,所以放之前必须确认它是空的;放到 321 之后就退化成一句无用查询,不会报错,只是保护静默失效。判定分两条路:0016 本就空则立即放行(快速路径,不查 0017);0016 有板则快照这些 materialId,等它们既离开 0016、又出现在 2号内部堆栈(0017),确认板真的回流到位而不是刚离开还在途中。 + properties: + feedback: {} + goal: + properties: + poll_interval: + default: 5.0 + description: 轮询间隔(秒),默认 5 秒 type: number - WH4_x4_y2_z1_9_materialName: - default: '' + timeout: + default: 3600 + description: 最长等待秒数,默认 3600 秒(1 小时);超时报错 + type: integer + type_keyword: + default: 分液瓶板 + title: 板型关键字 + description: 默认「分液瓶板」会同时匹配 5ml 与 20ml。扣电路应显式传「5ml分液瓶板」——否则 0016 里若停着一块 20ml 板(它走 3→2,不经 1→2 回 0017),「到位 0017」这个判据永远不成立,只能等到超时。 type: string - WH4_x4_y2_z1_9_quantity: - default: 0.0 - type: number - WH4_x5_y1_z1_5_materialName: + match_by_material_id: + default: true + title: 按 materialId 追踪回流 + description: 是否按 materialId 精确追踪板回流到 0017。置 false 则退化为只要 0016 腾空即放行(旧行为)。 + type: boolean + target_wh_id: default: '' + title: 回流目标仓库 whId + description: 留空则用 2号手套箱内部堆栈(0017)。一般不需要填。 type: string - WH4_x5_y1_z1_5_quantity: - default: 0.0 + required: [] + type: object + result: + properties: + arrived_material_ids: + description: 已确认回流到 0017 的 materialId + items: + type: string + type: array + elapsed_seconds: type: number - WH4_x5_y2_z1_10_materialName: - default: '' + fast_path: + description: 是否走了快速路径(进入时 0016 本来就空) + type: boolean + pending_material_ids: + description: 进入时 0016 上待回流的 materialId 快照 + items: + type: string + type: array + poll_count: + type: integer + status: type: string - WH4_x5_y2_z1_10_quantity: - default: 0.0 + type: object + required: + - goal + title: stack_inquiry_2to1参数 + type: object + type: UniLabJsonCommand + auto-stack_inquiry_window_5_free: + feedback: {} + goal: {} + goal_default: + required_slots: 4 + poll_interval: 5.0 + timeout: 36000 + type_keyword: '' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 建单【前置】门控:等 5号自动传递窗(0018) 的空闲库位数 ≥ required_slots。必须排在 create_orders 之前——配液流程结束时电导板已经直接堆在 0018 上,等「到位」没有意义;真正的瓶颈是 0018 只有 4 个库位,而一块板对应一个温度点。上一轮的板测完转去 0027、腾出空位后,下一轮才能建单。放到 create_orders 之后不会报错,只是保护静默失效(门控会打 warning 提示位置可疑)。 + properties: + feedback: {} + goal: + properties: + required_slots: + default: 4 + title: 本轮需要的库位数 + description: 等于本轮温度点数(1–4),应与传给 conductivity_test_inline 的 temperature_points 长度一致。默认 4 是最保守取值(等 0018 全空),按实际温度点数传可少等。超出 1–4 直接报错,因为传递窗物理上放不下。 + maximum: 4 + minimum: 1 + type: integer + poll_interval: + default: 5.0 + description: 轮询间隔(秒),默认 5 秒 type: number - xlsx_path: - default: D:\UniLab\Uni-Lab-OS\unilabos\devices\workstation\bioyond_studio\bioyond_cell\material_template.xlsx + timeout: + default: 36000 + description: 最长等待秒数,默认 36000 秒(10 小时),匹配电导测试时长 + type: integer + type_keyword: + default: '' + title: 板型关键字 + description: 只统计含该关键字的板占用;留空则任何物料都算占位(0018 是限量通道,默认按最保守口径算)。 type: string required: [] type: object result: + properties: + elapsed_seconds: + type: number + free_slots: + description: 放行时的空闲库位数 + type: integer + poll_count: + type: integer + required_slots: + type: integer + status: + type: string + total_slots: + description: 传递窗总库位数 + type: integer type: object required: - goal - title: scheduler_start_and_auto_feeding_v2参数 + title: stack_inquiry_window_5_free参数 type: object type: UniLabJsonCommand - auto-scheduler_stop: + auto-has_interlock_claim: feedback: {} goal: {} goal_default: {} @@ -1841,7 +1458,7 @@ bioyond_cell: placeholder_keys: {} result: {} schema: - description: '' + description: 凭证是否仍在手。 properties: feedback: {} goal: @@ -1849,10 +1466,41 @@ bioyond_cell: required: [] type: object result: + type: boolean + required: + - goal + title: has_interlock_claim参数 + type: object + type: UniLabJsonCommand + auto-monitor_manual_stack_3: + feedback: {} + goal: {} + goal_default: + poll_interval: 5.0 + max_duration: 3600 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 持续轮询监测3个堆栈的库位占用情况:3号箱手动堆栈(code=0007)、3号箱自动堆栈-左(code=0008)、1号2号手套箱交接堆栈(code=0016)。每 poll_interval 秒分别查一次 warehouse-info 并打印各堆栈当前各库位占用物料,累计运行达到 max_duration 秒后自动停止。用途:人工盯这三个堆栈的物料进出(调试/观察)。单次查询失败只告警重试,不中断监测。 + properties: + feedback: {} + goal: + properties: + poll_interval: + default: 5.0 + description: 轮询间隔(秒),默认5秒 + type: number + max_duration: + default: 3600 + description: 最长监测时长(秒),默认3600秒(1小时),到点自动停止(框架不接受前端cancel,故用此参数控制停止) + type: integer + required: [] type: object + result: {} required: - goal - title: scheduler_stop参数 + title: monitor_manual_stack_3参数 type: object type: UniLabJsonCommand auto-storage_batch_inbound: @@ -1871,15 +1519,12 @@ bioyond_cell: properties: items: items: - additionalProperties: - type: string type: object type: array required: - items type: object - result: - type: object + result: {} required: - goal title: storage_batch_inbound参数 @@ -1908,8 +1553,7 @@ bioyond_cell: - material_id - location_id type: object - result: - type: object + result: {} required: - goal title: storage_inbound参数 @@ -1930,8 +1574,7 @@ bioyond_cell: properties: {} required: [] type: object - result: - type: object + result: {} required: - goal title: transfer_1_to_2参数 @@ -1945,7 +1588,14 @@ bioyond_cell: source_x: 1 source_y: 1 source_z: 1 - handles: {} + source_pos: null + handles: + input: + - data_key: '@this@@@source_pos' + data_source: handle + data_type: object + handler_key: vial_32_source_pos_output + label: 20ml瓶板源坐标 placeholder_keys: {} result: {} schema: @@ -1956,30 +1606,154 @@ bioyond_cell: properties: source_wh_id: default: 3a19debc-84b4-0359-e2d4-b3beea49348b - description: 来源仓库ID + description: 来源仓库 Id (默认为3号仓库) type: string source_x: default: 1 - description: 来源位置X坐标 + description: 来源位置 X 坐标 type: integer source_y: default: 1 - description: 来源位置Y坐标 + description: 来源位置 Y 坐标 type: integer source_z: default: 1 - description: 来源位置Z坐标 + description: 来源位置 Z 坐标 type: integer required: [] type: object + result: {} + required: + - goal + title: transfer_3_to_2参数 + type: object + type: UniLabJsonCommand + auto-transfer_3_to_2_auto: + feedback: {} + goal: {} + goal_default: + plate_type: 20ml分液瓶板 + vial_plates: null + handles: + input: + - data_key: '@this@@@vial_plates' + data_source: handle + data_type: array + handler_key: vial_plates_input + label: 分液瓶板列表 + output: + - data_key: total + data_source: executor + data_type: integer + handler_key: transfer_total + label: 转运总数 + - data_key: success + data_source: executor + data_type: integer + handler_key: transfer_success + label: 成功数量 + - data_key: failed + data_source: executor + data_type: integer + handler_key: transfer_failed + label: 失败数量 + placeholder_keys: {} + result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + message: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer + type: object + schema: + description: >- + 自动批量 3-2 转运:只搬当前在「自动堆栈-左」的分液瓶板(软包用)。 + 电导板停在 5 号自动传递窗,会被自动跳过,无需按板型区分。 + properties: + feedback: {} + goal: + properties: + plate_type: + default: 20ml分液瓶板 + title: 板型关键字 + description: 参与 32 转运的板型,默认:20ml分液瓶板 + type: string + vial_plates: + title: 分液瓶板 + description: 自动传递参数,可忽略 + items: + properties: + locationId: + type: string + materialId: + type: string + orderCode: + type: string + typeName: + type: string + required: + - materialId + type: object + type: array + required: + - vial_plates + type: object result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + message: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer type: object required: - goal - title: transfer_3_to_2参数 + title: transfer_3_to_2_auto参数 type: object type: UniLabJsonCommand auto-transfer_3_to_2_to_1: + always_free: true feedback: {} goal: {} goal_default: @@ -1987,7 +1761,14 @@ bioyond_cell: source_x: 1 source_y: 1 source_z: 1 - handles: {} + source_pos: null + handles: + input: + - data_key: '@this@@@source_pos' + data_source: handle + data_type: object + handler_key: vial_321_source_pos_output + label: 5ml瓶板源坐标 placeholder_keys: {} result: {} schema: @@ -2010,11 +1791,134 @@ bioyond_cell: type: integer required: [] type: object + result: {} + required: + - goal + title: transfer_3_to_2_to_1参数 + type: object + type: UniLabJsonCommand + auto-transfer_3_to_2_to_1_auto: + feedback: {} + goal: {} + goal_default: + target_device: BatteryStation + target_location: bottle_rack_6x2 + vial_plates: null + source_pos: null + handles: + input: + - data_key: '@this@@@vial_plates' + data_source: handle + data_type: array + handler_key: vial_plates_input + label: 分液瓶板列表 + output: + - data_key: total + data_source: executor + data_type: integer + handler_key: transfer_total + label: 转运总数 + - data_key: success + data_source: executor + data_type: integer + handler_key: transfer_success + label: 成功数量 + - data_key: failed + data_source: executor + data_type: integer + handler_key: transfer_failed + label: 失败数量 + placeholder_keys: {} + result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer + type: object + schema: + description: 自动批量转运分液瓶板(从配液站到扣电站) + properties: + feedback: {} + goal: + properties: + target_device: + default: BatteryStation + title: 目标设备 + description: 默认:BatteryStation + type: string + target_location: + default: bottle_rack_6x2 + title: 目标位置 + description: 默认:bottle_rack_6x2 + type: string + vial_plates: + title: 分液瓶板 + description: 自动传递参数,可忽略 + items: + properties: + locationId: + type: string + materialId: + type: string + orderCode: + type: string + required: + - locationId + - materialId + type: object + type: array + required: + - vial_plates + type: object result: + properties: + failed: + type: integer + results: + items: + properties: + error: + type: string + index: + type: integer + materialId: + type: string + orderCode: + type: string + result: + type: object + status: + type: string + type: object + type: array + success: + type: integer + total: + type: integer type: object required: - goal - title: transfer_3_to_2_to_1参数 + title: transfer_3_to_2_to_1_auto参数 type: object type: UniLabJsonCommand auto-update_push_ip: @@ -2035,11 +1939,10 @@ bioyond_cell: ip: type: string port: - type: integer + type: string required: [] type: object - result: - type: object + result: {} required: - goal title: update_push_ip参数 @@ -2061,15 +1964,16 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code type: object - result: - type: object + result: {} required: - goal title: wait_for_order_finish参数 @@ -2092,18 +1996,20 @@ bioyond_cell: goal: properties: order_code: + description: 任务编号 type: string poll_interval: default: 0.5 + description: 轮询间隔(秒),默认 0.5 秒 type: number timeout: default: 36000 + description: 超时时间(秒) type: integer required: - order_code type: object - result: - type: object + result: {} required: - goal title: wait_for_order_finish_polling参数 @@ -2135,8 +2041,7 @@ bioyond_cell: type: integer required: [] type: object - result: - type: boolean + result: {} required: - goal title: wait_for_transfer_task参数 @@ -2144,7 +2049,13 @@ bioyond_cell: type: UniLabJsonCommand module: unilabos.devices.workstation.bioyond_studio.bioyond_cell.bioyond_cell_workstation:BioyondCellWorkstation status_types: - device_id: '' + device_id: String + data_formulation_total_count: int + data_formulation_completed_count: int + data_formulation_completion_percentage: float + data_dispense_total_bottles: int + data_dispense_completed_bottles: int + data_dispense_completion_percentage: float type: python config_info: [] description: '' @@ -2154,10 +2065,15 @@ bioyond_cell: config: properties: bioyond_config: + description: '从 JSON 文件加载的 bioyond 配置字典 + + 包含 api_host, api_key, HTTP_host, HTTP_port 等配置' type: object deck: + description: Deck 配置(可选,会从 JSON 中自动处理) type: string protocol_type: + description: 协议类型(可选) type: string required: [] type: object @@ -2165,7 +2081,20 @@ bioyond_cell: properties: device_id: type: string + data_formulation_total_count: + type: integer + data_formulation_completed_count: + type: integer + data_formulation_completion_percentage: + type: number + data_dispense_total_bottles: + type: integer + data_dispense_completed_bottles: + type: integer + data_dispense_completion_percentage: + type: number required: - device_id type: object + registry_type: device version: 1.0.0 diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml index 547b54ffb..21f36e162 100644 --- a/unilabos/registry/devices/bioyond_dispensing_station.yaml +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -47,8 +47,10 @@ bioyond_dispensing_station: goal: properties: report_request: + description: WorkstationReportRequest 对象,包含任务完成信息 type: string used_materials: + description: 物料使用记录列表 type: string required: - report_request @@ -102,6 +104,7 @@ bioyond_dispensing_station: goal: properties: material_name: + description: 物料名称 type: string required: - material_name @@ -611,10 +614,10 @@ bioyond_dispensing_station: goal: properties: target_device_id: - description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备) + description: 目标反应站设备ID(所有转移组使用同一个设备) type: string transfer_groups: - description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组 + description: '转移任务组列表,每组包含:' type: array required: - target_device_id @@ -694,10 +697,13 @@ bioyond_dispensing_station: config: properties: config: + description: 配置字典,应包含material_type_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml index df5a35085..76681d779 100644 --- a/unilabos/registry/devices/coin_cell_workstation.yaml +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -64,59 +64,12 @@ coincellassemblyworkstation_device: properties: {} required: [] type: object - result: - type: boolean + result: {} required: - goal title: fun_wuliao_test参数 type: object type: UniLabJsonCommand - auto-func_allpack_cmd: - feedback: {} - goal: {} - goal_default: - assembly_pressure: 4200 - assembly_type: 7 - elec_num: null - elec_use_num: null - elec_vol: 50 - file_path: /Users/sml/work - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: - assembly_pressure: - default: 4200 - type: integer - assembly_type: - default: 7 - type: integer - elec_num: - type: string - elec_use_num: - type: string - elec_vol: - default: 50 - type: integer - file_path: - default: /Users/sml/work - type: string - required: - - elec_num - - elec_use_num - type: object - result: - type: object - required: - - goal - title: func_allpack_cmd参数 - type: object - type: UniLabJsonCommand auto-func_allpack_cmd_simp: feedback: {} goal: {} @@ -125,6 +78,10 @@ coincellassemblyworkstation_device: assembly_type: 7 battery_clean_ignore: false battery_pressure_mode: true + positive_plate_pick_mode: false + negative_plate_thickness_mode: false + thick_negative_plate_blow_time: 0 + thin_negative_plate_blow_time: 0 dual_drop_first_volume: 25 dual_drop_mode: false dual_drop_start_timing: false @@ -132,13 +89,13 @@ coincellassemblyworkstation_device: elec_num: null elec_use_num: null elec_vol: 50 - file_path: /Users/sml/work - fujipian_juzhendianwei: 0 - fujipian_panshu: 0 - gemo_juzhendianwei: 0 - gemopanshu: 0 - lvbodian: true - qiangtou_juzhendianwei: 0 + file_path: D:\Uni-Lab-OS-Data\date + ne_plate_matrix: 0 + ne_plate_num: 0 + sep_plate_matrix: 0 + sep_plate_num: 0 + aluminum_foil: true + tip_box_matrix: 0 handles: {} placeholder_keys: {} result: {} @@ -149,81 +106,119 @@ coincellassemblyworkstation_device: goal: properties: assembly_pressure: - default: 4200 - description: 电池压制力(N) + default: 3200 + title: 压制压力(N) + description: 根据实验需求填写压制力 type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + title: 组装类型 + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + title: 忽略电池擦拭 + description: false=使用,True=忽略 type: boolean battery_pressure_mode: default: true - description: 是否启用压力模式 + title: 电池压制模式 + description: False=压力检测模式,True=距离模式 + type: boolean + positive_plate_pick_mode: + default: false + title: 正极片取料方式 + description: False=弹夹取料, True=水平取料 + type: boolean + negative_plate_thickness_mode: + default: false + title: 负极片厚度测量模式 + description: False=厚负极片, True=薄负极片 type: boolean + thick_negative_plate_blow_time: + default: 0 + title: 厚负极片吹扫时间(s) + description: 厚负极片吹气时间 + type: integer + thin_negative_plate_blow_time: + default: 0 + title: 薄负极片吹扫时间(s) + description: 薄负极片吹气时间 + type: integer dual_drop_first_volume: - default: 25 - description: 二次滴液第一次排液体积(μL) + default: 0 + title: 二次滴液首次滴加体积(uL) + description: 根据实验需求填写首次加液体积,保证充分浸润 type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + title: 二次滴液模式 + description: False=单次滴液, True=二次滴液 type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + title: 二次滴液开始滴液时机 + description: False=正极片前, True=正极片后 type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + title: 二次滴液吸液时机 + description: False=正常吸液, True=先吸液 type: boolean elec_num: - description: 电解液瓶数 + title: 电解液瓶数 + description: 现场清点电解液瓶数进行填写 type: string elec_use_num: + default: 5 + title: 每瓶电解液组装的电池颗数 description: 每瓶电解液组装电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + title: 电解液体积(uL) + description: 选择二次滴液则为两次滴液体积之和 type: integer file_path: - default: /Users/sml/work + default: D:\Uni-Lab-OS-Data\date + title: 扣电组装实验报告文件路径 description: 实验记录保存路径 type: string - fujipian_juzhendianwei: + ne_plate_matrix: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 负极片矩阵点位 + description: 负极片盘为5*4方形盘,从0编号至19,0-7与12-19为摆放负极片位置,8-11(第3行)实际无法摆放负极片 type: integer - fujipian_panshu: + ne_plate_num: default: 0 - description: 负极片盘数 + title: 负极片盘数 + description: 现场清点负极片盘数进行填写 type: integer - gemo_juzhendianwei: + sep_plate_matrix: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 隔膜矩阵点位 + description: 隔膜盘为5*4方形盘,从0编号至19,0-7与12-19为摆放隔膜位置,8-11(第3行)实际无法摆放隔膜 type: integer - gemopanshu: + sep_plate_num: default: 0 - description: 隔膜盘数 + title: 隔膜盘数 + description: 现场清点隔膜盘数进行填写 type: integer - lvbodian: + aluminum_foil: default: true + title: 铝箔 description: 是否使用铝箔垫片 type: boolean - qiangtou_juzhendianwei: + tip_box_matrix: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 移液枪头盒矩阵点位 + description: 在1号箱界面-参数设定中查看 type: integer required: - elec_num - elec_use_num type: object - result: - type: object + result: {} required: - goal title: func_allpack_cmd_simp参数 @@ -292,7 +287,62 @@ coincellassemblyworkstation_device: title: func_pack_device_init参数 type: object type: UniLabJsonCommand - auto-func_pack_device_init_auto_start_combined: + auto-coin_cell_sim_delay: + feedback: {} + goal: {} + goal_default: + delay_minutes: 3 + report_interval: 15.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 长时延模拟:空转指定时长,模拟扣电组装的完整流程耗时。用于配液↔扣电联动的时序验证——替换掉真实的 coin_cell_init + coin_cell_start,即可在不动硬件的情况下观察配液工站互锁门控是否按预期阻塞、放行。分段 sleep 并周期上报剩余时间,避免长阻塞看起来像卡死。 + properties: + feedback: {} + goal: + properties: + delay_minutes: + default: 3 + title: 模拟耗时(分钟) + description: 模拟耗时,推荐档位 3 / 5 / 10 分钟,但接受任意正值,上限 60 分钟。不做白名单是因为快速联调常需要 30 秒级时长;上限只用于拦住打错字。 + type: number + report_interval: + default: 15.0 + title: 进度上报间隔(秒) + description: 分段 sleep 的步长,每段打一次剩余时间日志。默认 15 秒。 + type: number + required: [] + type: object + result: + properties: + actual_seconds: + description: 实际耗时(秒) + type: number + delay_minutes: + description: 生效的模拟时长(分钟) + type: number + end_time: + description: 结束时间(本地,毫秒精度) + type: string + end_time_ms: + description: 结束时间 Unix 毫秒时间戳 + type: integer + start_time: + description: 开始时间(本地,毫秒精度) + type: string + start_time_ms: + description: 开始时间 Unix 毫秒时间戳,便于与 multitask_probe 的时序数据对齐 + type: integer + status: + type: string + type: object + required: + - goal + title: coin_cell_sim_delay参数 + type: object + type: UniLabJsonCommand + auto-coin_cell_init: feedback: {} goal: {} goal_default: @@ -308,15 +358,15 @@ coincellassemblyworkstation_device: properties: material_search_enable: default: false - description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻) + title: 使用物料检索 + description: "是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击‘是'(启用)或‘否'(不启用)。默认为False(不启用物料搜寻)。" type: boolean required: [] type: object - result: - type: boolean + result: {} required: - goal - title: func_pack_device_init_auto_start_combined参数 + title: coin_cell_init参数 type: object type: UniLabJsonCommand auto-func_pack_device_start: @@ -355,8 +405,7 @@ coincellassemblyworkstation_device: properties: {} required: [] type: object - result: - type: boolean + result: {} required: - goal title: func_pack_device_stop参数 @@ -366,7 +415,7 @@ coincellassemblyworkstation_device: feedback: {} goal: {} goal_default: - file_path: D:\coin_cell_data + file_path: D:\Uni-Lab-OS-Data\date handles: {} placeholder_keys: {} result: {} @@ -377,12 +426,11 @@ coincellassemblyworkstation_device: goal: properties: file_path: - default: D:\coin_cell_data + default: D:\Uni-Lab-OS-Data\date type: string required: [] type: object - result: - type: boolean + result: {} required: - goal title: func_pack_get_msg_cmd参数 @@ -396,12 +444,10 @@ coincellassemblyworkstation_device: handles: input: - data_key: bottle_num - data_source: workflow + data_source: handle data_type: integer handler_key: bottle_count - io_type: source label: 配液瓶数 - required: true placeholder_keys: {} result: {} schema: @@ -436,8 +482,7 @@ coincellassemblyworkstation_device: properties: {} required: [] type: object - result: - type: boolean + result: {} required: - goal title: func_pack_send_finished_cmd参数 @@ -474,8 +519,7 @@ coincellassemblyworkstation_device: - assembly_type - assembly_pressure type: object - result: - type: boolean + result: {} required: - goal title: func_pack_send_msg_cmd参数 @@ -485,7 +529,7 @@ coincellassemblyworkstation_device: feedback: {} goal: {} goal_default: - file_path: /Users/sml/work + file_path: D:\Uni-Lab-OS-Data\date handles: {} placeholder_keys: {} result: {} @@ -496,7 +540,7 @@ coincellassemblyworkstation_device: goal: properties: file_path: - default: /Users/sml/work + default: D:\Uni-Lab-OS-Data\date type: string required: [] type: object @@ -506,7 +550,7 @@ coincellassemblyworkstation_device: title: func_read_data_and_output参数 type: object type: UniLabJsonCommand - auto-func_sendbottle_allpack_multi: + auto-coin_cell_start: feedback: {} goal: {} goal_default: @@ -514,6 +558,10 @@ coincellassemblyworkstation_device: assembly_type: 7 battery_clean_ignore: false battery_pressure_mode: true + positive_plate_pick_mode: false + negative_plate_thickness_mode: false + thick_negative_plate_blow_time: 0 + thin_negative_plate_blow_time: 0 dual_drop_first_volume: 25 dual_drop_mode: false dual_drop_start_timing: false @@ -521,22 +569,31 @@ coincellassemblyworkstation_device: elec_num: null elec_use_num: null elec_vol: 50 - file_path: /Users/sml/work - fujipian_juzhendianwei: 0 - fujipian_panshu: 0 - gemo_juzhendianwei: 0 - gemopanshu: 0 - lvbodian: true - qiangtou_juzhendianwei: 0 + file_path: D:\Uni-Lab-OS-Data\date + ne_plate_matrix: 0 + ne_plate_num: 0 + sep_plate_matrix: 0 + sep_plate_num: 0 + aluminum_foil: true + tip_box_matrix: 0 handles: input: - data_key: elec_num - data_source: workflow + data_source: handle data_type: integer handler_key: bottle_count - io_type: source label: 配液瓶数 - required: true + - data_key: formulations + data_source: handle + data_type: array + handler_key: formulations_input + label: 配方信息列表 + output: + - data_key: assembly_data + data_source: executor + data_type: array + handler_key: assembly_data_output + label: 扣电组装数据列表 placeholder_keys: {} result: {} schema: @@ -547,83 +604,120 @@ coincellassemblyworkstation_device: properties: assembly_pressure: default: 4200 - description: 电池压制力(N) + title: 压制压力(N) + description: 根据实验需求填写压制力 type: integer assembly_type: default: 7 - description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) + title: 组装类型 + description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫) type: integer battery_clean_ignore: default: false - description: 是否忽略电池清洁步骤 + title: 忽略电池擦拭 + description: false=使用,True=忽略 type: boolean battery_pressure_mode: default: true - description: 是否启用压力模式 + title: 电池压制模式 + description: False=压力检测模式,True=距离模式 type: boolean + positive_plate_pick_mode: + default: false + title: 正极片取料方式 + description: False=弹夹取料, True=水平取料 + type: boolean + negative_plate_thickness_mode: + default: false + title: 负极片厚度测量模式 + description: False=厚负极片, True=薄负极片 + type: boolean + thick_negative_plate_blow_time: + default: 0 + title: 厚负极片吹扫时间(s) + description: 厚负极片吹气时间 + type: integer + thin_negative_plate_blow_time: + default: 0 + title: 薄负极片吹扫时间(s) + description: 薄负极片吹气时间 + type: integer dual_drop_first_volume: default: 25 - description: 二次滴液第一次排液体积(μL) + title: 二次滴液首次滴加体积(uL) + description: 根据实验需求填写首次加液体积,保证充分浸润 type: integer dual_drop_mode: default: false - description: 电解液添加模式(false=单次滴液, true=二次滴液) + title: 二次滴液模式 + description: False=单次滴液, True=二次滴液 type: boolean dual_drop_start_timing: default: false - description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) + title: 二次滴液开始滴液时机 + description: False=正极片前, True=正极片后 type: boolean dual_drop_suction_timing: default: false - description: 二次滴液吸液时机(false=正常吸液, true=先吸液) + title: 二次滴液吸液时机 + description: False=正常吸液, True=先吸液 type: boolean elec_num: - description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数 + title: 电解液瓶数 + description: 现场清点电解液瓶数进行填写 type: string elec_use_num: + title: 每瓶电解液组装的电池颗数 description: 每瓶电解液组装电池数 type: string elec_vol: default: 50 - description: 电解液吸液量(μL) + title: 电解液体积(uL) + description: 选择二次滴液则为两次滴液体积之和 type: integer file_path: - default: /Users/sml/work + default: D:\Uni-Lab-OS-Data\date + title: 扣电组装实验报告文件路径 description: 实验记录保存路径 type: string - fujipian_juzhendianwei: + ne_plate_matrix: default: 0 - description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 负极片矩阵点位 + description: 负极片盘为5*4方形盘,从0编号至19,0-7与12-19为摆放负极片位置,8-11(第3行)实际无法摆放负极片 type: integer - fujipian_panshu: + ne_plate_num: default: 0 - description: 负极片盘数 + title: 负极片盘数 + description: 现场清点负极片盘数进行填写 type: integer - gemo_juzhendianwei: + sep_plate_matrix: default: 0 - description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 隔膜矩阵点位 + description: 隔膜盘为5*4方形盘,从0编号至19,0-7与12-19为摆放隔膜位置,8-11(第3行)实际无法摆放隔膜 type: integer - gemopanshu: + sep_plate_num: default: 0 - description: 隔膜盘数 + title: 隔膜盘数 + description: 现场清点隔膜盘数进行填写 type: integer - lvbodian: + aluminum_foil: default: true + title: 铝箔 description: 是否使用铝箔垫片 type: boolean - qiangtou_juzhendianwei: + tip_box_matrix: default: 0 - description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2) + title: 移液枪头盒矩阵点位 + description: 在1号箱界面-参数设定中查看 type: integer required: - elec_num - elec_use_num type: object - result: - type: object + result: {} required: - goal - title: func_sendbottle_allpack_multi参数 + title: coin_cell_start参数 type: object type: UniLabJsonCommand auto-func_stop_read_data: @@ -672,18 +766,11 @@ coincellassemblyworkstation_device: title: modify_deck_name参数 type: object type: UniLabJsonCommand - auto-qiming_coin_cell_code: + auto-post_init: feedback: {} goal: {} goal_default: - battery_clean_ignore: false - battery_pressure: 4000 - battery_pressure_mode: true - fujipian_juzhendianwei: 0 - fujipian_panshu: null - gemo_juzhendianwei: 0 - gemopanshu: 0 - lvbodian: true + ros_node: null handles: {} placeholder_keys: {} result: {} @@ -693,41 +780,23 @@ coincellassemblyworkstation_device: feedback: {} goal: properties: - battery_clean_ignore: - default: false - type: boolean - battery_pressure: - default: 4000 - type: integer - battery_pressure_mode: - default: true - type: boolean - fujipian_juzhendianwei: - default: 0 - type: integer - fujipian_panshu: - type: integer - gemo_juzhendianwei: - default: 0 - type: integer - gemopanshu: - default: 0 - type: integer - lvbodian: - default: true - type: boolean + ros_node: + type: object required: - - fujipian_panshu + - ros_node type: object - result: - type: boolean + result: {} required: - goal - title: qiming_coin_cell_code参数 + title: post_init参数 type: object type: UniLabJsonCommand module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation status_types: + data_10mm_positive_plate_remaining: float + data_12mm_positive_plate_remaining: float + data_16mm_positive_plate_remaining: float + data_aluminum_foil_remaining: float data_assembly_coin_cell_num: int data_assembly_pressure: int data_assembly_time: float @@ -735,14 +804,23 @@ coincellassemblyworkstation_device: data_axis_y_pos: float data_axis_z_pos: float data_coin_cell_code: str - data_coin_num: int + data_coin_type: int + data_current_assembling_count: int + data_current_completed_count: int data_electrolyte_code: str data_electrolyte_volume: int + data_finished_battery_ng_remaining_capacity: float + data_finished_battery_remaining_capacity: float + data_flat_washer_remaining: float data_glove_box_o2_content: float data_glove_box_pressure: float data_glove_box_water_content: float + data_negative_shell_remaining: float data_open_circuit_voltage: float + data_order_completion_percentage: float data_pole_weight: float + data_positive_shell_remaining: float + data_spring_washer_remaining: float request_rec_msg_status: bool request_send_msg_status: bool sys_mode: str @@ -772,6 +850,14 @@ coincellassemblyworkstation_device: type: object data: properties: + data_10mm_positive_plate_remaining: + type: number + data_12mm_positive_plate_remaining: + type: number + data_16mm_positive_plate_remaining: + type: number + data_aluminum_foil_remaining: + type: number data_assembly_coin_cell_num: type: integer data_assembly_pressure: @@ -786,22 +872,40 @@ coincellassemblyworkstation_device: type: number data_coin_cell_code: type: string - data_coin_num: + data_coin_type: + type: integer + data_current_assembling_count: + type: integer + data_current_completed_count: type: integer data_electrolyte_code: type: string data_electrolyte_volume: type: integer + data_finished_battery_ng_remaining_capacity: + type: number + data_finished_battery_remaining_capacity: + type: number + data_flat_washer_remaining: + type: number data_glove_box_o2_content: type: number data_glove_box_pressure: type: number data_glove_box_water_content: type: number + data_negative_shell_remaining: + type: number data_open_circuit_voltage: type: number + data_order_completion_percentage: + type: number data_pole_weight: type: number + data_positive_shell_remaining: + type: number + data_spring_washer_remaining: + type: number request_rec_msg_status: type: boolean request_send_msg_status: @@ -811,24 +915,36 @@ coincellassemblyworkstation_device: sys_status: type: string required: + - sys_status + - sys_mode + - request_rec_msg_status + - request_send_msg_status - data_assembly_coin_cell_num - - data_assembly_pressure - - data_assembly_time + - data_open_circuit_voltage - data_axis_x_pos - data_axis_y_pos - data_axis_z_pos + - data_pole_weight + - data_assembly_pressure + - data_electrolyte_volume + - data_coin_type + - data_current_assembling_count + - data_current_completed_count - data_coin_cell_code - - data_coin_num - data_electrolyte_code - - data_electrolyte_volume - - data_glove_box_o2_content - data_glove_box_pressure + - data_glove_box_o2_content - data_glove_box_water_content - - data_open_circuit_voltage - - data_pole_weight - - request_rec_msg_status - - request_send_msg_status - - sys_mode - - sys_status + - data_10mm_positive_plate_remaining + - data_12mm_positive_plate_remaining + - data_16mm_positive_plate_remaining + - data_aluminum_foil_remaining + - data_positive_shell_remaining + - data_flat_washer_remaining + - data_negative_shell_remaining + - data_spring_washer_remaining + - data_finished_battery_remaining_capacity + - data_finished_battery_ng_remaining_capacity type: object + registry_type: device version: 1.0.0 diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index fdcc89dd0..15d962865 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel: type: object model: mesh: thermo_orbitor_rs2_hotel - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml index 6d87f4291..e3494cac1 100644 --- a/unilabos/registry/devices/laiyu_liquid_test.yaml +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -18,6 +18,7 @@ xyz_stepper_controller: goal: properties: degrees: + description: 角度值 type: number required: - degrees @@ -44,6 +45,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -71,6 +73,7 @@ xyz_stepper_controller: properties: enable: default: true + description: True为使能,False为失能 type: boolean required: [] type: object @@ -99,9 +102,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object enable: default: true + description: True为使能,False为失能 type: boolean required: - axis @@ -152,6 +157,7 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object required: - axis @@ -183,16 +189,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object position: + description: 目标位置(步数) type: integer precision: default: 100 + description: 到位精度 type: integer speed: default: 5000 + description: 运行速度(rpm) type: integer required: - axis @@ -225,16 +236,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object degrees: + description: 目标角度(度) type: number precision: default: 100 + description: 精度 type: integer speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -267,16 +283,21 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer axis: + description: 电机轴 type: object precision: default: 100 + description: 精度 type: integer revolutions: + description: 目标圈数 type: number speed: default: 5000 + description: 移动速度 type: integer required: - axis @@ -309,15 +330,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 运行速度 type: integer x: + description: X轴目标位置 type: integer y: + description: Y轴目标位置 type: integer z: + description: Z轴目标位置 type: integer required: [] type: object @@ -350,15 +376,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_deg: + description: X轴目标角度(度) type: number y_deg: + description: Y轴目标角度(度) type: number z_deg: + description: Z轴目标角度(度) type: number required: [] type: object @@ -391,15 +422,20 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度 type: integer speed: default: 5000 + description: 移动速度 type: integer x_rev: + description: X轴目标圈数 type: number y_rev: + description: Y轴目标圈数 type: number z_rev: + description: Z轴目标圈数 type: number required: [] type: object @@ -427,6 +463,7 @@ xyz_stepper_controller: goal: properties: revolutions: + description: 圈数 type: number required: - revolutions @@ -456,10 +493,13 @@ xyz_stepper_controller: properties: acceleration: default: 1000 + description: 加速度(rpm/s) type: integer axis: + description: 电机轴 type: object speed: + description: 运行速度(rpm),正值正转,负值反转 type: integer required: - axis @@ -487,6 +527,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -513,6 +554,7 @@ xyz_stepper_controller: goal: properties: steps: + description: 步数 type: integer required: - steps @@ -564,9 +606,11 @@ xyz_stepper_controller: goal: properties: axis: + description: 电机轴 type: object timeout: default: 30.0 + description: 超时时间(秒) type: number required: - axis @@ -591,11 +635,14 @@ xyz_stepper_controller: properties: baudrate: default: 115200 + description: 波特率 type: integer port: + description: 串口端口名 type: string timeout: default: 1.0 + description: 通信超时时间 type: number required: - port diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 4d2f72884..c01662c36 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -510,9 +510,11 @@ liquid_handler: goal: properties: msg: + description: information to be printed type: string seconds: default: 0 + description: seconds to wait type: string required: [] type: object @@ -2963,15 +2965,22 @@ liquid_handler: additionalProperties: false properties: channel: + description: int maximum: 2147483647 minimum: -2147483648 type: integer dis_to_top: + description: 'float + + Height in mm to move to relative to the well top.' maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number well: additionalProperties: false + description: 'Well + + The target well.' properties: category: type: string @@ -4829,11 +4838,13 @@ liquid_handler: config: properties: backend: + description: Backend to use. type: object channel_num: default: 8 type: integer deck: + description: Deck to use. type: object simulator: default: false @@ -4883,14 +4894,17 @@ liquid_handler.biomek: bind_parent_id: type: string liquid_input_slot: + description: 液体输入槽列表 items: type: integer type: array liquid_type: + description: 液体类型列表 items: type: string type: array liquid_volume: + description: 液体体积列表 items: type: integer type: array @@ -4901,6 +4915,7 @@ liquid_handler.biomek: type: object type: array slot_on_deck: + description: 甲板上的槽位 type: integer required: - resource_tracker @@ -5036,20 +5051,27 @@ liquid_handler.biomek: additionalProperties: false properties: none_keys: + description: 需要设置为None的键列表 items: type: string type: array protocol_author: + description: 协议作者 type: string protocol_date: + description: 协议日期 type: string protocol_description: + description: 协议描述 type: string protocol_name: + description: 协议名称 type: string protocol_type: + description: 协议类型 type: string protocol_version: + description: 协议版本 type: string title: LiquidHandlerProtocolCreation_Goal type: object diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 4f3b972ad..96b9daf67 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -87,7 +87,7 @@ neware_battery_test_system: properties: filepath: default: bts_status.json - description: 输出JSON文件路径 + description: 输出文件路径 type: string required: [] type: object @@ -146,7 +146,7 @@ neware_battery_test_system: goal: properties: plate_num: - description: 盘号 (1 或 2),如果为null则返回所有盘的状态 + description: 盘号 (1 或 2),如果为None则返回所有盘的状态 type: integer required: [] type: object @@ -219,7 +219,7 @@ neware_battery_test_system: title: StrSingleInput type: object type: StrSingleInput - submit_from_csv: + submit_from_csv_export_ndax: feedback: {} goal: csv_path: string @@ -231,7 +231,41 @@ neware_battery_test_system: placeholder_keys: {} result: {} schema: - description: 从CSV文件批量提交Neware测试任务 + description: 从CSV文件批量提交Neware测试任务(备份格式为NDA) + properties: + feedback: {} + goal: + properties: + csv_path: + description: 输入CSV文件路径 + type: string + output_dir: + default: . + description: 输出目录,用于存储XML文件和备份,默认当前目录 + type: string + required: + - csv_path + type: object + result: + type: object + required: + - goal + title: submit_from_csv_export_ndax参数 + type: object + type: UniLabJsonCommand + submit_from_csv_export_excel: + feedback: {} + goal: + csv_path: string + output_dir: string + goal_default: + csv_path: null + output_dir: . + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 从CSV文件批量提交Neware测试任务(备份格式为Excel) properties: feedback: {} goal: @@ -250,7 +284,7 @@ neware_battery_test_system: type: object required: - goal - title: submit_from_csv参数 + title: submit_from_csv_export_excel参数 type: object type: UniLabJsonCommand test_connection_action: @@ -302,14 +336,14 @@ neware_battery_test_system: goal: properties: backup_dir: - description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir) + description: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir type: string file_pattern: default: '*' - description: 文件通配符模式,例如 *.csv 或 Battery_*.nda + description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件) type: string oss_prefix: - description: OSS对象路径前缀(默认使用self.oss_prefix) + description: OSS 对象前缀,默认使用类初始化时的配置 type: string required: [] type: object @@ -320,11 +354,1092 @@ neware_battery_test_system: title: upload_backup_to_oss参数 type: object type: UniLabJsonCommand + mock_assembly_data: + type: UniLabJsonCommand + goal: {} + feedback: {} + result: + assembly_data: assembly_data + success: success + return_info: return_info + schema: + title: mock_assembly_data参数 + description: 模拟扣电组装站 auto-func_sendbottle_allpack_multi 输出固定的 assembly_data(用于测试 neware 完整链路) + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + required: [] + feedback: {} + result: + type: object + required: + - goal + goal_default: {} + handles: + input: [] + output: + - handler_key: assembly_data_output + data_type: array + label: 扣电组装数据列表(模拟) + data_key: assembly_data + data_source: executor + placeholder_keys: {} + manual_confirm: + type: UniLabJsonCommand + goal: + resource: resource + target_device: target_device + mount_resource: mount_resource + formulations: formulations + assembly_data: assembly_data + csv_export_dir: csv_export_dir + # 超时由 node_type=manual_confirm 的外层调度/前端等待机制处理;设备函数体内不做本地计时中断 + timeout_seconds: timeout_seconds + assignee_user_ids: assignee_user_ids + A: A + B: B + C: C + feedback: {} + result: + resource: resource + coin_cell_code: coin_cell_code + electrolyte_code: electrolyte_code + target_device: target_device + mount_resource: mount_resource + collector_mass: collector_mass + active_material: active_material + capacity: capacity + battery_system: battery_system + pole_weight: pole_weight + schema: + title: manual_confirm参数 + description: manual_confirm的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + title: UniLabOS设备ID + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + title: 资源(无需填写) + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + target_device: + title: 目标设备 + type: string + description: 无需填写 + mount_resource: + title: 新威测试通道 + description: 需要在此指定新威通道 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + A: + title: 模式A + description: 模式A:所有电池参数一致(仅在此区填一组参数,后端自动发送到所有电池) + type: object + properties: + default_collector_mass: + type: number + title: 集流体质量(mg) + description: 集流体质量 (mg),整批共用 + default_active_material: + type: string + title: 活性物质含量 + description: 活性物质含量(如 "0.97" 或 "97%"),整批共用 + default_capacity: + type: number + title: 克容量(mAh/g) + description: 克容量 (mAh/g),整批共用 + default_battery_system: + type: string + title: 电池体系 + description: xml 工步标识(如 "811_LI_002"),整批共用 + B: + title: 模式B + description: 模式B:参数不一致 且 数量少(按上游电池数量逐颗填写 4 个参数,长度需等于电池总数) + type: object + properties: + collector_mass: + type: array + title: 集流体质量(mg) + items: + type: number + active_material: + type: array + title: 活性物质含量 + items: + type: string + capacity: + type: array + title: 克容量(mAh/g) + items: + type: number + battery_system: + type: array + title: 电池体系 + items: + type: string + C: + title: 模式C + description: 模式C:参数不一致 且 数量多(上传 CSV 文件,按 coin_cell_code 与电池对齐;CSV 表头需包含 coin_cell_code(或 电池条码) / 集流体质量(或 collector_mass) / 活性物质含量(或 active_material) / 克容量(或 capacity) / 电池体系(或 xml工步/battery_system);行序不敏感) + type: object + properties: + param_csv_path: + type: string + title: 参数CSV路径 + description: CSV 文件绝对路径 + formulations: + title: 配方表 + type: array + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + orderCode: + type: string + title: 订单编号 + orderName: + type: string + title: 订单名称 + real_mass_ratio: + type: object + title: 实际质量比 + target_mass_ratio: + type: object + title: 目标质量比 + prep_bottle_barcode: + type: string + title: 配液瓶条码 + vial_bottle_barcodes: + type: string + title: 分液瓶条码 + error: + type: string + title: 错误信息 + assembly_data: + title: 组装数据 + type: array + description: 扣电组装数据列表(每颗电池一个对象,含 Time/open_circuit_voltage/pole_weight 等 9 字段) + items: + type: object + additionalProperties: false + properties: + Time: + type: string + title: 时间 + open_circuit_voltage: + type: number + title: 开路电压 + pole_weight: + type: number + title: 极片质量 + assembly_time: + type: number + title: 组装时间 + assembly_pressure: + type: number + title: 组装压力 + target_assembly_pressure: + type: number + title: 目标组装压力 + electrolyte_volume: + type: number + title: 电解液体积 + data_coin_type: + type: integer + title: 扣电类型 + electrolyte_code: + type: string + title: 电解液编码 + coin_cell_code: + type: string + title: 扣式电池编码 + csv_export_dir: + type: string + default: 'D:\2604Agentic_test' + title: "电池通道CSV导出目录\n(需要新威电脑的路径)" + description: 整合 CSV 导出根目录(按日期子目录分组) + timeout_seconds: + title: 通知超时时间(s) + type: integer + assignee_user_ids: + title: 通知用户 + type: array + items: + type: string + required: + - timeout_seconds + - assignee_user_ids + _unilabos_placeholder_info: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + feedback: {} + result: + type: object + required: + - goal + goal_default: + resource: [] + target_device: '' + mount_resource: [] + formulations: [] + assembly_data: [] + csv_export_dir: 'D:\2604Agentic_test' + timeout_seconds: 86400 + assignee_user_ids: [] + A: + default_collector_mass: null + default_active_material: null + default_capacity: null + default_battery_system: null + B: + collector_mass: [] + active_material: [] + capacity: [] + battery_system: [] + C: + param_csv_path: '' + handles: + input: + - handler_key: formulations + data_type: array + label: 配方信息列表 + data_key: formulations + data_source: handle + io_type: source + - handler_key: assembly_data + data_type: array + label: 扣电组装数据列表 + data_key: assembly_data + data_source: handle + io_type: source + output: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: executor + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource.@flatten + data_source: executor + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource.@flatten + data_source: executor + - handler_key: collector_mass + data_type: collector_mass + label: 极流体质量 + data_key: collector_mass + data_source: executor + - handler_key: active_material + data_type: active_material + label: 活性物质含量 + data_key: active_material + data_source: executor + - handler_key: capacity + data_type: capacity + label: 克容量 + data_key: capacity + data_source: executor + - handler_key: battery_system + data_type: battery_system + label: xml工步 + data_key: battery_system + data_source: executor + - handler_key: pole_weight + data_type: array + label: 极片质量 + data_key: pole_weight + data_source: executor + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: executor + - handler_key: electrolyte_code + data_type: array + label: 电解液二维码列表 + data_key: electrolyte_code + data_source: executor + placeholder_keys: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + always_free: true + feedback_interval: 300 + node_type: manual_confirm + submit_auto_export_excel: + type: UniLabJsonCommandAsync + goal: + resource: resource + mount_resource: mount_resource + collector_mass: collector_mass + active_material: active_material + capacity: capacity + battery_system: battery_system + pole_weight: pole_weight + coin_cell_code: coin_cell_code + electrolyte_code: electrolyte_code + output_dir: output_dir + feedback: {} + result: + return_info: return_info + success: success + submitted_count: submitted_count + total_count: total_count + results: results + schema: + title: submit_auto_export_excel参数 + description: submit_auto_export_excel的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + title: 资源 + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + mount_resource: + title: 挂载资源 + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + collector_mass: + title: 集流体质量(g) + description: 无需填写 + type: array + items: + type: number + active_material: + title: 活性物质 + description: 无需填写 + type: array + items: + type: number + capacity: + title: 容量 + description: 无需填写 + type: array + items: + type: number + battery_system: + title: 电池体系 + description: 无需填写 + type: array + items: + type: string + pole_weight: + title: 极片质量(g) + description: 无需填写 + type: array + items: + type: number + coin_cell_code: + title: 扣式电池编码 + description: 无需填写 + type: array + items: + type: string + electrolyte_code: + title: 电解液编码 + description: 无需填写 + type: array + items: + type: string + output_dir: + type: string + default: 'D:\2604Agentic_test' + title: "电池测试对应关系文件输出目录\n(需要新威电脑的路径)" + description: 备份输出根目录,子目录 xml_dir/backup_dir 将自动创建 + required: + - mount_resource + - collector_mass + - active_material + - capacity + - battery_system + _unilabos_placeholder_info: + resource: unilabos_resources + mount_resource: unilabos_resources + feedback: {} + result: {} + required: + - goal + goal_default: + resource: [] + mount_resource: [] + collector_mass: [] + active_material: [] + capacity: [] + battery_system: [] + pole_weight: [] + coin_cell_code: [] + electrolyte_code: [] + output_dir: 'D:\2604Agentic_test' + handles: + input: + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource + data_source: handle + io_type: source + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource + data_source: handle + io_type: source + - handler_key: collector_mass + data_type: collector_mass + label: 极流体质量 + data_key: collector_mass + data_source: handle + io_type: source + - handler_key: active_material + data_type: active_material + label: 活性物质含量 + data_key: active_material + data_source: handle + io_type: source + - handler_key: capacity + data_type: capacity + label: 克容量 + data_key: capacity + data_source: handle + io_type: source + - handler_key: battery_system + data_type: battery_system + label: xml工步 + data_key: battery_system + data_source: handle + io_type: source + - handler_key: pole_weight + data_type: array + label: 极片质量 + data_key: pole_weight + data_source: handle + io_type: source + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: handle + io_type: source + - handler_key: electrolyte_code + data_type: array + label: 电解液二维码列表 + data_key: electrolyte_code + data_source: handle + io_type: source + output: [] + placeholder_keys: + resource: unilabos_resources + mount_resource: unilabos_resources + feedback_interval: 1.0 + battery_transfer_confirm: + type: UniLabJsonCommandAsync + goal: + resource: resource + target_device: target_device + mount_resource: mount_resource + timeout_seconds: timeout_seconds + assignee_user_ids: assignee_user_ids + feedback: {} + result: {} + schema: + title: battery_transfer_confirm参数 + description: battery_transfer_confirm的参数schema + type: object + properties: + goal: + type: object + properties: + unilabos_device_id: + type: string + default: '' + description: UniLabOS设备ID,用于指定执行动作的具体设备实例 + resource: + title: 资源 + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: resource + type: array + target_device: + title: 目标设备 + type: string + description: 无需填写 + mount_resource: + title: 挂载资源 + description: 无需填写 + items: + type: object + additionalProperties: false + properties: + id: + type: string + name: + type: string + sample_id: + type: string + children: + type: array + items: + type: string + parent: + type: string + type: + type: string + category: + type: string + pose: + type: object + properties: + position: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + title: position + additionalProperties: false + orientation: + type: object + properties: + x: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + y: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + z: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + w: + type: number + minimum: -1.7976931348623157e+308 + maximum: 1.7976931348623157e+308 + required: + - x + - y + - z + - w + title: orientation + additionalProperties: false + required: + - position + - orientation + title: pose + additionalProperties: false + config: + type: string + data: + type: string + title: mount_resource + type: array + timeout_seconds: + title: 超时时间(s) + type: integer + assignee_user_ids: + title: 分配用户 + description: 通知用户 + type: array + items: + type: string + required: + - resource + - target_device + - mount_resource + - timeout_seconds + - assignee_user_ids + _unilabos_placeholder_info: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + feedback: {} + result: {} + required: + - goal + goal_default: + resource: [] + target_device: '' + mount_resource: [] + timeout_seconds: 3600 + assignee_user_ids: [] + handles: + input: + - handler_key: target_device + data_type: device_id + label: 目标设备 + data_key: target_device + data_source: handle + io_type: source + - handler_key: resource + data_type: resource + label: 待转移资源 + data_key: resource + data_source: handle + io_type: source + - handler_key: mount_resource + data_type: resource + label: 目标孔位 + data_key: mount_resource + data_source: handle + io_type: source + - handler_key: coin_cell_code + data_type: array + label: 电池条码列表 + data_key: coin_cell_code + data_source: handle + io_type: source + output: [] + placeholder_keys: + resource: unilabos_resources + target_device: unilabos_devices + mount_resource: unilabos_resources + assignee_user_ids: unilabos_manual_confirm + always_free: true + feedback_interval: 300 + node_type: manual_confirm module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem status_types: channel_status: Dict[int, Dict] connection_info: Dict[str, str] - device_summary: dict + device_summary: str status: str total_channels: int type: python @@ -335,20 +1450,41 @@ neware_battery_test_system: init_param_schema: config: properties: + datacore_config: + description: 'DataCore 大装置数据接入配置(来自图 JSON),支持字段 + + enabled / ingest_url / device_id / device_key / timeout / + + total_timeout / retry_interval;不配置则不推送' + type: object devtype: + description: 设备类型标识 type: string ip: + description: TCP服务器IP地址 type: string - machine_id: - default: 1 - type: integer + machine_ids: + default: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 86 + items: + type: integer + type: array oss_prefix: default: neware_backup + description: OSS对象路径前缀,默认"neware_backup" type: string oss_upload_enabled: default: false + description: 是否启用OSS上传功能,默认False type: boolean port: + description: TCP端口 type: integer size_x: default: 50 @@ -360,6 +1496,7 @@ neware_battery_test_system: default: 20 type: number timeout: + description: 通信超时时间(秒) type: integer required: [] type: object @@ -374,7 +1511,7 @@ neware_battery_test_system: type: string type: object device_summary: - type: object + type: string status: type: string total_channels: diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml index c1290beae..dc81b6710 100644 --- a/unilabos/registry/devices/organic_miscellaneous.yaml +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -207,8 +207,12 @@ separator.homemade: goal: properties: condition: + description: The condition to be monitored, either 'delta' or 'time'. type: string value: + description: 'The threshold value for the condition. + + `delta > 0.05`, `time > 60`' type: string required: - condition @@ -305,12 +309,17 @@ separator.homemade: event: type: string settling_time: + description: The duration for which to settle after stirring, in + seconds. Defaults to 10. type: string stir_speed: + description: The speed of stirring, in RPM. Defaults to 300. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number stir_time: + description: The duration for which to stir, in seconds. Defaults + to 10. maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml index 95a082d53..25d647f7c 100644 --- a/unilabos/registry/devices/pump_and_valve.yaml +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position @@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: volume: + description: 'absolute position of the plunger, unit: mL' type: number required: - volume @@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08: goal: properties: max_velocity: + description: 'maximum velocity of the plunger, unit: ml/s' type: number position: + description: 'absolute position of the plunger, unit: ml' type: number required: - position diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 1372140d2..7ab22df61 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -32,7 +32,7 @@ reaction_station.bioyond: type: integer end_point: default: 0 - description: 终点计时点 (Start=开始前, End=结束后) + description: 终点计时点 (Start=0, End=1) type: integer end_step_key: default: '' @@ -40,11 +40,11 @@ reaction_station.bioyond: type: string start_point: default: 0 - description: 起点计时点 (Start=开始前, End=结束后) + description: 起点计时点 (Start=0, End=1) type: integer start_step_key: default: '' - description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择) + description: 起点步骤Key (可选, 默认为空则自动选择) type: string required: - duration @@ -91,6 +91,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 订单参数的JSON字符串 type: string required: - json_str @@ -117,6 +118,7 @@ reaction_station.bioyond: goal: properties: workflow_ids: + description: 要删除的工作流ID数组 items: type: string type: array @@ -145,6 +147,7 @@ reaction_station.bioyond: goal: properties: json_str: + description: 'JSON格式的字符串,包含:' type: string required: - json_str @@ -197,6 +200,7 @@ reaction_station.bioyond: goal: properties: web_workflow_json: + description: JSON 格式的网页工作流列表 type: string required: - web_workflow_json @@ -228,8 +232,10 @@ reaction_station.bioyond: goal: properties: reactor_id: + description: 反应器编号 (1-5) type: integer temperature: + description: 目标温度 (°C) type: number required: - reactor_id @@ -257,6 +263,7 @@ reaction_station.bioyond: goal: properties: preintake_id: + description: 通量ID type: string required: - preintake_id @@ -338,6 +345,7 @@ reaction_station.bioyond: goal: properties: value: + description: 工作流 ID 列表 items: type: string type: array @@ -365,6 +373,7 @@ reaction_station.bioyond: goal: properties: workflow_id: + description: 工作流ID type: string required: - workflow_id @@ -424,11 +433,11 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称(不能为空) + description: 物料名称(液体种类) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '90' @@ -436,14 +445,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液公式(mL) + description: 分液量(μL) type: string required: - assign_material_name @@ -525,11 +534,11 @@ reaction_station.bioyond: properties: assign_material_name: default: BAPP - description: 物料名称 + description: 物料名称(试剂瓶位) type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -537,15 +546,15 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(int类型, 1=否, 2=是) type: integer volume: default: '350' - description: 分液公式(mL) + description: 分液质量(g) type: string required: [] type: object @@ -593,26 +602,28 @@ reaction_station.bioyond: description: 物料名称 type: string solvents: - description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + description: '溶剂信息的字典或JSON字符串(可选),格式如下: + + {' type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度设定(C) type: number time: default: '360' - description: 观察时间(分钟),默认360 + description: 观察时间(分钟) type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是),默认NO + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume: - description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 + description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算) type: string required: - assign_material_name @@ -671,33 +682,32 @@ reaction_station.bioyond: description: 物料名称 type: string extracted_actuals: - description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) + description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume type: string feeding_order_data: - description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: - {"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}' + description: feeding_order JSON字符串或对象,用于获取m二酐值 type: string temperature: default: 25.0 - description: 温度设定(°C),默认25.00 + description: 温度(C) type: number time: default: '90' - description: 观察时间(分钟),默认90 + description: 观察时间(分钟) type: string titration_type: default: '2' - description: 是否滴定(NO=否, YES=是),默认YES + description: 是否滴定(NO=1, YES=2),默认2 type: string torque_variation: default: 2 - description: 是否观察 (NO=否, YES=是),默认YES + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + description: 分液公式(μL),如果提供则直接使用,否则自动计算 type: string x_value: - description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 + description: 手工输入的x值,格式如 "1-2-3" type: string required: - assign_material_name @@ -738,7 +748,7 @@ reaction_station.bioyond: type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度(C) type: number time: default: '0' @@ -746,14 +756,14 @@ reaction_station.bioyond: type: string titration_type: default: '1' - description: 是否滴定(NO=否, YES=是) + description: 是否滴定(NO=1, YES=2) type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer volume_formula: - description: 分液公式(mL) + description: 分液公式(μL) type: string required: - volume_formula @@ -786,7 +796,7 @@ reaction_station.bioyond: description: 任务名称 type: string workflow_name: - description: 工作流名称 + description: 合并后的工作流名称 type: string required: - workflow_name @@ -819,15 +829,15 @@ reaction_station.bioyond: goal: properties: assign_material_name: - description: 物料名称 + description: 物料名称(不能为空) type: string cutoff: default: '900000' - description: 粘度上限 + description: 粘度上限(需为有效数字字符串,默认 "900000") type: string temperature: default: -10.0 - description: 温度设定(°C) + description: 温度设定(C,范围:-50.00 至 100.00) type: number required: - assign_material_name @@ -909,11 +919,11 @@ reaction_station.bioyond: description: 物料名称(用于获取试剂瓶位ID) type: string material_id: - description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟) + description: 粉末类型ID, Salt=1, Flour=2, BTDA=3 type: string temperature: default: 25.0 - description: 温度设定(°C) + description: 温度设定(C) type: number time: default: '0' @@ -921,7 +931,7 @@ reaction_station.bioyond: type: string torque_variation: default: 1 - description: 是否观察 (NO=否, YES=是) + description: 是否观察(NO=1, YES=2) type: integer required: - material_id @@ -945,10 +955,13 @@ reaction_station.bioyond: config: properties: config: + description: 配置字典,应包含workflow_mappings等配置 type: object deck: + description: Deck对象 type: string protocol_type: + description: 协议类型(由ROS系统传递,此处忽略) type: string required: [] type: object diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index ff357ad4a..b96e53412 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -198,6 +198,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -241,6 +243,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -284,6 +288,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object @@ -329,7 +334,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual: type: object model: mesh: arm_slider - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro type: device version: 1.0.0 robotic_arm.UR: diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml index 74b01e806..14539321d 100644 --- a/unilabos/registry/devices/robot_linear_motion.yaml +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -709,6 +709,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes option, target, + speed, lift_height, mt_height type: string title: SendCmd_Goal type: object @@ -752,6 +754,8 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes quaternion, speed, + position type: string title: SendCmd_Goal type: object @@ -795,6 +799,7 @@ linear_motion.toyo_xyz.sim: additionalProperties: false properties: command: + description: A JSON-formatted string that includes speed type: string title: SendCmd_Goal type: object diff --git a/unilabos/registry/devices/virtual_device.yaml b/unilabos/registry/devices/virtual_device.yaml index 0fce38244..a34d6f556 100644 --- a/unilabos/registry/devices/virtual_device.yaml +++ b/unilabos/registry/devices/virtual_device.yaml @@ -2179,6 +2179,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 端口号 (1-8) type: integer required: - port_number @@ -2225,6 +2226,7 @@ virtual_multiway_valve: goal: properties: port_number: + description: 目标端口号 (1-8) type: integer required: - port_number @@ -2261,6 +2263,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -2304,6 +2307,7 @@ virtual_multiway_valve: additionalProperties: false properties: command: + description: 目标位置 (0-8) 或位置字符串 type: string title: SendCmd_Goal type: object @@ -3960,6 +3964,14 @@ virtual_separator: io_type: source label: bottom_phase_out side: SOUTH + - data_key: top_outlet + data_source: executor + data_type: fluid + description: 上相(轻相)液体输出口 + handler_key: topphaseout + io_type: source + label: top_phase_out + side: NORTH - data_key: mechanical_port data_source: handle data_type: mechanical @@ -4207,6 +4219,7 @@ virtual_solenoid_valve: additionalProperties: false properties: string: + description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"' type: string title: StrSingleInput_Goal type: object @@ -4250,6 +4263,7 @@ virtual_solenoid_valve: additionalProperties: false properties: command: + description: '"OPEN"/"CLOSED" 或其他控制命令' type: string title: SendCmd_Goal type: object @@ -4410,16 +4424,20 @@ virtual_solid_dispenser: event: type: string mass: + description: 质量字符串 (如 "2.9 g") type: string mol: + description: 摩尔数字符串 (如 "0.12 mol") type: string purpose: + description: 添加目的 type: string rate_spec: type: string ratio: type: string reagent: + description: 试剂名称 type: string stir: type: boolean @@ -4431,6 +4449,7 @@ virtual_solid_dispenser: type: string vessel: additionalProperties: false + description: 目标容器 properties: category: type: string @@ -5560,8 +5579,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 拉取速度 (ml/s) type: number volume: + description: 要拉取的体积 (ml) type: number required: - volume @@ -5588,8 +5609,10 @@ virtual_transfer_pump: goal: properties: velocity: + description: 推出速度 (ml/s) type: number volume: + description: 要推出的体积 (ml) type: number required: - volume @@ -5685,10 +5708,12 @@ virtual_transfer_pump: additionalProperties: false properties: max_velocity: + description: 移动速度 (ml/s) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number position: + description: 目标位置 (ml) maximum: 1.7976931348623157e+308 minimum: -1.7976931348623157e+308 type: number @@ -5837,8 +5862,10 @@ virtual_transfer_pump: config: properties: config: + description: 配置字典,包含max_volume, port等参数 type: object device_id: + description: 设备ID type: string required: [] type: object diff --git a/unilabos/registry/devices/xrd_d7mate.yaml b/unilabos/registry/devices/xrd_d7mate.yaml index 2b49ae552..38e31718a 100644 --- a/unilabos/registry/devices/xrd_d7mate.yaml +++ b/unilabos/registry/devices/xrd_d7mate.yaml @@ -409,11 +409,11 @@ xrd_d7mate: properties: end_theta: default: 80.0 - description: 结束角度(≥5.5°,且必须大于start_theta) + description: 结束角度(≥5.5°,且必须大于 start_theta) type: number exp_time: default: 0.1 - description: 曝光时间(0.1-5.0秒) + description: 曝光时间(0.1-5.0 秒) type: number increment: default: 0.05 @@ -421,7 +421,7 @@ xrd_d7mate: type: number sample_id: default: '' - description: 样品标识符 + description: 样品名称 type: string start_theta: default: 10.0 @@ -433,7 +433,7 @@ xrd_d7mate: type: string wait_minutes: default: 3.0 - description: 允许上样后等待分钟数 + description: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟) type: number required: [] title: StartWorkflow_Goal @@ -492,12 +492,15 @@ xrd_d7mate: properties: host: default: 127.0.0.1 + description: 设备IP地址 type: string port: default: 6001 + description: 通信端口,默认6001 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/devices/zhida_gcms.yaml b/unilabos/registry/devices/zhida_gcms.yaml index 37adbd795..b10b29ad9 100644 --- a/unilabos/registry/devices/zhida_gcms.yaml +++ b/unilabos/registry/devices/zhida_gcms.yaml @@ -217,6 +217,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: Base64编码的CSV数据(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -257,6 +258,7 @@ zhida_gcms: additionalProperties: false properties: string: + description: CSV文件路径(ROS2参数名) type: string title: StrSingleInput_Goal type: object @@ -289,12 +291,15 @@ zhida_gcms: properties: host: default: 192.168.3.184 + description: 设备IP地址,本地部署时可使用'127.0.0.1' type: string port: default: 5792 + description: 通信端口,默认5792 type: string timeout: default: 10.0 + description: 超时时间,单位秒 type: string required: [] type: object diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index 15b1b537b..75677b4f2 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -238,6 +238,7 @@ def _setup_host_node(self): "class_name": "unilabos_class", }, "always_free": True, + "feedback_interval": 300.0, }, "test_latency": test_latency_action, "auto-test_resource": test_resource_action, @@ -270,6 +271,7 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= registry_cache.pkl 一个文件中,删除即可完全重置。 """ import time as _time + from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION from unilabos.registry.ast_registry_scanner import scan_directory scan_t0 = _time.perf_counter() @@ -285,6 +287,10 @@ def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only= # ---- 统一缓存:一个 pkl 包含所有数据 ---- unified_cache = self._load_config_cache() ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}}) + if ast_cache.get("version") != AST_SCAN_CACHE_VERSION: + ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}} + unified_cache["_ast_scan"] = ast_cache + unified_cache.pop("_build_results", None) # 默认:扫描 unilabos 包所在的父目录 pkg_root = Path(__file__).resolve().parent.parent # .../unilabos @@ -560,13 +566,47 @@ def _generate_schema_from_info( return prop_schema + @staticmethod + def _apply_docstring_param_metadata( + schema: Dict[str, Any], + doc_info: Dict[str, Any], + field_to_param: Optional[Dict[str, str]] = None, + apply_defaults: bool = False, + ) -> None: + """Apply parsed docstring display names and descriptions to schema properties.""" + if not schema or not doc_info: + return + + props = schema.get("properties", {}) + if not isinstance(props, dict): + return + + param_descs = doc_info.get("params", {}) or {} + param_display_names = doc_info.get("param_display_names", {}) or {} + for field_name, prop_schema in props.items(): + if not isinstance(prop_schema, dict): + continue + param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name + if not isinstance(param_name, str): + continue + param_name = param_name.removesuffix("[]") + if param_name in param_display_names: + prop_schema["title"] = param_display_names[param_name] + elif apply_defaults and not prop_schema.get("title"): + prop_schema["title"] = field_name + + if param_name in param_descs: + prop_schema["description"] = param_descs[param_name] + elif apply_defaults and "description" not in prop_schema: + prop_schema["description"] = "" + def _generate_unilab_json_command_schema( self, method_args: list, docstring: Optional[str] = None, import_map: Optional[Dict[str, str]] = None, + apply_doc_defaults: bool = False, ) -> Dict[str, Any]: """根据方法参数和 docstring 生成 UniLabJsonCommand schema""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema = { "type": "object", @@ -597,12 +637,10 @@ def _generate_unilab_json_command_schema( param_name, param_type, param_default, import_map=import_map ) - if param_name in param_descs: - schema["properties"][param_name]["description"] = param_descs[param_name] - if param_required: schema["required"].append(param_name) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults) return schema def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: @@ -798,6 +836,7 @@ def _build_json_command_entry(method_name, method_info, action_args=None): type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand" params = method_info.get("params", []) method_doc = method_info.get("docstring") + method_doc_info = parse_docstring(method_doc) goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap) if action_args is not None: @@ -827,10 +866,15 @@ def _build_json_command_entry(method_name, method_info, action_args=None): # action handles: 从 @action(handles=[...]) 提取并转换为标准格式 raw_handles = (action_args or {}).get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) - # placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测 - pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params) + # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充 + pk = detect_placeholder_keys(params) + pk.update((action_args or {}).get("placeholder_keys") or {}) # 从方法返回值类型生成 result schema result_schema = None @@ -845,13 +889,20 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "goal": goal, "feedback": (action_args or {}).get("feedback") or {}, "result": (action_args or {}).get("result") or {}, - "schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema), + "schema": wrap_action_schema( + goal_schema, + action_name, + description=(action_args or {}).get("description") or method_doc_info.get("description", ""), + result_schema=result_schema, + ), "goal_default": goal_default, "handles": handles, "placeholder_keys": pk, } if (action_args or {}).get("always_free") or method_info.get("always_free"): entry["always_free"] = True + _fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0)) + entry["feedback_interval"] = _fb_iv nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType) if nt: entry["node_type"] = nt @@ -882,7 +933,11 @@ def _build_json_command_entry(method_name, method_info, action_args=None): action_name = f"auto-{action_name}" raw_handles = action_args.get("handles") - handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) + handles = ( + normalize_ast_action_handles(raw_handles) + if isinstance(raw_handles, list) + else (raw_handles or {}) + ) method_params = method_info.get("params", []) @@ -975,20 +1030,34 @@ def _build_json_command_entry(method_name, method_info, action_args=None): "schema": schema, "goal_default": goal_default, "handles": handles, - "placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params), + "placeholder_keys": { + **detect_placeholder_keys(method_params), + **(action_args.get("placeholder_keys") or {}), + }, } if action_args.get("always_free") or method_info.get("always_free"): action_entry["always_free"] = True + _fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0)) + action_entry["feedback_interval"] = _fb_iv nt = normalize_enum_value(action_args.get("node_type"), NodeType) if nt: action_entry["node_type"] = nt + goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {}) + self._apply_docstring_param_metadata( + goal_schema_for_docs, + parse_docstring(method_info.get("docstring")), + goal, + apply_defaults=True, + ) action_value_mappings[action_name] = action_entry action_value_mappings = dict(sorted(action_value_mappings.items())) # --- init_param_schema = { config: , data: } --- init_params = ast_meta.get("init_params", []) - config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap) + config_schema = self._generate_schema_from_ast_params( + init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap + ) data_schema = self._generate_status_schema_from_ast( ast_meta.get("status_properties", {}), imap ) @@ -1036,7 +1105,6 @@ def _generate_schema_from_ast_params( ) -> Dict[str, Any]: """Generate JSON Schema from AST-extracted parameter list.""" doc_info = parse_docstring(docstring) - param_descs = doc_info.get("params", {}) schema: Dict[str, Any] = { "type": "object", @@ -1066,12 +1134,10 @@ def _generate_schema_from_ast_params( pname, ptype, pdefault, import_map ) - if pname in param_descs: - schema["properties"][pname]["description"] = param_descs[pname] - if prequired: schema["required"].append(pname) + self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True) return schema def _generate_status_schema_from_ast( @@ -1801,7 +1867,7 @@ def _load_single_device_file( else: action_key = f"auto-{k}" goal_schema = self._generate_unilab_json_command_schema( - v["args"], import_map=enhanced_import_map + v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map ) ret_type = v.get("return_type", "") result_schema = None @@ -1810,7 +1876,13 @@ def _load_single_device_file( "result", ret_type, None, import_map=enhanced_import_map ) old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {}) - new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema) + doc_info = parse_docstring(v.get("docstring")) + new_schema = wrap_action_schema( + goal_schema, + action_key, + description=doc_info.get("description", ""), + result_schema=result_schema, + ) old_schema = old_cfg.get("schema", {}) if old_schema: preserve_field_descriptions(new_schema, old_schema) @@ -1876,6 +1948,12 @@ def _load_single_device_file( merged_pk = dict(old_cfg.get("placeholder_keys", {})) merged_pk.update(detect_placeholder_keys(v["args"])) + goal_schema_for_docs = ( + entry_schema.get("properties", {}).get("goal", {}) + if isinstance(entry_schema, dict) + else {} + ) + self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal) entry = { "type": entry_type, @@ -1896,7 +1974,8 @@ def _load_single_device_file( device_config["init_param_schema"] = {} init_schema = self._generate_unilab_json_command_schema( - enhanced_info["init_params"], "__init__", + enhanced_info["init_params"], + docstring=enhanced_info.get("init_docstring"), import_map=enhanced_import_map, ) device_config["init_param_schema"]["config"] = init_schema @@ -1943,7 +2022,9 @@ def _load_single_device_file( action_str_type_mapping[action_type_str] = target_type if target_type is not None: try: - action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict() + action_config["goal_default"] = ROS2MessageInstance( + target_type.Goal() + ).get_python_dict() except Exception: action_config["goal_default"] = {} prev_schema = action_config.get("schema", {}) @@ -2135,6 +2216,7 @@ def obtain_registry_device_info(self): "unilabos_device_id": { "type": "string", "default": "", + "title": "设备ID", "description": "UniLabOS设备ID,用于指定执行动作的具体设备实例", }, **schema["properties"]["goal"]["properties"], @@ -2206,7 +2288,14 @@ def get_yaml_output(self, device_id: str) -> str: lab_registry = Registry() -def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): +def build_registry( + registry_paths=None, + devices_dirs=None, + upload_registry=False, + check_mode=False, + complete_registry=False, + external_only=False, +): """ 构建或获取Registry单例实例 """ @@ -2220,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False if path not in current_paths: lab_registry.registry_paths.append(path) - lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) + lab_registry.setup( + devices_dirs=devices_dirs, + upload_registry=upload_registry, + complete_registry=complete_registry, + external_only=external_only, + ) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) lab_registry.resolve_all_types() diff --git a/unilabos/registry/resources/battery/bottle_carriers.yaml b/unilabos/registry/resources/battery/bottle_carriers.yaml new file mode 100644 index 000000000..d004ee8ba --- /dev/null +++ b/unilabos/registry/resources/battery/bottle_carriers.yaml @@ -0,0 +1,12 @@ +YIHUA_Electrolyte_12VialCarrier: + category: + - battery_bottle_carriers + class: + module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier + type: pylabrobot + description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/YB_bottle.yaml b/unilabos/registry/resources/bioyond/YB_bottle.yaml index 199173723..ff43031a3 100644 --- a/unilabos/registry/resources/bioyond/YB_bottle.yaml +++ b/unilabos/registry/resources/bioyond/YB_bottle.yaml @@ -1,84 +1,140 @@ -YB_20ml_fenyeping: +YB_Vial_20mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping + module: unilabos.resources.bioyond.YB_bottles:YB_Vial_20mL type: pylabrobot - description: YB_20ml_fenyeping + description: YB_Vial_20mL handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_5ml_fenyeping: +YB_Vial_5mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping + module: unilabos.resources.bioyond.YB_bottles:YB_Vial_5mL type: pylabrobot - description: YB_5ml_fenyeping + description: YB_Vial_5mL handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_jia_yang_tou_da: +YB_DosingHead_L: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da + module: unilabos.resources.bioyond.YB_bottles:YB_DosingHead_L type: pylabrobot - description: YB_jia_yang_tou_da + description: YB_DosingHead_L handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_pei_ye_da_Bottle: +YB_PrepBottle_60mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_60mL type: pylabrobot - description: YB_pei_ye_da_Bottle + description: YB_PrepBottle_60mL handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_pei_ye_xiao_Bottle: +YB_PrepBottle_15mL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_15mL type: pylabrobot - description: YB_pei_ye_xiao_Bottle + description: YB_PrepBottle_15mL handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_qiang_tou: +YB_Tip_5000uL: category: - - yb3 - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_5000uL type: pylabrobot - description: YB_qiang_tou + description: YB_Tip_5000uL handles: [] icon: '' init_param_schema: {} version: 1.0.0 -YB_ye_Bottle: +YB_Tip_1000uL: + category: + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_1000uL + type: pylabrobot + description: YB_Tip_1000uL + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_Tip_50uL: + category: + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_Tip_50uL + type: pylabrobot + description: YB_Tip_50uL + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_NormalLiq_250mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_250mL_Bottle + type: pylabrobot + description: YB_NormalLiq_250mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_NormalLiq_100mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_100mL_Bottle + type: pylabrobot + description: YB_NormalLiq_100mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_HighVis_250mL_Bottle: + category: + - YB_bottle_carriers + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_250mL_Bottle + type: pylabrobot + description: YB_HighVis_250mL_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +YB_HighVis_100mL_Bottle: category: - - yb3 - YB_bottle_carriers - YB_bottle class: - module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle + module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_100mL_Bottle type: pylabrobot - description: YB_ye_Bottle + description: YB_HighVis_100mL_Bottle handles: [] icon: '' init_param_schema: {} diff --git a/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml b/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml index 76b6b9387..c352d0f2e 100644 --- a/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml +++ b/unilabos/registry/resources/bioyond/YB_bottle_carriers.yaml @@ -1,168 +1,180 @@ -YB_100ml_yeti: +YB_Vial_20mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_20mL_Carrier type: pylabrobot - description: YB_100ml_yeti + description: YB_Vial_20mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_20ml_fenyepingban: +YB_Vial_5mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_5mL_Carrier type: pylabrobot - description: YB_20ml_fenyepingban + description: YB_Vial_5mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_5ml_fenyepingban: +YB_6StockCarrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier type: pylabrobot - description: YB_5ml_fenyepingban + description: YB_6StockCarrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_6StockCarrier: +YB_6VialCarrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier type: pylabrobot - description: YB_6StockCarrier + description: YB_6VialCarrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_6VialCarrier: +YB_DosingHead_L_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_DosingHead_L_Carrier type: pylabrobot - description: YB_6VialCarrier + description: YB_DosingHead_L_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_gao_nian_ye_Bottle: +YB_PrepBottle_60mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_60mL_Carrier type: pylabrobot - description: YB_gao_nian_ye_Bottle + description: YB_PrepBottle_60mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_gaonianye: +YB_PrepBottle_15mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_15mL_Carrier type: pylabrobot - description: YB_gaonianye + description: YB_PrepBottle_15mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_jia_yang_tou_da_Carrier: +YB_TipRack_Mixed: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_Mixed type: pylabrobot - description: YB_jia_yang_tou_da_Carrier + description: YB_TipRack_Mixed handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_peiyepingdaban: +YB_TipRack_5000uL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_5000uL type: pylabrobot - description: YB_peiyepingdaban + description: YB_TipRack_5000uL handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_peiyepingxiaoban: +YB_TipRack_50uL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_50uL type: pylabrobot - description: YB_peiyepingxiaoban + description: YB_TipRack_50uL handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_qiang_tou_he: +YB_Adapter_60mL: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Adapter_60mL type: pylabrobot - description: YB_qiang_tou_he + description: YB_Adapter_60mL handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_shi_pei_qi_kuai: +YB_NormalLiq_250mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_250mL_Carrier type: pylabrobot - description: YB_shi_pei_qi_kuai + description: YB_NormalLiq_250mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_ye: +YB_NormalLiq_100mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_100mL_Carrier type: pylabrobot - description: YB_ye_Bottle_Carrier + description: YB_NormalLiq_100mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 -YB_ye_100ml_Bottle: +YB_HighVis_250mL_Carrier: category: - - yb3 - YB_bottle_carriers class: - module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_250mL_Carrier type: pylabrobot - description: YB_ye_100ml_Bottle + description: YB_HighVis_250mL_Carrier handles: [] icon: '' init_param_schema: {} + registry_type: resource version: 1.0.0 +YB_HighVis_100mL_Carrier: + category: + - YB_bottle_carriers + class: + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_100mL_Carrier + type: pylabrobot + description: YB_HighVis_100mL_Carrier + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 \ No newline at end of file diff --git a/unilabos/registry/resources/bioyond/deck.yaml b/unilabos/registry/resources/bioyond/deck.yaml index 5770a2d12..244e812ba 100644 --- a/unilabos/registry/resources/bioyond/deck.yaml +++ b/unilabos/registry/resources/bioyond/deck.yaml @@ -20,22 +20,22 @@ BIOYOND_PolymerReactionStation_Deck: icon: 反应站.webp init_param_schema: {} version: 1.0.0 -BIOYOND_YB_Deck: +BioyondElectrolyteDeck: category: - deck class: - module: unilabos.resources.bioyond.decks:YB_Deck + module: unilabos.resources.bioyond.decks:bioyond_electrolyte_deck type: pylabrobot description: BIOYOND ElectrolyteFormulationStation Deck handles: [] icon: 配液站.webp init_param_schema: {} version: 1.0.0 -CoincellDeck: +YihuaCoinCellDeck: category: - deck class: - module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck + module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:yihua_coin_cell_deck type: pylabrobot description: YIHUA CoinCellAssembly Deck handles: [] diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml index 3f0aa9d2a..751f1aa5e 100644 --- a/unilabos/registry/resources/common/resource_container.yaml +++ b/unilabos/registry/resources/common/resource_container.yaml @@ -17,7 +17,7 @@ hplc_plate: - 0 - 0 - 3.1416 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro type: resource version: 1.0.0 plate_96: @@ -39,7 +39,7 @@ plate_96: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro type: resource version: 1.0.0 plate_96_high: @@ -61,7 +61,7 @@ plate_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro type: resource version: 1.0.0 tiprack_96_high: @@ -76,7 +76,7 @@ tiprack_96_high: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -92,7 +92,7 @@ tiprack_96_high: - 1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro type: resource version: 1.0.0 tiprack_box: @@ -107,7 +107,7 @@ tiprack_box: init_param_schema: {} model: children_mesh: tip/meshes/tip.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro children_mesh_tf: - 0.0045 - 0.0045 @@ -123,6 +123,6 @@ tiprack_box: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml index 586e3cfeb..400bc9312 100644 --- a/unilabos/registry/resources/laiyu/container.yaml +++ b/unilabos/registry/resources/laiyu/container.yaml @@ -11,7 +11,7 @@ bottle_container: init_param_schema: {} model: children_mesh: bottle/meshes/bottle.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro children_mesh_tf: - 0.04 - 0.04 @@ -27,7 +27,7 @@ bottle_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro type: resource version: 1.0.0 tube_container: @@ -43,7 +43,7 @@ tube_container: init_param_schema: {} model: children_mesh: tube/meshes/tube.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro children_mesh_tf: - 0.017 - 0.017 @@ -59,6 +59,6 @@ tube_container: - 0 - 0 - 0 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml index 85da0ca7c..89973dded 100644 --- a/unilabos/registry/resources/laiyu/deck.yaml +++ b/unilabos/registry/resources/laiyu/deck.yaml @@ -10,6 +10,6 @@ TransformXYZDeck: init_param_schema: {} model: mesh: liquid_transform_xyz - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml index 10e91cef3..0e35e7b1c 100644 --- a/unilabos/registry/resources/opentrons/deck.yaml +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -10,7 +10,7 @@ OTDeck: init_param_schema: {} model: mesh: opentrons_liquid_handler - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro type: device version: 1.0.0 hplc_station: @@ -25,6 +25,6 @@ hplc_station: init_param_schema: {} model: mesh: hplc_station - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml index 20a71995a..883bf147b 100644 --- a/unilabos/registry/resources/opentrons/plates.yaml +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl - children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro + children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 nest_96_wellplate_200ul_flat: @@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 thermoscientificnunc_96_wellplate_1300ul: diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml index d1682b2af..ec8380185 100644 --- a/unilabos/registry/resources/opentrons/tip_racks.yaml +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul: - -1.5708 - 0 - 1.5708 - path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro + path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource version: 1.0.0 opentrons_96_filtertiprack_10ul: diff --git a/unilabos/registry/utils.py b/unilabos/registry/utils.py index eb342c5c7..6b1acb2be 100644 --- a/unilabos/registry/utils.py +++ b/unilabos/registry/utils.py @@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception): # --------------------------------------------------------------------------- _SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$") +_PARAM_HEADER_RE = re.compile( + r"^\s*(?P\w[\w]*)\s*(?:\[(?P[^\]]+)\])?(?:\s*\([^)]*\))?\s*$" +) + + +def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]: + """Parse ``name[display_name]`` or Google-style ``name (type)``.""" + match = _PARAM_HEADER_RE.match(param_part.strip()) + if not match: + return param_part.strip().split("(")[0].strip(), None + + display_name = match.group("display_name") + if display_name is not None: + display_name = display_name.strip() or None + return match.group("name").strip(), display_name def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: """ - 解析 Google-style docstring,提取描述和参数说明。 + 解析 docstring,提取描述和参数说明。 + + 支持: + - Google-style ``Args:`` / ``Parameters:`` 小节 + - 直接参数行 ``field: desc`` + - 带显示名参数行 ``field[Display Name]: desc`` Returns: - {"description": "短描述", "params": {"param1": "参数1描述", ...}} + { + "description": "短描述", + "params": {"param1": "参数1描述", ...}, + "param_display_names": {"param1": "显示名", ...}, + } """ - result: Dict[str, Any] = {"description": "", "params": {}} + result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}} if not docstring: return result @@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: if not lines: return result - result["description"] = lines[0].strip() - in_args = False + current_section: Optional[str] = None current_param: Optional[str] = None + current_display_name: Optional[str] = None current_desc_parts: list = [] - for line in lines[1:]: + def flush_current_param() -> None: + nonlocal current_param, current_display_name, current_desc_parts + if current_param is None: + return + result["params"][current_param] = "\n".join(current_desc_parts).strip() + if current_display_name: + result["param_display_names"][current_param] = current_display_name + current_param = None + current_display_name = None + current_desc_parts = [] + + first_line = lines[0].strip() + start_index = 0 + if not _SECTION_RE.match(first_line) and ":" not in first_line: + result["description"] = first_line + start_index = 1 + + for line in lines[start_index:]: stripped = line.strip() + if not stripped: + if current_param is not None: + current_desc_parts.append("") + continue + section_match = _SECTION_RE.match(stripped) if section_match: - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() - current_param = None - current_desc_parts = [] - section_name = section_match.group(1).lower() - in_args = section_name in ("args", "arguments", "parameters", "params") + flush_current_param() + current_section = section_match.group(1).lower() + in_args = current_section in ("args", "arguments", "parameters", "params") continue - if not in_args: + parse_as_param = in_args or current_section is None + if not parse_as_param: continue - if ":" in stripped and not stripped.startswith(" "): - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + if ":" in stripped: + flush_current_param() param_part, _, desc_part = stripped.partition(":") - param_name = param_part.strip().split("(")[0].strip() + param_name, display_name = _parse_docstring_param_header(param_part) current_param = param_name + current_display_name = display_name current_desc_parts = [desc_part.strip()] elif current_param is not None: aline = line @@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: aline = aline[1:] current_desc_parts.append(aline.strip()) - if current_param is not None: - result["params"][current_param] = "\n".join(current_desc_parts).strip() + flush_current_param() return result diff --git a/unilabos/resources/battery/bottle_carriers.py b/unilabos/resources/battery/bottle_carriers.py index 9d9827cdd..4003ae73b 100644 --- a/unilabos/resources/battery/bottle_carriers.py +++ b/unilabos/resources/battery/bottle_carriers.py @@ -1,9 +1,6 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d from unilabos.resources.itemized_carrier import Bottle, BottleCarrier -from unilabos.resources.bioyond.YB_bottles import ( - YB_pei_ye_xiao_Bottle, -) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -51,6 +48,5 @@ def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier: carrier.num_items_x = 2 carrier.num_items_y = 6 carrier.num_items_z = 1 - for i in range(12): - carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") + # 载架初始化为空,瓶子由实际转运操作填入,避免反序列化时重复 assign return carrier diff --git a/unilabos/resources/battery/electrode_sheet.py b/unilabos/resources/battery/electrode_sheet.py index 22f98affa..080046073 100644 --- a/unilabos/resources/battery/electrode_sheet.py +++ b/unilabos/resources/battery/electrode_sheet.py @@ -135,6 +135,7 @@ class BatteryState(TypedDict): open_circuit_voltage: float assembly_pressure: float electrolyte_volume: float + pole_weight: float # 极片称重 (mg) info: Optional[str] # 附加信息 @@ -179,6 +180,7 @@ def __init__( open_circuit_voltage=0.0, assembly_pressure=0.0, electrolyte_volume=0.0, + pole_weight=0.0, info=None ) diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py index 04328a407..aeddea7b4 100644 --- a/unilabos/resources/battery/magazine.py +++ b/unilabos/resources/battery/magazine.py @@ -53,13 +53,28 @@ def size_z(self) -> float: return self.get_size_z() def serialize(self) -> dict: - return { - **super().serialize(), + data = super().serialize() + # 物料余量由寄存器接管,不再持久化极片子节点, + # 防止旧数据写回数据库后下次启动时再次引发重复 UUID。 + data["children"] = [] + data.update({ "size_x": self.size_x or 10.0, "size_y": self.size_y or 10.0, "size_z": self.size_z or 10.0, "max_sheets": self.max_sheets, - } + }) + return data + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + """反序列化时丢弃极片子节点(ElectrodeSheet 等)。 + + 物料余量已由寄存器接管,不再在资源树中追踪每个极片实体。 + 清空 children 可防止数据库中的旧极片记录被重新加载,避免重复 UUID 报错。 + """ + data = dict(data) + data["children"] = [] + return super().deserialize(data, allow_marshal=allow_marshal) class MagazineHolder(ItemizedResource): @@ -220,7 +235,7 @@ def MagazineHolder_6_Cathode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, @@ -258,7 +273,7 @@ def MagazineHolder_6_Anode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, @@ -335,7 +350,7 @@ def MagazineHolder_4_Cathode( size_y=size_y, size_z=size_z, locations=locations, - klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode], + klasses=None, hole_diameter=hole_diameter, hole_depth=hole_depth, max_sheets_per_hole=max_sheets_per_hole, diff --git a/unilabos/resources/bioyond/YB_bottle_carriers.py b/unilabos/resources/bioyond/YB_bottle_carriers.py index 29a532427..d328689df 100644 --- a/unilabos/resources/bioyond/YB_bottle_carriers.py +++ b/unilabos/resources/bioyond/YB_bottle_carriers.py @@ -2,15 +2,18 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier from unilabos.resources.bioyond.YB_bottles import ( - YB_jia_yang_tou_da, - YB_ye_Bottle, - YB_ye_100ml_Bottle, - YB_gao_nian_ye_Bottle, - YB_5ml_fenyeping, - YB_20ml_fenyeping, - YB_pei_ye_xiao_Bottle, - YB_pei_ye_da_Bottle, - YB_qiang_tou, + YB_DosingHead_L, + YB_NormalLiq_250mL_Bottle, + YB_NormalLiq_100mL_Bottle, + YB_HighVis_250mL_Bottle, + YB_HighVis_100mL_Bottle, + YB_Vial_5mL, + YB_Vial_20mL, + YB_PrepBottle_15mL, + YB_PrepBottle_60mL, + YB_Tip_5000uL, + YB_Tip_1000uL, + YB_Tip_50uL, ) # 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial @@ -206,7 +209,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier: return carrier # 1瓶载架 - 单个中央位置 -def YB_ye(name: str) -> BottleCarrier: +def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -233,17 +236,17 @@ def YB_ye(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_ye", + model="YB_NormalLiq_250mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_ye_Bottle(f"{name}_flask_1") + carrier[0] = YB_NormalLiq_250mL_Bottle(f"{name}_flask_1") return carrier # 高粘液瓶载架 - 单个中央位置 -def YB_gaonianye(name: str) -> BottleCarrier: +def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -270,17 +273,17 @@ def YB_gaonianye(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_gaonianye", + model="YB_HighVis_250mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1") + carrier[0] = YB_HighVis_250mL_Bottle(f"{name}_flask_1") return carrier -# 100ml液体瓶载架 - 单个中央位置 -def YB_100ml_yeti(name: str) -> BottleCarrier: +# 100mL普通液瓶载架 - 单个中央位置 +def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -307,16 +310,52 @@ def YB_100ml_yeti(name: str) -> BottleCarrier: resource_size_y=beaker_diameter, name_prefix=name, ), - model="YB_100ml_yeti", + model="YB_NormalLiq_100mL_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1") + carrier[0] = YB_NormalLiq_100mL_Bottle(f"{name}_flask_1") return carrier -# 5ml分液瓶板 - 4x2布局,8个位置 -def YB_5ml_fenyepingban(name: str) -> BottleCarrier: +# 100mL高粘液瓶载架 - 单个中央位置 +def YB_HighVis_100mL_Carrier(name: str) -> BottleCarrier: + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 20.0 + + # 烧杯尺寸 + beaker_diameter = 60.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="YB_HighVis_100mL_Carrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = YB_HighVis_100mL_Bottle(f"{name}_flask_1") + return carrier + +# 5mL分液瓶板 - 4x2布局,8个位置 +def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -355,18 +394,18 @@ def YB_5ml_fenyepingban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_5ml_fenyepingban", + model="YB_Vial_5mL_Carrier", ) carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] for i in range(8): - carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}") + carrier[i] = YB_Vial_5mL(f"{name}_vial_{ordering[i]}") return carrier -# 20ml分液瓶板 - 4x2布局,8个位置 -def YB_20ml_fenyepingban(name: str) -> BottleCarrier: +# 20mL分液瓶板 - 4x2布局,8个位置 +def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -405,18 +444,18 @@ def YB_20ml_fenyepingban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_20ml_fenyepingban", + model="YB_Vial_20mL_Carrier", ) carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] for i in range(8): - carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}") + carrier[i] = YB_Vial_20mL(f"{name}_vial_{ordering[i]}") return carrier # 配液瓶(小)板 - 4x2布局,8个位置 -def YB_peiyepingxiaoban(name: str) -> BottleCarrier: +def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) @@ -455,19 +494,19 @@ def YB_peiyepingxiaoban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_peiyepingxiaoban", + model="YB_PrepBottle_15mL_Carrier", ) carrier.num_items_x = 4 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] for i in range(8): - carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}") + carrier[i] = YB_PrepBottle_15mL(f"{name}_bottle_{ordering[i]}") return carrier # 配液瓶(大)板 - 2x2布局,4个位置 -def YB_peiyepingdaban(name: str) -> BottleCarrier: +def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -475,9 +514,9 @@ def YB_peiyepingdaban(name: str) -> BottleCarrier: carrier_size_z = 95.0 # 瓶位尺寸 - bottle_diameter = 55.0 - bottle_spacing_x = 60.0 # X方向间距 - bottle_spacing_y = 60.0 # Y方向间距 + bottle_diameter = 40.0 + bottle_spacing_x = 50.0 # X方向间距 + bottle_spacing_y = 50.0 # Y方向间距 # 计算起始位置 (居中排列) start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2 @@ -505,18 +544,18 @@ def YB_peiyepingdaban(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_peiyepingdaban", + model="YB_PrepBottle_60mL_Carrier", ) carrier.num_items_x = 2 carrier.num_items_y = 2 carrier.num_items_z = 1 ordering = ["A1", "A2", "B1", "B2"] for i in range(4): - carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}") + carrier[i] = YB_PrepBottle_60mL(f"{name}_bottle_{ordering[i]}") return carrier # 加样头(大)板 - 1x1布局,1个位置 -def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier: +def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier: # 载架尺寸 (mm) carrier_size_x = 127.8 @@ -554,16 +593,16 @@ def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_jia_yang_tou_da_Carrier", + model="YB_DosingHead_L_Carrier", ) carrier.num_items_x = 1 carrier.num_items_y = 1 carrier.num_items_z = 1 - carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1") + carrier[0] = YB_DosingHead_L(f"{name}_head_1") return carrier -def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: +def YB_Adapter_60mL(name: str) -> BottleCarrier: """适配器块 - 单个中央位置""" # 载架尺寸 (mm) @@ -591,7 +630,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: resource_size_y=adapter_diameter, name_prefix=name, ), - model="YB_shi_pei_qi_kuai", + model="YB_Adapter_60mL", ) carrier.num_items_x = 1 carrier.num_items_y = 1 @@ -600,7 +639,7 @@ def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier: return carrier -def YB_qiang_tou_he(name: str) -> BottleCarrier: +def YB_TipRack_50uL(name: str) -> BottleCarrier: """枪头盒 - 8x12布局,96个位置""" # 载架尺寸 (mm) @@ -609,9 +648,9 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: carrier_size_z = 55.0 # 枪头尺寸 - tip_diameter = 10.0 - tip_spacing_x = 9.0 # X方向间距 - tip_spacing_y = 9.0 # Y方向间距 + tip_diameter = 7.0 + tip_spacing_x = 7.5 # X方向间距 + tip_spacing_y = 7.5 # Y方向间距 # 计算起始位置 (居中排列) start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2 @@ -639,7 +678,7 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: size_y=carrier_size_y, size_z=carrier_size_z, sites=sites, - model="YB_qiang_tou_he", + model="YB_TipRack_50uL", ) carrier.num_items_x = 12 carrier.num_items_y = 8 @@ -648,6 +687,182 @@ def YB_qiang_tou_he(name: str) -> BottleCarrier: for i in range(96): row = chr(65 + i // 12) # A-H col = (i % 12) + 1 # 1-12 - carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}") + carrier[i] = YB_Tip_50uL(f"{name}_tip_{row}{col}") + return carrier + + +def YB_TipRack_5000uL(name: str) -> BottleCarrier: + """枪头盒 - 4x6布局,24个位置""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 95.0 + + # 枪头尺寸 + tip_diameter = 16.0 + tip_spacing_x = 16.5 # X方向间距 + tip_spacing_y = 16.5 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (6 - 1) * tip_spacing_x - tip_diameter) / 2 + start_y = (carrier_size_y - (4 - 1) * tip_spacing_y - tip_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=6, + num_items_y=4, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=tip_spacing_x, + item_dy=tip_spacing_y, + size_x=tip_diameter, + size_y=tip_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="YB_TipRack_5000uL", + ) + carrier.num_items_x = 6 + carrier.num_items_y = 4 + carrier.num_items_z = 1 + # 创建24个枪头 + for i in range(24): + row = chr(65 + i // 6) # A-D + col = (i % 6) + 1 # 1-6 + carrier[i] = YB_Tip_5000uL(f"{name}_tip_{row}{col}") + return carrier + + + +def YB_TipRack_Mixed(name: str) -> BottleCarrier: + """混合枪头盒 - 复杂布局 + 上层: 2x8空位(原50uL枪头位置,现空余) + 中层: 4x4布局,放5000uL枪头 + 下层: 2x8布局,放1000uL枪头 + """ + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 95.0 + + # 各类枪头的尺寸参数 + tip_5000_diameter = 16.0 + tip_5000_spacing_x = 16.5 + tip_5000_spacing_y = 16.5 + + tip_1000_diameter = 7.0 + tip_1000_spacing_x = 7.5 + tip_1000_spacing_y = 7.5 + + # 空位尺寸(上层2x8,原50uL位置) + empty_diameter = 7.0 + empty_spacing_x = 7.5 + empty_spacing_y = 7.5 + + # 计算各层的起始位置 + # 上层空位 (2x8) + empty_top_start_x = (carrier_size_x - (8 - 1) * empty_spacing_x - empty_diameter) / 2 + empty_top_start_y = 5.0 + + # 中层5000uL (4x4) + tip_5000_start_x = (carrier_size_x - (4 - 1) * tip_5000_spacing_x - tip_5000_diameter) / 2 + tip_5000_start_y = empty_top_start_y + 2 * empty_spacing_y + 5.0 + + # 下层1000uL (2x8) + tip_1000_start_x = (carrier_size_x - (8 - 1) * tip_1000_spacing_x - tip_1000_diameter) / 2 + tip_1000_start_y = tip_5000_start_y + 4 * tip_5000_spacing_y + 5.0 + + sites = {} + + # 创建上层空位 (2x8) - 不创建实际的枪头对象 + empty_top_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=8, + num_items_y=2, + dx=empty_top_start_x, + dy=empty_top_start_y, + dz=5.0, + item_dx=empty_spacing_x, + item_dy=empty_spacing_y, + size_x=empty_diameter, + size_y=empty_diameter, + size_z=carrier_size_z, + ) + # 添加空位,索引 0-15 + for k, v in empty_top_sites.items(): + v.name = f"{name}_empty_top_{v.name}" + sites[k] = v + + # 创建中层5000uL枪头位 (4x4),索引 16-31 + tip_5000_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, + num_items_y=4, + dx=tip_5000_start_x, + dy=tip_5000_start_y, + dz=15.0, + item_dx=tip_5000_spacing_x, + item_dy=tip_5000_spacing_y, + size_x=tip_5000_diameter, + size_y=tip_5000_diameter, + size_z=carrier_size_z, + ) + for i, (k, v) in enumerate(tip_5000_sites.items()): + v.name = f"{name}_5000_{v.name}" + sites[16 + i] = v + + # 创建下层1000uL枪头位 (2x8),索引 32-47 + tip_1000_sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=8, + num_items_y=2, + dx=tip_1000_start_x, + dy=tip_1000_start_y, + dz=25.0, + item_dx=tip_1000_spacing_x, + item_dy=tip_1000_spacing_y, + size_x=tip_1000_diameter, + size_y=tip_1000_diameter, + size_z=carrier_size_z, + ) + for i, (k, v) in enumerate(tip_1000_sites.items()): + v.name = f"{name}_1000_{v.name}" + sites[32 + i] = v + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="YB_TipRack_Mixed", + ) + carrier.num_items_x = 8 # 最大宽度 + carrier.num_items_y = 8 # 总行数 (2+4+2) + carrier.num_items_z = 1 + + # 为5000uL枪头创建实例 (16个),对应索引 16-31 + for i in range(16): + row = chr(65 + i // 4) # A-D + col = (i % 4) + 1 # 1-4 + carrier[16 + i] = YB_Tip_5000uL(f"{name}_tip5000_{row}{col}") + + # 为1000uL枪头创建实例 (16个),对应索引 32-47 + for i in range(16): + row = chr(65 + i // 8) # A-B + col = (i % 8) + 1 # 1-8 + carrier[32 + i] = YB_Tip_1000uL(f"{name}_tip1000_{row}{col}") + return carrier diff --git a/unilabos/resources/bioyond/YB_bottles.py b/unilabos/resources/bioyond/YB_bottles.py index acbbf35b3..1f7b8d8db 100644 --- a/unilabos/resources/bioyond/YB_bottles.py +++ b/unilabos/resources/bioyond/YB_bottles.py @@ -1,7 +1,7 @@ from unilabos.resources.itemized_carrier import Bottle, BottleCarrier # 工厂函数 """加样头(大)""" -def YB_jia_yang_tou_da( +def YB_DosingHead_L( name: str, diameter: float = 20.0, height: float = 100.0, @@ -15,11 +15,11 @@ def YB_jia_yang_tou_da( height=height, max_volume=max_volume, barcode=barcode, - model="YB_jia_yang_tou_da", + model="YB_DosingHead_L", ) -"""液1x1""" -def YB_ye_Bottle( +"""250mL普通液""" +def YB_NormalLiq_250mL_Bottle( name: str, diameter: float = 40.0, height: float = 70.0, @@ -33,87 +33,105 @@ def YB_ye_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_ye_Bottle", + model="YB_NormalLiq_250mL_Bottle", ) -"""100ml液体""" -def YB_ye_100ml_Bottle( +"""100mL普通液""" +def YB_NormalLiq_100mL_Bottle( name: str, diameter: float = 50.0, height: float = 90.0, max_volume: float = 100000.0, # 100mL barcode: str = None, ) -> Bottle: - """创建100ml液体瓶""" + """创建100mL普通液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_100ml_yeti", + model="YB_NormalLiq_100mL_Bottle", ) -"""高粘液""" -def YB_gao_nian_ye_Bottle( +"""100mL高粘液""" +def YB_HighVis_100mL_Bottle( + name: str, + diameter: float = 50.0, + height: float = 90.0, + max_volume: float = 100000.0, # 100mL + barcode: str = None, +) -> Bottle: + """创建100mL高粘液瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_HighVis_100mL_Bottle", + ) + +"""250mL高粘液""" +def YB_HighVis_250mL_Bottle( name: str, diameter: float = 40.0, height: float = 70.0, max_volume: float = 50000.0, # 50mL barcode: str = None, ) -> Bottle: - """创建高粘液瓶""" + """创建250mL高粘液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="High_Viscosity_Liquid", + model="YB_HighVis_250mL_Bottle", ) -"""5ml分液瓶""" -def YB_5ml_fenyeping( +"""5mL分液瓶""" +def YB_Vial_5mL( name: str, diameter: float = 20.0, height: float = 50.0, max_volume: float = 5000.0, # 5mL barcode: str = None, ) -> Bottle: - """创建5ml分液瓶""" + """创建5mL分液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_5ml_fenyeping", + model="YB_Vial_5mL", ) -"""20ml分液瓶""" -def YB_20ml_fenyeping( +"""20mL分液瓶""" +def YB_Vial_20mL( name: str, diameter: float = 30.0, height: float = 65.0, max_volume: float = 20000.0, # 20mL barcode: str = None, ) -> Bottle: - """创建20ml分液瓶""" + """创建20mL分液瓶""" return Bottle( name=name, diameter=diameter, height=height, max_volume=max_volume, barcode=barcode, - model="YB_20ml_fenyeping", + model="YB_Vial_20mL", ) """配液瓶(小)""" -def YB_pei_ye_xiao_Bottle( +def YB_PrepBottle_15mL( name: str, diameter: float = 35.0, height: float = 60.0, - max_volume: float = 30000.0, # 30mL + max_volume: float = 15000.0, # 15mL barcode: str = None, ) -> Bottle: """创建配液瓶(小)""" @@ -123,15 +141,15 @@ def YB_pei_ye_xiao_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_pei_ye_xiao_Bottle", + model="YB_PrepBottle_15mL", ) """配液瓶(大)""" -def YB_pei_ye_da_Bottle( +def YB_PrepBottle_60mL( name: str, - diameter: float = 55.0, - height: float = 100.0, - max_volume: float = 150000.0, # 150mL + diameter: float = 40.0, + height: float = 80.0, + max_volume: float = 60000.0, # 60mL barcode: str = None, ) -> Bottle: """创建配液瓶(大)""" @@ -141,11 +159,29 @@ def YB_pei_ye_da_Bottle( height=height, max_volume=max_volume, barcode=barcode, - model="YB_pei_ye_da_Bottle", + model="YB_PrepBottle_60mL", ) -"""枪头""" -def YB_qiang_tou( +"""5000uL枪头""" +def YB_Tip_5000uL( + name: str, + diameter: float = 10.0, + height: float = 50.0, + max_volume: float = 5000.0, # 5mL + barcode: str = None, +) -> Bottle: + """创建枪头""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_Tip_5000uL", + ) + +"""1000uL枪头""" +def YB_Tip_1000uL( name: str, diameter: float = 10.0, height: float = 50.0, @@ -159,5 +195,23 @@ def YB_qiang_tou( height=height, max_volume=max_volume, barcode=barcode, - model="YB_qiang_tou", + model="YB_Tip_1000uL", ) + +"""50uL枪头""" +def YB_Tip_50uL( + name: str, + diameter: float = 10.0, + height: float = 50.0, + max_volume: float = 50.0, # 50uL + barcode: str = None, +) -> Bottle: + """创建枪头""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_Tip_50uL", + ) \ No newline at end of file diff --git a/unilabos/resources/bioyond/YB_warehouses.py b/unilabos/resources/bioyond/YB_warehouses.py index 7b3ab510d..0a437db5d 100644 --- a/unilabos/resources/bioyond/YB_warehouses.py +++ b/unilabos/resources/bioyond/YB_warehouses.py @@ -367,6 +367,30 @@ def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse: removed_positions=None ) +def bioyond_warehouse_4x2x1(name: str) -> WareHouse: + """创建BioYond 4x2x1仓库(2行×4列×1层:A01-A04, B01-B04) + + 用于"5号右侧手动堆栈"(LIMS code=0027)。 + 使用行优先排序,前端展示为: + A01 | A02 | A03 | A04 + B01 | B02 | B03 | B04 + """ + return warehouse_factory( + name=name, + num_items_x=4, # 4列 (01-04) + num_items_y=2, # 2行 (A-B) + num_items_z=1, # 1层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=147.0, + item_dy=106.0, + item_dz=130.0, + category="warehouse", + col_offset=0, + layout="row-major", + ) + def bioyond_warehouse_1x8x4(name: str) -> WareHouse: """创建BioYond 8x4x1反应站堆栈(A01~D08)""" return warehouse_factory( diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index 5f3b2c4ec..b9cb572db 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -1,4 +1,3 @@ -from os import name from pylabrobot.resources import Deck, Coordinate, Rotation from unilabos.resources.bioyond.YB_warehouses import ( @@ -11,6 +10,7 @@ bioyond_warehouse_2x2x1, # 新增:321和43窗口 (2行×2列) bioyond_warehouse_1x3x3, bioyond_warehouse_5x3x1, # 新增:手动传递窗仓库 (5行×3列) + bioyond_warehouse_4x2x1, # 新增:5号右侧手动堆栈 (2行×4列) bioyond_warehouse_10x1x1, bioyond_warehouse_3x3x1, bioyond_warehouse_3x3x1_2, @@ -34,11 +34,8 @@ def __init__( size_y: float = 1080.0, size_z: float = 1500.0, category: str = "deck", - setup: bool = False ) -> None: super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) - if setup: - self.setup() def setup(self) -> None: # 添加仓库 @@ -66,6 +63,7 @@ def setup(self) -> None: for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) + class BIOYOND_PolymerPreparationStation_Deck(Deck): def __init__( self, @@ -74,11 +72,8 @@ def __init__( size_y: float = 1080.0, size_z: float = 1500.0, category: str = "deck", - setup: bool = False ) -> None: super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0) - if setup: - self.setup() def setup(self) -> None: # 添加仓库 - 配液站的3个堆栈,使用Bioyond系统中的实际名称 @@ -101,7 +96,8 @@ def setup(self) -> None: for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) -class BIOYOND_YB_Deck(Deck): + +class BioyondElectrolyteDeck(Deck): def __init__( self, name: str = "YB_Deck", @@ -109,7 +105,7 @@ def __init__( size_y: float = 1400.0, size_z: float = 2670.0, category: str = "deck", - setup: bool = False + setup: bool = False, ) -> None: super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0) if setup: @@ -118,9 +114,12 @@ def __init__( def setup(self) -> None: # 添加仓库 self.warehouses = { - "321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列 - "43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列 + "自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), # 2行×2列 + "自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), # 2行×2列 "手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03 + "5号右侧手动堆栈": bioyond_warehouse_4x2x1("5号右侧手动堆栈"), # A01-A04, B01-B04 + # LIMS code=0018,电导板配液完成后停在此处,等 conductivity_test_inline + "5号自动传递窗": bioyond_warehouse_2x2x1("5号自动传递窗"), # 2行×2列 "手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03 "加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"), "加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"), @@ -130,32 +129,43 @@ def setup(self) -> None: "大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"), "大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"), "2号手套箱内部堆栈": bioyond_warehouse_3x3x1("2号手套箱内部堆栈"), # 新增:3行×3列 (A01-C03) + "大分液瓶堆栈": bioyond_warehouse_3x3x1("大分液瓶堆栈"), # 新增:3行×3列 (A01-C03),承接配液分液完成后的20ml分液瓶板 + "小分液瓶堆栈": bioyond_warehouse_3x3x1("小分液瓶堆栈"), # 新增:3行×3列 (A01-C03),承接5ml分液瓶板 } # warehouse 的位置 self.warehouse_locations = { - "321窗口": Coordinate(-150.0, 158.0, 0.0), - "43窗口": Coordinate(4160.0, 158.0, 0.0), - "手动传递窗左": Coordinate(-150.0, 877.0, 0.0), - "手动传递窗右": Coordinate(4160.0, 877.0, 0.0), - "加样头堆栈左": Coordinate(385.0, 1300.0, 0.0), - "加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0), - - "15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0), - "母液加样右": Coordinate(2152.0, 333.0, 0.0), - "大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0), - "大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0), - "2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整 + "自动堆栈-左": Coordinate(-150.0, 1142.0, 0.0), + "自动堆栈-右": Coordinate(4160.0, 1142.0, 0.0), + "手动传递窗左": Coordinate(-150.0, 423.0, 0.0), + "手动传递窗右": Coordinate(4160.0, 423.0, 0.0), + "5号右侧手动堆栈": Coordinate(4600.0, 423.0, 0.0), # 位于手动传递窗右的右侧 + "5号自动传递窗": Coordinate(4600.0, 1142.0, 0.0), # 自动堆栈-右的右侧、5号右侧手动堆栈的上方 + "加样头堆栈左": Coordinate(385.0, 0, 0.0), + "加样头堆栈右": Coordinate(2187.0, 0, 0.0), + + "15ml配液堆栈左": Coordinate(749.0, 945.0, 0.0), + "母液加样右": Coordinate(2152.0, 967.0, 0.0), + "大瓶母液堆栈左": Coordinate(1164.0, 624.0, 0.0), + "大瓶母液堆栈右": Coordinate(2717.0, 624.0, 0.0), + "2号手套箱内部堆栈": Coordinate(-800, 800.0, 0.0), # 新增:位置需根据实际硬件调整 + "大分液瓶堆栈": Coordinate(-800, 400.0, 0.0), # 新增:位置占位值,需根据实际硬件校准 + "小分液瓶堆栈": Coordinate(-800, 0.0, 0.0), # 新增:位置占位值,需根据实际硬件校准 } for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) -def YB_Deck(name: str) -> Deck: - by=BIOYOND_YB_Deck(name=name) - by.setup() - return by +# 向后兼容别名,日后废弃 +BIOYOND_YB_Deck = BioyondElectrolyteDeck +def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck: + deck = BioyondElectrolyteDeck(name=name) + deck.setup() + return deck +# 向后兼容别名,日后废弃 +def YB_Deck(name: str) -> BioyondElectrolyteDeck: + return bioyond_electrolyte_deck(name) diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index c8f1cc2cd..0f8e1b3a6 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -797,9 +797,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st bottle = plr_material[number] = initialize_resource( {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR ) - bottle.tracker.liquids = [ - (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) - ] + if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [ + (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) + ] bottle.code = detail.get("code", "") logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})") else: @@ -808,9 +809,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st # 只对有 capacity 属性的容器(液体容器)处理液体追踪 if hasattr(plr_material, 'capacity'): bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + if hasattr(bottle, 'tracker') and bottle.tracker is not None: + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) @@ -1033,7 +1035,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀) - # tracker.liquids 格式: [(物料名称, 数量), ...] + # tracker.liquids 格式: [(物料名称, 数量, 单位), ...] material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶") if hasattr(bottle, "tracker") and bottle.tracker.liquids: # 如果有液体,使用液体的名称 @@ -1051,7 +1053,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "typeId": bottle_type_info[1], "code": bottle.code if hasattr(bottle, "code") else "", "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "x": bioyond_x, "y": bioyond_y, "z": 1, @@ -1124,7 +1126,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict "barCode": "", "name": material_name, # 使用物料名称而不是资源名称 "unit": default_unit, # 使用配置的单位或默认单位 - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "Parameters": parameters_json # API 实际要求的字段(必需) } @@ -1133,6 +1135,11 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict extra_info = getattr(resource, "unilabos_extra", {}) update_site = extra_info.get("update_resource_site") + # 可选条码:用户在 unilabos_extra["barCode"] 中填了就带上,否则保持 "" + bar_code = extra_info.get("barCode", "") + if bar_code: + material["barCode"] = bar_code + if update_site: # 情况1: 有明确的位置更新请求 (如从 A02 移动到 A03) # 需要从 warehouse_mapping 中查找目标库位的 UUID diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index fe55c39e5..a09befbf3 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -111,6 +111,7 @@ def __init__( category: Optional[str] = "carrier", model: Optional[str] = None, invisible_slots: Optional[str] = None, + barcode: Optional[str] = None, ): super().__init__( name=name, @@ -120,6 +121,9 @@ def __init__( category=category, model=model, ) + # 整板条码(来自前端 config.barcode)。PLR 基类期望 barcode 是带 serialize() 的对象, + # 这里按字符串保存,序列化时单独处理,避免类型错误。 + self.barcode = barcode self.num_items = len(sites) self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z self.invisible_slots = [] if invisible_slots is None else invisible_slots @@ -179,6 +183,35 @@ def assign_child_resource( idx = i break + if idx is None and location is not None: + # 精确坐标匹配失败(常见原因:DB 存储的 z=0,而槽位定义 z=dz>0)。 + # 降级为仅按 XY 坐标进行近似匹配,找到后使用槽位自身的正确坐标写回, + # 避免因 Z 偏移导致反序列化中断。 + _XY_TOLERANCE = 2.0 # mm,覆盖浮点误差和 z 偏移 + min_dist = float("inf") + nearest_idx = None + for _i, _loc in enumerate(self.child_locations.values()): + _d = (((_loc.x - location.x) ** 2) + ((_loc.y - location.y) ** 2)) ** 0.5 + if _d < min_dist: + min_dist = _d + nearest_idx = _i + if nearest_idx is not None and min_dist <= _XY_TOLERANCE: + from unilabos.utils.log import logger as _logger + _slot_label = list(self.child_locations.keys())[nearest_idx] + _logger.warning( + f"[ItemizedCarrier '{self.name}'] 资源 '{resource.name}' 坐标 {location} 与槽位 " + f"'{_slot_label}' {list(self.child_locations.values())[nearest_idx]} 的 XY 吻合" + f"(XY 偏差={min_dist:.2f}mm),按 XY 近似匹配成功,z 偏移已被修正。" + ) + idx = nearest_idx + + if idx is None: + raise ValueError( + f"[ItemizedCarrier '{self.name}'] 无法为资源 '{resource.name}' 找到匹配的槽位。\n" + f" 已知槽位: {list(self.child_locations.keys())}\n" + f" 传入坐标: {location}\n" + f" 提示: XY 近似匹配也失败,请检查资源坐标或 Carrier 槽位定义是否正确。" + ) if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") location = list(self.child_locations.values())[idx] @@ -437,8 +470,17 @@ def get_free_sites(self) -> List[int]: return [spot for spot, resource in self.sites.items() if resource is None] def serialize(self): + # PLR 基类 serialize 期望 barcode 是带 serialize() 的对象,这里是字符串, + # 先临时清空避免报错,super().serialize() 后再以字符串形式写回。 + _barcode = getattr(self, "barcode", None) + self.barcode = None + try: + base = super().serialize() + finally: + self.barcode = _barcode return { - **super().serialize(), + **base, + "barcode": _barcode, "num_items_x": self.num_items_x, "num_items_y": self.num_items_y, "num_items_z": self.num_items_z, @@ -455,6 +497,20 @@ def serialize(self): } for identifier, location in self.child_locations.items()] } + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + # 先取出 barcode(字符串),避免 PLR 基类把它当作 Barcode 对象解析而报错; + # 反序列化完成后再以字符串形式写回。 + barcode_data = data.pop("barcode", None) + instance = super().deserialize(data, allow_marshal=allow_marshal) + if isinstance(barcode_data, str): + instance.barcode = barcode_data + elif isinstance(barcode_data, dict): + instance.barcode = barcode_data.get("data", "") + else: + instance.barcode = "" + return instance + class BottleCarrier(ItemizedCarrier): """瓶载架 - 直接继承自 TubeCarrier""" @@ -469,6 +525,7 @@ def __init__( category: str = "bottle_carrier", model: Optional[str] = None, invisible_slots: List[str] = None, + barcode: Optional[str] = None, **kwargs, ): super().__init__( @@ -480,4 +537,5 @@ def __init__( category=category, model=model, invisible_slots=invisible_slots, + barcode=barcode, ) diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 3fb945b64..d12e64cfe 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -580,6 +580,11 @@ def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states name_to_extra[node.res_content.name] = node.res_content.extra name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass + # 前端把条码填在节点 config.barcode 中(而非 extra),这里注入到 unilabos_extra["barCode"], + # 供下游 sync_to_external / resource_tree_transfer 透传给奔曜;未填则不写,保持默认 "" + _node_barcode = node.res_content.config.get("barcode") + if _node_barcode: + name_to_extra[node.res_content.name]["barCode"] = _node_barcode for child in node.children: collect_node_data(child, name_to_uuid, all_states, name_to_extra) @@ -612,6 +617,31 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): d["model"] = res.config.get("model", None) return d + def _deduplicate_plr_dict(d: dict, _seen: set = None) -> dict: + """递归去除 children 中同名重复节点(全树范围、保留首次出现)。 + + 根本原因:同一槽位被 sync_from_external(Bioyond 同步)重复写入, + 导致数据库中同一 WareHouse 下存在多条同名 BottleCarrier 记录(不同 UUID)。 + PLR 的 _check_naming_conflicts 在全树范围检查名称唯一性, + 重复名称会在 deserialize 时抛出 ValueError,导致节点启动失败。 + 此函数在 sub_cls.deserialize 前预先清理,保证名称唯一。 + """ + if _seen is None: + _seen = set() + children = d.get("children", []) + deduped = [] + for child in children: + child = _deduplicate_plr_dict(child, _seen) + cname = child.get("name") + if cname not in _seen: + _seen.add(cname) + deduped.append(child) + else: + logger.warning( + f"[资源树去重] 发现重复资源名称 '{cname}',跳过重复项(历史脏数据)" + ) + return {**d, "children": deduped} + plr_resources = [] tracker = DeviceNodeResourceTracker() @@ -622,6 +652,8 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): collect_node_data(tree.root_node, name_to_uuid, all_states, name_to_extra) has_model = tree.root_node.res_content.type != "deck" plr_dict = node_to_plr_dict(tree.root_node, has_model) + plr_dict = _deduplicate_plr_dict(plr_dict) + try: sub_cls = find_subclass(plr_dict["type"], PLRResource) if skip_devices and plr_dict["type"] == "device": @@ -640,6 +672,14 @@ def node_to_plr_dict(node: ResourceDictInstance, has_model: bool): location = cast(Coordinate, deserialize(plr_dict["location"])) plr_resource.location = location + + # 预填 Container 类型资源在新版 PLR 中要求必须存在的键, + # 防止旧数据库状态缺失这些键时 load_all_state 抛出 KeyError。 + for state in all_states.values(): + if isinstance(state, dict): + state.setdefault("liquid_history", []) + state.setdefault("pending_liquids", {}) + plr_resource.load_all_state(all_states) # 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra tracker.loop_set_uuid(plr_resource, name_to_uuid) diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 929a4e4de..865fa65d2 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -41,8 +41,9 @@ def warehouse_factory( # 根据 layout 决定 y 坐标计算 if layout == "row-major": - # 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值 - y = dy + row * item_dy + # 行优先:row=0(A行) 应该显示在上方 + # 前端现在 y 越大越靠上,所以 row=0 对应最大的 y + y = dy + (num_items_y - row - 1) * item_dy elif layout == "vertical-col-major": # 竖向warehouse: row=0 对应顶部(y小),row=n-1 对应底部(y大) # 但标签 01 应该在底部,所以使用反向映射 diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index e249bc0ff..c1773051a 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -4,6 +4,8 @@ import threading import time import traceback + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from typing import ( get_type_hints, TypeVar, @@ -43,6 +45,7 @@ ) from unilabos.resources.plr_additional_res_reg import register from unilabos.ros.msgs.message_converter import ( + String, convert_to_ros_msg, convert_from_ros_msg_with_mapping, convert_to_ros_msg_with_mapping, @@ -78,6 +81,67 @@ T = TypeVar("T") +class RclpyAsyncMutex: + """rclpy executor 兼容的异步互斥锁 + + 通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。 + """ + + def __init__(self, name: str = ""): + self._lock = threading.Lock() + self._acquired = False + self._queue: List[Future] = [] + self._name = name + self._holder: Optional[str] = None + + async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""): + """获取锁。如果已被占用,则异步等待直到锁释放。""" + # t0 = time.time() + with self._lock: + # qlen = len(self._queue) + if not self._acquired: + self._acquired = True + self._holder = tag + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)" + # ) + return + waiter = Future() + self._queue.append(waiter) + # node.lab_logger().info( + # f"[Mutex:{self._name}] 等待锁 tag={tag} " + # f"(holder={self._holder}, queue={qlen + 1})" + # ) + await waiter + # wait_ms = (time.time() - t0) * 1000 + self._holder = tag + # node.lab_logger().info( + # f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)" + # ) + + def release(self, node: "BaseROS2DeviceNode"): + """释放锁,通过 executor task 唤醒下一个等待者。""" + with self._lock: + # old_holder = self._holder + if self._queue: + next_waiter = self._queue.pop(0) + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})" + # ) + + async def _wake(): + if not next_waiter.done(): + next_waiter.set_result(None) + + rclpy.get_global_executor().create_task(_wake()) + else: + self._acquired = False + self._holder = None + # node.lab_logger().debug( + # f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲" + # ) + + # 在线设备注册表 registered_devices: Dict[str, "DeviceInfoType"] = {} @@ -187,7 +251,8 @@ def __init__( ): self.node = node self.name = name - self.msg_type = msg_type + self.msg_type = self._normalize_msg_type(msg_type) + self.original_msg_type = msg_type self.get_method = get_method self.timer_period = initial_period self.print_publish = print_publish @@ -195,16 +260,36 @@ def __init__( self._value = None try: - self.publisher_ = node.create_publisher(msg_type, f"{name}", qos) + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) except Exception as e: self.node.lab_logger().error( - f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {e}" + f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败," + f"可能由于注册表有误,类型: {msg_type},错误: {e}" ) + self.msg_type = String + try: + self.publisher_ = node.create_publisher(self.msg_type, f"{name}", qos) + self.node.lab_logger().warning( + f"属性 {name} 的发布类型已降级为 String,原始类型: {msg_type}" + ) + except Exception: + self.publisher_ = None self.timer = node.create_timer(self.timer_period, self.publish_property) self.__loop = ROS2DeviceNode.get_asyncio_loop() - str_msg_type = str(msg_type)[8:-2] + str_msg_type = str(self.msg_type)[8:-2] self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") + @staticmethod + def _normalize_msg_type(msg_type): + if msg_type in (dict, list, tuple, set) or msg_type in ("dict", "list", "tuple", "set"): + return String + return msg_type + + def _normalize_value(self, value): + if self.msg_type is String and isinstance(value, (dict, list, tuple, set)): + return json.dumps(value, ensure_ascii=False, cls=TypeEncoder) + return value + def get_property(self): if asyncio.iscoroutinefunction(self.get_method): # 如果是异步函数,运行事件循环并等待结果 @@ -239,12 +324,16 @@ def publish_property(self): pass # self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}") if value is not None: + if self.publisher_ is None: + return + value = self._normalize_value(value) msg = convert_to_ros_msg(self.msg_type, value) self.publisher_.publish(msg) # self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功") except Exception as e: + topic = getattr(self.publisher_, "topic", self.name) self.node.lab_logger().error( - f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}" + f"【.publish_property】发布属性 {topic} 出错: {str(e)}\n{traceback.format_exc()}" ) def change_frequency(self, period): @@ -355,6 +444,8 @@ def __init__( max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" ) + self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}") + # 创建资源管理客户端 self._resource_clients: Dict[str, Client] = { "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), @@ -378,15 +469,40 @@ def re_register_device(req, res): return res async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): + _cmd = _fast_loads(req.command) + _res_name = _cmd.get("resource", [{}]) + _res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name + else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?") + _ar_tag = f"{_res_name}" + # _t_enter = time.time() + # self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource") + await self._append_resource_lock.acquire(self, tag=_ar_tag) + # _t_locked = time.time() + try: + return await _append_resource_inner(req, res, _ar_tag) + # _t_done = time.time() + # self.lab_logger().info( + # f"[AR:{_ar_tag}] 完成 " + # f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms " + # f"执行={(_t_done - _t_locked) * 1000:.0f}ms " + # f"总计={(_t_done - _t_enter) * 1000:.0f}ms" + # ) + except Exception as _ex: + self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}") + raise + finally: + self._append_resource_lock.release(self) + + async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""): from pylabrobot.resources.deck import Deck from pylabrobot.resources import Coordinate from pylabrobot.resources import Plate - # 物料传输到对应的node节点 + # _t0 = time.time() client = self._resource_clients["c2s_update_resource_tree"] request = SerialCommand.Request() request2 = SerialCommand.Request() - command_json = json.loads(req.command) + command_json = _fast_loads(req.command) namespace = command_json["namespace"] bind_parent_id = command_json["bind_parent_id"] edge_device_id = command_json["edge_device_id"] @@ -439,7 +555,11 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" ) # noinspection PyUnresolvedReferences - request.command = json.dumps( + # _t1 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..." + # ) + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -450,7 +570,11 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons } ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t2 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms" + # ) + uuid_maps = _fast_loads(tree_response.response) plr_instances = rts.to_plr_resources() for plr_instance in plr_instances: self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) @@ -508,7 +632,7 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons for input_well, liquid_type, liquid_volume, liquid_input_slot in zip( input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT ): - input_well.set_liquids([(liquid_type, liquid_volume, "uL")]) + input_well.set_liquids([(liquid_type, liquid_volume, "ul")]) final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( input_wells ).dump() @@ -527,12 +651,13 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) - # 调整了液体以及Deck之后要重新Assign # noinspection PyUnresolvedReferences + # _t3 = time.time() rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) + # _n_parent = len(rts_with_parent.all_nodes) if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid - request.command = json.dumps( + request.command = _fast_dumps_str( { "action": "add", "data": { @@ -542,11 +667,18 @@ async def append_resource(req: SerialCommand_Request, res: SerialCommand_Respons }, } ) + # _t4 = time.time() + # self.lab_logger().debug( + # f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..." + # ) tree_response: SerialCommand.Response = await client.call_async(request) - uuid_maps = json.loads(tree_response.response) + # _t5 = time.time() + uuid_maps = _fast_loads(tree_response.response) self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) - self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") - # 这里created_resources不包含parent_resource + # self._lab_logger.info( + # f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms " + # f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms" + # ) # 发送给ResourceMeshManager action_client = ActionClient( self, @@ -683,7 +815,11 @@ async def get_resource_with_dir(self, resource_id: str, with_children: bool = Tr ) # 发送请求并等待响应 response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) + if not response.response: + raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应") raw_data = json.loads(response.response) + if not raw_data: + raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) @@ -1132,7 +1268,8 @@ async def transfer_resource_to_another( if uid is None: raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运") target_uids.append(uid) - srv_address = f"/srv{target_device_id}/s2c_resource_tree" + _ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}" + srv_address = f"/srv{_ns}/s2c_resource_tree" sclient = self.create_client(SerialCommand, srv_address) # 等待服务可用(设置超时) if not sclient.wait_for_service(timeout_sec=5.0): @@ -1182,7 +1319,7 @@ async def transfer_resource_to_another( return False time.sleep(0.05) self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") - return None + return "转运完成" def register_device(self): """向注册表中注册设备信息""" @@ -1567,37 +1704,81 @@ def _handle_future_exception(fut: Future): feedback_msg_types = action_type.Feedback.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types() - while future is not None and not future.done(): - if goal_handle.is_cancel_requested: - self.lab_logger().info(f"取消动作: {action_name}") - future.cancel() # 尝试取消线程池中的任务 - goal_handle.canceled() - return action_type.Result() - - self._time_spent = time.time() - time_start - self._time_remaining = time_overall - self._time_spent - - # 发布反馈 - feedback_values = {} - for msg_name, attr_name in action_value_mapping["feedback"].items(): - if hasattr(self.driver_instance, f"get_{attr_name}"): - method = getattr(self.driver_instance, f"get_{attr_name}") - if not asyncio.iscoroutinefunction(method): - feedback_values[msg_name] = method() - elif hasattr(self.driver_instance, attr_name): - feedback_values[msg_name] = getattr(self.driver_instance, attr_name) - - if self._print_publish: - self.lab_logger().info(f"反馈: {feedback_values}") - - feedback_msg = convert_to_ros_msg_with_mapping( - ros_msg_type=action_type.Feedback(), - obj=feedback_values, - value_mapping=action_value_mapping["feedback"], + # 低频 feedback timer(10s),不阻塞完成检测 + _feedback_timer = None + + def _publish_feedback(): + if future is not None and not future.done(): + self._time_spent = time.time() - time_start + self._time_remaining = time_overall - self._time_spent + feedback_values = {} + for msg_name, attr_name in action_value_mapping["feedback"].items(): + if hasattr(self.driver_instance, f"get_{attr_name}"): + method = getattr(self.driver_instance, f"get_{attr_name}") + if not asyncio.iscoroutinefunction(method): + feedback_values[msg_name] = method() + elif hasattr(self.driver_instance, attr_name): + feedback_values[msg_name] = getattr(self.driver_instance, attr_name) + if self._print_publish: + self.lab_logger().info(f"反馈: {feedback_values}") + feedback_msg = convert_to_ros_msg_with_mapping( + ros_msg_type=action_type.Feedback(), + obj=feedback_values, + value_mapping=action_value_mapping["feedback"], + ) + goal_handle.publish_feedback(feedback_msg) + + if action_value_mapping.get("feedback"): + _fb_interval = action_value_mapping.get("feedback_interval", 0.5) + _feedback_timer = self.create_timer( + _fb_interval, _publish_feedback, callback_group=self.callback_group ) - goal_handle.publish_feedback(feedback_msg) - time.sleep(0.5) + # 等待 action 完成 + if future is not None: + if isinstance(future, Task): + # rclpy Task:直接 await,完成瞬间唤醒 + try: + _raw_result = await future + except Exception as e: + _raw_result = e + else: + # concurrent.futures.Future(同步 action):用 rclpy 兼容的轮询 + _poll_future = Future() + + def _on_sync_done(fut): + async def _wake(): + if not _poll_future.done(): + _poll_future.set_result(None) + + # ThreadPoolExecutor callbacks run outside the rclpy executor. + # Wake the awaiting action coroutine from the executor thread; + # otherwise it may only resume when the executor naturally wakes up. + rclpy.get_global_executor().create_task(_wake()) + + future.add_done_callback(_on_sync_done) + await _poll_future + try: + _raw_result = future.result() + except Exception as e: + _raw_result = e + + # 确保 execution_error/success 被正确设置(不依赖 done callback 时序) + if isinstance(_raw_result, BaseException): + if not execution_error: + execution_error = traceback.format_exception( + type(_raw_result), _raw_result, _raw_result.__traceback__ + ) + execution_error = "".join(execution_error) + execution_success = False + action_return_value = _raw_result + elif not execution_error: + execution_success = True + action_return_value = _raw_result + + # 清理 feedback timer + if _feedback_timer is not None: + _feedback_timer.cancel() if future is not None and future.cancelled(): self.lab_logger().info(f"动作 {action_name} 已取消") @@ -1606,8 +1787,12 @@ def _handle_future_exception(fut: Future): # self.lab_logger().info(f"动作执行完成: {action_name}") del future + # 执行失败时跳过物料状态更新 + if execution_error: + execution_success = False + # 向Host更新物料当前状态 - if action_name not in ["create_resource_detailed", "create_resource"]: + if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]: for k, v in goal.get_fields_and_field_types().items(): if v not in ["unilabos_msgs/Resource", "sequence"]: continue @@ -1663,7 +1848,7 @@ def _handle_future_exception(fut: Future): for attr_name in result_msg_types.keys(): if attr_name in ["success", "reached_goal"]: - setattr(result_msg, attr_name, True) + setattr(result_msg, attr_name, execution_success) elif attr_name == "return_info": setattr( result_msg, @@ -1769,7 +1954,7 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: raise ValueError("至少需要提供一个 UUID") uuids_list = list(uuids) - future = self._resource_clients["c2s_update_resource_tree"].call_async( + future: Future = self._resource_clients["c2s_update_resource_tree"].call_async( SerialCommand.Request( command=json.dumps( { @@ -1795,6 +1980,8 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: raise Exception(f"资源查询返回空结果: {uuids_list}") raw_data = json.loads(response.response) + if not raw_data: + raise Exception(f"资源原始查询返回空结果: {raw_data}") # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) @@ -1816,10 +2003,15 @@ def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]: mapped_plr_resources = [] for uuid in uuids_list: + found = None for plr_resource in figured_resources: r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) - mapped_plr_resources.append(r) - break + if r is not None: + found = r + break + if found is None: + raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源") + mapped_plr_resources.append(found) return mapped_plr_resources @@ -1912,16 +2104,27 @@ async def _execute_driver_command_async(self, string: str): f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" ) - async def _convert_resource_async(self, resource_data: Dict[str, Any]): - """异步转换资源数据为实例""" - # 使用封装的get_resource_with_dir方法获取PLR资源 - plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True) + async def _convert_resource_async(self, resource_data: "ResourceDictType"): + """异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询""" + unilabos_uuid = resource_data.get("uuid") + + if unilabos_uuid: + resource_tree = await self.get_resource([unilabos_uuid], with_children=True) + plr_resources = resource_tree.to_plr_resources() + if plr_resources: + plr_resource = plr_resources[0] + else: + raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空") + else: + res_id = resource_data.get("id") or resource_data.get("name", "") + if not res_id: + raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}") + plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True) # 通过资源跟踪器获取本地实例 res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) if len(res) == 0: - # todo: 后续通过decoration来区分,减少warning - self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") + self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例") return plr_resource elif len(res) == 1: return res[0] @@ -2169,4 +2372,4 @@ class DeviceInfoType(TypedDict): status_publishers: Dict[str, PropertyPublisher] actions: Dict[str, ActionServer] hardware_interface: Dict[str, Any] - base_node_instance: BaseROS2DeviceNode + base_node_instance: BaseROS2DeviceNode \ No newline at end of file diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index e5e212b1b..89b60a5bf 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -4,6 +4,8 @@ import time import traceback import uuid + +from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads from dataclasses import dataclass, field from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union @@ -618,22 +620,17 @@ async def create_resource( } ) ] - response: List[str] = await self.create_resource_detailed( resources, device_ids, bind_parent_id, bind_location, other_calling_param ) - try: - assert len(response) == 1, "Create Resource应当只返回一个结果" - for i in response: - res = json.loads(i) - if "suc" in res: - raise ValueError(res.get("error")) - return res - except Exception as ex: - pass - _n = "\n" - raise ValueError(f"创建资源时失败!\n{_n.join(response)}") + assert len(response) == 1, "Create Resource应当只返回一个结果" + for i in response: + res = json.loads(i) + if "suc" in res and not res["suc"]: + raise ValueError(res.get("error", "未知错误")) + return res + raise ValueError(f"创建资源时失败!响应为空") def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None: """ @@ -1168,7 +1165,7 @@ async def _resource_tree_action_add_callback(self, data: dict, response: SerialC else: physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {})) - response.response = json.dumps(uuid_mapping) if success else "FAILED" + response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED" self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK @@ -1178,6 +1175,7 @@ async def _resource_tree_action_get_callback(self, data: dict, response: SerialC resource_response = http_client.resource_tree_get(uuid_list, with_children) response.response = json.dumps(resource_response) + self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}") async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response): """ @@ -1230,9 +1228,26 @@ async def _resource_tree_update_callback(self, request: SerialCommand_Request, r """ try: # 解析请求数据 - data = json.loads(request.command) + data = _fast_loads(request.command) action = data["action"] - self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") + inner = data.get("data", {}) + if action == "add": + mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?" + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"mount={mount_uuid}.. nodes≈{node_count}" + elif action in ("get", "remove"): + uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner + source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}" + elif action == "update": + tree_data = inner.get("data", []) if isinstance(inner, dict) else inner + node_count = len(tree_data) if isinstance(tree_data, list) else "?" + source = f"nodes≈{node_count}" + else: + source = "" + self.lab_logger().info( + f"[Host Node-Resource] Resource tree {action} request received ({source})" + ) data = data["data"] if action == "add": await self._resource_tree_action_add_callback(data, response) @@ -1631,8 +1646,11 @@ def test_latency(self) -> TestLatencyReturn: }) def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict: """ - timeout_seconds: 超时时间(秒),默认3600秒 - 修改的结果无效,是只读的 + 人工确认节点:确认对话框中的数据为只读,人工修改不会生效(仅确认/放行)。 + + Args: + timeout_seconds: 超时时间(秒),默认3600秒;由外层 manual_confirm 等待机制处理 + assignee_user_ids: 通知人员 """ return kwargs @@ -1676,7 +1694,9 @@ def handle_pong_response(self, pong_data: dict): else: self.lab_logger().warning("⚠️ 收到无效的Pong响应(缺少ping_id)") - def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid_list: List[str]) -> bool: + def notify_resource_tree_update( + self, device_id: str, action: str, resource_uuid_list: List[str] + ) -> Optional[bool]: """ 通知设备节点更新资源树 @@ -1686,13 +1706,14 @@ def notify_resource_tree_update(self, device_id: str, action: str, resource_uuid resource_uuid_list: 资源UUIDs Returns: - bool: 操作是否成功 + True if the update completed, False if it failed, None if it was intentionally skipped. """ try: - # 检查设备是否存在 if device_id not in self.devices_names: - self.lab_logger().error(f"[Host Node-Resource] Device {device_id} not found in devices_names") - return False + self.lab_logger().info( + f"[Host Node-Resource] 在线增加设备暂不支持,跳过设备 {device_id} 的资源树 {action} 更新" + ) + return None namespace = self.devices_names[device_id] device_key = f"{namespace}/{device_id}" diff --git a/unilabos/test/experiments/virtual_bench.json b/unilabos/test/experiments/virtual_bench.json index d37fa6ee4..0cffe842e 100644 --- a/unilabos/test/experiments/virtual_bench.json +++ b/unilabos/test/experiments/virtual_bench.json @@ -22,6 +22,447 @@ "arm_state": "idle", "message": "工作台就绪" } + }, + { + "id": "PRCXI", + "name": "PRCXI", + "type": "device", + "class": "liquid_handler.prcxi", + "parent": "", + "pose": { + "size": { + "width": 562, + "height": 394, + "depth": 0 + } + }, + "config": { + "axis": "Left", + "deck": { + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + "_resource_child_name": "PRCXI_Deck" + }, + "host": "10.20.30.184", + "port": 9999, + "debug": true, + "setup": true, + "is_9320": true, + "timeout": 10, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "simulator": true, + "channel_num": 2 + }, + "data": { + "reset_ok": true + }, + "schema": {}, + "description": "", + "model": null, + "position": { + "x": 0, + "y": 240, + "z": 0 + } + }, + { + "id": "PRCXI_Deck", + "name": "PRCXI_Deck", + "children": [], + "parent": "PRCXI", + "type": "deck", + "class": "", + "position": { + "x": 10, + "y": 10, + "z": 0 + }, + "config": { + "type": "PRCXI9300Deck", + "size_x": 542, + "size_y": 374, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "deck", + "barcode": null, + "preferred_pickup_location": null, + "sites": [ + { + "label": "T1", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "container", + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T2", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T3", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T4", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 0, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T5", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T6", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T7", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T8", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 96, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T9", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T10", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T11", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T12", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 192, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T13", + "visible": true, + "occupied_by": null, + "position": { + "x": 0, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T14", + "visible": true, + "occupied_by": null, + "position": { + "x": 138, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T15", + "visible": true, + "occupied_by": null, + "position": { + "x": 276, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + }, + { + "label": "T16", + "visible": true, + "occupied_by": null, + "position": { + "x": 414, + "y": 288, + "z": 0 + }, + "size": { + "width": 128.0, + "height": 86, + "depth": 0 + }, + "content_type": [ + "plate", + "tip_rack", + "plates", + "tip_racks", + "tube_rack", + "adaptor" + ] + } + ] + }, + "data": {} } ], "links": [] diff --git a/unilabos/test/experiments/yibin_electrolyte_config_example.json b/unilabos/test/experiments/yibin_electrolyte_config_example.json index d5efc3578..ba25c0ac9 100644 --- a/unilabos/test/experiments/yibin_electrolyte_config_example.json +++ b/unilabos/test/experiments/yibin_electrolyte_config_example.json @@ -13,7 +13,7 @@ "deck": { "data": { "_resource_child_name": "YB_Bioyond_Deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + "_resource_type": "unilabos.resources.bioyond.decks:BioyondElectrolyteDeck" } }, "protocol_type": [], @@ -103,15 +103,14 @@ "children": [], "parent": "bioyond_cell_workstation", "type": "deck", - "class": "BIOYOND_YB_Deck", + "class": "BioyondElectrolyteDeck", "position": { "x": 0, "y": 0, "z": 0 }, "config": { - "type": "BIOYOND_YB_Deck", - "setup": true, + "type": "BioyondElectrolyteDeck", "rotation": { "x": 0, "y": 0, diff --git a/unilabos/utils/datacore.py b/unilabos/utils/datacore.py new file mode 100644 index 000000000..3aee4efcd --- /dev/null +++ b/unilabos/utils/datacore.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +DataCore 大装置数据接入公共工具 + +把设备产出的 CSV 推送到 DataCore 接入接口(HTTP Basic Auth + multipart 上传)。 +接入凭据由各设备的图 JSON 配置提供(datacore_config),不在代码中硬编码。 +""" + +import os +import time +from typing import Any, Callable, Dict, Optional + +import requests + +# 默认接入地址(可用环境变量 DATACORE_INGEST_URL 覆盖) +DATACORE_INGEST_URL = "https://datacore.dp.qifalab.cn/api/ingest/big-device-csv" + + +def push_csv_to_datacore(csv_path, url=None, device_id=None, device_key=None, + timeout=20, total_timeout=60, retry_interval=5, + log: Callable[[str], Any] = print): + """ + 把 CSV 文件推送到 DataCore 大装置数据接入接口(HTTP Basic Auth + multipart 上传)。 + 带失败重试:在 total_timeout 的总时间预算内反复尝试,单次请求超时不超过剩余预算。 + + 等价 curl: + curl -u 'device_id:****' -F 'file=@your.csv;type=text/csv' \\ + https://datacore.dp.qifalab.cn/api/ingest/big-device-csv + + Args: + csv_path: 本地 CSV 文件路径 + url: 接入地址,默认取环境变量 DATACORE_INGEST_URL 或内置常量 + device_id: 设备号(Basic Auth 用户名),默认取环境变量 DATACORE_DEVICE_ID + device_key: 密钥(Basic Auth 密码),默认取环境变量 DATACORE_DEVICE_KEY + timeout: 单次请求超时(秒) + total_timeout: 失败重试的总时间预算(秒),默认 60(约 1 分钟) + retry_interval: 两次尝试之间的等待(秒) + log: 日志输出函数,默认 print,设备侧可传 logger.info + + Returns: + bool: 推送是否成功 + """ + url = url or os.getenv("DATACORE_INGEST_URL", DATACORE_INGEST_URL) + device_id = device_id or os.getenv("DATACORE_DEVICE_ID", "") + device_key = device_key or os.getenv("DATACORE_DEVICE_KEY", "") + + if not csv_path or not os.path.exists(csv_path): + log(f"[DataCore] 错误: CSV 文件不存在 {csv_path}") + return False + + if not device_id or not device_key: + log("[DataCore] 错误: 缺少接入凭据(device_id / device_key),请检查图 JSON 中的 datacore_config") + return False + + filename = os.path.basename(csv_path) + # 一次性读入内存,重试时复用,避免每次重新读盘 + with open(csv_path, "rb") as f: + file_bytes = f.read() + + deadline = time.monotonic() + max(1.0, float(total_timeout)) + attempt = 0 + last_err = None + while True: + remaining = deadline - time.monotonic() + # 首次必做;之后预算耗尽则停止 + if attempt >= 1 and remaining <= 0: + break + attempt += 1 + # 单次请求超时不超过剩余预算,保证整体不会明显超过 total_timeout + req_timeout = max(1.0, min(float(timeout), remaining)) if remaining > 0 else float(timeout) + log(f"[DataCore] 第 {attempt} 次推送 {filename} 到 {url}" + f"(剩余预算 {max(0.0, remaining):.0f}s,单次超时 {req_timeout:.0f}s)") + try: + response = requests.post( + url, + auth=(device_id, device_key), + files={"file": (filename, file_bytes, "text/csv")}, + timeout=req_timeout, + ) + response.raise_for_status() + log(f"[DataCore] 推送成功(第 {attempt} 次): {filename} -> HTTP {response.status_code}") + return True + except requests.exceptions.RequestException as e: + last_err = e + resp_text = e.response.text if getattr(e, "response", None) is not None else "无响应" + log(f"[DataCore] 第 {attempt} 次推送失败: {e}; 服务器响应: {resp_text}") + except Exception as e: + last_err = e + log(f"[DataCore] 第 {attempt} 次推送异常: {e}") + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(float(retry_interval), remaining)) + + log(f"[DataCore] 推送最终失败(共 {attempt} 次,总预算 {total_timeout}s): {filename};最后错误: {last_err}") + return False + + +def push_csv_by_config(csv_path: str, datacore_config: Optional[Dict[str, Any]], + tag: str = "", log: Callable[[str], Any] = print) -> bool: + """ + 按图 JSON 中的 datacore_config 推送 CSV,异常全部吞掉,绝不影响主流程。 + + Args: + csv_path: 本地 CSV 文件路径 + datacore_config: 设备图 JSON 中的 datacore_config 字典,支持字段: + enabled / ingest_url / device_id / device_key / + timeout / total_timeout / retry_interval + 为空或 enabled=false 时跳过推送 + tag: 日志前缀标识,一般传节点名 + log: 日志输出函数,默认 print + + Returns: + bool: 推送是否成功(跳过时返回 False) + """ + prefix = f"[{tag}] " if tag else "" + cfg = datacore_config or {} + + if not cfg: + log(f"{prefix}[DataCore] 未配置 datacore_config,跳过推送: {csv_path}") + return False + if not cfg.get("enabled", True): + log(f"{prefix}[DataCore] datacore_config.enabled=false,跳过推送: {csv_path}") + return False + + try: + ok = push_csv_to_datacore( + csv_path, + url=cfg.get("ingest_url"), + device_id=cfg.get("device_id"), + device_key=cfg.get("device_key"), + timeout=cfg.get("timeout", 20), + total_timeout=cfg.get("total_timeout", 60), + retry_interval=cfg.get("retry_interval", 5), + log=log, + ) + log(f"{prefix}[DataCore] 推送{'成功' if ok else '失败'}: {csv_path}") + return ok + except Exception as e: + log(f"{prefix}[DataCore] 推送异常: {e}") + return False diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 366694be5..5dcff22f9 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -33,10 +33,83 @@ def _is_chinese_locale() -> bool: def _has_uv() -> bool: global _USE_UV if _USE_UV is None: - _USE_UV = shutil.which("uv") is not None + uv_path = shutil.which("uv") + if not uv_path: + _USE_UV = False + else: + try: + result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10) + _USE_UV = result.returncode == 0 + except Exception: + _USE_UV = False return _USE_UV +def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: + if installer == "uv": + # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。 + # 显式 --python sys.executable 让 uv 把当前解释器(conda/venv/system 都行) + # 视为目标环境,绕开 venv 检测。 + cmd = ["uv", "pip", "install", "--python", sys.executable] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"] + if upgrade: + cmd.append("--upgrade") + cmd.append(package) + if is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) + return cmd + + +def _installer_candidates() -> List[str]: + installers: List[str] = [] + if _has_uv(): + installers.append("uv") + installers.append("pip") + return installers + + +def _git_url_from_requirement(requirement: str) -> Optional[str]: + if not requirement.startswith("git+"): + return None + return requirement[4:].split("#", 1)[0] + + +def _repo_dir_name(git_url: str) -> str: + repo_name = git_url.rstrip("/").rsplit("/", 1)[-1] + return repo_name[:-4] if repo_name.endswith(".git") else repo_name + + +def _print_manual_git_install_hint(requirement: str) -> None: + git_url = _git_url_from_requirement(requirement) + if not git_url: + return + + repo_dir = _repo_dir_name(git_url) + install_cmd = ( + f'uv pip install --python "{sys.executable}" -e .' + if _has_uv() + else f"{sys.executable} -m pip install -e ." + ) + if _is_chinese_locale() and not _has_uv(): + install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" + + print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning") + print_status("可以手动拉取代码后在本地安装:", "warning") + print_status(f" git clone {git_url}", "warning") + print_status(f" cd {repo_dir}", "warning") + print_status(" git pull", "warning") + print_status(f" {install_cmd}", "warning") + print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning") + print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning") + + def _install_packages( packages: List[str], upgrade: bool = False, @@ -53,7 +126,7 @@ def _install_packages( return True is_chinese = _is_chinese_locale() - use_uv = _has_uv() + installers = _installer_candidates() failed: List[str] = [] for pkg in packages: @@ -63,35 +136,30 @@ def _install_packages( else: print_status(f"正在{action_word} {pkg}...", "info") - if use_uv: - cmd = ["uv", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - else: - cmd = [sys.executable, "-m", "pip", "install"] - if upgrade: - cmd.append("--upgrade") - cmd.append(pkg) - if is_chinese: - cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - if result.returncode == 0: - installer = "uv" if use_uv else "pip" - print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") - else: - stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" - print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error") - failed.append(pkg) - except subprocess.TimeoutExpired: - print_status(f"× {pkg} {action_word}超时 (300s)", "error") - failed.append(pkg) - except Exception as e: - print_status(f"× {pkg} {action_word}异常: {e}", "error") + pkg_installed = False + last_error = "unknown error" + + for installer in installers: + cmd = _install_command(installer, pkg, upgrade, is_chinese) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success") + pkg_installed = True + break + + last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error" + print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning") + except subprocess.TimeoutExpired: + last_error = "timeout after 300s" + print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning") + except Exception as e: + last_error = str(e) + print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning") + + if not pkg_installed: + print_status(f"× {pkg} {action_word}失败: {last_error}", "error") + _print_manual_git_install_hint(pkg) failed.append(pkg) if failed: @@ -188,7 +256,13 @@ def __init__(self): "crcmod": "crcmod-plus", } - self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} + # 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败 + pylabrobot_url = ( + "git+https://gitee.com/xuwznln/pylabrobot.git" + if _is_chinese_locale() + else "git+https://github.com/Xuwznln/pylabrobot.git" + ) + self.special_packages = {"pylabrobot": pylabrobot_url} self.version_requirements = { "msgcenterpy": "0.1.8", diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 7fe2f501e..8d0e8bf10 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -206,6 +206,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] "ast_analysis_success": False, "import_map": {}, "init_params": [], + "init_docstring": None, "status_methods": {}, "action_methods": {}, } @@ -251,6 +252,7 @@ def get_enhanced_class_info(self, module_path: str, **_kwargs) -> Dict[str, Any] # 映射到统一字段名(与 registry.py complete_registry 消费端一致) result["init_params"] = body.get("init_params", []) + result["init_docstring"] = body.get("init_docstring") result["status_methods"] = body.get("status_properties", {}) result["action_methods"] = { k: { diff --git a/unilabos/utils/log-origin.py b/unilabos/utils/log-origin.py new file mode 100644 index 000000000..cee3269b2 --- /dev/null +++ b/unilabos/utils/log-origin.py @@ -0,0 +1,385 @@ +import logging +import os +import platform +from datetime import datetime +import ctypes +import atexit +import inspect +from typing import Tuple, cast + +# 添加TRACE级别到logging模块 +TRACE_LEVEL = 5 +logging.addLevelName(TRACE_LEVEL, "TRACE") + + +class CustomRecord: + custom_stack_info: Tuple[str, int, str, str] + + +# Windows颜色支持 +if platform.system() == "Windows": + # 尝试启用Windows终端的ANSI支持 + kernel32 = ctypes.windll.kernel32 + # 获取STD_OUTPUT_HANDLE + STD_OUTPUT_HANDLE = -11 + # 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + # 获取当前控制台模式 + handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + mode = ctypes.c_ulong() + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + # 启用ANSI处理 + kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + # 程序退出时恢复控制台设置 + @atexit.register + def reset_console(): + kernel32.SetConsoleMode(handle, mode.value) + + +# 定义不同日志级别的颜色 +class ColoredFormatter(logging.Formatter): + """自定义日志格式化器,支持颜色输出""" + + # ANSI 颜色代码 + COLORS = { + "RESET": "\033[0m", # 重置 + "BOLD": "\033[1m", # 加粗 + "GRAY": "\033[37m", # 灰色 + "WHITE": "\033[97m", # 白色 + "BLACK": "\033[30m", # 黑色 + "TRACE_LEVEL": "\033[1;90m", # 加粗深灰色 + "DEBUG_LEVEL": "\033[1;36m", # 加粗青色 + "INFO_LEVEL": "\033[1;32m", # 加粗绿色 + "WARNING_LEVEL": "\033[1;33m", # 加粗黄色 + "ERROR_LEVEL": "\033[1;31m", # 加粗红色 + "CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色 + "TRACE_TEXT": "\033[90m", # 深灰色 + "DEBUG_TEXT": "\033[37m", # 灰色 + "INFO_TEXT": "\033[97m", # 白色 + "WARNING_TEXT": "\033[33m", # 黄色 + "ERROR_TEXT": "\033[31m", # 红色 + "CRITICAL_TEXT": "\033[35m", # 紫色 + "DATE": "\033[37m", # 日期始终使用灰色 + } + + def __init__(self, use_colors=True): + super().__init__() + # 强制启用颜色 + self.use_colors = use_colors + + def format(self, record): + # 检查是否有自定义堆栈信息 + if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore + r = cast(CustomRecord, record) + frame_info = r.custom_stack_info + record.filename = frame_info[0] + record.lineno = frame_info[1] + record.funcName = frame_info[2] + if len(frame_info) > 3: + record.name = frame_info[3] + if not self.use_colors: + return self._format_basic(record) + + level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"]) + text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"]) + date_color = self.COLORS["DATE"] + reset = self.COLORS["RESET"] + + # 日期格式 + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + + # 模块和函数信息 + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + right_info = f" [{func_line}] [{module_path}]" + + # 主要消息 + main_msg = record.getMessage() + + # 构建基本消息格式 + formatted_message = ( + f"{date_color}{datetime_str}{reset} " + f"{level_color}[{record.levelname}]{reset} " + f"{text_color}{main_msg}" + f"{date_color}{right_info}{reset}" + ) + + # 处理异常信息 + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + exc_text + reset + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset + + return formatted_message + + def _format_basic(self, record): + """基本格式化,不包含颜色""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + right_info = f" [{func_line}] [{module_path}]" + + formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" + + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + exc_text + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + self.formatStack(record.stack_info) + + return formatted_message + + def formatException(self, exc_info): + """重写异常格式化,确保异常信息保持正确的格式和颜色""" + # 获取标准的异常格式化文本 + formatted_exc = super().formatException(exc_info) + return formatted_exc + + +# 配置日志处理器 +def configure_logger(loglevel=None, working_dir=None): + """配置日志记录器 + + Args: + loglevel: 日志级别,可以是字符串('TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') + 或logging模块的常量(如logging.DEBUG)或TRACE_LEVEL + """ + # 获取根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(TRACE_LEVEL) + # 设置日志级别 + numeric_level = logging.DEBUG + if loglevel is not None: + if isinstance(loglevel, str): + # 将字符串转换为logging级别 + if loglevel.upper() == "TRACE": + numeric_level = TRACE_LEVEL + else: + numeric_level = getattr(logging, loglevel.upper(), None) + if not isinstance(numeric_level, int): + print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") + else: + numeric_level = loglevel + + # 移除已存在的处理器 + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # 创建控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别 + + # 使用自定义的颜色格式化器 + color_formatter = ColoredFormatter() + console_handler.setFormatter(color_formatter) + + # 添加处理器到根日志记录器 + root_logger.addHandler(console_handler) + + # 如果指定了工作目录,添加文件处理器 + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + # 生成日志文件名:日期 时间.log + log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + # 创建文件处理器 + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) + + # 使用不带颜色的格式化器 + file_formatter = ColoredFormatter(use_colors=False) + file_handler.setFormatter(file_formatter) + + root_logger.addHandler(file_handler) + + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.INFO) + + + +# 配置日志系统 +configure_logger() + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +# 获取调用栈信息的工具函数 +def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]: + """ + 获取调用者的信息 + + Args: + stack_level: 堆栈回溯的级别,0表示当前函数,1表示调用者,依此类推 + + Returns: + (filename, line_number, function_name, module_name) 元组 + """ + # 堆栈级别需要加3: + # +1 因为这个函数本身占一层 + # +1 因为日志函数(debug, info等)占一层 + # +1 因为下面调用 inspect.stack() 也占一层 + frame = inspect.currentframe() + try: + # 跳过适当的堆栈帧 + for _ in range(stack_level + 3): + if frame and frame.f_back: + frame = frame.f_back + else: + break + + if frame: + filename = frame.f_code.co_filename if frame.f_code else "unknown" + line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0 + function_name = frame.f_code.co_name if frame.f_code else "unknown" + + # 获取模块名称 + module_name = "unknown" + if frame.f_globals and "__name__" in frame.f_globals: + module_name = frame.f_globals["__name__"].rsplit(".", 1)[0] + + return (filename, line_number, function_name, module_name) + return ("unknown", 0, "unknown", "unknown") + finally: + del frame # 避免循环引用 + + +# 便捷日志记录函数 +def debug(msg, *args, stack_level=0, **kwargs): + """ + 记录DEBUG级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.debug的其他参数 + """ + # 获取调用者信息 + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.debug(msg, *args, **kwargs) + + +def info(msg, *args, stack_level=0, **kwargs): + """ + 记录INFO级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.info的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.info(msg, *args, **kwargs) + + +def warning(msg, *args, stack_level=0, **kwargs): + """ + 记录WARNING级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.warning的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.warning(msg, *args, **kwargs) + + +def error(msg, *args, stack_level=0, **kwargs): + """ + 记录ERROR级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.error的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.error(msg, *args, **kwargs) + + +def critical(msg, *args, stack_level=0, **kwargs): + """ + 记录CRITICAL级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.critical的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.critical(msg, *args, **kwargs) + + +def trace(msg, *args, stack_level=0, **kwargs): + """ + 记录TRACE级别日志(比DEBUG级别更低) + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.log的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.log(TRACE_LEVEL, msg, *args, **kwargs) + + +logger.trace = trace + +# 测试日志输出(如果直接运行此文件) +if __name__ == "__main__": + print("测试不同日志级别的颜色输出:") + trace("这是一条跟踪日志 (TRACE级别显示为深灰色,其他文本也为深灰色)") + debug("这是一条调试日志 (DEBUG级别显示为蓝色,其他文本为灰色)") + info("这是一条信息日志 (INFO级别显示为绿色,其他文本为白色)") + warning("这是一条警告日志 (WARNING级别显示为黄色,其他文本也为黄色)") + error("这是一条错误日志 (ERROR级别显示为红色,其他文本也为红色)") + critical("这是一条严重错误日志 (CRITICAL级别显示为紫色,其他文本也为紫色)") + # 测试异常输出 + try: + 1 / 0 + except Exception as e: + error(f"发生错误: {e}", exc_info=True) diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index da085f147..d389fcd23 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -63,10 +63,31 @@ class ColoredFormatter(logging.Formatter): "DATE": "\033[37m", # 日期始终使用灰色 } - def __init__(self, use_colors=True): + def __init__(self, use_colors=True, microseconds=False, show_thread=False): super().__init__() # 强制启用颜色 self.use_colors = use_colors + # microseconds: 保留微秒级时间戳(默认毫秒),便于精确排查时序 + self.microseconds = microseconds + # show_thread: 输出线程名,便于区分 queue/收发等并发逻辑 + self.show_thread = show_thread + + def _format_datetime(self, record) -> str: + """构建时间戳字符串,可选微秒级精度""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f") + if not self.microseconds: + datetime_str = datetime_str[:-3] # 截断到毫秒 + return datetime_str + "]" + + def _format_right_info(self, record) -> str: + """构建右侧的线程/函数/模块定位信息""" + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + thread_part = f" [{record.threadName}]" if self.show_thread else "" + return f"{thread_part} [{func_line}] [{module_path}]" def format(self, record): # 检查是否有自定义堆栈信息 @@ -87,15 +108,10 @@ def format(self, record): reset = self.COLORS["RESET"] # 日期格式 - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + datetime_str = self._format_datetime(record) - # 模块和函数信息 - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + # 线程、模块和函数信息 + right_info = self._format_right_info(record) # 主要消息 main_msg = record.getMessage() @@ -123,13 +139,8 @@ def format(self, record): def _format_basic(self, record): """基本格式化,不包含颜色""" - datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" - filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) - if "/" in filename: - filename = filename.split("/")[-1] - module_path = f"{record.name}.{filename}" - func_line = f"{record.funcName}:{record.lineno}" - right_info = f" [{func_line}] [{module_path}]" + datetime_str = self._format_datetime(record) + right_info = self._format_right_info(record) formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" @@ -152,6 +163,26 @@ def formatException(self, exc_info): return formatted_exc +def _to_numeric_level(loglevel, default=logging.DEBUG) -> int: + """将日志级别(字符串/常量)统一转换为数字级别。 + + Args: + loglevel: 'TRACE'/'DEBUG'/'INFO'/... 字符串,或 logging 常量,或 None + default: 解析失败或为 None 时使用的默认级别 + """ + if loglevel is None: + return default + if isinstance(loglevel, str): + if loglevel.upper() == "TRACE": + return TRACE_LEVEL + numeric_level = getattr(logging, loglevel.upper(), None) + if not isinstance(numeric_level, int): + print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") + return default + return numeric_level + return loglevel + + # 配置日志处理器 def configure_logger(loglevel=None, working_dir=None): """配置日志记录器 @@ -164,18 +195,7 @@ def configure_logger(loglevel=None, working_dir=None): root_logger = logging.getLogger() root_logger.setLevel(TRACE_LEVEL) # 设置日志级别 - numeric_level = logging.DEBUG - if loglevel is not None: - if isinstance(loglevel, str): - # 将字符串转换为logging级别 - if loglevel.upper() == "TRACE": - numeric_level = TRACE_LEVEL - else: - numeric_level = getattr(logging, loglevel.upper(), None) - if not isinstance(numeric_level, int): - print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") - else: - numeric_level = loglevel + numeric_level = _to_numeric_level(loglevel) # 移除已存在的处理器 for handler in root_logger.handlers[:]: @@ -191,9 +211,23 @@ def configure_logger(loglevel=None, working_dir=None): # 添加处理器到根日志记录器 root_logger.addHandler(console_handler) + + # 降低第三方库的日志级别,避免过多输出 + # pymodbus 库的日志太详细,设置为 WARNING + logging.getLogger('pymodbus').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING) + logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING) + + # websockets 库的日志输出较多,设置为 WARNING + logging.getLogger('websockets').setLevel(logging.WARNING) + logging.getLogger('websockets.client').setLevel(logging.WARNING) + logging.getLogger('websockets.server').setLevel(logging.WARNING) + + # ROS 节点的状态更新日志过于频繁,设置为 INFO + logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO) # 如果指定了工作目录,添加文件处理器 - log_filepath = None if working_dir is not None: logs_dir = os.path.join(working_dir, "logs") os.makedirs(logs_dir, exist_ok=True) @@ -214,6 +248,95 @@ def configure_logger(loglevel=None, working_dir=None): logging.getLogger("asyncio").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO) + + +# ============================================================================ +# 服务端通信(WebSocket)独立日志 +# 单独成文件、全量保留到本地、微秒级时间戳 + 线程名,便于排查通信/queue 时序问题 +# ============================================================================ +COMM_LOGGER_NAME = "unilabos.comm" +_comm_file_handler: "logging.Handler | None" = None # 便于重启时清理 websockets 库 handler + + +def _attach_trace_method(target_logger: logging.Logger) -> logging.Logger: + """为指定 logger 附加 .trace 方法,行为与模块级 trace 一致。 + + 通过 stacklevel=2 跳过本包装函数,使日志定位到真实调用处而非此处。 + """ + if not hasattr(target_logger, "trace"): + def _trace(msg, *args, _lg=target_logger, **kwargs): + kwargs.setdefault("stacklevel", 2) + _lg.log(TRACE_LEVEL, msg, *args, **kwargs) + + target_logger.trace = _trace # type: ignore[attr-defined] + return target_logger + + +def get_comm_logger() -> logging.Logger: + """获取通信专用 logger。 + + 未调用 ``configure_comm_logger`` 之前,该 logger 没有独立 handler 且 + ``propagate=True``,会回退到根 logger,行为与现状一致(安全降级)。 + """ + return _attach_trace_method(logging.getLogger(COMM_LOGGER_NAME)) + + +def configure_comm_logger(working_dir=None, loglevel=None): + """为服务端通信(WebSocket)配置独立日志,复用 ``ColoredFormatter`` 逻辑。 + + - 独立文件:``/logs/ws_comm_<日期 时间>.log``,TRACE 全量落本地 + - 微秒级时间戳 + 线程名,便于排查 queue 机制、收发时序与并发标识 + - ``propagate=False``,与主日志解耦,避免日志混在一起 + - 控制台仍保留实时输出(级别与主控制台一致),不丢失现有可见性 + - 同步把 ``websockets`` 库自身的协议日志(握手/ping/pong/关闭)落到同一文件 + + Args: + working_dir: 工作目录(``unilabos_data``),None 时不写文件 + loglevel: 控制台日志级别,与主日志保持一致 + + Returns: + 日志文件绝对路径(未配置文件时为 None) + """ + global _comm_file_handler + + comm_logger = get_comm_logger() + comm_logger.setLevel(TRACE_LEVEL) + comm_logger.propagate = False # 与根 logger 解耦,单独成文件 + + # 移除旧 handler,支持重启重复调用 + for handler in comm_logger.handlers[:]: + comm_logger.removeHandler(handler) + handler.close() + + # 控制台 handler:保留实时可见性,带线程名便于现场观察 + console_handler = logging.StreamHandler() + console_handler.setLevel(_to_numeric_level(loglevel)) + console_handler.setFormatter(ColoredFormatter(use_colors=True, show_thread=True)) + comm_logger.addHandler(console_handler) + + log_filepath = None + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + log_filename = "ws_comm_" + datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) # 全量保留到本地 + # 文件不带颜色,开启微秒精度 + 线程名 + file_handler.setFormatter(ColoredFormatter(use_colors=False, microseconds=True, show_thread=True)) + comm_logger.addHandler(file_handler) + + # websockets 库自身日志(协议层)也归集到同一文件,方便排查链路问题; + # 保持其 propagate=True,不影响主日志原有行为。 + ws_lib_logger = logging.getLogger("websockets") + if _comm_file_handler is not None and _comm_file_handler in ws_lib_logger.handlers: + ws_lib_logger.removeHandler(_comm_file_handler) + ws_lib_logger.addHandler(file_handler) + _comm_file_handler = file_handler + + comm_logger.info(f"[CommLogger] 通信日志已初始化,文件: {log_filepath}") return log_filepath diff --git a/unilabos/utils/tools.py b/unilabos/utils/tools.py index 3c7b742ed..e67192088 100644 --- a/unilabos/utils/tools.py +++ b/unilabos/utils/tools.py @@ -17,6 +17,14 @@ def fast_dumps_pretty(obj, **kwargs) -> bytes: default=json_default, ) + def fast_loads(data) -> dict: + """JSON 反序列化,优先使用 orjson。接受 str / bytes。""" + return orjson.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: + """JSON 序列化为 str,优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg)。""" + return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8") + def normalize_json(info: dict) -> dict: """经 JSON 序列化/反序列化一轮来清理非标准类型。""" return orjson.loads(orjson.dumps(info, default=json_default)) @@ -29,6 +37,14 @@ def fast_dumps(obj, **kwargs) -> bytes: # type: ignore[misc] def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc] return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8") + def fast_loads(data) -> dict: # type: ignore[misc] + if isinstance(data, bytes): + data = data.decode("utf-8") + return json.loads(data) + + def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc] + return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder) + def normalize_json(info: dict) -> dict: # type: ignore[misc] return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder)) diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index ead5eded3..3e49ed044 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.19 + 0.11.3 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln