diff --git a/.cursor/skills/add-device/SKILL.md b/.cursor/skills/add-device/SKILL.md new file mode 100644 index 00000000..61b6252e --- /dev/null +++ b/.cursor/skills/add-device/SKILL.md @@ -0,0 +1,160 @@ +--- +name: add-device +description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型. +--- + +# 添加新设备到 Uni-Lab-OS + +**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。 + +该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。 + +--- + +## 装饰器参考 + +### @device — 设备类装饰器 + +```python +from unilabos.registry.decorators import device + +# 单设备 +@device( + id="my_device.vendor", # 注册表唯一标识(必填) + category=["temperature"], # 分类标签列表(必填) + description="设备描述", # 设备描述 + display_name="显示名称", # UI 显示名称(默认用 id) + icon="DeviceIcon.webp", # 图标文件名 + version="1.0.0", # 版本号 + device_type="python", # "python" 或 "ros2" + handles=[...], # 端口列表(InputHandle / OutputHandle) + model={...}, # 3D 模型配置 + hardware_interface=HardwareInterface(...), # 硬件通信接口 +) + +# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置) +@device( + 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"}, + }, + category=["pump_and_valve"], +) +``` + +### @action — 动作方法装饰器 + +```python +from unilabos.registry.decorators import action + +@action # 无参:注册为 UniLabJsonCommand 动作 +@action() # 同上 +@action(description="执行操作") # 带描述 +@action( + action_type=HeatChill, # 指定 ROS Action 消息类型 + goal={"temperature": "temp"}, # Goal 字段映射 + feedback={}, # Feedback 字段映射 + result={}, # Result 字段映射 + handles=[...], # 动作级别端口 + goal_default={"temp": 25.0}, # Goal 默认值 + placeholder_keys={...}, # 参数占位符 + always_free=True, # 不受排队限制 + auto_prefix=True, # 强制使用 auto- 前缀 + parent=True, # 从父类 MRO 获取参数签名 +) +``` + +**自动识别规则:** +- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名) +- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作 +- `_` 开头的方法 → 不扫描 +- `@not_action` 标记的方法 → 排除 + +### @topic_config — 状态属性配置 + +```python +from unilabos.registry.decorators import topic_config + +@property +@topic_config( + period=5.0, # 发布周期(秒),默认 5.0 + print_publish=False, # 是否打印发布日志 + qos=10, # QoS 深度,默认 10 + name="custom_name", # 自定义发布名称(默认用属性名) +) +def temperature(self) -> float: + return self.data.get("temperature", 0.0) +``` + +### 辅助装饰器 + +```python +from unilabos.registry.decorators import not_action, always_free + +@not_action # 标记为非动作(post_init、辅助方法等) +@always_free # 标记为不受排队限制(查询类操作) +``` + +--- + +## 设备模板 + +```python +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 + +@device(id="my_device", category=["my_category"], description="设备描述") +class MyDevice: + _ros_node: BaseROS2DeviceNode + + def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): + self.device_id = device_id or "my_device" + self.config = config or {} + self.logger = logging.getLogger(f"MyDevice.{self.device_id}") + self.data: Dict[str, Any] = {"status": "Idle"} + + @not_action + def post_init(self, ros_node: BaseROS2DeviceNode) -> None: + self._ros_node = ros_node + + @action + async def initialize(self) -> bool: + self.data["status"] = "Ready" + return True + + @action + async def cleanup(self) -> bool: + self.data["status"] = "Offline" + return True + + @action(description="执行操作") + def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]: + """带 @action 装饰器 → 注册为 'my_action' 动作""" + return {"success": True} + + def get_info(self) -> Dict[str, Any]: + """无 @action → 自动注册为 'auto-get_info' 动作""" + return {"device_id": self.device_id} + + @property + @topic_config() + def status(self) -> str: + return self.data.get("status", "Idle") + + @property + @topic_config(period=2.0) + def temperature(self) -> float: + return self.data.get("temperature", 0.0) +``` + +### 要点 + +- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部 +- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)` +- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode` +- 运行时状态存储在 `self.data` 字典中 +- 设备文件放在 `unilabos/devices//` 目录下 diff --git a/.cursor/skills/add-resource/SKILL.md b/.cursor/skills/add-resource/SKILL.md new file mode 100644 index 00000000..1b67a872 --- /dev/null +++ b/.cursor/skills/add-resource/SKILL.md @@ -0,0 +1,351 @@ +--- +name: add-resource +description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse. +--- + +# 添加新物料资源 + +Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。 + +--- + +## 资源类型 + +| 类型 | 基类 | 用途 | 示例 | +|------|------|------|------| +| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 | +| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 | +| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 | +| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck | + +**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle` + +WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。 + +--- + +## @resource 装饰器 + +```python +from unilabos.registry.decorators import resource + +@resource( + id="my_resource_id", # 注册表唯一标识(必填) + category=["bottles"], # 分类标签列表(必填) + description="资源描述", + icon="", # 图标 + version="1.0.0", + handles=[...], # 端口列表(InputHandle / OutputHandle) + model={...}, # 3D 模型配置 + class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos" +) +``` + +--- + +## 创建规范 + +### 命名规则 + +1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一 +2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial +3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致 + +### 子物料命名示例 + +```python +# Carrier 内部的 sites 用 name 前缀 +for k, v in sites.items(): + v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ... + +# Carrier 中放置 Bottle 时用 name 前缀 +carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1" +carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1" + +# create_homogeneous_resources 使用 name_prefix +sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[...], + name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ... +) + +# Deck setup 中用仓库名称作为 name 传入 +self.warehouses = { + "堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左" + "试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈" +} +``` + +### 其他规范 + +- **max_volume 单位为 μL**:500mL = 500000 +- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz` +- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局 +- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()` +- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources//` 下 +- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错 +- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型 + +--- + +## 资源模板 + +### Bottle + +```python +from unilabos.registry.decorators import resource +from unilabos.resources.itemized_carrier import Bottle + + +@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶") +def My_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, + barcode: str = None, +) -> Bottle: + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="My_Reagent_Bottle", + ) +``` + +**Bottle 参数:** +- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入) +- `diameter`: 瓶体直径 (mm) +- `height`: 瓶体高度 (mm) +- `max_volume`: 最大容积(**μL**,500mL = 500000) +- `barcode`: 条形码(可选) + +### BottleCarrier + +```python +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.carrier import create_ordered_items_2d +from unilabos.resources.itemized_carrier import BottleCarrier +from unilabos.registry.decorators import resource + + +@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架") +def My_6SlotCarrier(name: str) -> BottleCarrier: + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, num_items_y=2, + dx=10.0, dy=10.0, dz=5.0, + item_dx=42.0, item_dy=35.0, + size_x=20.0, size_y=20.0, size_z=50.0, + ) + # 子 site 用 name 作为前缀 + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, size_x=146.0, size_y=80.0, size_z=55.0, + sites=sites, model="My_6SlotCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + + # 放置 Bottle 时用 name 作为前缀 + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] + for i in range(6): + carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}") + return carrier +``` + +### WareHouse / Deck 放置位 + +WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。 + +#### WareHouse(使用 warehouse_factory) + +```python +from unilabos.resources.warehouse import warehouse_factory +from unilabos.registry.decorators import resource + + +@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库") +def my_warehouse_4x4(name: str) -> "WareHouse": + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移 + item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距 + resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸 + model="my_warehouse_4x4", + col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05) + layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向 + ) +``` + +`warehouse_factory` 参数说明: +- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm) +- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量 +- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸 +- `layout`:影响 slot 标签和坐标映射 + - `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列) + - `"col-major"`:A01,B01,...,A02,B02,...(列优先) + - `"vertical-col-major"`:竖向排列,y 坐标反向 + +#### Deck 组装 WareHouse + +Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标: + +```python +from pylabrobot.resources import Deck, Coordinate +from unilabos.registry.decorators import resource + + +@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck") +class MyStation_Deck(Deck): + def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0, + category="deck", setup=False, **kwargs) -> None: + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z) + if setup: + self.setup() + + def setup(self) -> None: + self.warehouses = { + "堆栈1左": my_warehouse_4x4("堆栈1左"), + "堆栈1右": my_warehouse_4x4("堆栈1右"), + } + self.warehouse_locations = { + "堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算 + "堆栈1右": Coordinate(2350.0, 400.0, 0.0), + } + for wh_name, wh in self.warehouses.items(): + self.assign_child_resource(wh, location=self.warehouse_locations[wh_name]) +``` + +#### Site 模式(前端定向放置) + +适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局: + +```python +import collections +from typing import Any, Dict, List, Optional +from pylabrobot.resources import Deck, Resource, Coordinate +from unilabos.registry.decorators import resource + + +@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck") +class MyLabDeck(Deck): + # 根据设备台面实测批量计算各 slot 坐标偏移 + _DEFAULT_SITE_POSITIONS = [ + (0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4 + (0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8 + ] + _DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0} + _DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"] + + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, + sites: Optional[List[Dict[str, Any]]] = None, **kwargs): + super().__init__(size_x, size_y, size_z, name) + if sites is not None: + self.sites = [dict(s) for s in sites] + else: + self.sites = [] + for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS): + self.sites.append({ + "label": f"T{i + 1}", # 前端显示的槽位标签 + "visible": True, # 是否在前端可见 + "position": {"x": x, "y": y, "z": z}, # 槽位物理坐标 + "size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸 + "content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型 + }) + self._ordering = collections.OrderedDict( + (site["label"], None) for site in self.sites + ) + + def assign_child_resource(self, resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + spot: Optional[int] = None): + idx = spot + if spot is None: + for i, site in enumerate(self.sites): + if site.get("label") == resource.name: + idx = i + break + if idx is None: + for i in range(len(self.sites)): + if self._get_site_resource(i) is None: + idx = i + break + if idx is None: + raise ValueError(f"No available site for '{resource.name}'") + loc = Coordinate(**self.sites[idx]["position"]) + super().assign_child_resource(resource, location=loc, reassign=reassign) + + def serialize(self) -> dict: + data = super().serialize() + sites_out = [] + for i, site in enumerate(self.sites): + occupied = self._get_site_resource(i) + sites_out.append({ + "label": site["label"], + "visible": site.get("visible", True), + "occupied_by": occupied.name if occupied else None, + "position": site["position"], + "size": site["size"], + "content_type": site["content_type"], + }) + data["sites"] = sites_out + return data +``` + +**Site 字段说明:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name | +| `visible` | bool | 是否在前端可见 | +| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 | +| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) | +| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` | + +**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。 + +--- + +## 文件位置 + +``` +unilabos/resources/ +├── / # 按项目分组 +│ ├── bottles.py # Bottle 工厂函数 +│ ├── bottle_carriers.py # Carrier 工厂函数 +│ ├── warehouses.py # WareHouse 工厂函数 +│ └── decks.py # Deck 类定义 +``` + +--- + +## 验证 + +```bash +# 资源可导入 +python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))" + +# 启动测试(AST 自动扫描) +unilab -g .json +``` + +仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。 + +--- + +## 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | +| 装饰器定义 | `unilabos/registry/decorators.py` | diff --git a/.cursor/skills/add-resource/reference.md b/.cursor/skills/add-resource/reference.md new file mode 100644 index 00000000..a227d0c8 --- /dev/null +++ b/.cursor/skills/add-resource/reference.md @@ -0,0 +1,292 @@ +# 资源高级参考 + +本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 类继承体系 + +``` +PyLabRobot +├── Resource (PLR 基类) +│ ├── Well +│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器 +│ ├── Deck +│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面 +│ ├── ResourceHolder → 槽位占位符 +│ └── Container +│ └── Battery (unilabos) → 组装好的电池 +│ +├── ItemizedCarrier (unilabos, 继承 Resource) +│ ├── BottleCarrier (unilabos) → 瓶载架 +│ └── WareHouse (unilabos) → 堆栈仓库 +│ +├── ItemizedResource (PLR) +│ └── MagazineHolder (unilabos) → 子弹夹载架 +│ +└── ResourceStack (PLR) + └── Magazine (unilabos) → 子弹夹洞位 +``` + +### Bottle 类细节 + +```python +class Bottle(Well): + def __init__(self, name, diameter, height, max_volume, + size_x=0.0, size_y=0.0, size_z=0.0, + barcode=None, category="container", model=None, **kwargs): + super().__init__( + name=name, + size_x=diameter, # PLR 用 diameter 作为 size_x/size_y + size_y=diameter, + size_z=height, # PLR 用 height 作为 size_z + max_volume=max_volume, + category=category, + model=model, + bottom_type="flat", + cross_section_type="circle" + ) +``` + +注意 `size_x = size_y = diameter`,`size_z = height`。 + +### ItemizedCarrier 核心方法 + +| 方法 | 说明 | +|------|------| +| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 | +| `__setitem__(identifier, resource)` | 向槽位放入资源 | +| `get_child_identifier(child)` | 获取子资源的标识符 | +| `capacity` | 总槽位数 | +| `sites` | 所有槽位字典 | + +--- + +## 2. 序列化与反序列化 + +### PLR ↔ UniLab 转换 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab | +| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR | + +### `from_plr_resources` 流程 + +``` +PLR Resource + ↓ build_uuid_mapping (递归生成 UUID) + ↓ resource.serialize() → dict + ↓ resource.serialize_all_state() → states + ↓ resource_plr_inner (递归构建 ResourceDictInstance) +ResourceTreeSet +``` + +关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。 + +### `to_plr_resources` 流程 + +``` +ResourceTreeSet + ↓ collect_node_data (收集 UUID、状态、扩展数据) + ↓ node_to_plr_dict (转为 PLR 字典格式) + ↓ find_subclass(type_name, PLRResource) (查找 PLR 子类) + ↓ sub_cls.deserialize(plr_dict) (反序列化) + ↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展) +PLR Resource +``` + +### Bottle 序列化 + +```python +class Bottle(Well): + def serialize(self) -> dict: + data = super().serialize() + return {**data, "diameter": self.diameter, "height": self.height} + + @classmethod + def deserialize(cls, data: dict, allow_marshal=False): + barcode_data = data.pop("barcode", None) + instance = super().deserialize(data, allow_marshal=allow_marshal) + if barcode_data and isinstance(barcode_data, str): + instance.barcode = barcode_data + return instance +``` + +--- + +## 3. Bioyond 物料同步 + +### 双向转换函数 + +| 函数 | 位置 | 方向 | +|------|------|------| +| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR | +| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond | + +### `resource_bioyond_to_plr` 流程 + +``` +Bioyond 物料列表 + ↓ reverse_type_mapping: {typeName → (model, UUID)} + ↓ 对每个物料: + typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor") + initialize_resource({"name": unique_name, "class": model}) + ↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等) + ↓ 处理 detail (子物料/坐标) + ↓ 按 locationName 放入 deck.warehouses 对应槽位 +PLR 资源列表 +``` + +### `resource_plr_to_bioyond` 流程 + +``` +PLR 资源列表 + ↓ 遍历每个资源: + 载架(capacity > 1): 生成 details 子物料 + 坐标 + 单瓶: 直接映射 + ↓ type_mapping 查找 typeId + ↓ warehouse_mapping 查找位置 UUID + ↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations) +Bioyond 物料列表 +``` + +### BioyondResourceSynchronizer + +工作站通过 `ResourceSynchronizer` 自动同步物料: + +```python +class BioyondResourceSynchronizer(ResourceSynchronizer): + def sync_from_external(self) -> bool: + all_data = [] + all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材 + all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品 + all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂 + unilab_resources = resource_bioyond_to_plr( + all_data, + type_mapping=self.workstation.bioyond_config["material_type_mappings"], + deck=self.workstation.deck + ) + # 更新 deck 上的资源 +``` + +--- + +## 4. 非瓶类资源 + +### ElectrodeSheet(极片) + +路径:`unilabos/resources/battery/electrode_sheet.py` + +```python +class ElectrodeSheet(ResourcePLR): + """片状材料(极片、隔膜、弹片、垫片等)""" + _unilabos_state = { + "diameter": 0.0, + "thickness": 0.0, + "mass": 0.0, + "material_type": "", + "color": "", + "info": "", + } +``` + +工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil` + +### Battery(电池) + +```python +class Battery(Container): + """组装好的电池""" + _unilabos_state = { + "color": "", + "electrolyte_name": "", + "open_circuit_voltage": 0.0, + } +``` + +### Magazine / MagazineHolder(子弹夹) + +```python +class Magazine(ResourceStack): + """子弹夹洞位,可堆叠 ElectrodeSheet""" + # direction, max_sheets + +class MagazineHolder(ItemizedResource): + """多洞位子弹夹""" + # hole_diameter, hole_depth, max_sheets_per_hole +``` + +工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。 + +--- + +## 5. 仓库工厂模式参考 + +### 实际 warehouse 工厂函数示例 + +```python +# 行优先 4x4 仓库 +def bioyond_warehouse_1x4x4(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="row-major", # A01,A02,A03,A04, B01,... + ) + +# 右侧 4x4 仓库(列名偏移) +def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=4, num_items_y=4, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + col_offset=4, # A05,A06,A07,A08 + layout="row-major", + ) + +# 竖向仓库(站内试剂存放) +def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=1, num_items_y=2, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=147.0, item_dy=106.0, item_dz=130.0, + layout="vertical-col-major", + ) + +# 行偏移(F 行开始) +def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse: + return warehouse_factory( + name=name, + num_items_x=3, num_items_y=5, num_items_z=1, + dx=10.0, dy=10.0, dz=10.0, + item_dx=159.0, item_dy=183.0, item_dz=130.0, + row_offset=row_offset, # 0→A行起,5→F行起 + layout="row-major", + ) +``` + +### layout 类型说明 + +| layout | 命名顺序 | 适用场景 | +|--------|---------|---------| +| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 | +| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 | +| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) | + +--- + +## 6. 关键路径 + +| 内容 | 路径 | +|------|------| +| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` | +| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` | +| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` | +| Bioyond 物料转换 | `unilabos/resources/graphio.py` | +| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` | +| 电池资源 | `unilabos/resources/battery/` | +| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` | diff --git a/.cursor/skills/add-workstation/SKILL.md b/.cursor/skills/add-workstation/SKILL.md new file mode 100644 index 00000000..534e5ba6 --- /dev/null +++ b/.cursor/skills/add-workstation/SKILL.md @@ -0,0 +1,626 @@ +--- +name: add-workstation +description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation. +--- + +# Uni-Lab-OS 工作站接入指南 + +工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。 + +--- + +## 工作站类型 + +| 类型 | 基类 | 适用场景 | +| ------------------- | ----------------- | ---------------------------------- | +| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) | +| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 | +| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 | + +--- + +## @device 装饰器(工作站) + +工作站也使用 `@device` 装饰器注册,参数与普通设备一致: + +```python +@device( + id="my_workstation", # 注册表唯一标识(必填) + category=["workstation"], # 分类标签 + description="我的工作站", +) +``` + +如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。 + +--- + +## 工作站驱动模板 + +### 模板 A:基于外部系统的工作站 + +```python +import logging +from typing import Dict, Any, Optional +from pylabrobot.resources import Deck + +from unilabos.registry.decorators import device, topic_config, not_action +from unilabos.devices.workstation.workstation_base import WorkstationBase + +try: + from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +except ImportError: + ROS2WorkstationNode = None + + +@device(id="my_workstation", category=["workstation"], description="我的工作站") +class MyWorkstation(WorkstationBase): + _ros_node: "ROS2WorkstationNode" + + def __init__(self, config=None, deck=None, protocol_type=None, **kwargs): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + self.logger = logging.getLogger("MyWorkstation") + self.api_host = self.config.get("api_host", "") + self._status = "Idle" + + @not_action + def post_init(self, ros_node: "ROS2WorkstationNode"): + super().post_init(ros_node) + self._ros_node = ros_node + + async def scheduler_start(self, **kwargs) -> Dict[str, Any]: + """注册为工作站动作""" + return {"success": True} + + async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]: + """注册为工作站动作""" + return {"success": True} + + @property + @topic_config() + def workflow_sequence(self) -> str: + return "[]" + + @property + @topic_config() + def material_info(self) -> str: + return "{}" +``` + +### 模板 B:Protocol 工作站 + +直接使用 `ProtocolNode`,通常不需要自定义驱动类: + +```python +from unilabos.devices.workstation.workstation_base import ProtocolNode +``` + +在图文件中配置 `protocol_type` 即可。 + +--- + +## 子设备访问(sub_devices) + +工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法: + +```python +# 在工站驱动类的方法中访问子设备 +sub = self._ros_node.sub_devices["pump_1"] + +# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例) +sub.driver_instance.some_method(arg1, arg2) + +# .ros_node_instance — 子设备的 ROS2 节点实例 +sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action +``` + +**常见用法**: + +```python +class MyWorkstation(WorkstationBase): + def my_protocol(self, **kwargs): + # 获取子设备驱动实例 + pump = self._ros_node.sub_devices["pump_1"].driver_instance + heater = self._ros_node.sub_devices["heater_1"].driver_instance + + # 直接调用子设备方法 + pump.aspirate(volume=100) + heater.set_temperature(80) +``` + +> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。 + +--- + +## 硬件通信接口(hardware_interface) + +硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。 + +### 工作原理 + +`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`): + +**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。 + +**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置: + +``` +hardware_interface = d.ros_node_instance._hardware_interface +# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"} +``` + +1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])` + - 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换 +2. 从通信设备获取真正的 `read`/`write` 方法 +3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上 + +因此: + +- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`) +- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备) +- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化 + +### HardwareInterface 参数说明 + +```python +from unilabos.registry.decorators import HardwareInterface + +HardwareInterface( + name="hardware_interface", # __init__ 中接收通信实例的属性名 + read="send_command", # 通信设备上暴露的读方法名 + write="send_command", # 通信设备上暴露的写方法名 + extra_info=["list_ports"], # 可选:额外暴露的方法 +) +``` + +**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。 + +### 示例 1:泵(name="hardware_interface") + +```python +from unilabos.registry.decorators import device, HardwareInterface + +@device( + id="my_pump", + category=["pump_and_valve"], + hardware_interface=HardwareInterface( + name="hardware_interface", + read="send_command", + write="send_command", + ), +) +class MyPump: + def __init__(self, port=None, address="1", **kwargs): + # name="hardware_interface" → 系统替换 self.hardware_interface + self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例 + self.address = address + + def send_command(self, command: str): + full_command = f"/{self.address}{command}\r\n" + self.hardware_interface.write(bytearray(full_command, "ascii")) + return self.hardware_interface.read_until(b"\n") +``` + +### 示例 2:电磁阀(name="io_device_port",自定义属性名) + +```python +@device( + id="solenoid_valve", + category=["pump_and_valve"], + hardware_interface=HardwareInterface( + name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port + read="read_io_coil", + write="write_io_coil", + ), +) +class SolenoidValve: + def __init__(self, io_device_port: str = None, **kwargs): + # name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1" + self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例 +``` + +### Serial 通信设备(class="serial") + +`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`: + +```python +from serial import Serial, SerialException +from threading import Lock + +class ROS2SerialNode(BaseROS2DeviceNode): + def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs): + self.port = port + self.baudrate = baudrate + self._hardware_interface = { + "name": "hardware_interface", + "write": "send_command", + "read": "read_data", + } + self._query_lock = Lock() + + self.hardware_interface = Serial(baudrate=baudrate, port=port) + + BaseROS2DeviceNode.__init__( + self, driver_instance=self, registry_name=registry_name, + device_id=device_id, status_types={}, action_value_mappings={}, + hardware_interface=self._hardware_interface, print_publish=False, + ) + self.create_service(SerialCommand, "serialwrite", self.handle_serial_request) + + def send_command(self, command: str): + with self._query_lock: + self.hardware_interface.write(bytearray(f"{command}\n", "ascii")) + return self.hardware_interface.read_until(b"\n").decode() + + def read_data(self): + with self._query_lock: + return self.hardware_interface.read_until(b"\n").decode() +``` + +在图文件中使用 `"class": "serial"` 即可创建串口代理: + +```json +{ + "id": "serial_pump", + "class": "serial", + "parent": "my_station", + "config": { "port": "COM7", "baudrate": 9600 } +} +``` + +### 图文件配置 + +**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化: + +```json +{ + "nodes": [ + { + "id": "my_station", + "class": "workstation", + "children": ["serial_pump", "pump_1", "pump_2"], + "config": { "protocol_type": ["PumpTransferProtocol"] } + }, + { + "id": "serial_pump", + "class": "serial", + "parent": "my_station", + "config": { "port": "COM7", "baudrate": 9600 } + }, + { + "id": "pump_1", + "class": "syringe_pump_with_valve.runze.SY03B-T08", + "parent": "my_station", + "config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 } + }, + { + "id": "pump_2", + "class": "syringe_pump_with_valve.runze.SY03B-T08", + "parent": "my_station", + "config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 } + } + ], + "links": [ + { + "source": "pump_1", + "target": "serial_pump", + "type": "communication", + "port": { "pump_1": "port", "serial_pump": "port" } + }, + { + "source": "pump_2", + "target": "serial_pump", + "type": "communication", + "port": { "pump_2": "port", "serial_pump": "port" } + } + ] +} +``` + +### 通信协议速查 + +| 协议 | config 参数 | 依赖包 | 通信设备 class | +| -------------------- | ------------------------------ | ---------- | -------------------------- | +| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `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` | stdlib | 自定义 | +| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` | + +参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json` + +--- + +## Deck 与物料生命周期 + +### 1. Deck 入参与两种初始化模式 + +系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。 + +有两种初始化模式: + +#### init 初始化(推荐) + +`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。 + +```json +"config": { + "deck": { + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck", + "_resource_child_name": "PRCXI_Deck" + } +} +``` + +#### deserialize 初始化 + +`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等): + +```json +"config": { + "deck": { + "data": { + "_resource_child_name": "YB_Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + } + } +} +``` + +没有特殊需求时推荐 init 初始化。 + +#### config.deck 字段说明 + +| 字段 | 说明 | +|------|------| +| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) | +| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 | + +#### 设备 __init__ 接收 + +```python +def __init__(self, config=None, deck=None, protocol_type=None, **kwargs): + super().__init__(deck=deck, **kwargs) + # deck 已经是反序列化后的 Deck 实例 + # → PRCXI9300Deck / BIOYOND_YB_Deck 等 +``` + +#### Deck 节点(图文件中) + +Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id: + +```json +{ + "id": "PRCXI_Deck", + "parent": "PRCXI", + "type": "deck", + "class": "", + "children": [], + "config": { + "type": "PRCXI9300Deck", + "size_x": 542, "size_y": 374, "size_z": 0, + "category": "deck", + "sites": [...] + }, + "data": {} +} +``` + +- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段) +- `children` 初始为空时,由同步器或手动初始化填充 +- `config.type` 填 Deck 类名 + +### 2. Deck 为空时自行初始化 + +如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容: + +```python +@not_action +def post_init(self, ros_node): + super().post_init(ros_node) + if self.deck and not self.deck.children: + self._initialize_default_deck() + +def _initialize_default_deck(self): + from my_labware import My_TipRack, My_Plate + self.deck.assign_child_resource(My_TipRack("T1"), spot=0) + self.deck.assign_child_resource(My_Plate("T2"), spot=1) +``` + +### 3. 物料双向同步 + +当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步: + +```python +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer + +class MyResourceSynchronizer(ResourceSynchronizer): + def sync_from_external(self) -> bool: + """从外部系统同步到 self.workstation.deck""" + external_data = self._query_external_materials() + # 以外部工站为准:根据外部数据反向创建 PLR 资源实例 + for item in external_data: + cls = self._resolve_resource_class(item["type"]) + resource = cls(name=item["name"], **item["params"]) + self.workstation.deck.assign_child_resource(resource, spot=item["slot"]) + return True + + def sync_to_external(self, resource) -> bool: + """将 UniLab 侧物料变更同步到外部系统""" + # 以 UniLab 为准:将 PLR 资源转为外部格式并推送 + external_format = self._convert_to_external(resource) + return self._push_to_external(external_format) + + def handle_external_change(self, change_info) -> bool: + """处理外部系统主动推送的变更""" + return True +``` + +同步策略取决于业务场景: + +- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上 +- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统 + +在工作站 `post_init` 中初始化同步器: + +```python +@not_action +def post_init(self, ros_node): + super().post_init(ros_node) + self.resource_synchronizer = MyResourceSynchronizer(self) + self.resource_synchronizer.sync_from_external() +``` + +### 4. 序列化与持久化(serialize / serialize_state) + +资源类需正确实现序列化,系统据此完成持久化和前端同步。 + +**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用: + +```python +class MyDeck(Deck): + def __init__(self, name, size_x, size_y, size_z, + sites=None, # serialize() 输出的字段 + rotation=None, # serialize() 输出的字段 + barcode=None, # serialize() 输出的字段 + **kwargs): # 兜底:接受所有未知的 serialize 字段 + super().__init__(size_x, size_y, size_z, name) + # ... + + def serialize(self) -> dict: + data = super().serialize() + data["sites"] = [...] # 自定义字段 + return data +``` + +**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复: + +```python +class MyPlate(Plate): + def __init__(self, name, size_x, size_y, size_z, + material_info=None, **kwargs): + super().__init__(name, size_x, size_y, size_z, **kwargs) + self._unilabos_state = {} + if material_info: + self._unilabos_state["Material"] = material_info + + def serialize_state(self) -> Dict[str, Any]: + data = super().serialize_state() + data.update(self._unilabos_state) + return data +``` + +关键要点: + +- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`) +- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等) +- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None) + +### 5. 子物料自动同步 + +子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。 + +### 6. 图文件配置(参考 prcxi_9320_slim.json) + +```json +{ + "nodes": [ + { + "id": "my_station", + "type": "device", + "class": "my_workstation", + "config": { + "deck": { + "_resource_type": "unilabos.resources.my_module:MyDeck", + "_resource_child_name": "my_deck" + }, + "host": "10.20.30.1", + "port": 9999 + } + }, + { + "id": "my_deck", + "parent": "my_station", + "type": "deck", + "class": "", + "children": [], + "config": { + "type": "MyLabDeck", + "size_x": 542, + "size_y": 374, + "size_z": 0, + "category": "deck", + "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": ["plate", "tip_rack", "tube_rack", "adaptor"] + } + ] + }, + "data": {} + } + ], + "edges": [] +} +``` + +Deck 节点要点: + +- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`) +- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取) +- `children` 初始为空(由同步器或手动初始化填充) +- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径 + +--- + +## 子设备 + +子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。 + +子设备约束: + +- 图文件中 `parent` 指向工作站 ID +- 在工作站 `children` 数组中列出 + +--- + +## 关键规则 + +1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数 +2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck +3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料 +4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external` +5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用 +6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接 +7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()` +8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup` +9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图 + +--- + +## 验证 + +```bash +# 模块可导入 +python -c "from unilabos.devices.workstation.. import " + +# 启动测试(AST 自动扫描) +unilab -g .json +``` + +--- + +## 现有工作站参考 + +| 工作站 | 驱动类 | 类型 | +| -------------- | ----------------------------- | -------- | +| Protocol 通用 | `ProtocolNode` | Protocol | +| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 | +| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 | + +参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。 diff --git a/.cursor/skills/add-workstation/reference.md b/.cursor/skills/add-workstation/reference.md new file mode 100644 index 00000000..0c1b9f0d --- /dev/null +++ b/.cursor/skills/add-workstation/reference.md @@ -0,0 +1,371 @@ +# 工作站高级模式参考 + +本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。 +Agent 在需要实现这些功能时按需阅读。 + +--- + +## 1. 外部系统集成模式 + +### 1.1 RPC 客户端 + +与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。 + +```python +from unilabos.device_comms.rpc import BaseRequest + + +class MySystemRPC(BaseRequest): + """外部系统 RPC 客户端""" + + def __init__(self, host: str, api_key: str): + super().__init__(host) + self.api_key = api_key + + def _request(self, endpoint: str, data: dict = None) -> dict: + return self.post( + url=f"{self.host}/api/{endpoint}", + params={ + "apiKey": self.api_key, + "requestTime": self.get_current_time_iso8601(), + "data": data or {}, + }, + ) + + def query_status(self) -> dict: + return self._request("status/query") + + def create_order(self, order_data: dict) -> dict: + return self._request("order/create", order_data) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`) + +### 1.2 HTTP 回调服务 + +接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。 + +```python +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService + + +class MyWorkstation(WorkstationBase): + def __init__(self, config=None, deck=None, **kwargs): + super().__init__(deck=deck, **kwargs) + self.config = config or {} + http_cfg = self.config.get("http_service_config", {}) + self._http_service_config = { + "host": http_cfg.get("http_service_host", "127.0.0.1"), + "port": http_cfg.get("http_service_port", 8080), + } + self.http_service = None + + def post_init(self, ros_node): + super().post_init(ros_node) + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() +``` + +**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发): + +| 端点 | 调用的工作站方法 | +|------|-----------------| +| `/report/step_finish` | `process_step_finish_report(report_request)` | +| `/report/sample_finish` | `process_sample_finish_report(report_request)` | +| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` | +| `/report/material_change` | `process_material_change_report(report_data)` | +| `/report/error_handling` | `handle_external_error(error_data)` | + +实现对应方法即可接收回调: + +```python +def process_step_finish_report(self, report_request) -> Dict[str, Any]: + """处理步骤完成报告""" + step_name = report_request.data.get("stepName") + return {"success": True, "message": f"步骤 {step_name} 已处理"} + +def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]: + """处理订单完成报告""" + order_code = report_request.data.get("orderCode") + return {"success": True} +``` + +参考:`unilabos/devices/workstation/workstation_http_service.py` + +### 1.3 连接监控 + +独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。 + +```python +class ConnectionMonitor: + def __init__(self, workstation, check_interval=30): + self.workstation = workstation + self.check_interval = check_interval + self._running = False + self._thread = None + + def start(self): + self._running = True + self._thread = threading.Thread(target=self._monitor_loop, daemon=True) + self._thread.start() + + def _monitor_loop(self): + while self._running: + try: + # 调用外部系统接口检测连接 + self.workstation.hardware_interface.ping() + status = "online" + except Exception: + status = "offline" + time.sleep(self.check_interval) +``` + +参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`) + +--- + +## 2. Config 结构模式 + +工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式: + +### 2.1 外部系统连接 + +```json +{ + "api_host": "http://192.168.1.100:8080", + "api_key": "YOUR_API_KEY" +} +``` + +### 2.2 HTTP 回调服务 + +```json +{ + "http_service_config": { + "http_service_host": "127.0.0.1", + "http_service_port": 8080 + } +} +``` + +### 2.3 物料类型映射 + +将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。 + +```json +{ + "material_type_mappings": { + "PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"], + "BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."] + } +} +``` + +### 2.4 仓库映射 + +将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。 + +```json +{ + "warehouse_mapping": { + "仓库名": { + "uuid": "warehouse-uuid", + "site_uuids": { + "A01": "site-uuid-A01", + "A02": "site-uuid-A02" + } + } + } +} +``` + +### 2.5 工作流映射 + +将内部工作流名映射到外部系统的工作流 ID。 + +```json +{ + "workflow_mappings": { + "internal_workflow_name": "external-workflow-uuid" + } +} +``` + +### 2.6 物料默认参数 + +```json +{ + "material_default_parameters": { + "NMP": { + "unit": "毫升", + "density": "1.03", + "densityUnit": "g/mL", + "description": "N-甲基吡咯烷酮" + } + } +} +``` + +--- + +## 3. 资源同步机制 + +### 3.1 ResourceSynchronizer + +抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。 + +```python +from unilabos.devices.workstation.workstation_base import ResourceSynchronizer + + +class MyResourceSynchronizer(ResourceSynchronizer): + def __init__(self, workstation, api_client): + super().__init__(workstation) + self.api_client = api_client + + def sync_from_external(self) -> bool: + """从外部系统拉取物料到 deck""" + external_materials = self.api_client.list_materials() + for material in external_materials: + plr_resource = self._convert_to_plr(material) + self.workstation.deck.assign_child_resource(plr_resource, coordinate) + return True + + def sync_to_external(self, plr_resource) -> bool: + """将 deck 中的物料变更推送到外部系统""" + external_data = self._convert_from_plr(plr_resource) + self.api_client.update_material(external_data) + return True + + def handle_external_change(self, change_info) -> bool: + """处理外部系统推送的物料变更""" + return True +``` + +### 3.2 update_resource — 上传资源树到云端 + +将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景: + +```python +# 在 post_init 中上传初始 deck +from unilabos.ros.nodes.base_device_node import ROS2DeviceNode + +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} +) + +# 在动作方法中更新特定资源 +ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [updated_plate]} +) +``` + +--- + +## 4. 工作流序列管理 + +工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。 + +```python +class MyWorkstation(WorkstationBase): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._workflow_sequence = [] + + @property + def workflow_sequence(self) -> str: + """返回 JSON 字符串,ROS 自动发布""" + import json + return json.dumps(self._workflow_sequence) + + async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]: + """添加工作流到队列""" + self._workflow_sequence.append({ + "name": workflow_name, + "status": "pending", + "created_at": time.time(), + }) + return {"success": True} + + async def clear_workflows(self) -> Dict[str, Any]: + """清空工作流队列""" + self._workflow_sequence = [] + return {"success": True} +``` + +--- + +## 5. 站间物料转移 + +工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。 + +```python +async def transfer_materials_to_another_station( + self, + target_device_id: str, + transfer_groups: list, + **kwargs, +) -> Dict[str, Any]: + """将物料转移到另一个工作站""" + target_node = self._children.get(target_device_id) + if not target_node: + # 通过 ROS 节点查找非子设备的目标站 + pass + + for group in transfer_groups: + resource = self.find_resource_by_name(group["resource_name"]) + # 从本站 deck 移除 + resource.unassign() + # 调用目标站的接收方法 + # ... + + return {"success": True, "transferred": len(transfer_groups)} +``` + +参考:`BioyondDispensingStation.transfer_materials_to_reaction_station` + +--- + +## 6. post_init 完整模式 + +`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。 + +```python +def post_init(self, ros_node): + super().post_init(ros_node) + + # 1. 初始化外部系统客户端(此时 config 已可用) + self.rpc_client = MySystemRPC( + host=self.config.get("api_host"), + api_key=self.config.get("api_key"), + ) + self.hardware_interface = self.rpc_client + + # 2. 启动连接监控 + self.connection_monitor = ConnectionMonitor(self) + self.connection_monitor.start() + + # 3. 启动 HTTP 回调服务 + if hasattr(self, '_http_service_config'): + self.http_service = WorkstationHTTPService( + workstation_instance=self, + host=self._http_service_config["host"], + port=self._http_service_config["port"], + ) + self.http_service.start() + + # 4. 上传 deck 到云端 + ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, True, + **{"resources": [self.deck]} + ) + + # 5. 初始化资源同步器(可选) + self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client) +``` diff --git a/.cursor/skills/batch-insert-reagent/SKILL.md b/.cursor/skills/batch-insert-reagent/SKILL.md new file mode 100644 index 00000000..cd946cc3 --- /dev/null +++ b/.cursor/skills/batch-insert-reagent/SKILL.md @@ -0,0 +1,233 @@ +--- +name: batch-insert-reagent +description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent. +--- + +# 批量录入试剂 Skill + +通过云端 API 批量录入试剂信息,支持逐条或批量操作。 + +## 前置条件(缺一不可) + +使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token(任选一种方式): + +```bash +# 方式一:Python 一行生成 +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" + +# 方式二:手动计算 +# base64(ak:sk) → Authorization: Lab +``` + +### 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` | + +确认后设置: +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab " +``` + +**两项全部就绪后才可发起 API 请求。** + +## Session State + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) + +## 请求约定 + +所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 录入试剂 + +```bash +curl -s -X POST "$BASE/api/v1/lab/reagent" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{ + "lab_uuid": "", + "cas": "", + "name": "<试剂名称>", + "molecular_formula": "<分子式>", + "smiles": "", + "stock_in_quantity": <入库数量>, + "unit": "<单位字符串>", + "supplier": "<供应商>", + "production_date": "<生产日期 ISO 8601>", + "expiry_date": "<过期日期 ISO 8601>" + }' +``` + +返回成功时包含试剂 UUID: +```json +{"code": 0, "data": {"uuid": "xxx", ...}} +``` + +--- + +## 试剂字段说明 + +| 字段 | 类型 | 必填 | 说明 | 示例 | +|------|------|------|------|------| +| `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"` | 克 | +| `"kg"` | 千克 | +| `"瓶"` | 瓶 | + +> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。 + +--- + +## 批量录入策略 + +### 方式一:用户提供 JSON 数组 + +用户一次性给出多条试剂数据: + +```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"} +] +``` + +Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。 + +Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。 + +### 方式二:用户逐个描述 + +用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段: + +1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断) +2. 构建完整的请求体 +3. 向用户确认后提交 + +### 方式三:从 CSV/表格批量导入 + +用户提供 CSV 或表格文件路径,agent 读取并解析: + +```bash +# 期望的 CSV 格式(首行为表头) +cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date +7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z +``` + +### 执行与汇报 + +每次 API 调用后: +1. 检查返回 `code`(0 = 成功) +2. 记录成功/失败数量 +3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」 +4. 如有失败,列出失败的试剂名称和错误信息 + +--- + +## 常见试剂速查表 + +| 名称 | 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 | + +> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。 + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件) +- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等) +- [ ] Step 6: 向用户确认待录入的试剂列表 +- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid) +- [ ] Step 8: 汇总结果(成功/失败数量及详情) +``` + +--- + +## 完整示例 + +用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」 + +Agent 构建的请求序列: + +```json +// 第 1 条 +{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} + +// 第 2 条 +{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} + +// 第 3 条 +{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"} +``` diff --git a/.cursor/skills/batch-submit-experiment/SKILL.md b/.cursor/skills/batch-submit-experiment/SKILL.md new file mode 100644 index 00000000..de6fed5e --- /dev/null +++ b/.cursor/skills/batch-submit-experiment/SKILL.md @@ -0,0 +1,325 @@ +--- +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/实验轮次/实验状态. +--- + +# 批量提交实验指南 + +通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。 + +## 前置条件(缺一不可) + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token(任选一种方式): + +```bash +# 方式一:Python 一行生成 +python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" + +# 方式二:手动计算 +# base64(ak:sk) → Authorization: Lab +``` + +### 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` | + +确认后设置: +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. req_device_registry_upload.json(设备注册表) + +**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。** + +按优先级搜索: + +``` +/unilabos_data/req_device_registry_upload.json +/req_device_registry_upload.json +``` + +也可直接 Glob 搜索:`**/req_device_registry_upload.json` + +找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。 + +**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。 + +### 4. workflow_uuid(目标工作流) + +用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。 + +**四项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态,避免重复询问用户: + +- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**) +- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**) +- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择) +- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取) + +## 请求约定 + +所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 +> +> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 列出实验室项目(让用户选择项目) + +```bash +curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH" +``` + +返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。 + +用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。 + +### 3. 列出可用 workflow + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH" +``` + +返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。 + +### 4. 获取 workflow 模板详情 + +```bash +curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH" +``` + +返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取: +- 每个 action 节点的 `node_uuid` +- 每个节点对应的设备 ID(`resource_template_name`) +- 每个节点的动作名(`node_template_name`) +- 每个节点的现有参数(`param`) + +> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。 + +### 5. 提交实验(创建 notebook) + +```bash +curl -s -X POST "$BASE/api/v1/lab/notebook" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体结构: + +```json +{ + "lab_uuid": "", + "project_uuid": "", + "workflow_uuid": "", + "name": "<实验名称>", + "node_params": [ + { + "sample_uuids": ["<样品UUID1>", "<样品UUID2>"], + "datas": [ + { + "node_uuid": "", + "param": {}, + "sample_params": [ + { + "container_uuid": "<容器UUID>", + "sample_value": { + "liquid_names": "<液体名称>", + "volumes": 1000 + } + } + ] + } + ] + } + ] +} +``` + +> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。 + +### 6. 查询 notebook 状态 + +提交成功后,使用返回的 notebook UUID 查询执行状态: + +```bash +curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH" +``` + +提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。 + +--- + +## Notebook 请求体详解 + +### node_params 结构 + +`node_params` 是一个数组,**每个元素代表一轮实验**: + +- 要跑 2 轮 → `node_params` 有 2 个元素 +- 要跑 N 轮 → `node_params` 有 N 个元素 + +### 每轮的字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `sample_uuids` | array\ | 该轮实验的样品 UUID 数组,无样品时传 `[]` | +| `datas` | array | 该轮中每个 workflow 节点的参数配置 | + +### datas 中每个节点 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `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}` | + +--- + +## 从本地注册表生成 param 模板 + +### 自动方式 — 运行脚本 + +```bash +python scripts/gen_notebook_params.py \ + --auth \ + --base \ + --workflow-uuid \ + [--registry ] \ + [--rounds <轮次数>] \ + [--output <输出文件路径>] +``` + +> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。 + +脚本会: +1. 调用 workflow detail API 获取所有 action 节点 +2. 读取本地注册表,为每个节点查找对应的 action schema +3. 生成 `notebook_template.json`,包含: + - 完整 `node_params` 骨架 + - 每个节点的 param 字段及类型说明 + - `_schema_info` 辅助信息(不提交,仅供参考) + +### 手动方式 + +如果脚本不可用或注册表不存在: + +1. 调用 API #4 获取 workflow 详情 +2. 找到每个 action 节点的 `node_uuid` +3. 在本地注册表中查找对应设备的 `action_value_mappings`: + ``` + resources[].id == + → resources[].class.action_value_mappings..schema.properties.goal.properties + ``` +4. 将 schema 中的 properties 作为 `param` 的字段模板 +5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值 + +### 注册表结构参考 + +```json +{ + "resources": [ + { + "id": "liquid_handler.prcxi", + "class": { + "module": "unilabos.devices.xxx:ClassName", + "action_value_mappings": { + "transfer_liquid": { + "type": "LiquidHandlerTransfer", + "schema": { + "properties": { + "goal": { + "properties": { + "asp_vols": {"type": "array", "items": {"type": "number"}}, + "sources": {"type": "array"} + }, + "required": ["asp_vols", "sources"] + } + } + }, + "goal_default": {} + } + } + } + } + ] +} +``` + +`param` 填写时,使用 `goal.properties` 中的字段名和类型。 + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid +- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择) +- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名 +- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json +- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板 +- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params) +- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交 +- [ ] Step 11: 检查返回结果,记录 notebook UUID +- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度 +``` + +--- + +## 常见问题 + +### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗? + +是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。 + +### Q: 多轮实验的参数完全不同吗? + +通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。 + +### Q: 如何获取 sample_uuids 和 container_uuid? + +这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。 diff --git a/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py new file mode 100644 index 00000000..f22b37e8 --- /dev/null +++ b/.cursor/skills/batch-submit-experiment/scripts/gen_notebook_params.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。 + +用法: + python gen_notebook_params.py --auth --base --workflow-uuid [选项] + +选项: + --auth Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀) + --base API 基础 URL(如 https://uni-lab.test.bohrium.com) + --workflow-uuid 目标 workflow 的 UUID + --registry 本地注册表文件路径(默认自动搜索) + --rounds 实验轮次数(默认 1) + --output 输出模板文件路径(默认 notebook_template.json) + --dump-response 打印 workflow detail API 的原始响应(调试用) + +示例: + python gen_notebook_params.py \\ + --auth YTFmZDlkNGUtxxxx \\ + --base https://uni-lab.test.bohrium.com \\ + --workflow-uuid abc-123-def \\ + --rounds 2 +""" +import copy +import json +import os +import sys +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError + +REGISTRY_FILENAME = "req_device_registry_upload.json" + + +def find_registry(explicit_path=None): + """查找本地注册表文件,逻辑同 extract_device_actions.py""" + if explicit_path: + if os.path.isfile(explicit_path): + return explicit_path + if os.path.isdir(explicit_path): + fp = os.path.join(explicit_path, REGISTRY_FILENAME) + if os.path.isfile(fp): + return fp + print(f"警告: 指定的注册表路径不存在: {explicit_path}") + return None + + candidates = [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + ] + for c in candidates: + if os.path.isfile(c): + return c + + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", "..")) + for c in candidates: + path = os.path.join(workspace_root, c) + if os.path.isfile(path): + return path + + cwd = os.getcwd() + for _ in range(5): + parent = os.path.dirname(cwd) + if parent == cwd: + break + cwd = parent + for c in candidates: + path = os.path.join(cwd, c) + if os.path.isfile(path): + return path + return None + + +def load_registry(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def build_registry_index(registry_data): + """构建 device_id → action_value_mappings 的索引""" + index = {} + for res in registry_data.get("resources", []): + rid = res.get("id", "") + avm = res.get("class", {}).get("action_value_mappings", {}) + if rid and avm: + index[rid] = avm + return index + + +def flatten_goal_schema(action_data): + """从 action_value_mappings 条目中提取 goal 层的 schema""" + schema = action_data.get("schema", {}) + goal_schema = schema.get("properties", {}).get("goal", {}) + return goal_schema if goal_schema else schema + + +def build_param_template(goal_schema): + """根据 goal schema 生成 param 模板,含类型标注""" + properties = goal_schema.get("properties", {}) + required = set(goal_schema.get("required", [])) + template = {} + for field_name, field_def in properties.items(): + if field_name == "unilabos_device_id": + continue + ftype = field_def.get("type", "any") + default = field_def.get("default") + if default is not None: + template[field_name] = default + elif ftype == "string": + template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})" + elif ftype == "number" or ftype == "integer": + template[field_name] = 0 + elif ftype == "boolean": + template[field_name] = False + elif ftype == "array": + template[field_name] = [] + elif ftype == "object": + template[field_name] = {} + else: + template[field_name] = f"$TODO ({ftype})" + return template + + +def fetch_workflow_detail(base_url, auth_token, workflow_uuid): + """调用 workflow detail API""" + url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}" + req = Request(url, method="GET") + req.add_header("Authorization", f"Lab {auth_token}") + try: + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + print(f"API 错误 {e.code}: {body}") + return None + except URLError as e: + print(f"网络错误: {e.reason}") + return None + + +def extract_nodes_from_response(response): + """ + 从 workflow detail 响应中提取 action 节点列表。 + 适配多种可能的响应格式。 + + 返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...] + """ + data = response.get("data", response) + + search_keys = ["nodes", "workflow_nodes", "node_list", "steps"] + nodes_raw = None + for key in search_keys: + if key in data and isinstance(data[key], list): + nodes_raw = data[key] + break + + if nodes_raw is None: + if isinstance(data, list): + nodes_raw = data + else: + for v in data.values(): + if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict): + nodes_raw = v + break + + if not nodes_raw: + print("警告: 未能从响应中提取节点列表") + print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__) + return [] + + result = [] + for node in nodes_raw: + if not isinstance(node, dict): + continue + + node_uuid = ( + node.get("uuid") + or node.get("node_uuid") + or node.get("id") + or "" + ) + resource_name = ( + node.get("resource_template_name") + or node.get("device_id") + or node.get("resource_name") + or node.get("device_name") + or "" + ) + template_name = ( + node.get("node_template_name") + or node.get("action_name") + or node.get("template_name") + or node.get("action") + or node.get("name") + or "" + ) + existing_param = node.get("param", {}) or {} + + if node_uuid: + result.append((node_uuid, resource_name, template_name, existing_param)) + + return result + + +def generate_template(nodes, registry_index, rounds): + """生成 notebook 提交模板""" + node_params = [] + schema_info = {} + + datas_template = [] + for node_uuid, resource_name, template_name, existing_param in nodes: + param_template = {} + matched = False + + if resource_name and template_name and resource_name in registry_index: + avm = registry_index[resource_name] + if template_name in avm: + goal_schema = flatten_goal_schema(avm[template_name]) + param_template = build_param_template(goal_schema) + goal_default = avm[template_name].get("goal_default", {}) + if goal_default: + for k, v in goal_default.items(): + if k in param_template and v is not None: + param_template[k] = v + matched = True + + schema_info[node_uuid] = { + "device_id": resource_name, + "action_name": template_name, + "action_type": avm[template_name].get("type", ""), + "schema_properties": list(goal_schema.get("properties", {}).keys()), + "required": goal_schema.get("required", []), + } + + if not matched and existing_param: + param_template = existing_param + + if not matched and not existing_param: + schema_info[node_uuid] = { + "device_id": resource_name, + "action_name": template_name, + "warning": "未在本地注册表中找到匹配的 action schema", + } + + datas_template.append({ + "node_uuid": node_uuid, + "param": param_template, + "sample_params": [ + { + "container_uuid": "$TODO_CONTAINER_UUID", + "sample_value": { + "liquid_names": "$TODO_LIQUID_NAME", + "volumes": 0, + }, + } + ], + }) + + for i in range(rounds): + node_params.append({ + "sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}", + "datas": copy.deepcopy(datas_template), + }) + + return { + "lab_uuid": "$TODO_LAB_UUID", + "project_uuid": "$TODO_PROJECT_UUID", + "workflow_uuid": "$TODO_WORKFLOW_UUID", + "name": "$TODO_EXPERIMENT_NAME", + "node_params": node_params, + "_schema_info(仅参考,提交时删除)": schema_info, + } + + +def parse_args(argv): + """简单的参数解析""" + opts = { + "auth": None, + "base": None, + "workflow_uuid": None, + "registry": None, + "rounds": 1, + "output": "notebook_template.json", + "dump_response": False, + } + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--auth" and i + 1 < len(argv): + opts["auth"] = argv[i + 1] + i += 2 + elif arg == "--base" and i + 1 < len(argv): + opts["base"] = argv[i + 1].rstrip("/") + i += 2 + elif arg == "--workflow-uuid" and i + 1 < len(argv): + opts["workflow_uuid"] = argv[i + 1] + i += 2 + elif arg == "--registry" and i + 1 < len(argv): + opts["registry"] = argv[i + 1] + i += 2 + elif arg == "--rounds" and i + 1 < len(argv): + opts["rounds"] = int(argv[i + 1]) + i += 2 + elif arg == "--output" and i + 1 < len(argv): + opts["output"] = argv[i + 1] + i += 2 + elif arg == "--dump-response": + opts["dump_response"] = True + i += 1 + else: + print(f"未知参数: {arg}") + i += 1 + return opts + + +def main(): + opts = parse_args(sys.argv[1:]) + + if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]: + print("用法:") + print(" python gen_notebook_params.py --auth --base --workflow-uuid [选项]") + print() + print("必需参数:") + print(" --auth Lab token(base64(ak:sk))") + print(" --base API 基础 URL") + print(" --workflow-uuid 目标 workflow UUID") + print() + print("可选参数:") + print(" --registry 注册表文件路径(默认自动搜索)") + print(" --rounds 实验轮次数(默认 1)") + print(" --output 输出文件路径(默认 notebook_template.json)") + print(" --dump-response 打印 API 原始响应") + sys.exit(1) + + # 1. 查找并加载本地注册表 + registry_path = find_registry(opts["registry"]) + registry_index = {} + if registry_path: + mtime = os.path.getmtime(registry_path) + gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + print(f"注册表: {registry_path} (生成时间: {gen_time})") + registry_data = load_registry(registry_path) + registry_index = build_registry_index(registry_data) + print(f"已索引 {len(registry_index)} 个设备的 action schemas") + else: + print("警告: 未找到本地注册表,将跳过 param 模板生成") + print(" 提交时需要手动填写各节点的 param 字段") + + # 2. 获取 workflow 详情 + print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}") + response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"]) + if not response: + print("错误: 无法获取 workflow 详情") + sys.exit(1) + + if opts["dump_response"]: + print("\n=== API 原始响应 ===") + print(json.dumps(response, indent=2, ensure_ascii=False)[:5000]) + print("=== 响应结束(截断至 5000 字符) ===\n") + + # 3. 提取节点 + nodes = extract_nodes_from_response(response) + if not nodes: + print("错误: 未能从 workflow 中提取任何 action 节点") + print("请使用 --dump-response 查看原始响应结构") + sys.exit(1) + + print(f"\n找到 {len(nodes)} 个 action 节点:") + print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}") + print(" " + "-" * 110) + for node_uuid, resource_name, template_name, _ in nodes: + matched = "✓" if (resource_name in registry_index and + template_name in registry_index.get(resource_name, {})) else "✗" + print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}") + + # 4. 生成模板 + template = generate_template(nodes, registry_index, opts["rounds"]) + template["workflow_uuid"] = opts["workflow_uuid"] + + output_path = opts["output"] + with open(output_path, "w", encoding="utf-8") as f: + json.dump(template, f, indent=2, ensure_ascii=False) + print(f"\n模板已写入: {output_path}") + print(f" 轮次数: {opts['rounds']}") + print(f" 节点数/轮: {len(nodes)}") + print() + print("下一步:") + print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值") + print(" 2. 删除 _schema_info 字段(仅供参考)") + print(" 3. 使用 POST /api/v1/lab/notebook 提交") + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/create-device-skill/SKILL.md b/.cursor/skills/create-device-skill/SKILL.md new file mode 100644 index 00000000..20cd2f33 --- /dev/null +++ b/.cursor/skills/create-device-skill/SKILL.md @@ -0,0 +1,380 @@ +--- +name: create-device-skill +description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device. +--- + +# 创建设备 Skill 指南 + +本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。 + +## 数据源 + +- **设备注册表**: `unilabos_data/req_device_registry_upload.json` +- **结构**: `{ "resources": [{ "id": "", "class": { "module": "", "action_value_mappings": { ... } } }] }` +- **生成时机**: `unilab` 启动并完成注册表上传后自动生成 +- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为 + +## 创建流程 + +### Step 0 — 收集必备信息(缺一不可,否则询问后终止) + +开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。 + +向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」 + +#### 必备项 ①:ak / sk(认证凭据) + +来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。 + +获取后立即生成 AUTH token: + +```bash +python ./scripts/gen_auth.py +# 或从 config.py 提取 +python ./scripts/gen_auth.py --config +``` + +认证算法:`base64(ak:sk)` → `Authorization: Lab ` + +#### 必备项 ②:--addr(目标环境) + +决定 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 | + +#### 必备项 ③:req_device_registry_upload.json(设备注册表) + +数据文件由 `unilab` 启动时自动生成,需要定位它: + +**推断 working_dir**(即 `unilabos_data` 所在目录): + +| 条件 | working_dir 取值 | +|------|------------------| +| 传了 `--working_dir` | `/unilabos_data/`(若子目录已存在则直接用) | +| 仅传了 `--config` | `/unilabos_data/` | +| 都没传 | `<当前工作目录>/unilabos_data/` | + +**按优先级搜索文件**: + +``` +<推断的 working_dir>/unilabos_data/req_device_registry_upload.json +<推断的 working_dir>/req_device_registry_upload.json +/unilabos_data/req_device_registry_upload.json +``` + +也可以直接 Glob 搜索:`**/req_device_registry_upload.json` + +找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。 + +**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。** + +#### 必备项 ④:目标设备 + +用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。 + +如果用户不确定,运行提取脚本列出所有设备供选择: + +```bash +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 — 列出可用设备 + +运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择: + +```bash +# 自动搜索(默认在 unilabos_data/ 和当前目录查找) +python ./scripts/extract_device_actions.py + +# 指定注册表文件路径 +python ./scripts/extract_device_actions.py --registry +``` + +脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。 + +### Step 2 — 提取 Action Schema + +用户选择设备后,运行提取脚本: + +```bash +python ./scripts/extract_device_actions.py [--registry ] ./skills//actions/ +``` + +脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 + +每个 action 生成一个 JSON 文件,包含: +- `type` — 作为 API 调用的 `action_type` +- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义) +- `goal` — goal 字段映射(含占位符 `$placeholder`) +- `goal_default` — 默认值 + +### Step 3 — 写 action-index.md + +按模板为每个 action 写条目: + +```markdown +### `` + +<用途描述(一句话)> + +- **Schema**: [`actions/.json`](actions/.json) +- **核心参数**: `param1`, `param2`(从 schema.required 获取) +- **可选参数**: `param3`, `param4` +- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头) +``` + +描述规则: +- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容) +- 从 `schema.required` 区分核心/可选参数 +- 按功能分类(移液、枪头、外设等) +- 标注 `placeholder_keys` 中的字段类型: + - `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点) + - `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device) + - `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点) + - `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找) + - `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name) +- array 类型字段 → `[{id, name, uuid}, ...]` +- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径 + +### Step 4 — 写 SKILL.md + +直接复用 `unilab-device-api` 的 API 模板,修改: +- 设备名称 +- Action 数量 +- 目录列表 +- Session state 中的 `device_name` +- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab `(不要硬编码 `Api` 类型的 key) +- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 +- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名) + +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 +# - #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 + +## Placeholder Slot 填写规则 +- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} +- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 +- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 +- unilabos_class → ClassSlot → "class_name" 字符串 +- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组 +- 特例:create_resource 的 res_id 允许填不存在的路径 +- 列出本设备所有 Slot 字段、类型及含义 + +## 渐进加载策略 +## 完整工作流 Checklist +``` + +### Step 5 — 验证 + +检查文件完整性: +- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情) +- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表 +- [ ] `action-index.md` 列出所有 action 并有描述 +- [ ] `actions/` 目录中每个 action 有对应 JSON 文件 +- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段 +- [ ] 描述能让 agent 判断该用哪个 action + +## Action JSON 文件结构 + +```json +{ + "type": "LiquidHandlerTransfer", // → API 的 action_type + "goal": { // goal 字段映射 + "sources": "sources", + "targets": "targets", + "tip_racks": "tip_racks", + "asp_vols": "asp_vols" + }, + "schema": { // ← 直接是 goal 的 schema(已提升) + "type": "object", + "properties": { // 参数定义(即请求中 goal 的字段) + "sources": { "type": "array", "items": { "type": "object" } }, + "targets": { "type": "array", "items": { "type": "object" } }, + "asp_vols": { "type": "array", "items": { "type": "number" } } + }, + "required": [...], + "_unilabos_placeholder_info": { // ← Slot 类型标记 + "sources": "unilabos_resources", + "targets": "unilabos_resources", + "tip_racks": "unilabos_resources" + } + }, + "goal_default": { ... }, // 默认值 + "placeholder_keys": { // ← 汇总所有 Slot 字段 + "sources": "unilabos_resources", // ResourceSlot + "targets": "unilabos_resources", + "tip_racks": "unilabos_resources", + "target_device_id": "unilabos_devices" // DeviceSlot + } +} +``` + +> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。 +> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。 + +## Placeholder Slot 类型体系 + +`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**,配合液体配方 | + +### ResourceSlot(`unilabos_resources`) + +最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): + +```json +{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} +``` + +- 单个(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` = 目标位置) + +> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 + +### DeviceSlot(`unilabos_devices`) + +填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径: + +``` +"/host_node" +"/bioyond_cell/reaction_station" +``` + +- 只填路径字符串,不需要 `{id, uuid}` 对象 +- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备) + +### NodeSlot(`unilabos_nodes`) + +范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**: + +``` +"/PRCXI/PRCXI_Deck" +``` + +- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`) + +### ClassSlot(`unilabos_class`) + +填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找: + +``` +"container" +``` + +### FormulationSlot(`unilabos_formulation`) + +描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**: + +```json +[ + { + "sample_uuid": "", + "well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1", + "liquids": [ + { "name": "LiPF6", "volume": 0.6 }, + { "name": "DMC", "volume": 1.2 } + ] + } +] +``` + +#### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` | +| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) | +| `liquids` | array | 要加入的液体列表 | +| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) | +| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) | + +#### 填写规则 + +- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选 +- 每个数组元素代表一个目标容器的配方 +- 一个容器可以加入多种液体(`liquids` 数组多条记录) +- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息 + +### 通过 API #12 获取资源树 + +```bash +curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" +``` + +注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 + +## 最终目录结构 + +``` +.// +├── SKILL.md # API 端点 + 渐进加载指引 +├── action-index.md # 动作索引:描述/用途/核心参数 +└── actions/ # 每个 action 的完整 JSON Schema + ├── action1.json + ├── action2.json + └── ... +``` diff --git a/.cursor/skills/create-device-skill/scripts/extract_device_actions.py b/.cursor/skills/create-device-skill/scripts/extract_device_actions.py new file mode 100644 index 00000000..c17f6102 --- /dev/null +++ b/.cursor/skills/create-device-skill/scripts/extract_device_actions.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +从 req_device_registry_upload.json 中提取指定设备的 action schema。 + +用法: + # 列出所有设备及 action 数量(自动搜索注册表文件) + python extract_device_actions.py + + # 指定注册表文件路径 + python extract_device_actions.py --registry + + # 提取指定设备的 action 到目录 + python extract_device_actions.py + python extract_device_actions.py --registry + +示例: + python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json + python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/ +""" +import json +import os +import sys +from datetime import datetime + +REGISTRY_FILENAME = "req_device_registry_upload.json" + +def find_registry(explicit_path=None): + """ + 查找 req_device_registry_upload.json 文件。 + + 搜索优先级: + 1. 用户通过 --registry 显式指定的路径 + 2. /unilabos_data/req_device_registry_upload.json + 3. /req_device_registry_upload.json + 4. /../../.. (workspace根) 下的 unilabos_data/ + 5. 向上逐级搜索父目录(最多 5 层) + """ + if explicit_path: + if os.path.isfile(explicit_path): + return explicit_path + if os.path.isdir(explicit_path): + fp = os.path.join(explicit_path, REGISTRY_FILENAME) + if os.path.isfile(fp): + return fp + print(f"警告: 指定的路径不存在: {explicit_path}") + return None + + candidates = [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + ] + + for c in candidates: + if os.path.isfile(c): + return c + + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", "..")) + for c in candidates: + path = os.path.join(workspace_root, c) + if os.path.isfile(path): + return path + + cwd = os.getcwd() + for _ in range(5): + parent = os.path.dirname(cwd) + if parent == cwd: + break + cwd = parent + for c in candidates: + path = os.path.join(cwd, c) + if os.path.isfile(path): + return path + + return None + +def load_registry(path): + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + +def list_devices(data): + """列出所有包含 action_value_mappings 的设备,同时返回 module 路径""" + resources = data.get('resources', []) + devices = [] + for res in resources: + rid = res.get('id', '') + cls = res.get('class', {}) + avm = cls.get('action_value_mappings', {}) + module = cls.get('module', '') + if avm: + devices.append((rid, len(avm), module)) + return devices + +def flatten_schema_to_goal(action_data): + """将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装""" + schema = action_data.get('schema', {}) + goal_schema = schema.get('properties', {}).get('goal', {}) + if goal_schema: + action_data = dict(action_data) + action_data['schema'] = goal_schema + return action_data + + +def extract_actions(data, device_id, output_dir): + """提取指定设备的 action schema 到独立 JSON 文件""" + resources = data.get('resources', []) + for res in resources: + if res.get('id') == device_id: + cls = res.get('class', {}) + module = cls.get('module', '') + avm = cls.get('action_value_mappings', {}) + if not avm: + print(f"设备 {device_id} 没有 action_value_mappings") + return [] + + if module: + py_path = module.split(":")[0].replace(".", "/") + ".py" + class_name = module.split(":")[-1] if ":" in module else "" + print(f"Python 源码: {py_path}") + if class_name: + print(f"设备类: {class_name}") + + os.makedirs(output_dir, exist_ok=True) + written = [] + for action_name in sorted(avm.keys()): + action_data = flatten_schema_to_goal(avm[action_name]) + filename = action_name.replace('-', '_') + '.json' + filepath = os.path.join(output_dir, filename) + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(action_data, f, indent=2, ensure_ascii=False) + written.append(filename) + print(f" {filepath}") + return written + + print(f"设备 {device_id} 未找到") + return [] + +def main(): + args = sys.argv[1:] + explicit_registry = None + + if "--registry" in args: + idx = args.index("--registry") + if idx + 1 < len(args): + explicit_registry = args[idx + 1] + args = args[:idx] + args[idx + 2:] + else: + print("错误: --registry 需要指定路径") + sys.exit(1) + + registry_path = find_registry(explicit_registry) + if not registry_path: + print(f"错误: 找不到 {REGISTRY_FILENAME}") + print() + print("解决方法:") + print(" 1. 先运行 unilab 启动命令,等待注册表生成") + print(" 2. 用 --registry 指定文件路径:") + print(f" python {sys.argv[0]} --registry ") + print() + print("搜索过的路径:") + for p in [ + os.path.join("unilabos_data", REGISTRY_FILENAME), + REGISTRY_FILENAME, + os.path.join("", "unilabos_data", REGISTRY_FILENAME), + ]: + print(f" - {p}") + sys.exit(1) + + print(f"注册表: {registry_path}") + mtime = os.path.getmtime(registry_path) + gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + size_mb = os.path.getsize(registry_path) / (1024 * 1024) + print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)") + data = load_registry(registry_path) + + if len(args) == 0: + devices = list_devices(data) + print(f"\n找到 {len(devices)} 个设备:") + print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}") + print("-" * 120) + for did, count, module in sorted(devices, key=lambda x: x[0]): + py_path = module.split(":")[0].replace(".", "/") + ".py" if module else "" + print(f"{did:<50} {count:>7} {py_path}") + + elif len(args) == 2: + device_id = args[0] + output_dir = args[1] + print(f"\n提取 {device_id} 的 actions 到 {output_dir}/") + written = extract_actions(data, device_id, output_dir) + if written: + print(f"\n共写入 {len(written)} 个 action 文件") + + else: + print("用法:") + print(" python extract_device_actions.py [--registry ] # 列出设备") + print(" python extract_device_actions.py [--registry ] # 提取 actions") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/.cursor/skills/create-device-skill/scripts/gen_auth.py b/.cursor/skills/create-device-skill/scripts/gen_auth.py new file mode 100644 index 00000000..f0cb9c9b --- /dev/null +++ b/.cursor/skills/create-device-skill/scripts/gen_auth.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +从 ak/sk 生成 UniLab API Authorization header。 + +算法: base64(ak:sk) → "Authorization: Lab " + +用法: + python gen_auth.py + python gen_auth.py --config + +示例: + python gen_auth.py myak mysk + python gen_auth.py --config experiments/config.py +""" +import base64 +import re +import sys + + +def gen_auth(ak: str, sk: str) -> str: + token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8") + return token + + +def extract_from_config(config_path: str) -> tuple: + """从 config.py 中提取 ak 和 sk""" + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content) + sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content) + if not ak_match or not sk_match: + return None, None + return ak_match.group(1), sk_match.group(1) + + +def main(): + args = sys.argv[1:] + + if len(args) == 2 and args[0] == "--config": + ak, sk = extract_from_config(args[1]) + if not ak or not sk: + print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置") + print("期望格式: ak = \"xxx\" sk = \"xxx\"") + sys.exit(1) + print(f"配置文件: {args[1]}") + elif len(args) == 2: + ak, sk = args + else: + print("用法:") + print(" python gen_auth.py ") + print(" python gen_auth.py --config ") + sys.exit(1) + + token = gen_auth(ak, sk) + print(f"ak: {ak}") + print(f"sk: {sk}") + print() + print(f"Authorization header:") + print(f" Authorization: Lab {token}") + print() + print(f"curl 用法:") + print(f' curl -H "Authorization: Lab {token}" ...') + print() + print(f"Shell 变量:") + print(f' AUTH="Authorization: Lab {token}"') + + +if __name__ == "__main__": + main() diff --git a/.cursor/skills/submit-agent-result/SKILL.md b/.cursor/skills/submit-agent-result/SKILL.md new file mode 100644 index 00000000..18923711 --- /dev/null +++ b/.cursor/skills/submit-agent-result/SKILL.md @@ -0,0 +1,275 @@ +--- +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结果. +--- + +# 提交历史实验记录指南 + +通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。 + +## 前置条件(缺一不可) + +使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。 + +### 1. ak / sk → AUTH + +询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。 + +生成 AUTH token: + +```bash +python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" +``` + +输出即为 token 值,拼接为 `Authorization: Lab `。 + +### 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` | + +确认后设置: +```bash +BASE="<根据 addr 确定的 URL>" +AUTH="Authorization: Lab <上面命令输出的 token>" +``` + +### 3. notebook_uuid(**必须询问用户**) + +**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」 + +notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。 + +如果用户不记得,可提示: +- 查看之前的对话记录中创建 notebook 时返回的 UUID +- 或通过平台页面查找对应的 notebook + +**绝不能跳过此步骤,没有 notebook_uuid 无法提交。** + +### 4. 实验结果数据 + +用户需要提供实验结果数据,支持以下方式: + +| 方式 | 说明 | +|------|------| +| JSON 文件 | 直接作为 `agent_result` 的内容合并 | +| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 | +| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON | + +**四项全部就绪后才可开始。** + +## Session State + +在整个对话过程中,agent 需要记住以下状态: + +- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**) +- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**) + +## 请求约定 + +所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。 + +> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。 +> +> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。 + +--- + +## API Endpoints + +### 1. 获取实验室信息(自动获取 lab_uuid) + +```bash +curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH" +``` + +返回: + +```json +{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} +``` + +记住 `data.uuid` 为 `lab_uuid`。 + +### 2. 提交实验结果(agent_result) + +```bash +curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '' +``` + +请求体结构: + +```json +{ + "notebook_uuid": "", + "agent_result": { + "": "", + "": 123, + "": {"a": 1, "b": 2}, + "": [{"col1": "v1", "col2": "v2"}, ...] + } +} +``` + +> **注意**:HTTP 方法是 **PUT**(不是 POST)。 + +#### 必要字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 | +| `agent_result` | object | 实验结果数据,任意 JSON 对象 | + +#### agent_result 内容格式 + +`agent_result` 接受**任意 JSON 对象**,常见格式: + +**简单键值对**: +```json +{ + "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"} + ] +} +``` + +**从 CSV 文件导入**(脚本自动转换): +```json +{ + "experiment_data": [ + {"温度": 25, "压力": 101.3, "产率": 0.85}, + {"温度": 30, "压力": 101.3, "产率": 0.91} + ] +} +``` + +--- + +## 整合脚本 + +本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。 + +### 用法 + +```bash +python scripts/prepare_agent_result.py \ + --notebook-uuid \ + --files data1.json data2.csv \ + [--auth ] \ + [--base ] \ + [--submit] \ + [--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`) | + +### 文件合并规则 + +| 文件类型 | 合并方式 | +|----------|----------| +| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 | +| `.json`(list/other) | 以文件名为 key 放入 `agent_result` | +| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 | + +多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。 + +### 示例 + +```bash +# 仅生成请求体文件(不提交) +python scripts/prepare_agent_result.py \ + --notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \ + --files results.json measurements.csv + +# 生成并直接提交 +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 \ + --submit +``` + +--- + +## 手动构建方式 + +如果不使用脚本,也可手动构建请求体: + +1. 将实验结果数据组装为 JSON 对象 +2. 写入临时文件: + +```json +{ + "notebook_uuid": "", + "agent_result": { ... } +} +``` + +3. 用 curl 提交: + +```bash +curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '@tmp_body.json' +``` + +--- + +## 完整工作流 Checklist + +``` +Task Progress: +- [ ] Step 1: 确认 ak/sk → 生成 AUTH token +- [ ] Step 2: 确认 --addr → 设置 BASE URL +- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid +- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过) +- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据) +- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体 +- [ ] Step 7: PUT /lab/notebook/agent-result 提交 +- [ ] Step 8: 检查返回结果,确认提交成功 +``` + +--- + +## 常见问题 + +### Q: notebook_uuid 从哪里获取? + +从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。 + +### Q: agent_result 有固定的 schema 吗? + +没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。 + +### Q: 可以多次提交同一个 notebook 的结果吗? + +可以,后续提交会覆盖之前的 agent_result。 + +### Q: 认证方式是 Lab 还是 Api? + +本指南统一使用 `Authorization: Lab ` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api ` 替代。 diff --git a/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py b/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py new file mode 100644 index 00000000..2ee4e17f --- /dev/null +++ b/.cursor/skills/submit-agent-result/scripts/prepare_agent_result.py @@ -0,0 +1,133 @@ +""" +读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。 + +用法: + python prepare_agent_result.py \ + --notebook-uuid \ + --files data1.json data2.csv \ + [--auth ] \ + [--base ] \ + [--submit] \ + [--output ] + +支持的输入文件格式: + - .json → 直接作为 dict 合并 + - .csv → 转为 {"filename": [row_dict, ...]} 格式 +""" + +import argparse +import base64 +import csv +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List + + +def read_json_file(filepath: str) -> Dict[str, Any]: + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def read_csv_file(filepath: str) -> List[Dict[str, Any]]: + rows = [] + with open(filepath, "r", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + for row in reader: + converted = {} + for k, v in row.items(): + try: + converted[k] = int(v) + except (ValueError, TypeError): + try: + converted[k] = float(v) + except (ValueError, TypeError): + converted[k] = v + rows.append(converted) + return rows + + +def merge_files(filepaths: List[str]) -> Dict[str, Any]: + """将多个文件合并为一个 agent_result dict""" + merged: Dict[str, Any] = {} + for fp in filepaths: + path = Path(fp) + ext = path.suffix.lower() + key = path.stem + + if ext == ".json": + data = read_json_file(fp) + if isinstance(data, dict): + merged.update(data) + else: + merged[key] = data + elif ext == ".csv": + merged[key] = read_csv_file(fp) + else: + print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr) + + return merged + + +def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]: + return { + "notebook_uuid": notebook_uuid, + "agent_result": agent_result, + } + + +def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]: + try: + import requests + except ImportError: + print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr) + sys.exit(1) + + url = f"{base}/api/v1/lab/notebook/agent-result" + headers = { + "Content-Type": "application/json", + "Authorization": f"Lab {auth}", + } + resp = requests.put(url, json=body, headers=headers, timeout=30) + return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text} + + +def main(): + parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体") + parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID") + parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)") + parser.add_argument("--auth", help="Lab token(base64(ak:sk))") + parser.add_argument("--base", help="API base URL") + parser.add_argument("--submit", action="store_true", help="直接提交到云端") + parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径") + + args = parser.parse_args() + + for fp in args.files: + if not os.path.exists(fp): + print(f"[错误] 文件不存在: {fp}", file=sys.stderr) + sys.exit(1) + + agent_result = merge_files(args.files) + body = build_request_body(args.notebook_uuid, agent_result) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(body, f, ensure_ascii=False, indent=2) + print(f"[完成] 请求体已保存: {args.output}") + print(f" notebook_uuid: {args.notebook_uuid}") + print(f" agent_result 字段数: {len(agent_result)}") + print(f" 合并文件数: {len(args.files)}") + + if args.submit: + if not args.auth or not args.base: + print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr) + sys.exit(1) + print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...") + result = submit(args.base, args.auth, body) + print(f" HTTP {result['status_code']}") + print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}") + + +if __name__ == "__main__": + main() diff --git a/CLAUDE.md b/CLAUDE.md index a251c9af..bd5ce566 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,124 +1,4 @@ -# CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Please follow the rules defined in: -## Build & Development - -```bash -# Install (requires mamba env with python 3.11) -mamba create -n unilab python=3.11.14 -mamba activate unilab -mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge -pip install -e . -uv pip install -r unilabos/utils/requirements.txt - -# Run with a device graph -unilab --graph --config --backend ros -unilab --graph --config --backend simple # no ROS2 needed - -# Common CLI flags -unilab --app_bridges websocket fastapi # communication bridges -unilab --test_mode # simulate hardware, no real execution -unilab --check_mode # CI validation of registry imports (AST-based) -unilab --skip_env_check # skip auto-install of dependencies -unilab --visual rviz|web|disable # visualization mode -unilab --is_slave # run as slave node -unilab --restart_mode # auto-restart on config changes (supervisor/child process) -unilab --external_devices_only # only load external device packages -unilab --extra_resource # load extra lab_ prefixed labware resources - -# Workflow upload subcommand -unilab workflow_upload -f -n --tags tag1 tag2 - -# Labware Manager (standalone web UI for PRCXI labware CRUD, port 8010) -python -m unilabos.labware_manager - -# Tests -pytest tests/ # all tests -pytest tests/resources/test_resourcetreeset.py # single test file -pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test - -# CI check (matches .github/workflows/ci-check.yml) -python -m unilabos --check_mode --skip_env_check - -# If registry YAML/Python files changed, regenerate before committing: -python -m unilabos --complete_registry - -# Documentation build -cd docs && python -m sphinx -b html . _build/html -v -``` - -## Architecture - -### Startup Flow - -`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client. - -### Core Layers - -**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: -1. **YAML definitions** in `registry/devices/*.yaml` and `registry/resources/` (backward-compatible) -2. **Python decorators** (`@device`, `@action`, `@resource` in `registry/decorators.py`) — preferred for new code - -AST scanning (`ast_registry_scanner.py`) discovers decorated classes without importing them, so `--check_mode` works without hardware dependencies. Class paths resolved to Python classes at runtime via `utils/import_manager.py`. - -Decorator usage pattern: -```python -from unilabos.registry.decorators import device, action, resource -from unilabos.registry.decorators import InputHandle, OutputHandle, HardwareInterface - -@device(id="my_device.v1", category=["category_name"], handles=[...]) -class MyDevice: - @action(action_type=SomeActionType) - def do_something(self): ... -``` - -**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`. - -**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers. - -**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/` — `host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`). - -**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger). - -**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`. - -**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections. - -**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002. - -**Labware Manager** (`unilabos/labware_manager/`): Standalone FastAPI web app (port 8010) for PRCXI labware CRUD. Pydantic models in `models.py`, JSON database in `labware_db.json`. Supports importing from existing Python/YAML (`importer.py`), code generation (`codegen.py`), and YAML generation (`yaml_gen.py`). Web UI with SVG visualization (`static/labware_viz.js`), dynamic form handling (`static/form_handler.js`), and Jinja2 templates. - -### Configuration System - -- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`) -- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`) -- Device topology defined in graph files (JSON node-link format or GraphML) - -### Key Data Flow - -1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)` -2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances -3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend) -4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`) - -### Test Data - -Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`. - -## Code Conventions - -- Code comments and log messages in **simplified Chinese** -- Python 3.11+, type hints expected -- Pydantic models for data validation (`resource_tracker.py`) -- Singleton pattern via `@singleton` decorator (`utils/decorator.py`) -- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths -- CLI argument dashes auto-converted to underscores for consistency -- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs) -- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`) -- CI runs on Windows (`windows-latest`); if registry files change, run `python -m unilabos --complete_registry` locally before committing - -## Licensing - -- Framework code: GPL-3.0 -- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute +@AGENTS.md diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index b1cc67eb..1dd056ae 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -80,19 +80,20 @@ class HTTPClient: f.write(json.dumps(payload, indent=4)) # 从序列化数据中提取所有节点的UUID(保存旧UUID) old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} + nodes_info = [x for xs in resources.dump() for x in xs] if not self.initialized or first_add: self.initialized = True info(f"首次添加资源,当前远程地址: {self.remote_addr}") response = requests.post( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, + json={"nodes": nodes_info, "mount_uuid": mount_uuid}, headers={"Authorization": f"Lab {self.auth}"}, timeout=60, ) else: response = requests.put( f"{self.remote_addr}/edge/material", - json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, + json={"nodes": nodes_info, "mount_uuid": mount_uuid}, headers={"Authorization": f"Lab {self.auth}"}, timeout=10, ) @@ -111,6 +112,7 @@ class HTTPClient: uuid_mapping[i["uuid"]] = i["cloud_uuid"] else: logger.error(f"添加物料失败: {response.text}") + logger.trace(f"添加物料失败: {nodes_info}") for u, n in old_uuids.items(): if u in uuid_mapping: n.res_content.uuid = uuid_mapping[u] diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index eed32e1b..851ae320 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -1113,7 +1113,7 @@ class MessageProcessor: "task_id": task_id, "job_id": job_id, "free": free, - "need_more": need_more, + "need_more": need_more + 1, }, } @@ -1253,7 +1253,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } self.message_processor.send_message(message) @@ -1286,7 +1286,7 @@ class QueueProcessor: "task_id": job_info.task_id, "job_id": job_info.job_id, "free": False, - "need_more": 10, + "need_more": 10 + 1, }, } success = self.message_processor.send_message(message) diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 3d957f62..37ba7d8a 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,4 +1,5 @@ import json +import os # from nt import device_encoding import threading @@ -62,7 +63,7 @@ def main( rclpy.init(args=rclpy_init_args) else: logger.info("[ROS] rclpy already initialized, reusing context") - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 创建主机节点 host_node = HostNode( "host_node", @@ -124,7 +125,7 @@ def slave( rclpy.init(args=rclpy_init_args) executor = rclpy.__executor if not executor: - executor = rclpy.__executor = MultiThreadedExecutor() + executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48)) # 1.5 启动 executor 线程 thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index f7373d18..e249bc0f 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -486,18 +486,12 @@ class BaseROS2DeviceNode(Node, Generic[T]): if len(rts.root_nodes) == 1 and parent_resource is not None: plr_instance = plr_instances[0] if isinstance(plr_instance, Plate): - empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) self.lab_logger().warning( f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个" ) - for liquid_type, liquid_volume, liquid_input_slot in zip( - ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT - ): - empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) - plr_instance.set_well_liquids(empty_liquid_info_in) try: # noinspection PyProtectedMember keys = list(plr_instance._ordering.keys()) @@ -511,6 +505,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): input_wells = [] for r in LIQUID_INPUT_SLOT: input_wells.append(plr_instance.children[r]) + 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")]) final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( input_wells ).dump() @@ -1256,9 +1254,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): return self._lab_logger def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): - """创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。""" - # 检测 @topic_config 装饰器配置 - topic_config = {} + """创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数。""" + topic_cfg = {} driver_class = type(self.driver_instance) # 区分 @property 和普通方法两种情况 @@ -1267,23 +1264,17 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) if is_prop: - # @property: 检测 fget 上的 @topic_config class_attr = getattr(driver_class, attr_name) if class_attr.fget is not None: - topic_config = get_topic_config(class_attr.fget) + topic_cfg = get_topic_config(class_attr.fget) else: - # 普通方法: 直接检测 attr_name 方法上的 @topic_config if hasattr(self.driver_instance, attr_name): method = getattr(self.driver_instance, attr_name) if callable(method): - topic_config = get_topic_config(method) - - # 没有 @topic_config 装饰器则跳过发布 - if not topic_config: - return + topic_cfg = get_topic_config(method) # 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name - cfg_name = topic_config.get("name") + cfg_name = topic_cfg.get("name") if cfg_name: publish_name = cfg_name elif attr_name.startswith("get_"): @@ -1291,10 +1282,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): else: publish_name = attr_name - # 使用装饰器配置或默认值 - cfg_period = topic_config.get("period") - cfg_print = topic_config.get("print_publish") - cfg_qos = topic_config.get("qos") + # @topic_config 参数覆盖默认值 + cfg_period = topic_cfg.get("period") + cfg_print = topic_cfg.get("print_publish") + cfg_qos = topic_cfg.get("qos") period: float = cfg_period if cfg_period is not None else initial_period print_publish: bool = cfg_print if cfg_print is not None else self._print_publish qos: int = cfg_qos if cfg_qos is not None else 10 @@ -1486,13 +1477,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): if uuid_indices: uuids = [item[1] for item in uuid_indices] resource_tree = await self.get_resource(uuids) - plr_resources = resource_tree.to_plr_resources(requested_uuids=uuids) + plr_resources = resource_tree.to_plr_resources() for i, (idx, _, resource_data) in enumerate(uuid_indices): - try: - plr_resource = plr_resources[i] - except Exception as e: - self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在") - raise e + plr_resource = plr_resources[i] if "sample_id" in resource_data: plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"] queried_resources[idx] = plr_resource @@ -1739,19 +1726,13 @@ class BaseROS2DeviceNode(Node, Generic[T]): if arg_type == "unilabos.registry.placeholder_type:ResourceSlot": resource_data = function_args[arg_name] if isinstance(resource_data, dict) and "id" in resource_data: - uid = resource_data.get("uuid", "") - # 优先从本地追踪器直接取(避免服务端未同步导致的空返回) - local_fast = self.resource_tracker.uuid_to_resources.get(uid) if uid else None - if local_fast is not None: - function_args[arg_name] = local_fast - else: - try: - function_args[arg_name] = self._convert_resources_sync(uid)[0] - except Exception as e: - self.lab_logger().error( - f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" - ) - raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") + try: + function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0] + except Exception as e: + self.lab_logger().error( + f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" + ) + raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}") # 处理 ResourceSlot 列表 elif isinstance(arg_type, tuple) and len(arg_type) == 2: @@ -1759,23 +1740,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): if arg_type[0] == "list" and arg_type[1] == resource_slot_type: resource_list = function_args[arg_name] if isinstance(resource_list, list): - uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r] - # 先尝试本地追踪器批量取 - local_hits = [ - self.resource_tracker.uuid_to_resources[u] - for u in uuids - if u in self.resource_tracker.uuid_to_resources - ] - if len(local_hits) == len(uuids): - function_args[arg_name] = local_hits - else: - try: - function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else [] - except Exception as e: - self.lab_logger().error( - f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" - ) - raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}") + try: + uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r] + function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else [] + except Exception as e: + self.lab_logger().error( + f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" + ) + raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}") # todo: 默认反报送 return function(**function_args) @@ -1827,18 +1799,6 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 转换为 PLR 资源 tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) if not len(tree_set.trees): - # 服务端未找到时,尝试从本地追踪器兜底(create_resource 刚完成但服务端未及时同步) - local_hits = [ - self.resource_tracker.uuid_to_resources[uid] - for uid in uuids_list - if uid in self.resource_tracker.uuid_to_resources - ] - if local_hits: - self.lab_logger().warning( - f"资源查询服务端返回空树,已从本地追踪器找到 " - f"{len(local_hits)}/{len(uuids_list)} 个资源: {uuids_list}" - ) - return local_hits raise Exception(f"资源查询返回空树: {raw_data}") plr_resources = tree_set.to_plr_resources() diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 24f7f3c5..b5accd51 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -726,7 +726,7 @@ def refactor_data( "template_name": template_name, "resource_name": resource_name, "description": step.get("description", step.get("purpose", f"{operation} operation")), - "lab_node_type": "Device", + "lab_node_type": "ILab", "param": step.get("parameters", step.get("action_args", {})), "footer": f"{template_name}-{resource_name}", }