Compare commits

..

4 Commits

Author SHA1 Message Date
Xuwznln
81e9068597 support notebook id 2026-05-20 18:14:13 +08:00
Xuwznln
be5ff9bc5c new build fix 2026-05-14 19:28:05 +08:00
Xuwznln
498bcd84f8 v0.11.2
(cherry picked from commit bcb1790897)
2026-05-14 18:22:09 +08:00
Xuwznln
35199eb863 env installation fix 2026-05-14 18:18:53 +08:00
30 changed files with 272 additions and 5700 deletions

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos name: unilabos
version: 0.11.1 version: 0.11.2
source: source:
path: ../../unilabos path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus - pymodbus
- matplotlib - matplotlib
- pylibftdi - pylibftdi
- uni-lab::unilabos-env ==0.11.1 - uni-lab::unilabos-env ==0.11.2
about: about:
repository: https://github.com/deepmodeling/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package: package:
name: unilabos-env name: unilabos-env
version: 0.11.1 version: 0.11.2
build: build:
noarch: generic noarch: generic

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos-full name: unilabos-full
version: 0.11.1 version: 0.11.2
build: build:
noarch: generic noarch: generic
@@ -11,7 +11,7 @@ build:
requirements: requirements:
run: run:
# Base unilabos package (includes unilabos-env) # Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.11.1 - uni-lab::unilabos ==0.11.2
# Documentation tools # Documentation tools
- sphinx - sphinx
- sphinx_rtd_theme - sphinx_rtd_theme

View File

@@ -105,6 +105,7 @@ jobs:
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging channels: conda-forge,robostack-staging
channel-priority: strict channel-priority: strict
activate-environment: build-env activate-environment: build-env
@@ -114,13 +115,15 @@ jobs:
- name: Install rattler-build and anaconda-client - name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y
- name: Show environment info - name: Show environment info
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
conda info conda info
conda list | grep -E "(rattler-build|anaconda-client)" conda list -n build-env | grep -E "(rattler-build|anaconda-client)"
conda run -n build-env rattler-build --version
conda run -n build-env anaconda --version
echo "Platform: ${{ matrix.platform }}" echo "Platform: ${{ matrix.platform }}"
echo "OS: ${{ matrix.os }}" echo "OS: ${{ matrix.os }}"
@@ -128,9 +131,9 @@ jobs:
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then if [[ "${{ matrix.platform }}" == "osx-arm64" ]]; then
rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge
else else
rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge conda run -n build-env rattler-build build -r ./recipes/msgs/recipe.yaml -c robostack -c robostack-staging -c conda-forge
fi fi
- name: List built packages - name: List built packages
@@ -171,5 +174,5 @@ jobs:
run: | run: |
for package in $(find ./output -name "*.conda"); do for package in $(find ./output -name "*.conda"); do
echo "Uploading $package to unilab organization..." echo "Uploading $package to unilab organization..."
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done

View File

@@ -98,6 +98,7 @@ jobs:
with: with:
miniforge-version: latest miniforge-version: latest
use-mamba: true use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging,uni-lab channels: conda-forge,robostack-staging,uni-lab
channel-priority: strict channel-priority: strict
activate-environment: build-env activate-environment: build-env
@@ -107,13 +108,15 @@ jobs:
- name: Install rattler-build and anaconda-client - name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y mamba install -n build-env --override-channels -c conda-forge rattler-build anaconda-client -y
- name: Show environment info - name: Show environment info
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
conda info conda info
conda list | grep -E "(rattler-build|anaconda-client)" conda list -n build-env | grep -E "(rattler-build|anaconda-client)"
conda run -n build-env rattler-build --version
conda run -n build-env anaconda --version
echo "Platform: ${{ matrix.platform }}" echo "Platform: ${{ matrix.platform }}"
echo "OS: ${{ matrix.os }}" echo "OS: ${{ matrix.os }}"
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
@@ -128,7 +131,7 @@ jobs:
if: steps.should_build.outputs.should_build == 'true' if: steps.should_build.outputs.should_build == 'true'
run: | run: |
echo "Building unilabos-env (conda environment dependencies)..." echo "Building unilabos-env (conda environment dependencies)..."
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge conda run -n build-env rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
- name: Upload unilabos-env to Anaconda.org (if enabled) - name: Upload unilabos-env to Anaconda.org (if enabled)
if: | if: |
@@ -140,7 +143,7 @@ jobs:
run: | run: |
echo "Uploading unilabos-env to uni-lab organization..." echo "Uploading unilabos-env to uni-lab organization..."
for package in $(find ./output -name "unilabos-env*.conda"); do for package in $(find ./output -name "unilabos-env*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: Build unilabos (with pip package) - name: Build unilabos (with pip package)
@@ -148,7 +151,7 @@ jobs:
run: | run: |
echo "Building unilabos package..." echo "Building unilabos package..."
# 如果已上传到 Anaconda从 uni-lab channel 获取 unilabos-env否则从本地 output 获取 # 如果已上传到 Anaconda从 uni-lab channel 获取 unilabos-env否则从本地 output 获取
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output conda run -n build-env rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos to Anaconda.org (if enabled) - name: Upload unilabos to Anaconda.org (if enabled)
if: | if: |
@@ -160,7 +163,7 @@ jobs:
run: | run: |
echo "Uploading unilabos to uni-lab organization..." echo "Uploading unilabos to uni-lab organization..."
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: Build unilabos-full - Only when explicitly requested - name: Build unilabos-full - Only when explicitly requested
@@ -170,7 +173,7 @@ jobs:
github.event.inputs.build_full == 'true' github.event.inputs.build_full == 'true'
run: | run: |
echo "Building unilabos-full package on ${{ matrix.platform }}..." echo "Building unilabos-full package on ${{ matrix.platform }}..."
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output conda run -n build-env rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos-full to Anaconda.org (if enabled) - name: Upload unilabos-full to Anaconda.org (if enabled)
if: | if: |
@@ -181,7 +184,7 @@ jobs:
run: | run: |
echo "Uploading unilabos-full to uni-lab organization..." echo "Uploading unilabos-full to uni-lab organization..."
for package in $(find ./output -name "unilabos-full*.conda"); do for package in $(find ./output -name "unilabos-full*.conda"); do
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package" conda run -n build-env anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
done done
- name: List built packages - name: List built packages

View File

@@ -1,576 +0,0 @@
# Peptide Station 新增三个节点:等待订单完成 + 下料确认 + take-out 同步
> 日期: 2026-05-20
> 目标文件: [peptide_station.py](../unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py)
> 参考实现:
> - [bioyond_cell_workstation.py](/Users/dp/python/yxz/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)`wait_for_order_finish`、`get_material_info`
> - [bioyond_rpc.py L782-824](../unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py)`take_out`
> - [workstation_architecture.md](../docs/developer_guide/examples/workstation_architecture.md)HTTP 报送进入 workstation运行态记录保存在 workstation 内存)
> 状态: 仅需求草稿,不写代码
---
## 一、需求背景
`BioyondPeptideStation` 当前实验流程在 `start_experiment`manual_confirm 启动调度器)之后即结束,缺少:
1. **等待奔耀回报实验完成**:调度器跑完后,奔耀通过 LIMS 推送 `POST /report/order_finish` 回调;目前 peptide_station 没有把这条推送封装成 action 节点,下游无法在工作流图上等待结果,也拿不到 `usedMaterials` 等下游所需信息。
2. **下料引导**:实验完成后操作员需要把样品/产物从仓位里取出,下料前需要看到每个物料对应的 **仓库 / 位置 / 物料名称 / 数量**;下料完成后还需要回写奔耀(调用 `take-out` 接口),让奔耀清空相应库位状态。
本轮新增三个 action 节点,串在 `start_experiment` 之后:
```text
submit_experiment_dayN
-> start_experiment(manual_confirm 上料)
-> wait_for_order_finish (等回调 + 生成 unloadTable)
-> confirm_unload_materials (manual_confirm 下料确认)
-> take_out_materials (调用 take-out 同步奔耀)
```
---
## 二、关键设计决策
### D1. 本轮只支持单订单,`order_ids` 只做占位
当前不实现多订单等待、乱序回调缓存、并发 wait 隔离。节点输入以 `order_id` 为主,`order_code` 可作为调试兜底。
`order_ids` 可在 handle/返回值中保留为占位字段,但实现只处理第一笔或直接忽略多订单列表。多订单、乱序回调、跨节点重跑复用缓存放到后续迭代。
### D2. `start_experiment` 需要显式透传订单字段
当前 `start_experiment` 只有输入 handles缺少输出 handles如果下游 wait 节点要接在 `start_experiment` 之后,必须让 `start_experiment` 透传:
- `order_id`
- `order_ids`(占位)
- `order_code`
- `resultTable`
实现时给 `start_experiment` 增加对应 `ActionOutputHandle`,并在返回值里保留这些字段。`submit_experiment_dayN``start_experiment` 嵌套字典也应包含 `order_code`,便于工作流编辑器连线。
### D3. `unloadTable` 必须与 `resultTable` 字段一致
`unloadTable` 不新增 `posX/posY/posZ/unit` 列,直接复用现有 `RESULT_TABLE_COLUMNS` 的四列:
```python
RESULT_TABLE_COLUMNS = [
{"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
{"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"},
]
```
`submit_experiment_dayN` 现有上料确认表 `resultTable` 形状如下;`unloadTable` 也必须保持同一 shape只改 `tableName` 和行数据来源:
```json
{
"data": [
{
"whName": "自动化堆栈",
"locationCode": "A1",
"materialName": "96孔板",
"quantity": "1"
}
],
"columns": [
{"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
{"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"}
],
"tableName": "resultTable"
}
```
`material-info` 官方 schema 中位置坐标字段是 `locations[].x/y/z`,不是 `posX/posY/posZ`。本轮下料表不展示坐标。
### D4. manual_confirm 只做人确认take-out 放到普通 action
`confirm_unload_materials` 只负责展示下料表、等待操作员确认并透传数据;真正的 `take-out` 调用放到后续普通 action `take_out_materials`
这样更接近 UniLab manual_confirm 的推荐模式manual_confirm 是人机确认检查点,副作用由独立普通 action 执行。
### D5. 本轮不做 unload context 缓存
虽然 workstation architecture 文档支持在 workstation 内存保存 HTTP 报送记录,但本轮暂不实现 `unload_context_cache` 或 order report 缓存。
因此本轮限制如下:
- `wait_for_order_finish` 只等待本次进入节点之后到达的 `/report/order_finish`
- 如果 push 早于 wait 节点到达,本轮不自动补救。
- 如果用户在 `confirm_unload_materials` approve 时忘记勾选,节点失败;当前架构不支持原地重新弹出同一个 manual_confirm也不在本轮实现失败节点重跑。
- 后续若要支持重跑复用,应在 `BioyondPeptideStation` 实例上新增 station runtime 的 `unload_context_cache`,按 `orderCode` 缓存 `unloadTable/material_ids/order_id` 等上下文。
---
## 三、节点 1`wait_for_order_finish`(等推送 + 生成 unloadTable
### 行为
1. 解析单订单目标:
- 首选 `order_id`
- 如果没有 `order_code`,通过 `self.hardware_interface.order_report(order_id)` 取返回数据中的 `code` 作为 `orderCode`
- `order_ids` 仅占位,本轮不实现多订单循环。
2. 设置 `self.last_order_code = order_code``self.last_order_report = None`,并 `self.order_finish_event.clear()`
3. 阻塞在 `self.order_finish_event.wait(timeout=timeout_seconds)` 等 LIMS 推送。
4. peptide_station override `process_order_finish_report(report_request, used_materials)`
- 先调用 `super().process_order_finish_report(...)` 保留父类行为(状态发布、物料同步等)。
-`report_request.data.orderCode == self.last_order_code` 时,把 `report_request.data` 存入 `self.last_order_report`,并 `set()` event。
- 非当前订单推送只记录日志,本轮不缓存。
5. 解除阻塞后解析 `status`
- `"30"` -> `success`
- `"-11"` -> `abnormal_stop`
- `"-12"` -> `manual_stop`
- 其它 -> `unknown_<status>`
- 超时 -> `timeout`
6.`report.usedMaterials[].materialId` 调用 `self.hardware_interface.material_info(material_id)`,带本地函数级 `material_info_cache` 避免重复请求。
7. 组装 `unloadTable``material_ids``preintake_ids``unload_summary` 并作为输出 handles 暴露。
### 入参goal_default
| 参数 | 类型 | 说明 |
|------|------|------|
| `order_id` | `str` | 来自 `start_experiment` 透传输出,必填优先 |
| `order_code` | `str` | 调试兜底;若已知 orderCode 可跳过 `order_report` 反查 |
| `order_ids` | `List[str]` | 占位字段;本轮不实现多订单 |
| `timeout_seconds` | `int` | 默认 `36000`10h |
| `poll_mode` | `bool` | 默认 `False`;如需要可沿用 bioyond_cell 的轮询等待风格 |
### 输出 handles
| key | data_type | 说明 |
|-----|-----------|------|
| `order_finish_status` | `str` | `success` / `abnormal_stop` / `manual_stop` / `timeout` / `unknown_*` |
| `order_finish_report` | `json` | 完整 `report_request.data` |
| `used_materials` | `json` | JSON 化后的 `usedMaterials` 列表 |
| `material_ids` | `json` | 从 `used_materials` 抽出的 `materialId` 列表,可为空 |
| `preintake_ids` | `json` | 本轮默认 `[]`,保留扩展点 |
| `unloadTable` | `table` | 下料表,字段与 `resultTable` 一致 |
| `unload_summary` | `json` | `{ "order_code": ..., "total_items": N, "missing_material_info": [...] }` |
| `order_id` | `str` | 透传给后续节点 |
| `order_code` | `str` | 透传给后续节点 |
| `order_ids` | `json` | 占位透传 |
### `unloadTable` 组装规则
返回结构:
```json
{
"data": [
{
"whName": "自动化堆栈",
"locationCode": "A1",
"materialName": "多肽产物",
"quantity": "10 mg"
}
],
"columns": [
{"name": "设备", "key": "whName"},
{"name": "位置", "key": "locationCode"},
{"name": "物料名称", "key": "materialName"},
{"name": "数量", "key": "quantity"}
],
"tableName": "unloadTable"
}
```
每行字段:
| key | 数据来源 |
|-----|----------|
| `whName` | `material_info.locations` 中匹配 `usedMaterials.locationId` 的 location 的 `whName`;匹配不到取第一条 location 的 `whName`;失败为空串 |
| `locationCode` | 匹配 location 的 `code`;匹配不到取第一条 location 的 `code`;再兜底 `usedMaterials.locationId` |
| `materialName` | `material_info.name`;失败为空串 |
| `quantity` | `usedMaterials.usedQuantity`,若 `material_info.unit` 存在则拼成字符串(如 `"10 mg"` |
`material-info` 失败时不抛异常,对应行尽量保留 `locationCode` / `quantity``whName``materialName` 用空串,并把 `materialId` 放入 `unload_summary.missing_material_info`
### 接口依赖
| 接口 | 调用方式 | 用途 |
|------|----------|------|
| `process_order_finish_report` 钩子 | 基类 HTTP 服务已注册 | 接 LIMS push |
| `POST /api/lims/order/order-report` | `self.hardware_interface.order_report(order_id)` | 从 `order_id` 反查 `orderCode` |
| `POST /api/lims/storage/material-info` | `self.hardware_interface.material_info(material_id)` | 查 `whName/locationCode/materialName/unit` |
#### `order_report(order_id)` API 形式
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": "<orderId UUID>"
}
```
响应中本节点只依赖 `data.code`
```json
{
"code": 1,
"message": null,
"timestamp": 1779255000000,
"data": {
"id": "<orderId UUID>",
"name": "实验260520-103000",
"code": "EXP260520-103000",
"status": 30,
"statusName": "完成"
}
}
```
`BioyondV1RPC.order_report(order_id)` 已经返回响应中的 `data`,所以实现中应读取 `raw.get("code")`,不是 `raw["data"]["code"]`
#### `material_info(material_id)` API 形式
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": "<materialId UUID>"
}
```
响应中本节点依赖:
```json
{
"id": "<materialId UUID>",
"name": "多肽产物",
"unit": "mg",
"locations": [
{
"id": "<locationId UUID>",
"whid": "<warehouse UUID>",
"whName": "自动化堆栈",
"code": "A1",
"x": 1,
"y": 1,
"z": 1,
"quantity": 10
}
]
}
```
`BioyondV1RPC.material_info(material_id)` 已经返回响应中的 `data`。字段映射:
| unloadTable key | 来源 |
|-----------------|------|
| `whName` | 匹配 `locationId``locations[].whName` |
| `locationCode` | 匹配 `locationId``locations[].code` |
| `materialName` | `name` |
| `quantity` | `usedMaterials[].usedQuantity` + `unit` |
### 实现要点
- `BioyondPeptideStation.__init__` 末尾追加 `self.order_finish_event = threading.Event()``self.last_order_code = None``self.last_order_report = None`
- 新增 `process_order_finish_report` override`super()`,再做单订单匹配。
- `used_materials` 参数是 `MaterialUsage` dataclass 列表;输出前必须转成 JSON dict。
- `unloadTable` 复用 `RESULT_TABLE_COLUMNS`,不新增 `UNLOAD_TABLE_COLUMNS`
---
## 四、节点 2`confirm_unload_materials`(人工下料确认)
### 行为
1. 接收节点 1 输出的 `order_id` / `order_code` / `material_ids` / `preintake_ids` / `unloadTable`
2. 进入 `NodeType.MANUAL_CONFIRM` 阻塞,操作员根据 `unloadTable` 物理下料。
3. 操作员勾选 `materials_unloaded=True` 并 approve 后,节点函数体继续。
4. 校验 `materials_unloaded == True`
- 为 True返回确认结果并透传 `order_id/material_ids/preintake_ids/unloadTable` 给节点 3。
- 为 False`RuntimeError("下料未确认,拒绝继续 take-out")`
### 入参goal_default
| 参数 | 类型 | 说明 |
|------|------|------|
| `order_id` | `str` | 来自节点 1必填 |
| `order_code` | `str` | 来自节点 1日志/排错用 |
| `material_ids` | `List[str]` | 来自节点 1可为空 |
| `preintake_ids` | `List[str]` | 来自节点 1默认 `[]` |
| `unloadTable` | `table` | 来自节点 1供人工确认展示 |
| `materials_unloaded` | `bool` | manual_confirm 勾选字段,默认 `False` |
| `timeout_seconds` | `int` | 默认 `3600` |
| `assignee_user_ids` | `List[str]` | `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}` |
### 输出 handles
| key | data_type | 说明 |
|-----|-----------|------|
| `unload_confirmed` | `bool` | 是否已人工确认下料 |
| `order_id` | `str` | 透传 |
| `order_code` | `str` | 透传 |
| `material_ids` | `json` | 透传 |
| `preintake_ids` | `json` | 透传 |
| `unloadTable` | `table` | 透传 |
### 实现要点
- 装饰器使用 `node_type=NodeType.MANUAL_CONFIRM`
- `always_free=True``placeholder_keys``feedback_interval=300` 与现有 `start_experiment` 保持一致。
- 本节点不调用 `take_out`,只做确认与透传。
- 忘记勾选后不会自动重新显示下料指引;本轮不实现缓存或失败节点原地重跑。
---
## 五、节点 3`take_out_materials`(调用 take-out 同步奔耀)
### 行为
1. 接收节点 2 透传的 `order_id` / `material_ids` / `preintake_ids`
2. 校验 `order_id` 非空。
3. 调用 `self.hardware_interface.take_out(order_id, preintake_ids=preintake_ids, material_ids=material_ids)`
4. 返回 `take_out_result``unloaded_count``success`
### 入参
| 参数 | 类型 | 说明 |
|------|------|------|
| `order_id` | `str` | 必填 |
| `material_ids` | `List[str]` | 可为空;为空时由奔耀按 `orderId` 处理的能力以后现场确认 |
| `preintake_ids` | `List[str]` | 可为空,默认 `[]` |
| `order_code` | `str` | 日志/排错用 |
### 输出 handles
| key | data_type | 说明 |
|-----|-----------|------|
| `take_out_result` | `json` | `take-out` 原始响应 `{code, message, timestamp, data}` |
| `unloaded_count` | `int` | `len(material_ids)` |
| `success` | `bool` | `take_out_result.code == 1``data` 不为 False |
### 接口依赖
| 接口 | 调用方式 | 用途 |
|------|----------|------|
| `POST /api/lims/order/take-out` | `self.hardware_interface.take_out(order_id, preintake_ids, material_ids)` | 通知奔耀同步取出 |
请求体 schemahelper script 已核对):
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": {
"orderId": "<UUID>",
"preintakeIds": [],
"materialIds": ["<UUID-1>", "<UUID-2>"]
}
}
```
响应 schema
```json
{
"code": 1,
"message": null,
"timestamp": 1779255000000,
"data": true
}
```
`BioyondV1RPC.take_out(...)` 返回完整响应包,因此 `take_out_materials` 应保留原始包到 `take_out_result`
### 实现要点
- 本轮不修改 `sample_waste_removal`,保持 backward compatibility。
- 新节点只调用现有完整能力的 `take_out(...)`
- `preintake_ids` / `material_ids` 都按可选列表处理,默认 `[]`
- `take-out` 返回 `code != 1` 时返回 `success=False` 并记录 warning是否抛异常留作开放问题。
---
## 六、端到端工作流连线
```mermaid
flowchart LR
submit["submit_experiment_dayN"] --> start["start_experiment<br/>manual_confirm: 上料"]
start -->|order_id, order_code| wait["wait_for_order_finish<br/>等 order_finish + 生成 unloadTable"]
wait -->|order_id, material_ids,<br/>preintake_ids, unloadTable| confirm["confirm_unload_materials<br/>manual_confirm: 操作员下料确认"]
confirm -->|order_id, material_ids,<br/>preintake_ids| takeout["take_out_materials<br/>调用 take-out 同步奔耀"]
bioyond["奔耀 LIMS"] -.HTTP POST /report/order_finish.-> wait
wait -.material-info.-> bioyond
takeout -.take-out.-> bioyond
```
---
## 七、影响面与兼容性
- **`peptide_station.py`**
- 修改 `start_experiment`:增加 `order_id/order_code/order_ids/resultTable` 输出 handles并在返回值透传。
- 增加 `wait_for_order_finish``confirm_unload_materials``take_out_materials` 三个 action。
- 增加 `process_order_finish_report` override。
- 增加 `_build_unload_table(...)` 等私有辅助方法。
- **`bioyond_rpc.py` 不动**
- `take_out` 已有完整 schema 能力。
- `sample_waste_removal` 本轮不改,保持兼容。
- **基类 `station.py` 不动**
- override 中保留 `super().process_order_finish_report(...)` 调用。
- **HTTP 服务不动**
- `WorkstationHTTPService` 已支持 `/report/order_finish`
- **本轮不做缓存**
- 不新增 `unload_context_cache`
- 不支持 push 早于 wait 的自动补救。
- 不支持失败 manual_confirm 原地重跑。
- **测试**:补在现有路径 `unilabos/devices/workstation/bioyond_studio/peptide_station/tests/test_peptide_station_contracts.py`
1. `start_experiment` 输出 handles/返回值透传 `order_id/order_code/order_ids/resultTable`
2. `process_order_finish_report` orderCode 匹配 / 不匹配时 event 是否触发。
3. `wait_for_order_finish` 单订单成功、超时、状态映射、`used_materials` JSON 化。
4. `_build_unload_table` 列顺序严格等于 `RESULT_TABLE_COLUMNS`,且无 `posX/posY/posZ/unit` 列。
5. `material-info` 失败时不抛异常,`missing_material_info` 正确记录。
6. `confirm_unload_materials` 未勾选时报错,勾选后透传下游字段且不调用 `take_out`
7. `take_out_materials` 调用 `hardware_interface.take_out(order_id, preintake_ids, material_ids)`,不调用 `sample_waste_removal`
---
## 八、待人类确认的开放问题
1. **过滤产物 vs 全量**`usedMaterials` 同时包含试剂、耗材、样品(`typeMode` 区分),下料表是否需要默认排除试剂/耗材?当前默认全量列出。
2. **take-out 失败是否阻塞工作流**:本计划暂定返回 `success=False` 并 warning不抛异常如果希望奔耀仓位状态必须一致可改为抛 `RuntimeError`
3. **后续缓存/重跑能力**:如果要支持 push 早到、忘勾选后重跑复用 `unloadTable`,后续应在 `BioyondPeptideStation` station runtime 上实现 `unload_context_cache`,但本轮不做。
4. **多订单**:本轮只保留 `order_ids` 占位,不实现多订单等待、乱序回调或并发 wait。
---
## 附录 AAPI schema 核对摘要
使用 `temp_benyao/scripts/api_helper.py --root temp_benyao/peptide` 核对:
### A.1 `POST /api/lims/storage/material-info`
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": "<materialId UUID>"
}
```
响应关键字段:
```json
{
"id": "<materialId UUID>",
"typeName": "样品",
"code": "MAT-001",
"barCode": "BC-001",
"name": "多肽产物",
"quantity": 10,
"lockQuantity": 0,
"unit": "mg",
"status": 1,
"isUse": true,
"locations": [
{
"id": "<locationId UUID>",
"whid": "<warehouse UUID>",
"whName": "自动化堆栈",
"code": "A1",
"x": 1,
"y": 1,
"z": 1,
"quantity": 10
}
],
"detail": []
}
```
注意schema 没有 `posX/posY/posZ`,本轮也不展示坐标。
### A.2 `POST /api/lims/order/take-out`
请求:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-20T10:50:00.123Z",
"data": {
"orderId": "<orderId UUID>",
"preintakeIds": [],
"materialIds": ["<materialId UUID>"]
}
}
```
响应:
```json
{
"code": 1,
"message": null,
"timestamp": 1779255000000,
"data": true
}
```
源码中已有 `BioyondV1RPC.take_out(order_id, preintake_ids=None, material_ids=None)`,本轮复用它。
### A.3 `/report/order_finish`
`/report/order_finish` 不在 Peptide JSON OpenAPI specs 中schema 依据:
- `unilabos/devices/workstation/workstation_http_service.py`
- `temp_benyao/peptide/docs/reference/api_manual.md`
关键字段:
```json
{
"token": "token-from-lims",
"request_time": "2026-05-20 10:50:00.123",
"data": {
"orderCode": "EXP260520-103000",
"orderName": "实验260520-103000",
"startTime": "2026-05-20 09:00:00",
"endTime": "2026-05-20 10:50:00",
"status": "30",
"usedMaterials": [
{
"materialId": "<materialId UUID>",
"locationId": "<locationId UUID>",
"typeMode": "1",
"usedQuantity": 10
}
]
}
}
```
`WorkstationHTTPService` 会把 `usedMaterials[]` 转成 `MaterialUsage` dataclass 列表传给 `process_order_finish_report(report_request, used_materials)`peptide 输出 `used_materials` handle 前需要转回 JSON dict
```json
[
{
"materialId": "<materialId UUID>",
"locationId": "<locationId UUID>",
"typeMode": "1",
"usedQuantity": 10
}
]
```
---
## 附录 B本轮不实现的内容
- 不做 station runtime 的 `unload_context_cache`
- 不做多订单。
- 不做 push 早到后的补救。
- 不做 failed manual_confirm 原地重跑。
- 不改前端。
- 不改 `sample_waste_removal`

View File

@@ -1,461 +0,0 @@
# Peptide Four-Checkbox Reset Plan
Date: 2026-05-21 16:30
Status: Proposal only / not executed
## Scope
This plan replaces `2026-05-21_1556_peptide_reset_sirna_reference_plan.md` for Peptide reset work.
User direction captured here:
- `take_out` is unnecessary for Peptide reset.
- Do not add a material-cache refresh checkbox.
- Change reset to four checkbox-controlled operations:
- 调度器复位
- 订单状态复位
- 库位复位
- 仪器复位
- The first three checkboxes default to checked.
- The fourth checkbox, 仪器复位 / `reset_devices`, defaults to unchecked.
- Replace the current public `reset` action with:
- `reset_auto`: normal ILab action node. This is the renamed/replaced version of the current reset implementation.
- `reset_manual`: manual-confirm action node with a physical cleanup confirmation message.
## Evidence Summary
Current Peptide source:
- Reset action code is currently in `unilabos/devices/workstation/bioyond_studio/peptide_station/peptide_station.py`.
- Current Peptide reset selects `scheduler_reset`, `reset_order_status`, and `reset_location`, and passes ids to order/location resets.
- `BioyondV1RPC.reset_devices()` already calls `/api/lims/device/reset-devices` with only `apiKey` and `requestTime`.
- `BioyondV1RPC.scheduler_reset()` already calls `/api/lims/scheduler/reset` with only `apiKey` and `requestTime`.
- `BioyondV1RPC.reset_order_status(order_id)` and `reset_location(location_id)` currently send `data`, but live probes showed that omitted `data` succeeds.
Live Peptide no-data reset probes using `temp_benyao/peptide/peptide_station_config.example.json`:
- `POST /api/lims/order/reset-order-status` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
- `POST /api/lims/scheduler/reset` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
- `POST /api/lims/storage/reset-location` with request keys `["apiKey", "requestTime"]` returned HTTP 200 and `code=1`.
- `reset-devices` was not live-probed in this session, but the current RPC wrapper already sends no `data`.
Raw findings:
- `temp_benyao/peptide/_findings/2026-05-21_1613_reset_order_status_no_data_live.md`
- `temp_benyao/peptide/_findings/2026-05-21_1615_remaining_resets_no_data_live.md`
## Proposed Public Actions
### `reset_auto`
Normal action node. This is the auto/no-manual-confirm path. It replaces the current public `reset` action; do not leave a second public `reset` action unless a later compatibility request explicitly asks for an alias.
Checkbox schema rule:
- Use plain `bool` annotations in the action signature.
- Do not use `Annotated[bool, Field(...)]` for these checkbox params in this implementation plan.
- The current AST registry schema path does not unwrap `Annotated[...]`; plain `bool` is required so generated JSON Schema marks the fields as boolean and the renderer can show checkboxes.
- Put human-facing labels/descriptions in the method docstring or action description. If field-level `Field(description=...)` metadata is required later, add registry `Annotated` support and a schema test as a separate change.
Decorator shape:
```python
@action(
always_free=True,
goal_default={
"reset_scheduler": True,
"reset_order_status": True,
"reset_location": True,
"reset_devices": False,
},
description="自动复位调度器/订单状态/库位,可选仪器复位",
)
def reset_auto(
self,
reset_scheduler: bool = True,
reset_order_status: bool = True,
reset_location: bool = True,
reset_devices: bool = False,
**kwargs: Any,
) -> Dict[str, Any]:
"""自动复位调度器/订单状态/库位,可选仪器复位。
Args:
reset_scheduler[调度器复位]: 调用 /api/lims/scheduler/reset默认勾选。
reset_order_status[订单状态复位]: 调用 /api/lims/order/reset-order-status默认勾选。
reset_location[库位复位]: 调用 /api/lims/storage/reset-location默认勾选。
reset_devices[仪器复位]: 调用 /api/lims/device/reset-devices默认不勾选。
"""
...
```
Implementation notes:
- Use real plain-`bool` parameters, not hidden `**kwargs` and not `Annotated`, so the action renderer can expose four checkboxes.
- Rename/replace the existing `reset` action as `reset_auto`; the implementation should not keep the old id-shaped `reset` action as another public path by default.
- Keep the three routine reset defaults checked.
- Keep `reset_devices` unchecked because it can be broader and more disruptive.
- Do not require or resolve order ids or location ids.
- Do not call `take_out`.
- Do not call `refresh_material_cache`.
### `reset_manual`
Manual-confirm node. It should show the operator a physical cleanup warning, then execute the same reset helper as `reset_auto` after the operator confirms.
Actual manual-confirm decorator pattern in this repo:
- Use `@action(node_type=NodeType.MANUAL_CONFIRM)`.
- Set `always_free=True`.
- Add `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}`.
- Include `timeout_seconds: int` and `assignee_user_ids: list[str]`.
- Add `goal_default` for `timeout_seconds` and `assignee_user_ids`.
- Manual-confirm actions are normally side-effect-light, but existing Peptide `start_experiment` is already a `MANUAL_CONFIRM` action that performs scheduler start after the operator gate, so a reset-after-confirm pattern is compatible with current Peptide style.
Proposed confirmation text:
```text
请确认G3、CEM、Tecan、撕膜机、封膜机、打标机、旋转堆栈上下料位、3个转台等位置的物料已清理完毕
请开门检查冰箱、IDOT、酶标仪、离心机、LCMS内部没有遗留物料。
```
Decorator/function shape:
```python
RESET_MANUAL_CONFIRM_MESSAGE = (
"请确认G3、CEM、Tecan、撕膜机、封膜机、打标机、旋转堆栈上下料位、3个转台等位置的物料已清理完毕\n"
"请开门检查冰箱、IDOT、酶标仪、离心机、LCMS内部没有遗留物料。"
)
@action(
always_free=True,
node_type=NodeType.MANUAL_CONFIRM,
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
goal_default={
"reset_scheduler": True,
"reset_order_status": True,
"reset_location": True,
"reset_devices": False,
"physical_cleanup_confirmed": False,
"timeout_seconds": 3600,
"assignee_user_ids": [],
},
feedback_interval=300,
description=RESET_MANUAL_CONFIRM_MESSAGE,
)
def reset_manual(
self,
reset_scheduler: bool = True,
reset_order_status: bool = True,
reset_location: bool = True,
reset_devices: bool = False,
physical_cleanup_confirmed: bool = False,
timeout_seconds: int = 3600,
assignee_user_ids: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""人工确认物理清理后执行复位。
Args:
reset_scheduler[调度器复位]: 调用 /api/lims/scheduler/reset默认勾选。
reset_order_status[订单状态复位]: 调用 /api/lims/order/reset-order-status默认勾选。
reset_location[库位复位]: 调用 /api/lims/storage/reset-location默认勾选。
reset_devices[仪器复位]: 调用 /api/lims/device/reset-devices默认不勾选。
physical_cleanup_confirmed[物理清理确认]: 确认清理提示中的物料检查已经完成,默认不勾选。
"""
...
```
Execution rule:
- If `physical_cleanup_confirmed` is false, return a blocked result and do not call any reset API.
- If it is true, call the same internal helper as `reset_auto`.
- Return `confirmation_message` in the result payload so call logs preserve the exact operator instruction text.
Renderer caveat:
- `description` should carry the warning in generated action metadata.
- `physical_cleanup_confirmed` must remain a plain `bool` so it renders as a checkbox.
- The cleanup warning should be carried by the action `description` and the docstring param description. Do not rely on `Field(description=...)` unless registry `Annotated` support has been implemented and tested.
- If the current frontend does not show action descriptions or docstring field descriptions reliably, add a read-only string parameter such as `confirmation_message: str = RESET_MANUAL_CONFIRM_MESSAGE` with `goal_default`, or use a handle-based display only after renderer behavior is verified.
## Shared Internal Helper
Both public actions should delegate to one helper, for example:
```python
def _execute_reset_operations(
self,
*,
reset_scheduler: bool,
reset_order_status: bool,
reset_location: bool,
reset_devices: bool,
) -> Dict[str, Any]:
...
```
Call order:
1. `scheduler_reset`
2. `reset_order_status`
3. `reset_location`
4. `reset_devices`
Result shape:
```python
{
"selected_operations": [
{"key": "reset_scheduler", "label": "调度器复位", "selected": True},
{"key": "reset_order_status", "label": "订单状态复位", "selected": True},
{"key": "reset_location", "label": "库位复位", "selected": True},
{"key": "reset_devices", "label": "仪器复位", "selected": False},
],
"executed_calls": [
{"operation": "scheduler_reset", "endpoint": "/api/lims/scheduler/reset", "result": {"code": 1}},
],
"skipped_operations": [
{"operation": "reset_devices", "reason": "checkbox_disabled"},
],
"warnings": [],
}
```
Failure handling:
- Execute selected operations sequentially and record each result.
- If an operation returns non-`1` code, add a warning and continue unless the caller later requests fail-fast.
- If an RPC method raises, catch it, record an error entry, and continue to the next selected operation unless fail-fast is introduced.
## RPC Wrapper Adjustment
Adjust the two id-shaped wrappers to no-data calls:
- `BioyondV1RPC.reset_order_status()` should no longer require `order_id`.
- `BioyondV1RPC.reset_location()` should no longer require `location_id`.
Current no-data wrappers already exist:
- `scheduler_reset()`
- `reset_devices()`
Suggested RPC signatures:
```python
def scheduler_reset(self) -> int: ...
def reset_order_status(self) -> int: ...
def reset_location(self) -> int: ...
def reset_devices(self) -> int: ...
```
Compatibility option:
```python
def reset_order_status(self, order_id: Optional[str] = None) -> int:
del order_id
...
def reset_location(self, location_id: Optional[str] = None) -> int:
del location_id
...
```
This keeps older code from crashing while making the actual wire request no-data.
## Adjusted Runtime API Schemas
These are the schemas Peptide reset code should target at runtime after the live no-data probes. They intentionally omit `data`, even though OpenAPI models nullable `data` for these endpoints.
All four requests use:
```json
{
"apiKey": "string",
"requestTime": "date-time"
}
```
No `data` field should be sent by default.
All four responses use:
```json
{
"code": 1,
"message": "",
"timestamp": 0
}
```
### 调度器复位
Endpoint:
```text
POST /api/lims/scheduler/reset
```
Adjusted request:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-21T08:15:16.494Z"
}
```
Live response:
```json
{
"code": 1,
"message": "",
"timestamp": 1779351316072
}
```
Notes:
- OpenAPI says `data` is nullable int32.
- Live Peptide accepted omitted `data`.
### 订单状态复位
Endpoint:
```text
POST /api/lims/order/reset-order-status
```
Adjusted request:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-21T08:13:34.750Z"
}
```
Live response:
```json
{
"code": 1,
"message": "",
"timestamp": 1779351214422
}
```
Notes:
- OpenAPI says `data` is nullable string.
- Live Peptide accepted omitted `data`.
- Do not model this as order-id scoped unless Bioyond confirms backend behavior.
### 库位复位
Endpoint:
```text
POST /api/lims/storage/reset-location
```
Adjusted request:
```json
{
"apiKey": "B10B5995",
"requestTime": "2026-05-21T08:15:18.924Z"
}
```
Live response:
```json
{
"code": 1,
"message": "",
"timestamp": 1779351318565
}
```
Notes:
- OpenAPI says `data` is nullable string.
- Live Peptide accepted omitted `data`.
- Do not model this as location-id scoped unless Bioyond confirms backend behavior.
### 仪器复位
Endpoint:
```text
POST /api/lims/device/reset-devices
```
Adjusted request:
```json
{
"apiKey": "B10B5995",
"requestTime": "date-time"
}
```
Expected response shape:
```json
{
"code": 1,
"message": "",
"timestamp": 0
}
```
Notes:
- OpenAPI says `data` is nullable string.
- Current `BioyondV1RPC.reset_devices()` already sends no `data`.
- This endpoint was not live-probed in the no-data reset session.
- Keep checkbox default unchecked.
## Tests To Add Before Implementation
1. `reset_auto` is not `NodeType.MANUAL_CONFIRM`.
2. `reset_manual` has `node_type=NodeType.MANUAL_CONFIRM`.
3. `reset_manual` metadata includes:
- `always_free=True`
- `placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"}`
- `timeout_seconds=3600`
- `assignee_user_ids=[]`
- `physical_cleanup_confirmed=False`
4. Both reset actions expose four real boolean params:
- `reset_scheduler`
- `reset_order_status`
- `reset_location`
- `reset_devices`
5. The generated registry schema marks those reset params as JSON Schema `type: boolean`, not `object` or `string`, so the frontend can render checkboxes.
6. `reset_auto` replaces the current public `reset` action. Unless a later compatibility request adds an alias, no old id-shaped public `reset` action remains.
7. Goal defaults are:
- first three reset checkboxes `True`
- `reset_devices=False`
8. `reset_manual(..., physical_cleanup_confirmed=False)` does not call any RPC reset method.
9. `reset_auto()` with defaults calls:
- `scheduler_reset()`
- `reset_order_status()`
- `reset_location()`
- not `reset_devices()`
10. `reset_auto(reset_devices=True)` also calls `reset_devices()`.
11. `reset_order_status()` and `reset_location()` RPC wrappers send no `data` key.
12. No reset path calls `take_out`.
13. No reset path calls `refresh_material_cache`.
## Non-Goals
- Do not implement `take_out` in reset.
- Do not refresh `material_cache` from reset.
- Do not resolve order ids or location ids for reset.
- Do not add Project/cache/browser cleanup routes.
- Do not make `reset_devices` default-on.
- Do not execute this plan during planning.

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.11.1 version: 0.11.2
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
target_directory: src target_directory: src

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.11.1" version: "0.11.2"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.11.1', version='0.11.2',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1 +1 @@
__version__ = "0.11.1" __version__ = "0.11.2"

View File

@@ -59,6 +59,7 @@ class JobAddReq(BaseModel):
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
node_id: str = Field(examples=["node_id"], description="node uuid", default="") node_id: str = Field(examples=["node_id"], description="node uuid", default="")
notebook_id: str = Field(examples=["notebook_id"], description="notebook uuid", default="")
server_info: dict = Field( server_info: dict = Field(
examples=[{"send_timestamp": 1717000000.0}], examples=[{"send_timestamp": 1717000000.0}],
description="server info (auto-generated if empty)", description="server info (auto-generated if empty)",

View File

@@ -10,29 +10,170 @@ import shutil
import sys import sys
_PATCH_MARKER = "# UniLabOS DLL Patch"
_PATCH_END_MARKER = "# End UniLabOS DLL Patch"
# 75 = EX_TEMPFAIL: 临时失败、重试即可,避免与业务退出码冲突
_RESTART_EXIT_CODE = 75
def _build_dll_patch(lib_bin: str, preload_pyd: str = "") -> str:
"""生成一段加在目标文件顶部的 DLL 加载补丁源码。
- 始终把 ``lib_bin`` 加入 DLL 搜索路径,并把 handle 挂在模块属性上,
防止 GC 清掉搜索路径(``os.add_dll_directory`` 的句柄被回收时
目录会被移除)。
- 可选地用 ``ctypes.CDLL`` 预加载一个 .pyd把它的依赖 DLL 提前装入
进程内存,作为 ``rclpy._rclpy_pybind11`` 这类首次加载点的兜底。
"""
# 用 repr() 序列化路径Python 解析 repr 的结果会还原成原始字符串,
# 不需要也不能再叠加 raw-string 前缀(叠了反而会让 \\ 变成两个反斜杠)。
lines = [
_PATCH_MARKER,
"import os as _ulab_os",
f"_ulab_p = {lib_bin!r}",
'if hasattr(_ulab_os, "add_dll_directory") and _ulab_os.path.isdir(_ulab_p):',
" try: _UNILAB_DLL_HANDLE = _ulab_os.add_dll_directory(_ulab_p)",
" except Exception: _UNILAB_DLL_HANDLE = None",
]
if preload_pyd:
lines.extend(
[
"import ctypes as _ulab_ctypes",
f"try: _ulab_ctypes.CDLL({preload_pyd!r})",
"except Exception: pass",
]
)
lines.append(_PATCH_END_MARKER)
return "\n".join(lines) + "\n"
def _apply_dll_patch(file_path: str, lib_bin: str, preload_pyd: str = "") -> bool:
"""把 DLL 补丁前置到 ``file_path``。文件不存在或已打过补丁则返回 False。"""
if not os.path.isfile(file_path):
return False
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
if _PATCH_MARKER in content:
return False
shutil.copy2(file_path, file_path + ".bak")
with open(file_path, "w", encoding="utf-8") as f:
f.write(_build_dll_patch(lib_bin, preload_pyd) + content)
return True
def _print_restart_banner(patched_files):
"""打印重启提示并以 EX_TEMPFAIL 退出。
- 不使用 ANSI 颜色码Windows 旧版 cmd / PowerShell 5 默认不开 VT 处理,
会把 ``\\033[1;33m`` 当做字面字符显示,反而让用户看不到正文。
- 同时写入 stderr 与 stdout某些上层 launcher / supervisor 只重定向
其中一路,写两遍能保证用户至少看到一份。
- 写入前防御性把流切到 UTF-8 with replace``main.py`` 里已经做过一次,
但本模块也可能被绕过 ``main.py`` 的代码路径直接 importreconfigure
失败也只是退回 errors=replace不影响整体流程。
"""
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
bar = "#" * 78
files_lines = [f"[UniLabOS] - {p}" for p in patched_files]
body = "\n".join(
[
"",
bar,
bar,
"##",
"## [UniLabOS] Windows + conda 下检测到 DLL 加载失败,已自动打补丁。",
"## [UniLabOS] DLL load failure detected on Windows + conda;",
"## [UniLabOS] the following files have been auto-patched:",
"##",
*[f"## {line}" for line in files_lines],
"##",
"## [UniLabOS] 当前进程的 rclpy 状态已损坏,补丁需要在新进程才生效。",
"## [UniLabOS] The current process is unusable; the patch only takes",
"## [UniLabOS] effect on a fresh process.",
"##",
"## >>> 请重新运行刚才的命令 / Please re-run the same command. <<<",
"##",
bar,
bar,
"",
]
)
for stream in (sys.stderr, sys.stdout):
try:
stream.write(body)
stream.flush()
except Exception:
try:
print(body, file=stream)
except Exception:
pass
sys.exit(_RESTART_EXIT_CODE)
def patch_rclpy_dll_windows(): def patch_rclpy_dll_windows():
"""在 Windows + conda 环境下 rclpy 打 DLL 加载补丁""" """在 Windows + conda 环境下修复 rclpy / rosidl typesupport 的 DLL 加载。
背景conda 安装的 ros 系列包,其原生扩展依赖 ``$CONDA_PREFIX/Library/bin``
下的 DLL只有 conda 环境被正确激活、且 PATH 中含 ``Library/bin`` 时,
``os.add_dll_directory`` 才能找到它们。当从快捷方式 / IDE / 子进程 /
没激活的 shell 启动 ``unilab`` 时,会出现 ``DLL load failed``。
本函数会:
1) 修补 ``rclpy/impl/implementation_singleton.py`` —— rclpy 自身的 C 扩展入口;
2) 修补 ``rpyutils/add_dll_directories.py`` —— 所有 ``*_s__rosidl_typesupport_c.pyd``
``geometry_msgs`` / ``std_msgs`` / ``sensor_msgs`` 等)的统一加载入口。
打完补丁后**必须重启进程**才能生效(当前进程的 rclpy 已经发生过
``ImportError``,子模块仍处于损坏状态)。因此函数会主动退出,并在
stdout/stderr 同时打印明显的重启提示,避免用户被后续报错淹没。
"""
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"): if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
return return
try: try:
import rclpy import rclpy # noqa: F401
return return
except ImportError as e: except ImportError as e:
if not str(e).startswith("DLL load failed"): if not str(e).startswith("DLL load failed"):
return return
cp = os.environ["CONDA_PREFIX"] cp = os.environ["CONDA_PREFIX"]
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py") lib_bin = os.path.join(cp, "Library", "bin")
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd")) site_packages = os.path.join(cp, "Lib", "site-packages")
if not os.path.exists(impl) or not pyd: if not os.path.isdir(lib_bin):
return return
with open(impl, "r", encoding="utf-8") as f:
content = f.read() patched = []
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n' # 1) rclpy 自身的入口
shutil.copy2(impl, impl + ".bak") rclpy_impl = os.path.join(site_packages, "rclpy", "impl", "implementation_singleton.py")
with open(impl, "w", encoding="utf-8") as f: rclpy_pyd_matches = glob.glob(os.path.join(site_packages, "rclpy", "_rclpy_pybind11*.pyd"))
f.write(patch + content) rclpy_pyd = rclpy_pyd_matches[0] if rclpy_pyd_matches else ""
if rclpy_pyd and _apply_dll_patch(rclpy_impl, lib_bin, preload_pyd=rclpy_pyd):
patched.append(rclpy_impl)
# 2) rpyutils —— 所有 rosidl typesupport pyd 的加载点;放在 rclpy 之后
# 例geometry_msgs/geometry_msgs_s__rosidl_typesupport_c.pyd
rpyutils_dll = os.path.join(site_packages, "rpyutils", "add_dll_directories.py")
if _apply_dll_patch(rpyutils_dll, lib_bin):
patched.append(rpyutils_dll)
if not patched:
# 已经打过补丁但 rclpy 仍然加载失败:原因不是缺 DLL 搜索路径,
# 不要再次打补丁污染文件,让上层看到真实的 ImportError。
return
_print_restart_banner(patched)
patch_rclpy_dll_windows() patch_rclpy_dll_windows()

View File

@@ -320,6 +320,7 @@ def job_add(req: JobAddReq) -> JobData:
action_name=action_name, action_name=action_name,
task_id=task_id, task_id=task_id,
job_id=job_id, job_id=job_id,
notebook_id=req.notebook_id,
device_action_key=device_action_key, device_action_key=device_action_key,
) )

View File

@@ -59,6 +59,7 @@ class QueueItem:
action_name: str action_name: str
task_id: str task_id: str
job_id: str job_id: str
notebook_id: str
device_action_key: str device_action_key: str
next_run_time: float = 0 # 下次执行时间戳 next_run_time: float = 0 # 下次执行时间戳
retry_count: int = 0 # 重试次数 retry_count: int = 0 # 重试次数
@@ -71,6 +72,7 @@ class JobInfo:
job_id: str job_id: str
task_id: str task_id: str
device_id: str device_id: str
notebook_id: str
action_name: str action_name: str
device_action_key: str device_action_key: str
status: JobStatus status: JobStatus
@@ -539,7 +541,10 @@ class MessageProcessor:
self.reconnect_count += 1 self.reconnect_count += 1
backoff = WSConfig.reconnect_interval backoff = WSConfig.reconnect_interval
logger.info( logger.info(
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})" "[MessageProcessor] 即将在 %s 秒后重连 (已尝试 %s/%s)",
backoff,
self.reconnect_count,
WSConfig.max_reconnect_attempts,
) )
await asyncio.sleep(backoff) await asyncio.sleep(backoff)
else: else:
@@ -703,6 +708,7 @@ class MessageProcessor:
action_name = data.get("action_name", "") action_name = data.get("action_name", "")
task_id = data.get("task_id", "") task_id = data.get("task_id", "")
job_id = data.get("job_id", "") job_id = data.get("job_id", "")
notebook_id = data.get("notebook_id", "")
if not all([device_id, action_name, task_id, job_id]): if not all([device_id, action_name, task_id, job_id]):
logger.error("[MessageProcessor] Missing required fields in query_action_state") logger.error("[MessageProcessor] Missing required fields in query_action_state")
@@ -718,6 +724,7 @@ class MessageProcessor:
job_id=job_id, job_id=job_id,
task_id=task_id, task_id=task_id,
device_id=device_id, device_id=device_id,
notebook_id=notebook_id,
action_name=action_name, action_name=action_name,
device_action_key=device_action_key, device_action_key=device_action_key,
status=JobStatus.QUEUE, status=JobStatus.QUEUE,
@@ -732,13 +739,27 @@ class MessageProcessor:
if can_start_immediately: if can_start_immediately:
# 可以立即开始 # 可以立即开始
await self._send_action_state_response( await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", True, 0 device_id,
action_name,
task_id,
job_id,
"query_action_status",
True,
0,
notebook_id=notebook_id,
) )
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately") logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
else: else:
# 需要排队 # 需要排队
await self._send_action_state_response( await self._send_action_state_response(
device_id, action_name, task_id, job_id, "query_action_status", False, 10 device_id,
action_name,
task_id,
job_id,
"query_action_status",
False,
10,
notebook_id=notebook_id,
) )
logger.trace(f"[MessageProcessor] Job {job_log} queued") logger.trace(f"[MessageProcessor] Job {job_log} queued")
@@ -768,6 +789,7 @@ class MessageProcessor:
job_id=req.job_id, job_id=req.job_id,
task_id=req.task_id, task_id=req.task_id,
device_id=req.device_id, device_id=req.device_id,
notebook_id=req.notebook_id,
action_name=action_name, action_name=action_name,
device_action_key=device_action_key, device_action_key=device_action_key,
status=JobStatus.QUEUE, status=JobStatus.QUEUE,
@@ -775,11 +797,16 @@ class MessageProcessor:
always_free=True, always_free=True,
) )
self.device_manager.add_queue_request(job_info) self.device_manager.add_queue_request(job_info)
existing_job = job_info
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start") logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else: else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)") logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return return
if existing_job and req.notebook_id and not existing_job.notebook_id:
existing_job.notebook_id = req.notebook_id
notebook_id = req.notebook_id or (existing_job.notebook_id if existing_job else "")
success = self.device_manager.start_job(req.job_id) success = self.device_manager.start_job(req.job_id)
if not success: if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}") logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -795,6 +822,7 @@ class MessageProcessor:
action_name=req.action, action_name=req.action,
task_id=req.task_id, task_id=req.task_id,
job_id=req.job_id, job_id=req.job_id,
notebook_id=notebook_id,
device_action_key=device_action_key, device_action_key=device_action_key,
) )
@@ -834,6 +862,7 @@ class MessageProcessor:
"job_id": req.job_id, "job_id": req.job_id,
"task_id": req.task_id, "task_id": req.task_id,
"device_id": req.device_id, "device_id": req.device_id,
"notebook_id": queue_item.notebook_id,
"action_name": req.action, "action_name": req.action,
"status": "failed", "status": "failed",
"feedback_data": {}, "feedback_data": {},
@@ -855,6 +884,7 @@ class MessageProcessor:
"query_action_status", "query_action_status",
True, True,
0, 0,
notebook_id=next_job.notebook_id,
) )
next_job_log = format_job_log( next_job_log = format_job_log(
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
@@ -1101,7 +1131,15 @@ class MessageProcessor:
logger.info(f"[MessageProcessor] Restart cleanup scheduled") logger.info(f"[MessageProcessor] Restart cleanup scheduled")
async def _send_action_state_response( async def _send_action_state_response(
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int self,
device_id: str,
action_name: str,
task_id: str,
job_id: str,
typ: str,
free: bool,
need_more: int,
notebook_id: str = "",
): ):
"""发送动作状态响应""" """发送动作状态响应"""
message = { message = {
@@ -1112,6 +1150,7 @@ class MessageProcessor:
"action_name": action_name, "action_name": action_name,
"task_id": task_id, "task_id": task_id,
"job_id": job_id, "job_id": job_id,
"notebook_id": notebook_id,
"free": free, "free": free,
"need_more": need_more + 1, "need_more": need_more + 1,
}, },
@@ -1194,6 +1233,7 @@ class QueueProcessor:
action_name=timeout_job.action_name, action_name=timeout_job.action_name,
task_id=timeout_job.task_id, task_id=timeout_job.task_id,
job_id=timeout_job.job_id, job_id=timeout_job.job_id,
notebook_id=timeout_job.notebook_id,
device_action_key=timeout_job.device_action_key, device_action_key=timeout_job.device_action_key,
) )
# 发布超时失败状态这会触发正常的job完成流程 # 发布超时失败状态这会触发正常的job完成流程
@@ -1252,6 +1292,7 @@ class QueueProcessor:
"action_name": job_info.action_name, "action_name": job_info.action_name,
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"notebook_id": job_info.notebook_id,
"free": False, "free": False,
"need_more": 10 + 1, "need_more": 10 + 1,
}, },
@@ -1291,6 +1332,7 @@ class QueueProcessor:
"action_name": job_info.action_name, "action_name": job_info.action_name,
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"notebook_id": job_info.notebook_id,
"free": False, "free": False,
"need_more": 10 + 1, "need_more": 10 + 1,
}, },
@@ -1336,12 +1378,15 @@ class QueueProcessor:
"action_name": next_job.action_name, "action_name": next_job.action_name,
"task_id": next_job.task_id, "task_id": next_job.task_id,
"job_id": next_job.job_id, "job_id": next_job.job_id,
"notebook_id": next_job.notebook_id,
"free": True, "free": True,
"need_more": 0, "need_more": 0,
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name) # next_job_log = format_job_log(
# next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
# )
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start") # logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
# 立即触发下一轮状态检查 # 立即触发下一轮状态检查
@@ -1510,6 +1555,7 @@ class WebSocketClient(BaseCommunicationClient):
"job_id": item.job_id, "job_id": item.job_id,
"task_id": item.task_id, "task_id": item.task_id,
"device_id": item.device_id, "device_id": item.device_id,
"notebook_id": item.notebook_id,
"action_name": item.action_name, "action_name": item.action_name,
"status": status, "status": status,
"feedback_data": feedback_data, "feedback_data": feedback_data,

View File

@@ -415,25 +415,21 @@ class BioyondV1RPC(BaseRequest):
return {} return {}
return response.get("data", {}) return response.get("data", {})
def reset_location(self, location_id: Optional[str] = None) -> int: def reset_location(self, location_id: str) -> int:
"""复位库位 """复位库位
现场实测 ``POST /api/lims/storage/reset-location`` 不传 ``data`` 即可成功
(见 ``temp_benyao/peptide/_findings/2026-05-21_1615_remaining_resets_no_data_live.md``
因此默认无 ``data`` 字段;保留 ``location_id`` 仅为兼容旧调用,传入会被忽略。
参数: 参数:
location_id: 兼容入参,已被忽略;新逻辑不再以 location 为粒度复位。 location_id: 库位ID
返回值: 返回值:
int: 成功返回1失败返回0 int: 成功返回1失败返回0
""" """
del location_id
response = self.post( response = self.post(
url=f'{self.host}/api/lims/storage/reset-location', url=f'{self.host}/api/lims/storage/reset-location',
params={ params={
"apiKey": self.api_key, "apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(), "requestTime": self.get_current_time_iso8601(),
"data": location_id,
}) })
if not response or response['code'] != 1: if not response or response['code'] != 1:
return 0 return 0
@@ -783,49 +779,6 @@ class BioyondV1RPC(BaseRequest):
return response.get("data", {}) return response.get("data", {})
def take_out(
self,
order_id: str,
preintake_ids: list[str] | None = None,
material_ids: list[str] | None = None,
) -> dict:
"""取出订单关联通量/物料
参数:
order_id: 订单ID
preintake_ids: 通量ID列表可为空
material_ids: 物料ID列表可为空
返回值:
dict: 服务端响应包,失败返回空字典
"""
if not order_id:
self._logger.error("取出订单关联通量/物料错误: 缺少订单ID")
return {}
params = {
"orderId": order_id,
"preintakeIds": list(preintake_ids or []),
"materialIds": list(material_ids or []),
}
response = self.post(
url=f'{self.host}/api/lims/order/take-out',
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": params,
})
if not response:
return {}
if response['code'] != 1:
self._logger.error(f"取出订单关联通量/物料错误: {response.get('message', '')}")
return response
return response
def cancel_order(self, json_str: str) -> bool: def cancel_order(self, json_str: str) -> bool:
"""取消指定任务 """取消指定任务
@@ -933,25 +886,21 @@ class BioyondV1RPC(BaseRequest):
return {} return {}
return response.get("data", {}) return response.get("data", {})
def reset_order_status(self, order_id: Optional[str] = None) -> int: def reset_order_status(self, order_id: str) -> int:
"""复位订单状态 """复位订单状态
现场实测 ``POST /api/lims/order/reset-order-status`` 不传 ``data`` 即可成功
(见 ``temp_benyao/peptide/_findings/2026-05-21_1613_reset_order_status_no_data_live.md``
因此默认无 ``data`` 字段;保留 ``order_id`` 仅为兼容旧调用,传入会被忽略。
参数: 参数:
order_id: 兼容入参,已被忽略;新逻辑不再以单订单为粒度复位。 order_id: 订单ID
返回值: 返回值:
int: 成功返回1失败返回0 int: 成功返回1失败返回0
""" """
del order_id
response = self.post( response = self.post(
url=f'{self.host}/api/lims/order/reset-order-status', url=f'{self.host}/api/lims/order/reset-order-status',
params={ params={
"apiKey": self.api_key, "apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(), "requestTime": self.get_current_time_iso8601(),
"data": order_id,
}) })
if not response or response['code'] != 1: if not response or response['code'] != 1:
return 0 return 0

View File

@@ -1,459 +0,0 @@
"""Per-action raw call/response log for Bioyond stations.
When a debug session is active, ``wrap_rpc_http`` replaces a ``BioyondV1RPC``
instance's ``post`` / ``get`` methods with closures that perform the HTTP
transport themselves, capture the request/response details, and append a record
to the active session before returning exactly what ``BaseRequest`` would have
returned. Outside of an active session the wrapped method delegates to the
original (unwrapped) implementation, leaving non-debug behavior intact.
The session writes a Markdown file under ``out_dir`` mirroring the format of
``bioyond_debug_records/2026-04-30_160316_day3_samplefile_only_raw_calls.md``
minus the "Raw Payload Argument" section.
This module has no dependency on ``BioyondV1RPC`` itself; the only contract is
that the wrapped instance descends from ``BaseRequest`` (i.e. has a logger
returned by ``self.get_logger()``).
"""
from __future__ import annotations
import contextvars
import copy
import inspect
import json
import re
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, List, Optional
import requests
__all__ = [
"CallRecord",
"CallLogContext",
"session",
"wrap_rpc_http",
"active_session",
]
_DEFAULT_TIMEOUT_GET = 30
_DEFAULT_TIMEOUT_POST = 120
@dataclass
class CallRecord:
"""One captured HTTP call inside a debug session."""
index: int
method: str
url: str
path: str
source: str
transport: str
http_status: Optional[int]
request_body: Any
response_body: Any
error: Optional[str] = None
@dataclass
class CallLogContext:
"""State for a single ``session()`` block.
A session lazily creates its file on the first appended record. Actions
that abort before any RPC produce no file.
"""
action: str
out_dir: Path
started_at: datetime
calls: List[CallRecord] = field(default_factory=list)
file_path: Optional[Path] = None
def append(self, record: CallRecord) -> None:
record.index = len(self.calls) + 1
self.calls.append(record)
self._write_file()
# -- file I/O -------------------------------------------------------------
def _resolve_file_path(self) -> Path:
if self.file_path is not None:
return self.file_path
timestamp = self.started_at.strftime("%Y-%m-%d_%H%M%S")
slug = _slugify_action(self.action)
candidate = self.out_dir / f"{timestamp}_{slug}_raw_calls.md"
suffix = 2
while candidate.exists():
candidate = (
self.out_dir
/ f"{timestamp}_{slug}_raw_calls_{suffix:02d}.md"
)
suffix += 1
self.file_path = candidate
return self.file_path
def _write_file(self) -> None:
path = self._resolve_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_render_markdown(self), encoding="utf-8")
_active_session: contextvars.ContextVar[Optional[CallLogContext]] = (
contextvars.ContextVar("_active_session", default=None)
)
def active_session() -> Optional[CallLogContext]:
"""Return the currently active :class:`CallLogContext`, if any."""
return _active_session.get()
@contextmanager
def session(action: str, out_dir: Path) -> Iterator[CallLogContext]:
"""Open a per-action debug session.
On entry, sets the module-level ``_active_session`` ContextVar so any
``wrap_rpc_http``'d clients on the same thread/task record their calls.
On exit, the previous active session (if any) is restored.
"""
ctx = CallLogContext(
action=str(action),
out_dir=Path(out_dir),
started_at=datetime.now(),
)
token = _active_session.set(ctx)
try:
yield ctx
finally:
_active_session.reset(token)
def wrap_rpc_http(rpc: Any) -> None:
"""Idempotently wrap ``rpc.post`` / ``rpc.get``.
When a session is active (``_active_session.get() is not None``), the
wrapped methods perform the HTTP call themselves with ``requests`` and
record the call before returning the same value ``BaseRequest`` would have
returned. When no session is active, the wrapped methods delegate to the
original implementation, preserving stock ``BaseRequest`` behavior.
Calling this twice on the same instance is a no-op. The wrapper does not
alter ``rpc.form_post`` (no Sirna action calls it as of plan 3).
"""
if rpc is None:
return
if getattr(rpc, "_debug_call_log_wrapped", False):
return
rpc._orig_post = rpc.post
rpc._orig_get = rpc.get
def _wrapped_post(
url: str,
params: Any = None,
files: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if files is not None:
kwargs["files"] = files
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_post(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="POST",
url=str(url),
path=_url_path(url),
source=source,
transport=_pick_transport(effective_params),
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.post(
url,
data=json.dumps(effective_params) if effective_params else None,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_POST,
files=files,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {response.request.body} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
else:
record.error = f"HTTP {response.status_code}: {response.text}"
try:
rpc.get_logger().error(
f"Request ERROR: ('Request ERROR:', {response.text!r})"
)
except Exception:
pass
return_value = None
ctx.append(record)
return return_value
def _wrapped_get(
url: str,
params: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_get(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="GET",
url=str(url),
path=_url_path(url),
source=source,
transport="params",
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.get(
url,
params=effective_params,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_GET,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {effective_params} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
ctx.append(record)
return return_value
rpc.post = _wrapped_post
rpc.get = _wrapped_get
rpc._debug_call_log_wrapped = True
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_URL_PATH_RE = re.compile(r"https?://[^/]+(/.*)?$")
_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _slugify_action(action: str) -> str:
slug = _SLUG_RE.sub("_", str(action)).strip("_")
return slug or "action"
def _url_path(url: Any) -> str:
text = str(url or "")
match = _URL_PATH_RE.match(text)
if match and match.group(1):
return match.group(1)
if text.startswith("/"):
return text
return text
def _pick_transport(params: Any) -> str:
if isinstance(params, dict) and "data" in params:
return "data"
return "params"
def _detect_source(rpc: Any) -> str:
"""Walk the call stack to find the outermost frame whose ``self`` is rpc."""
try:
stack = inspect.stack()
except Exception:
return ""
candidate = ""
try:
for frame_info in stack:
frame = frame_info.frame
if frame.f_locals.get("self", None) is rpc:
candidate = frame_info.function
return candidate
finally:
del stack
def _redact(params: Any) -> Any:
"""Return a copy of ``params`` with ``apiKey`` redacted."""
try:
cloned = copy.deepcopy(params)
except Exception:
return params
_redact_in_place(cloned)
return cloned
def _redact_in_place(value: Any) -> None:
if isinstance(value, dict):
for key in list(value.keys()):
if isinstance(key, str) and key.lower() == "apikey":
value[key] = "<redacted>"
else:
_redact_in_place(value[key])
elif isinstance(value, list):
for item in value:
_redact_in_place(item)
def _decode_response_body(response: Any) -> tuple[Any, Optional[str]]:
"""Best-effort response decoding used for both record + return value."""
text = getattr(response, "text", "")
try:
return response.json(), None
except Exception as exc:
if text:
return {"raw_text": text}, str(exc)
return None, str(exc)
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
def _render_markdown(ctx: CallLogContext) -> str:
title = f"# {ctx.action} Raw Call/Response Log"
parts: List[str] = [title, ""]
parts.append("## LIMS Calls")
parts.append("")
parts.append("| # | Method | Path | Source | HTTP |")
parts.append("|---|---|---|---|---|")
for record in ctx.calls:
anchor = _row_anchor(record)
http = (
f"`{record.http_status}`"
if record.http_status is not None
else "`-`"
)
parts.append(
f"| [{record.index}](#{anchor}) | `{record.method}` | "
f"`{record.path}` | `{record.source}` | {http} |"
)
parts.append("")
for record in ctx.calls:
parts.append(f"## {record.index} {record.method} {record.path}")
parts.append("")
parts.append(f"- Source: `{record.source}`")
parts.append(f"- Transport: `{record.transport}`")
if record.http_status is not None:
parts.append(f"- HTTP status: `{record.http_status}`")
else:
parts.append("- HTTP status: `-`")
if record.error:
parts.append(f"- Error: {record.error}")
parts.append("")
parts.append("### Request Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.request_body))
parts.append("```")
parts.append("")
parts.append("### Response Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.response_body))
parts.append("```")
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def _row_anchor(record: CallRecord) -> str:
"""Build a GitHub-style anchor matching ``## N METHOD /path``."""
raw = f"{record.index}-{record.method}-{record.path}"
raw = raw.lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
return raw.strip("-")
def _to_json_block(value: Any) -> str:
try:
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
except TypeError:
return json.dumps(str(value), ensure_ascii=False, indent=2)

View File

@@ -1,3 +0,0 @@
from .peptide_station import BioyondPeptideStation, fetch_workflow_list, load_peptide_config
__all__ = ["BioyondPeptideStation", "fetch_workflow_list", "load_peptide_config"]

View File

@@ -7,7 +7,6 @@ Bioyond Workstation Implementation
import time import time
import traceback import traceback
import threading import threading
from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
import json import json
@@ -15,7 +14,6 @@ from pathlib import Path
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.devices.workstation.bioyond_studio import debug_call_log
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.warehouse import WareHouse from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger from unilabos.utils.log import logger
@@ -56,17 +54,13 @@ class ConnectionMonitor:
def _monitor_loop(self): def _monitor_loop(self):
while self._running: while self._running:
try: try:
# 使用轻量级调度状态接口检查连接,避免启动时打印完整物料类型列表。 # 使用 lightweight API 检查连接
result = self.workstation.hardware_interface.scheduler_status() # query_matial_type_list 是比较快的查询
start_time = time.time()
result = self.workstation.hardware_interface.material_type_list()
status = "online" if result else "offline" status = "online" if result else "offline"
if status == "online": msg = "Connection established" if status == "online" else "Failed to get material type list"
msg = (
f"Scheduler status={result.get('status')}, "
f"hasTask={result.get('hasTask')}"
)
else:
msg = "Failed to get scheduler status"
if status != self._last_status: if status != self._last_status:
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}") logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
@@ -180,8 +174,6 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.warning("从Bioyond获取的物料数据为空") logger.warning("从Bioyond获取的物料数据为空")
return False return False
self._update_material_cache_from_stock(all_bioyond_data)
# 转换为UniLab格式 # 转换为UniLab格式
unilab_resources = resource_bioyond_to_plr( unilab_resources = resource_bioyond_to_plr(
all_bioyond_data, all_bioyond_data,
@@ -195,29 +187,6 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.error(f"从Bioyond同步物料数据失败: {e}") logger.error(f"从Bioyond同步物料数据失败: {e}")
return False return False
def _update_material_cache_from_stock(self, materials: List[Dict[str, Any]]) -> None:
"""用本次库存查询结果同步 RPC 的 name -> material id 缓存。"""
material_cache = getattr(self.bioyond_api_client, "material_cache", None)
if not isinstance(material_cache, dict):
return
before_count = len(material_cache)
for material in materials:
material_name = material.get("name")
material_id = material.get("id")
if material_name and material_id:
material_cache[material_name] = material_id
for detail_material in material.get("detail", []) or []:
detail_name = detail_material.get("name")
detail_id = detail_material.get("detailMaterialId") or detail_material.get("id")
if detail_name and detail_id:
material_cache[detail_name] = detail_id
logger.debug(
f"已用Bioyond库存同步物料缓存: {before_count} -> {len(material_cache)}"
)
def sync_to_external(self, resource: Any) -> bool: def sync_to_external(self, resource: Any) -> bool:
"""将本地物料数据变更同步到Bioyond系统""" """将本地物料数据变更同步到Bioyond系统"""
try: try:
@@ -709,70 +678,6 @@ class BioyondWorkstation(WorkstationBase):
集成Bioyond物料管理的工作站实现 集成Bioyond物料管理的工作站实现
""" """
# 子类(如 sirna / peptide覆写以指定默认 raw-call 日志目录。
# 路径相对仓库根;为 None 时若 debug_log=True 仍会写入临时位置。
_DEBUG_LOG_DEFAULT_DIR: Optional[str] = None
def _create_bioyond_rpc(self, config: Dict[str, Any]) -> BioyondV1RPC:
"""创建 Bioyond RPC 客户端并应用调试包装。
所有创建 ``BioyondV1RPC`` 的路径饿汉初始化、Sirna 延迟初始化、
以及未来的前端重新配置路径)都应通过该 helper
以确保 debug_log 包装与命名/日志策略保持一致。
"""
rpc = BioyondV1RPC(config)
debug_call_log.wrap_rpc_http(rpc)
return rpc
def _set_hardware_interface(self, rpc: BioyondV1RPC) -> BioyondV1RPC:
"""将已构造的 RPC 客户端设置到 ``self.hardware_interface``,并应用调试包装。"""
debug_call_log.wrap_rpc_http(rpc)
self.hardware_interface = rpc
return rpc
def _debug_log_resolved_dir(self) -> Path:
"""解析 ``debug_log_dir`` 为绝对路径。"""
configured = (getattr(self, "bioyond_config", {}) or {}).get("debug_log_dir")
default_dir = getattr(self, "_DEBUG_LOG_DEFAULT_DIR", None)
candidate = configured or default_dir or "bioyond_debug_records"
path = Path(candidate)
if not path.is_absolute():
repo_root = Path(__file__).resolve().parents[4]
path = repo_root / path
return path
def _ensure_debug_log_state(self) -> None:
"""从 ``self.bioyond_config`` 派生 ``_debug_log_enabled`` / ``_debug_log_dir``。
每次进入 ``_debug_call_session`` 时都重新解析,以兼容前端在运行时
修改 ``bioyond_config['debug_log']`` 或目录的场景;同时也容忍
子类(如 Sirna 延迟初始化)在 ``__init__`` 早期未触发本方法。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
self._debug_log_enabled = bool(cfg.get("debug_log"))
self._debug_log_dir = self._debug_log_resolved_dir()
@contextmanager
def _debug_call_session(self, action_name: str):
"""在 action 体外加一层 debug 会话上下文。
- ``debug_log`` 关闭时是空上下文,开销为 0。
- ``debug_log`` 开启时进入 :func:`debug_call_log.session`,所有
已被 ``wrap_rpc_http`` 包装过的 RPC 客户端都会捕获本次 action
产生的 HTTP 调用并写入 Markdown 文件。
子类(如 ``end_experiment``、``manual_unload`` 等)可以直接在
action 体里以 ``with self._debug_call_session("action_name"):`` 包裹。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
enabled = bool(cfg.get("debug_log"))
if not enabled:
yield None
return
out_dir = BioyondWorkstation._debug_log_resolved_dir(self)
with debug_call_log.session(action_name, out_dir) as ctx:
yield ctx
def _publish_task_status( def _publish_task_status(
self, self,
task_id: str, task_id: str,
@@ -957,7 +862,7 @@ class BioyondWorkstation(WorkstationBase):
self.bioyond_config = {} self.bioyond_config = {}
print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置") print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置")
self.hardware_interface = self._create_bioyond_rpc(self.bioyond_config) self.hardware_interface = BioyondV1RPC(self.bioyond_config)
def resource_tree_add(self, resources: List[ResourcePLR]) -> None: def resource_tree_add(self, resources: List[ResourcePLR]) -> None:
"""添加资源到资源树并更新ROS节点 """添加资源到资源树并更新ROS节点
@@ -1433,7 +1338,11 @@ class BioyondWorkstation(WorkstationBase):
if self.hardware_interface: if self.hardware_interface:
self.hardware_interface.scheduler_reset() self.hardware_interface.scheduler_reset()
# 重新同步资源,并用同一次库存查询结果更新物料缓存 # 新物料缓存
if self.hardware_interface:
self.hardware_interface.refresh_material_cache()
# 重新同步资源
if self.resource_synchronizer: if self.resource_synchronizer:
self.resource_synchronizer.sync_from_external() self.resource_synchronizer.sync_from_external()

View File

@@ -1,9 +0,0 @@
try:
from . import peptide_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize
except Exception: # pragma: no cover - 允许轻量环境导入非资源辅助函数
peptide_materials = None # type: ignore[assignment]
try:
from . import sirna_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize
except Exception: # pragma: no cover - 允许轻量环境导入非资源辅助函数
sirna_materials = None # type: ignore[assignment]

View File

@@ -1,8 +1,6 @@
from os import name from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.registry.decorators import resource
from unilabos.resources.bioyond.YB_warehouses import ( from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x4,
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08) bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08)
@@ -25,11 +23,6 @@ from unilabos.resources.bioyond.YB_warehouses import (
from unilabos.resources.bioyond.warehouses import ( from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左) bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左)
bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右) bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右)
bioyond_warehouse_sirna_automation_stack,
bioyond_warehouse_sirna_centrifuge_balance_plate_stack,
bioyond_warehouse_sirna_g3_liquid_handler,
bioyond_warehouse_numeric_stack, # 新增:数字编码堆栈 (用于多肽站)
bioyond_warehouse_live_grid,
) )
@@ -108,83 +101,6 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_SirnaStation_Deck",
category=["deck"],
description="BIOYOND 小核酸工作站 Deck",
icon="配液站.webp",
)
class BIOYOND_SirnaStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = {
"G3移液站": "xy_col_row",
"自动化堆栈": "xy_col_row",
"离心机配平板堆栈": "xy_col_row",
}
WAREHOUSE_BIOYOND_KEY_AXIS = {
"G3移液站": "col_row",
"自动化堆栈": "col_row",
"离心机配平板堆栈": "col_row",
}
# Bioyond warehouse UUID -> 本地仓库名称 映射。
# 留空时由配置station config 的 ``warehouse_bioyond_ids``)注入。
# graph 节点也可在 deck.config.warehouse_bioyond_ids 覆盖。
WAREHOUSE_BIOYOND_IDS: dict = {}
def __init__(
self,
name: str = "SirnaStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False,
warehouse_bioyond_ids: dict | None = None,
**kwargs,
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
# 按需写入实例级覆盖;保留默认空 mapping避免改动模型常量。
self.warehouse_bioyond_ids: dict = dict(self.WAREHOUSE_BIOYOND_IDS)
if warehouse_bioyond_ids:
self.warehouse_bioyond_ids.update(warehouse_bioyond_ids)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
data = data.copy()
data["setup"] = False
result = super().deserialize(data, allow_marshal=allow_marshal)
result._ensure_sirna_warehouse_metadata()
return result
def _ensure_sirna_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def setup(self) -> None:
# Sirna 读接口 /api/storage/location/locations-by-type 返回完整固定堆栈清单。
# LIMS 在库物料接口仍使用相同的 自动化堆栈 名称和数字库位编码。
self.warehouses = {
"G3移液站": bioyond_warehouse_sirna_g3_liquid_handler(),
"自动化堆栈": bioyond_warehouse_sirna_automation_stack(),
"离心机配平板堆栈": bioyond_warehouse_sirna_centrifuge_balance_plate_stack(),
}
self.warehouse_locations = {
"G3移液站": Coordinate(0.0, 0.0, 0.0),
"自动化堆栈": Coordinate(220.0, 0.0, 0.0),
"离心机配平板堆栈": Coordinate(1740.0, 0.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_YB_Deck(Deck): class BIOYOND_YB_Deck(Deck):
def __init__( def __init__(
self, self,
@@ -234,207 +150,12 @@ class BIOYOND_YB_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_PeptideStation_Deck",
category=["deck"],
description="BIOYOND 多肽工作站 Deck",
icon="preparation_station.webp",
)
class BIOYOND_PeptideStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = dict.fromkeys(
[
"自动化堆栈",
"低温冰箱仓库",
"Tecan移液站库",
"G3移液站库",
"IDOT移液站库",
"G3缓冲库",
"盖板缓冲库",
"配平板缓冲库",
"IDOT缓冲库",
"固相合成板底座缓冲位",
"离心机库位",
"热封膜机位",
],
"xy_col_row",
)
WAREHOUSE_BIOYOND_KEY_AXIS = dict.fromkeys(WAREHOUSE_BIOYOND_AXIS, "col_row")
def __init__(
self,
name: str = "PeptideStation_Deck",
size_x: float = 2700.0,
size_y: float = 2000.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
data = data.copy()
data["setup"] = False
# 已有序列化子资源,跳过 setup 避免重复创建
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
else:
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
result._ensure_peptide_warehouse_metadata()
return result
def _ensure_peptide_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def _frontend_y_flipped_coordinate(self, display_x: float, display_y: float, child) -> Coordinate:
"""把期望显示坐标转换为兼容前端 y 轴翻转的存储坐标。"""
return Coordinate(display_x, self.get_size_y() - display_y - child.get_size_y(), 0.0)
def setup(self) -> None:
# 多肽工作站仓库配置
# 基于 2026-05-09 live API probe 发现的实际仓库拓扑 (12个仓库)
# 数据来源: Bioyond 现场仓库发现结果。
self.warehouses = {
# 主自动化堆栈 - live API: code 10-17 -> x=17, y=10显示为 17 行×10 列
"自动化堆栈": bioyond_warehouse_numeric_stack(
"自动化堆栈",
rows=17,
columns=10,
bioyond_axis="xy_col_row",
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
# 低温存储
"低温冰箱仓库": bioyond_warehouse_live_grid(
"低温冰箱仓库",
rows=3,
columns=2,
slot_keys=["1", "2", "3", "4", "5", "6"],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
# 移液站库位
"Tecan移液站库": bioyond_warehouse_live_grid(
"Tecan移液站库",
rows=18,
columns=1,
slot_keys=[str(index) for index in range(1, 19)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"G3移液站库": bioyond_warehouse_live_grid(
"G3移液站库",
rows=18,
columns=1,
slot_keys=["1", "2", "3", "4", "垃圾桶", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"IDOT移液站库": bioyond_warehouse_live_grid(
"IDOT移液站库",
rows=12,
columns=1,
slot_keys=[f"0009-{index:04d}" for index in range(1, 13)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
# 缓冲库位
"G3缓冲库": bioyond_warehouse_live_grid(
"G3缓冲库",
rows=5,
columns=1,
slot_keys=[str(index) for index in range(1, 6)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"盖板缓冲库": bioyond_warehouse_live_grid(
"盖板缓冲库",
rows=7,
columns=1,
slot_keys=[str(index) for index in range(1, 8)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"配平板缓冲库": bioyond_warehouse_live_grid(
"配平板缓冲库",
rows=3,
columns=1,
slot_keys=[str(index) for index in range(1, 4)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"IDOT缓冲库": bioyond_warehouse_live_grid(
"IDOT缓冲库",
rows=2,
columns=1,
slot_keys=["1", "1"],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"固相合成板底座缓冲位": bioyond_warehouse_live_grid(
"固相合成板底座缓冲位",
rows=4,
columns=1,
slot_keys=[f"0015-{index:04d}" for index in range(1, 5)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
# 设备库位
"离心机库位": bioyond_warehouse_live_grid(
"离心机库位",
rows=4,
columns=1,
slot_keys=[f"0017-{index:04d}" for index in range(1, 5)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
"热封膜机位": bioyond_warehouse_live_grid(
"热封膜机位",
rows=2,
columns=1,
slot_keys=[f"0016-{index:04d}" for index in range(1, 3)],
bioyond_key_axis="col_row",
frontend_y_flip=True,
),
}
# 仓库显示布局:紧凑排列;存储 y 坐标按前端兼容翻转预先反向。
display_layout = {
"自动化堆栈": (0.0, 0.0),
"Tecan移液站库": (1520.0, 0.0),
"G3移液站库": (1710.0, 0.0),
"IDOT移液站库": (1900.0, 0.0),
"G3缓冲库": (2090.0, 0.0),
"盖板缓冲库": (2090.0, 580.0),
"低温冰箱仓库": (2280.0, 0.0),
"配平板缓冲库": (2280.0, 370.0),
"IDOT缓冲库": (2470.0, 370.0),
"固相合成板底座缓冲位": (2280.0, 740.0),
"离心机库位": (2470.0, 740.0),
"热封膜机位": (2280.0, 1210.0),
}
self.warehouse_locations = {
name: self._frontend_y_flipped_coordinate(x, y, self.warehouses[name])
for name, (x, y) in display_layout.items()
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck: def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name) by=BIOYOND_YB_Deck(name=name)
by.setup() by.setup()
return by return by

View File

@@ -1,247 +0,0 @@
"""Peptide Station Material Resource Definitions."""
from __future__ import annotations
from collections import OrderedDict
try:
from pylabrobot.resources import Container, Plate, TipRack
except Exception: # pragma: no cover - 允许无 pylabrobot 的轻量动作测试导入
class _FallbackResource:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
class Container(_FallbackResource): # type: ignore[no-redef]
pass
class Plate(_FallbackResource): # type: ignore[no-redef]
pass
class TipRack(_FallbackResource): # type: ignore[no-redef]
pass
try:
from unilabos.registry.decorators import resource
except Exception: # pragma: no cover - 允许无完整 registry 依赖时导入常量
def resource(*args, **kwargs):
def decorator(cls):
return cls
return decorator
def _ensure_itemized_ordering(kwargs: dict) -> None:
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
class _PeptideTipRack(TipRack):
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("with_tips", True)
_ensure_itemized_ordering(kwargs)
super().__init__(*args, **kwargs)
class _PeptidePlate(Plate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("plate_type", "skirted")
_ensure_itemized_ordering(kwargs)
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_1000ul_tip_rack",
category=["labware", "tip_rack"],
description="1000uL tip rack for Bioyond peptide station",
)
class BioyondPeptide_1000ul_TipRack(_PeptideTipRack):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_1000ul_tip_rack")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_200ul_tip_rack",
category=["labware", "tip_rack"],
description="200uL tip rack for Bioyond peptide station",
)
class BioyondPeptide_200ul_TipRack(_PeptideTipRack):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_200ul_tip_rack")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_50ul_tip_rack",
category=["labware", "tip_rack"],
description="50uL tip rack for Bioyond peptide station",
)
class BioyondPeptide_50ul_TipRack(_PeptideTipRack):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_50ul_tip_rack")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_96_well_deep_well_plate",
category=["labware", "plate"],
description="96 well deep well plate for Bioyond peptide station",
)
class BioyondPeptide_96WellDeepWellPlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_96_well_deep_well_plate")
kwargs.setdefault("size_z", 44.0)
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_96_well_synthesis_plate",
category=["labware", "plate"],
description="96 well solid-phase synthesis plate for Bioyond peptide station",
)
class BioyondPeptide_96WellSynthesisPlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_96_well_synthesis_plate")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_96_well_synthesis_plate_base",
category=["labware", "adapter"],
description="96 well solid-phase synthesis plate base for Bioyond peptide station",
)
class BioyondPeptide_96WellSynthesisPlateBase(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_96_well_synthesis_plate_base")
kwargs.setdefault("size_z", 20.0)
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_96_well_balance_plate",
category=["labware", "plate"],
description="96 well balance plate for Bioyond peptide station",
)
class BioyondPeptide_96WellBalancePlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_96_well_balance_plate")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_384_well_plate",
category=["labware", "plate"],
description="384 well plate for Bioyond peptide station",
)
class BioyondPeptide_384WellPlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_384_well_plate")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_384_lcms_plate",
category=["labware", "plate"],
description="384 well LCMS plate for Bioyond peptide station",
)
class BioyondPeptide_384LCMSPlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_384_lcms_plate")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_384_balance_plate",
category=["labware", "plate"],
description="384 well balance plate for Bioyond peptide station",
)
class BioyondPeptide_384BalancePlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_384_balance_plate")
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_cover_plate",
category=["labware", "cover"],
description="Cover plate for Bioyond peptide station",
)
class BioyondPeptide_CoverPlate(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_cover_plate")
kwargs.setdefault("size_z", 8.0)
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_sealing_base",
category=["labware", "adapter"],
description="Sealing base for Bioyond peptide station",
)
class BioyondPeptide_SealingBase(_PeptidePlate):
def __init__(self, *args, **kwargs):
kwargs.setdefault("model", "bioyond_peptide_sealing_base")
kwargs.setdefault("size_z", 20.0)
super().__init__(*args, **kwargs)
@resource(
id="bioyond_peptide_reagent_trough",
category=["labware", "trough"],
description="Reagent trough for Bioyond peptide station",
)
class BioyondPeptide_ReagentTrough(Container):
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 44.0)
kwargs.setdefault("max_volume", 300000.0)
kwargs.setdefault("model", "bioyond_peptide_reagent_trough")
super().__init__(*args, **kwargs)
DEFAULT_PEPTIDE_MATERIAL_TYPE_MAPPINGS = {
"bioyond_peptide_1000ul_tip_rack": ["1000μL枪头盒", "3a1890bb-736e-cfdd-3213-eb314e8a60f9"],
"bioyond_peptide_200ul_tip_rack": ["200μL枪头盒", "3a1890bb-36d1-964a-18bd-0bf0f2877a7b"],
"bioyond_peptide_50ul_tip_rack": ["50μL枪头盒", "3a1890bc-5fae-361c-cc09-e6f2f6dcd71d"],
"bioyond_peptide_96_well_deep_well_plate": ["96孔深孔板", "3a1890bc-1fa8-fe39-9faa-12279ed4569b"],
"bioyond_peptide_96_well_synthesis_plate": ["96孔固相合成板", "3a1871cb-99f3-f01d-23e2-08dbbd0045b5"],
"bioyond_peptide_96_well_synthesis_plate_base": ["96孔固相合成板底座", "3a1b997e-241b-64f0-80d1-47bca08799d1"],
"bioyond_peptide_96_well_balance_plate": ["96孔配平板", "3a187661-2378-1e20-fa5c-a27d49fdc15d"],
"bioyond_peptide_384_well_plate": ["384孔酶标板", "3a1890bf-2148-ed20-92bd-d85869947d9a"],
"bioyond_peptide_384_lcms_plate": ["384孔LCMS板", "3a1e6a8b-cb61-74da-a089-8e6f197f80f0"],
"bioyond_peptide_384_balance_plate": ["384孔配平板", "3a18be7e-47cc-888c-fc68-055753286826"],
"bioyond_peptide_cover_plate": ["防挥发盖板", "3a19d5a6-b0e2-b486-e5eb-bcabc632f4de"],
"bioyond_peptide_sealing_base": ["封膜底座", "3a1d1d7b-e33b-6975-165d-c56cba5ed345"],
"bioyond_peptide_reagent_trough": ["12道试剂槽", "3a18b431-ac58-ca2e-9680-2a4f5880ea45"],
}
MATERIAL_TYPE_CODE_TO_CLASS = {
"0001": BioyondPeptide_96WellSynthesisPlate,
"0002": BioyondPeptide_96WellBalancePlate,
"0008": BioyondPeptide_200ul_TipRack,
"0009": BioyondPeptide_1000ul_TipRack,
"0011": BioyondPeptide_96WellDeepWellPlate,
"0012": BioyondPeptide_50ul_TipRack,
"0016": BioyondPeptide_384WellPlate,
"0018": BioyondPeptide_384WellPlate,
"0024": BioyondPeptide_ReagentTrough,
"0026": BioyondPeptide_384BalancePlate,
"0035": BioyondPeptide_CoverPlate,
"0039": BioyondPeptide_96WellSynthesisPlateBase,
"0041": BioyondPeptide_SealingBase,
"0049": BioyondPeptide_384LCMSPlate,
}
def get_material_class_by_type_code(type_code: str):
"""Return a peptide material class by Bioyond material type code."""
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)

View File

@@ -1,192 +1,5 @@
from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
from unilabos.resources.warehouse import WareHouse, warehouse_factory from unilabos.resources.warehouse import WareHouse, warehouse_factory
class BioyondWareHouse(WareHouse):
"""Bioyond 仓库,额外保存服务端 x/y 坐标和库位标签语义。"""
def __init__(self, *args, bioyond_axis: str = "xy_row_col", bioyond_key_axis: str = "row_col", **kwargs):
super().__init__(*args, **kwargs)
self.bioyond_axis = bioyond_axis
self.bioyond_key_axis = bioyond_key_axis
def serialize(self) -> dict:
data = super().serialize()
data["bioyond_axis"] = self.bioyond_axis
data["bioyond_key_axis"] = self.bioyond_key_axis
return data
def bioyond_warehouse_numeric_stack(
name: str,
rows: int = 10,
columns: int = 17,
bioyond_axis: str = "xy_row_col",
bioyond_key_axis: str = "row_col",
frontend_y_flip: bool = False,
) -> WareHouse:
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。
bioyond_axis: 仓库级别的 Bioyond 坐标轴约定,供 graphio 的坐标映射使用。
- "xy_row_col" (default): Bioyond x→row, y→col (reaction/peptide 历史约定).
- "xy_col_row": Bioyond x→col, y→row (Sirna live API 实测约定).
bioyond_key_axis: 库位标签生成约定。
- "row_col" (default): 视觉行列和标签行列一致,例如 10 行 x 17 列 → 1-1..10-17。
- "col_row": 视觉行列转置,但标签仍保持 Bioyond row-col例如
17 行 x 10 列 → 1-1..10-17。
未设置时 graphio 回退到默认 "xy_row_col",其他调用方保持原行为。
"""
num_items_x = columns
num_items_y = rows
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
resource_size_x = 127.0
resource_size_y = 86.0
resource_size_z = 25.0
size_y = dy + item_dy * num_items_y
locations = []
for row in range(num_items_y):
display_y = dy + row * item_dy
y = size_y - display_y - resource_size_y if frontend_y_flip else display_y
for col in range(num_items_x):
locations.append(Coordinate(dx + col * item_dx, y, dz))
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=resource_size_x,
resource_size_y=resource_size_y,
resource_size_z=resource_size_z,
name_prefix=name,
)
if bioyond_key_axis == "row_col":
keys = [
f"{row + 1}-{col + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
elif bioyond_key_axis == "col_row":
keys = [
f"{col + 1}-{row + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
else:
raise ValueError(f"未知 Bioyond 库位标签约定: {bioyond_key_axis!r}")
warehouse = BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=size_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
return warehouse
def bioyond_warehouse_live_grid(
name: str,
rows: int,
columns: int,
slot_keys: list[str] | None = None,
bioyond_axis: str = "xy_col_row",
bioyond_key_axis: str = "row_col",
frontend_y_flip: bool = False,
) -> WareHouse:
"""创建 Bioyond 实测库位网格,按服务端 code 保存位点标签。
默认用于 Peptide live API 返回的坐标x 是视觉列y 是视觉行。
当服务端 code 重复时,为保持 PLR ordering 唯一性,会给后续重复项追加 ``#N``。
"""
num_items_x = columns
num_items_y = rows
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
resource_size_x = 127.0
resource_size_y = 86.0
resource_size_z = 25.0
size_y = dy + item_dy * num_items_y
locations = []
for row in range(num_items_y):
display_y = dy + row * item_dy
y = size_y - display_y - resource_size_y if frontend_y_flip else display_y
for col in range(num_items_x):
locations.append(Coordinate(dx + col * item_dx, y, dz))
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=resource_size_x,
resource_size_y=resource_size_y,
resource_size_z=resource_size_z,
name_prefix=name,
)
keys = slot_keys or [str(index + 1) for index in range(num_items_x * num_items_y)]
if len(keys) != len(holders):
raise ValueError(f"{name} 库位数量不匹配: keys={len(keys)}, holders={len(holders)}")
seen: dict[str, int] = {}
unique_keys: list[str] = []
for key in keys:
count = seen.get(key, 0) + 1
seen[key] = count
unique_keys.append(key if count == 1 else f"{key}#{count}")
return BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=size_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(unique_keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
# ================ 小核酸工作站相关堆栈 ================
def bioyond_warehouse_sirna_g3_liquid_handler(name: str = "G3移液站") -> WareHouse:
"""创建小核酸 G3 移液站库位堆栈:显示为 14 行 x 1 列,标签保持 1-1..1-14。"""
return bioyond_warehouse_numeric_stack(
name, rows=14, columns=1, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_automation_stack(name: str = "自动化堆栈") -> WareHouse:
"""创建小核酸自动化堆栈:显示为 17 行 x 10 列,标签保持 1-1..10-17。"""
return bioyond_warehouse_numeric_stack(
name, rows=17, columns=10, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_centrifuge_balance_plate_stack(name: str = "离心机配平板堆栈") -> WareHouse:
"""创建小核酸离心机配平板堆栈:显示为 1 行 x 2 列,标签保持 1-1、2-1。"""
return bioyond_warehouse_numeric_stack(
name, rows=1, columns=2, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
# ================ 反应站相关堆栈 ================ # ================ 反应站相关堆栈 ================
def bioyond_warehouse_1x4x4(name: str) -> WareHouse: def bioyond_warehouse_1x4x4(name: str) -> WareHouse:

View File

@@ -736,7 +736,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
continue continue
plr_material.code = material.get("barCode") or material.get("code") or "" plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
plr_material.unilabos_uuid = str(uuid.uuid4()) plr_material.unilabos_uuid = str(uuid.uuid4())
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询 # ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询
@@ -864,22 +864,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name] warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射: # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# - 历史 row_col 仓库中 x/y 直接按行/列参与索引。 x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
# - Sirna 的库位标签为 col-rowstock-material 返回 x=标签第二段、y=标签第一段。 y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
# 因此 x=13,y=4 应落到 key=4-13而不是交换后落到 3-5。
x = loc.get("x", 1)
y = loc.get("y", 1)
z = loc.get("z", 1) # 层号 (1-based, 通常为1) z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 仓库级别的轴约定覆盖。
# 对旧的 row-col 视觉标签bioyond_axis="xy_col_row" 需要交换 x/y。
# 对 Sirna 的 col-row 库位标签,原始 x/y 已能直接索引到 code 对应位置,不再交换。
bioyond_axis = getattr(warehouse, "bioyond_axis", "xy_row_col")
bioyond_key_axis = getattr(warehouse, "bioyond_key_axis", "row_col")
if bioyond_axis == "xy_col_row" and bioyond_key_axis != "col_row":
x, y = y, x
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右": if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4 y = y - 4 # 将5-8映射到1-4
@@ -923,43 +912,10 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
if 0 <= idx < warehouse.capacity: if 0 <= idx < warehouse.capacity:
slot_key = None if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
ordering = getattr(warehouse, "_ordering", {})
sites = getattr(warehouse, "sites", [])
if isinstance(ordering, dict) and idx < len(sites):
site_at_idx = sites[idx]
slot_key = next(
(key for key, site in ordering.items() if site is site_at_idx),
None,
)
current_resource = warehouse[idx]
if current_resource is None or isinstance(current_resource, (ResourceHolder, str)):
if isinstance(current_resource, str):
logger.warning(
f"⚠️ 物料 {unique_name} 覆盖 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} 的旧占位 occupied_by={current_resource!r}"
)
# 物料尺寸已在放入warehouse前根据需要进行了交换 # 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material warehouse[idx] = plr_material
logger.debug( logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} "
f"(Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})"
)
else:
parent = getattr(current_resource, "parent", None)
current_repr = repr(current_resource)
current_len = len(current_resource) if isinstance(current_resource, str) else None
logger.warning(
f"⚠️ 物料 {unique_name} 跳过放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''}:目标库位已有 "
f"{type(current_resource).__name__}"
f"(value={current_repr}, len={current_len})"
f"(name={getattr(current_resource, 'name', None)}, "
f"parent={getattr(parent, 'name', None)}, "
f"uuid={getattr(current_resource, 'unilabos_uuid', None)})"
)
else: else:
logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
else: else:

View File

@@ -18,7 +18,3 @@ def register():
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
# noinspection PyUnresolvedReferences
from unilabos.resources.bioyond.decks import BIOYOND_SirnaStation_Deck
# noinspection PyUnresolvedReferences
from unilabos.resources.bioyond.decks import BIOYOND_PeptideStation_Deck

View File

@@ -47,7 +47,10 @@ def _has_uv() -> bool:
def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]: def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]:
if installer == "uv": if installer == "uv":
cmd = ["uv", "pip", "install"] # uv >= 0.5 默认要求虚拟环境,对 conda env 会报 "No virtual environment found"。
# 显式 --python sys.executable 让 uv 把当前解释器conda/venv/system 都行)
# 视为目标环境,绕开 venv 检测。
cmd = ["uv", "pip", "install", "--python", sys.executable]
if upgrade: if upgrade:
cmd.append("--upgrade") cmd.append("--upgrade")
cmd.append(package) cmd.append(package)
@@ -89,7 +92,11 @@ def _print_manual_git_install_hint(requirement: str) -> None:
return return
repo_dir = _repo_dir_name(git_url) repo_dir = _repo_dir_name(git_url)
install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ." install_cmd = (
f'uv pip install --python "{sys.executable}" -e .'
if _has_uv()
else f"{sys.executable} -m pip install -e ."
)
if _is_chinese_locale() and not _has_uv(): if _is_chinese_locale() and not _has_uv():
install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3"> <package format="3">
<name>unilabos_msgs</name> <name>unilabos_msgs</name>
<version>0.11.1</version> <version>0.11.2</version>
<description>ROS2 Messages package for unilabos devices</description> <description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer> <maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer> <maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>