更新transfer部分

框选set liquid
跨slot的工作流合并
工作流名称,tag修勾
液体名称使用真实液体名称
This commit is contained in:
q434343
2026-05-25 16:03:55 +08:00
parent ad05e8c73e
commit 5be601177e
24 changed files with 6167 additions and 224 deletions

View File

@@ -23,8 +23,11 @@ unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
# Workflow upload subcommand
# Workflow upload subcommandP6.1 新增 --target_deviceP6.1.1 新增 --target_model
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
unilab workflow_upload -f <workflow.json> --target_device prcxi # P6.1 默认;同上 P6 行为
unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320 # P6.1.1:型号粒度
unilab workflow_upload -f <workflow.json> --target_device beckman # 未来支持,需在 YAML 中声明 target_devices.beckman
# Tests
pytest tests/ # all tests
@@ -72,6 +75,86 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
### Labware Mapping Table (`labware_mapping.yaml`) — P6 + P6.1 + P6.1.1
Opentrons → 目标仪器PRCXI / Beckman / Tecan ...)的「槽位重映射 + labware 归类 +
class_name 选择」全部外化到项目根的
[`labware_mapping.yaml`](./labware_mapping.yaml)(与 `pyproject.toml` 同级,最显眼的位置)。
要新增 SKU、新厂商、新型号、或调整 tip 量程档时,**只改 YAML不改 Python**。
- **YAML 两段顶层语义**P6.1.1 起 `slot_remap` 已下沉到 `target_devices` 内):
- `kinds` — 顺序敏感的 regex把 labware 字符串归到 `trash / tip_rack / tube_rack / plate`。**全局段**,与目标仪器无关。
- `target_devices.<name>` — 按目标仪器组织的规则段,内含三个字段:
- `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13``8 → 14``12+trash → 16`)。
- `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max``class_name`」规则,首个命中胜出。
- `models.<model_name>` — 可选的型号粒度覆盖slot_remap / rules缺失字段自动继承厂商级。
- **`target_devices` 内段名约定**
- `default`**固定段名**,兜底物料集 + 兜底 `slot_remap`。caller 传入的 `target_device``target_devices`
下未声明时,自动 fallback 到此段loader 单次 warning下游消费方零感知)。
**第一版按 prcxi 内容拷贝填充**(值仍是 `PRCXI_*`),但与 prcxi 段在 YAML 中
各自独立,可独立演进。**`default` 不支持 `models` 子段**——型号粒度差异必须落到具体仪器段。
- `prcxi` / `beckman` / `tecan` / ... — 具体仪器段厂商粒度caller 显式
`--target_device <name>` 时命中。可在 `models.<model>` 下声明同厂商不同型号的差异。
- **4 段 fallback 链**`slot_remap` / `rules` 共用):
1. `target_devices.<device>.models.<model>.<field>`caller 同时传 device + model
2. `target_devices.<device>.<field>`(厂商级;步骤 1 缺字段时静默 fallback
3. `target_devices.default.<field>`caller 传未声明 device或步骤 2 缺字段;打 warning
4. `_BUILTIN_DEFAULT.target_devices.default.<field>`YAML 误删 default 段时的最后兜底)
- **CLI 用法**
- P6.1`unilab workflow_upload -f <workflow.json> --target_device prcxi`
`--target_device` snake-case默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。
- P6.1.1:可加 `--target_model <name>`snake可省略默认 `None`)。
例:`unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320`
- **入口代码**`unilabos/workflow/labware_mapping.py` 暴露 `remap_slot` / `infer_kind` /
`resolve_target_class` / `reload_mapping`
API 签名P6.1.1
- `remap_slot(raw_slot, object_type="", *, target_device="prcxi", target_model=None)`
- `resolve_target_class(target_device, kind, hole_count=None, volume=None, *, target_model=None)`
`workflow/common.py``_map_deck_slot` / `_infer_reagent_kind` /
`_apply_tip_rack_class_from_transfer_volumes` / `_apply_target_labware_class_auto_match` /
`_reconcile_slot_carrier_target_class` 都已转调 YAML 并透传 `target_device` / `target_model`
YAML 未命中(孔数 / 体积超出 default 段覆盖范围)时 fallback 到
`prcxi_labware.get_prcxi_labware_template_specs` 的模板打分匹配,并打 warning 提示「请补到映射表」。
- **`labware_info` 字段重命名**P6 的 `prcxi_class_name` → P6.1 的 `target_class_name`
13 处全部同步刷新;旧 schema顶层 `vendors` / `slot_remap` 或任一 rule 内 `prcxi_class`
会触发 loader warning 并整段 fallback 到 builtin 默认表。
- **测试**
- `pytest tests/workflow/test_labware_mapping.py` —— 45 项单元测试(含 P6.1 + P6.1.1 用例:
`test_remap_slot_model_level_overrides_device_level`
`test_remap_slot_model_inherits_device_when_field_missing`
`test_legacy_top_level_slot_remap_rejected`
`test_default_section_models_subsection_warns` 等)。
- `pytest tests/workflow/test_build_protocol_graph_target_device.py` —— 6 项集成
测试(默认 / 显式 prcxi / unknown 段 fallback / per-device tip class / 字段重命名 /
P6.1.1 model-level slot_remap
- **设计文档**[`product_designs/protocol_convert/06-labware-mapping-table.md`](../product_designs/protocol_convert/06-labware-mapping-table.md)
§11.7 = P6.1 多目标仪器选择§11.8 = P6.1.1 槽位映射按厂商+型号分叉)。
### P2 跨 slot transfer_liquid 合并v2已落地
当一次 phase 中存在「单源吸取 → 跨多个 plate 分发」(典型 `steps/51b9a5.json` 9 plate × 12 well = 108 条 1:1 dispenseStage 2 + Stage 3 现在能把它折叠成 **1 个 merged set_liquid_from_plate + 1 个 transfer_liquid** 节点。
- **Stage 2**[`Protocols/protocol_converter/change_to_transfer_group.py`](../Protocols/protocol_converter/change_to_transfer_group.py)
- `_pair_mergeable` 只要求源 slot / tip 量程档 / use_channels 一致;不再要求 `_target_slot` 相同。
- `_merge_two_transfer_actions` 维护 `_target_slots: list[int]`(与 `_target_wells` 平行,每次 dispense 一条)。
- `export_transfer_actions` 通过 `_register_target_reagent_key` 统一注册 reagent_key跨 slot 时按 `_target_slots` 顺序拼出 `action_args.targets: list[str]`(同板退化为 `str`)。
- 末尾 `pop` 全部 `_` 前缀字段(包括新增的 `_target_slots`)。
- **Stage 3**[`Uni-Lab-OS/unilabos/workflow/common.py`](unilabos/workflow/common.py)
- 新增 `_emit_merged_set_liquid(...)`:对 `params.targets: list[str]` 的 transfer_liquid 节点,在其上游插入一个 **merged `set_liquid_from_plate`** 跨板聚合器;其 `param.wells` 是按 dispense 顺序通过 cursor 走 `reagent[key].well` 得出的有序跨板 well refs多入边每 plate 一条 `create_resource.labware → wells_identifier`),单出边(`output_wells → transfer_liquid.targets_identifier`)。
-`params["targets"]` 改写为 synthetic str `_merged_targets_<idx>` 并注册 `resource_last_writer`,保证 INPUT_PORT_MAPPING 走 P3 既有的单边路径。
- `OUTPUT_PORT_MAPPING` 在原始 `step.param.targets``list[str]` 时为每个 reagent_key 分别注册 transfer_liquid 的下游 writer。
- **PRCXI runtime**[`prcxi/prcxi.py`](unilabos/devices/liquid_handling/prcxi/prcxi.py)`change_slots` 改为遍历所有 source / target 的 parent plate 并按 plate name 去重(跨板 4 个 plate 都能 `update_pipetting_position`)。
- **`liquid_handler_abstract.transfer_liquid`****完全不改动**,主循环 `i % num_targets` 与单边 + 单 list 完全兼容。
CLI 行为不变:现有 `unilab workflow_upload -f <workflow.json> ...` 一切照旧;跨 slot 协议自动走 v2 路径。
测试:
- `pytest Protocols/protocol_converter/tests/test_cross_slot_merge.py` — Stage 2 单测 10 项。
- `pytest tests/workflow/test_common_cross_slot_v2.py` — Stage 3 集成测试 6 项。
- `pytest tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py` — device 跨板单测 6 项pylabrobot 不全时优雅 skip
设计文档:[`product_designs/protocol_convert/02-cross-slot-merge.md`](../product_designs/protocol_convert/02-cross-slot-merge.md)§9 v2 设计 + §11 落地记录)。
## Code Conventions
- Code comments and log messages in simplified Chinese

140
labware_mapping.yaml Normal file
View File

@@ -0,0 +1,140 @@
# Opentrons → 目标仪器 物料映射表P6.1.1
#
# 两段顶层 keyP6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**
# target_devices : 按目标仪器 + 型号组织rule = kind + hole_count + volume_min/max → class_name
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
#
# target_devices 段内结构:
# target_devices.<device>: # 厂商段(必填)
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
# <model_name>: # 型号子段
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
#
# 段名约定:
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
# target_devices.<name> : 具体仪器段prcxi / beckman / tecan ...)。
#
# 解析链remap_slot / resolve_target_class 共用,字段级 fallback
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
# 2. target_devices.<device>.<field> (caller 传 device或步骤 1 缺字段)
# 3. target_devices.default.<field> (caller 传未声明 device或步骤 2 缺字段)
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
#
# 编辑建议:
# 1. 顺序敏感kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
# 2. volume_min / volume_max 是闭区间µL。任一字段可省略都省略 = 不限制体积。
# 3. notes 仅作注释,不参与匹配。
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
# 没声明的字段自动继承厂商级。
# 6. P6.1.1 不再支持顶层 slot_remap检出顶层 slot_remap → warning + fallback 到 builtin。
#
# 设计文档product_designs/protocol_convert/06-labware-mapping-table.md§11.8
kinds:
# 顺序敏感的 regex第一个命中胜出
# 注意trash 必须在 tip_rack 之前tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack"
- { pattern: "trash", kind: trash }
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack与历史 _infer_reagent_kind 行为一致)
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
- { pattern: ".*", kind: plate }
target_devices:
# ─────────────────────────────────────────────────────────────────────────
# default兜底物料集 + 兜底 slot_remap。
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
# ─────────────────────────────────────────────────────────────────────────
default:
notes: "默认兜底物料集caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
slot_remap:
# raw slot → deck slot与对象类型无关
default:
"4": "13"
"8": "14"
# 按 object 字段覆盖 default
by_object:
trash:
"12": "16"
rules:
# ─ tip rack默认量程档≤10 / <300 / 否则 1000
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
# ─ tube rack ─
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL10 位兼容 4×6 适配器)" }
# ─ plate ─
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
# ─ trash ─
- { kind: trash, class_name: PRCXI_trash }
# ─────────────────────────────────────────────────────────────────────────
# prcxiPRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
# ─────────────────────────────────────────────────────────────────────────
prcxi:
slot_remap:
# PRCXI 多数型号通用的 deck 物理布局映射
default:
"4": "13"
"8": "14"
by_object:
trash:
"12": "16"
rules:
# ─ tip rackPRCXI 量程档≤10 / <300 / 否则 1000
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
# ─ tube rack ─
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL10 位兼容 4×6 适配器)" }
# ─ plate ─
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
# ─ trash ─
- { kind: trash, class_name: PRCXI_trash }
models:
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
"9320": {}
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
# 仅 slot_remap 不同rules 与厂商级一致 → 不重复声明(自动继承)。
"4040":
slot_remap:
default:
"4": "16"
"8": "14"
by_object:
trash:
"12": "20"
# ─────────────────────────────────────────────────────────────────────────
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
# 特别注意 tip 量程档可与 PRCXI 不同。
# ─────────────────────────────────────────────────────────────────────────
# beckman:
# slot_remap:
# default: {"4": "13"}
# by_object: {trash: {"12": "16"}}
# rules:
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
# - { kind: trash, class_name: Beckman_Trash }
# models:
# "i7":
# slot_remap:
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射

View File

@@ -0,0 +1,244 @@
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
测试覆盖:
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
- :func:`normalize_liquid_history`v3 dict / v2 tuple / list[str] / 混合 / 非法
- :func:`well_current_liquid_name`tracker.liquids 末项 / get_liquids fallback / 缺失
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action覆盖
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, List, Tuple
import pytest
# liquid_history 模块**不依赖** pylabrobot可在 PLR 环境缺失时独立 import / 单测。
from unilabos.devices.liquid_handling.liquid_history import (
LIQUID_HISTORY_MAX_ENTRIES,
LiquidHistoryEntry,
append_liquid_history,
normalize_liquid_history,
well_current_liquid_name,
)
# ---------------------------------------------------------------------------
# FixturesDummyTracker / DummyWell避免引入真实 PLR Well/VolumeTracker 依赖)
# ---------------------------------------------------------------------------
@dataclass
class DummyTracker:
"""模拟 PLR VolumeTracker仅暴露 P9 hook 关心的字段。"""
liquid_history: List[Any] = field(default_factory=list)
liquids: List[Tuple[Any, float]] = field(default_factory=list)
max_volume: float = 200.0
is_disabled: bool = False
@dataclass
class DummyWell:
"""模拟 PLR Well仅暴露 ``tracker``。"""
name: str = "well_A1"
max_volume: float = 200.0
tracker: DummyTracker = field(default_factory=DummyTracker)
# ---------------------------------------------------------------------------
# append_liquid_history
# ---------------------------------------------------------------------------
class TestAppendLiquidHistory:
def test_append_creates_v3_entry(self) -> None:
well = DummyWell()
append_liquid_history(well, "Plasma", 100.0, "set")
assert len(well.tracker.liquid_history) == 1
entry = well.tracker.liquid_history[0]
assert entry["name"] == "Plasma"
assert entry["volume"] == 100.0
assert entry["action"] == "set"
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
def test_append_aspirate_negative_volume(self) -> None:
well = DummyWell()
append_liquid_history(well, "Water", -50.0, "aspirate")
assert well.tracker.liquid_history[0]["volume"] == -50.0
assert well.tracker.liquid_history[0]["action"] == "aspirate"
def test_append_with_empty_name_keeps_empty_string(self) -> None:
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type"""
well = DummyWell()
append_liquid_history(well, "", 50.0, "dispense")
assert well.tracker.liquid_history[0]["name"] == ""
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
well = DummyWell()
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
assert well.tracker.liquid_history[0]["name"] == ""
def test_append_initializes_history_if_missing(self) -> None:
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
well = DummyWell()
del well.tracker.liquid_history # 模拟全新 PLR tracker
append_liquid_history(well, "X", 10.0, "set")
assert hasattr(well.tracker, "liquid_history")
assert len(well.tracker.liquid_history) == 1
def test_append_no_tracker_is_graceful(self) -> None:
"""well 无 tracker 时静默不抛(保护主流程)。"""
class NoTrackerWell:
name = "no_tracker"
well = NoTrackerWell()
append_liquid_history(well, "X", 10.0, "set") # 不应抛
assert not hasattr(well, "tracker")
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
well = DummyWell()
append_liquid_history(well, "X", 1.0, "")
assert well.tracker.liquid_history[0]["action"] == "legacy"
def test_append_respects_max_entries_rolling(self) -> None:
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
well = DummyWell()
well.tracker.liquid_history = [
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
]
append_liquid_history(well, "newest", 1.0, "set")
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
assert well.tracker.liquid_history[-1]["name"] == "newest"
assert well.tracker.liquid_history[0]["name"] != "old_0"
# ---------------------------------------------------------------------------
# normalize_liquid_history
# ---------------------------------------------------------------------------
class TestNormalizeLiquidHistory:
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
result = normalize_liquid_history(raw)
assert result == [{
"name": "A",
"volume": 100.0,
"action": "set",
"timestamp": "2026-05-22T00:00:00Z",
}]
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
raw = [{"name": "A"}]
result = normalize_liquid_history(raw)
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
assert "timestamp" not in result[0]
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
raw = [("A", 100), ("B", 50.5)]
result = normalize_liquid_history(raw)
assert result == [
{"name": "A", "volume": 100.0, "action": "legacy"},
{"name": "B", "volume": 50.5, "action": "legacy"},
]
def test_list_of_strings_upgraded(self) -> None:
raw = ["A", "B"]
result = normalize_liquid_history(raw)
assert result == [
{"name": "A", "volume": 0.0, "action": "legacy"},
{"name": "B", "volume": 0.0, "action": "legacy"},
]
def test_mixed_input_normalized(self) -> None:
raw = [
{"name": "A", "volume": 1, "action": "set"},
("B", 2),
"C",
]
result = normalize_liquid_history(raw)
assert [e["name"] for e in result] == ["A", "B", "C"]
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
def test_invalid_entries_dropped(self) -> None:
raw = [42, None, {"name": "A"}, ("only_one",)]
result = normalize_liquid_history(raw)
# 只保留 {"name": "A"} 这一条;其它都被丢弃
assert len(result) == 1
assert result[0]["name"] == "A"
assert result[0]["volume"] == 0.0 # 缺省补 0
def test_non_list_input_returns_empty(self) -> None:
assert normalize_liquid_history(None) == []
assert normalize_liquid_history("not_a_list") == []
assert normalize_liquid_history({"name": "X"}) == []
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
raw = [("A", "not_a_number")]
result = normalize_liquid_history(raw)
assert result[0]["volume"] == 0.0
# ---------------------------------------------------------------------------
# well_current_liquid_name
# ---------------------------------------------------------------------------
class TestWellCurrentLiquidName:
def test_returns_last_liquid_name_from_tuple(self) -> None:
well = DummyWell()
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
assert well_current_liquid_name(well) == "Plasma"
def test_returns_enum_like_name_attr(self) -> None:
class FakeLiquid:
name = "ETHANOL"
well = DummyWell()
well.tracker.liquids = [(FakeLiquid(), 100.0)]
assert well_current_liquid_name(well) == "ETHANOL"
def test_empty_liquids_returns_empty_string(self) -> None:
well = DummyWell()
well.tracker.liquids = []
assert well_current_liquid_name(well) == ""
def test_no_tracker_returns_empty_string(self) -> None:
class NoTrackerWell:
name = "x"
assert well_current_liquid_name(NoTrackerWell()) == ""
def test_none_liquid_returns_empty_string(self) -> None:
well = DummyWell()
well.tracker.liquids = [(None, 100.0)]
assert well_current_liquid_name(well) == ""
def test_string_liquid_returned_as_is(self) -> None:
well = DummyWell()
well.tracker.liquids = ["Saline"]
assert well_current_liquid_name(well) == "Saline"

View File

@@ -0,0 +1,239 @@
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力v2 设计
的核心依据):
- 输入 ``wells`` 列表来自多个 plate每板各一/多个 well``set_liquid`` 应按 plate
分桶串行调用每板一次plate-bucket 顺序按 first-occurrence
- 同板内多孔归到同一桶。
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
``ResourceTreeSet`` 的 monkeypatchdump 直接返回输入列表)。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Tuple
import pytest
# ----------------------------------------------------------------------
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
# CI统一 pylabrobot 版本跑全纯逻辑测试Stage 2 / Stage 3不受影响。
# ----------------------------------------------------------------------
LiquidHandlerAbstract = pytest.importorskip(
"unilabos.devices.liquid_handling.liquid_handler_abstract",
reason="pylabrobot 链未完整可用,跳过 device 单测CI 上请保证 pylabrobot ≥ 项目要求版本",
exc_type=ImportError,
).LiquidHandlerAbstract
# ==================== Duck-typed PLR-like 资源 ====================
@dataclass
class DummyPlate:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyPlate({self.name})"
@dataclass
class DummyWell:
name: str
parent: DummyPlate
max_volume: float = 1000.0
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
def set_liquids(self, items):
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
for name, vol in items:
self.liquid_history.append((str(name), float(vol)))
def __repr__(self) -> str: # pragma: no cover
return f"DummyWell({self.parent.name}/{self.name})"
# ==================== fixture装一台 FakeLiquidHandler ====================
@pytest.fixture
def patched_resource_tree(monkeypatch):
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
"""
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
class _FakeTree:
def __init__(self, items):
self._items = items
def dump(self):
return [
{"name": getattr(x, "name", None), "type": type(x).__name__}
for x in self._items
]
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
return _FakeTree(list(items))
monkeypatch.setattr(
lha.ResourceTreeSet,
"from_plr_resources",
staticmethod(_fake_from_plr_resources),
)
return lha
@pytest.fixture
def handler(patched_resource_tree):
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
class _FakeHandler(LiquidHandlerAbstract):
def __init__(self):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = 8
self.support_touch_tip = True
return _FakeHandler()
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
plate = DummyPlate(name=plate_name)
return [DummyWell(name=w, parent=plate) for w in well_names]
# ==================== 用例 ====================
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
"""单 plate 多孔set_liquids 按 wells 顺序逐项调用volumes 回拼一致。"""
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["reagent_X"] * 3,
volumes=[10.0, 20.0, 30.0],
)
# 每个 well 的 liquid_history 各 1 条
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
assert w.liquid_history == [("reagent_X", expected_vol)]
# 返回 volumes 顺序与输入一致
assert ret.volumes == [10.0, 20.0, 30.0]
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
51b9a5 简化(每板 1 孔4 plate × 1 well = 4 set_liquids 调用。
"""
p2 = _wells_grid("plate_slot2", ["A1"])
p3 = _wells_grid("plate_slot3", ["A1"])
p5 = _wells_grid("plate_slot5", ["A1"])
p6 = _wells_grid("plate_slot6", ["A1"])
wells = p2 + p3 + p5 + p6
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["l1"] * 4,
volumes=[8.3] * 4,
)
# 每个 well 都被 set_liquids 设过
for w in wells:
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
# volumes 顺序与输入对齐
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
# plate dump 应含 4 个 plate按 first-occurrence
plate_dump = ret.plate
plate_names = [p["name"] for p in plate_dump]
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
"""交错跨板wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
内部仍按 plate 分桶执行 set_liquidper-plate 串行),但返回顺序遵循输入 index。
"""
p2 = DummyPlate(name="plate_slot2")
p3 = DummyPlate(name="plate_slot3")
p5 = DummyPlate(name="plate_slot5")
w_p2_a1 = DummyWell(name="A1", parent=p2)
w_p2_a2 = DummyWell(name="A2", parent=p2)
w_p3_a1 = DummyWell(name="A1", parent=p3)
w_p5_a1 = DummyWell(name="A1", parent=p5)
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["l1"] * 4,
volumes=[10.0, 20.0, 30.0, 40.0],
)
# 每个 well 都被设液
assert w_p2_a1.liquid_history == [("l1", 10.0)]
assert w_p3_a1.liquid_history == [("l1", 20.0)]
assert w_p2_a2.liquid_history == [("l1", 30.0)]
assert w_p5_a1.liquid_history == [("l1", 40.0)]
# 返回 volumes 严格按输入 index 顺序回拼
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
# plate dump按 first-occurrenceplate_slot2 第 1 次出现于 idx=0plate_slot3 idx=1plate_slot5 idx=3
plate_names = [p["name"] for p in ret.plate]
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
"""``set_liquid`` 会按 ``max_volume`` 做 clamp防止初始化液量超容器容量。"""
plate = DummyPlate(name="plate_slot2")
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
ret = handler._set_liquid_grouped_by_plate(
wells=[well],
liquid_names=["overflow"],
volumes=[500.0], # 超过 max_volume=200
)
assert well.liquid_history == [("overflow", 200.0)]
assert ret.volumes == [200.0]
def test_grouped_by_plate_empty_names_short_circuit(handler):
"""``liquid_names`` 与 ``volumes`` 均为空早返回wells 列表回显但不设液。"""
wells = _wells_grid("plate_slot2", ["A1", "A2"])
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=[],
volumes=[],
)
# 不调用 set_liquids
assert all(w.liquid_history == [] for w in wells)
assert ret.volumes == []
# wells dump 仍返回输入列表
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
def test_grouped_by_plate_length_mismatch_raises(handler):
"""wells / liquid_names / volumes 长度不一致应直接 raise防御性校验"""
wells = _wells_grid("plate_slot2", ["A1", "A2"])
with pytest.raises(ValueError, match=r"必须等长"):
handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["r"] * 2,
volumes=[10.0], # 长度 1不匹配
)

View File

@@ -0,0 +1,566 @@
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5
- Helper``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``4 helper
位于 ``liquid_history.py``PLR-free 模块)。
- 单通道 transfer_liquid 主循环identity-keep / liquids-keep / 配置开关 /
未知 name 保守换 tip / aspirate 顶层归零时序。
- 8 通道分支:段锚孔 liquids-keep。
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
helper 测试独立于 PLR可在 ``pylabrobot`` 缺失环境下单独运行;端到端
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
from unilabos.devices.liquid_handling.liquid_history import (
capture_tip_liquid_name,
is_known_liquid_name,
same_liquid_via_liquids,
same_liquid_via_liquids_pair,
)
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
# 整段端到端测试自动 skip但 helper 测试照常执行。
try:
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
LiquidHandlerAbstract,
)
_PLR_AVAILABLE = True
_PLR_IMPORT_ERROR: Optional[Exception] = None
except Exception as exc: # pragma: no cover - 环境相关
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
_PLR_AVAILABLE = False
_PLR_IMPORT_ERROR = exc
# ---------------------------------------------------------------------------
# FixturesDummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
# ---------------------------------------------------------------------------
@dataclass
class DummyTracker:
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
liquids: List[Tuple[Any, float]] = field(default_factory=list)
max_volume: float = 200.0
is_disabled: bool = False
@dataclass
class DummyWell:
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
name: str = "well"
tracker: DummyTracker = field(default_factory=DummyTracker)
def __repr__(self) -> str: # pragma: no cover
return f"DummyWell({self.name})"
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
"""构造一个 well若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
well = DummyWell(name=name, tracker=DummyTracker())
if liquid_name is not None:
well.tracker.liquids = [(liquid_name, vol)]
return well
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
# E2E 测试用的 basePLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
# ``object`` 让模块仍能 import带 ``LiquidHandlerAbstract`` 的 e2e 测试用
# ``skipif`` 跳过。
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
"""不初始化真实 backend/deck仅记录 transfer_liquid 内部 4 类调用序列。
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
"""
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
# 不调用 super().__init__避免硬件 / ROS / PLR Deck 初始化。
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter(2048))
self.calls: List[Tuple[str, Any]] = []
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
def set_tiprack(self, tip_racks):
if not tip_racks:
return
# 跳过真实 set_tiprack依赖 PLR Deck
return
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
("aspirate", {"resources": list(resources), "vols": list(vols)})
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
("dispense", {"resources": list(resources), "vols": list(vols)})
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
self.calls.append(("discard_tips", {"use_channels": use_channels}))
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
"""T11 专用aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
用于验证 P10 v2 的关键时序约束tip name 必须在 aspirate **之前**预读,
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
"""
async def aspirate(self, resources, vols, **kwargs):
await super().aspirate(resources, vols, **kwargs)
# 模拟 PLR 顶层归零时 pop对每个 source well若 liquids 非空则 pop 顶层
for r in resources:
tracker = getattr(r, "tracker", None)
if tracker is not None and tracker.liquids:
tracker.liquids.pop()
def run(coro):
return asyncio.run(coro)
def call_names(lh: FakeLiquidHandler) -> List[str]:
return [c[0] for c in lh.calls]
# ---------------------------------------------------------------------------
# Helper 单元测试
# ---------------------------------------------------------------------------
class TestIsKnownLiquidName:
def test_empty_string_is_unknown(self) -> None:
assert is_known_liquid_name("") is False
def test_none_is_unknown(self) -> None:
assert is_known_liquid_name(None) is False
def test_literal_unknown_is_unknown(self) -> None:
assert is_known_liquid_name("unknown") is False
assert is_known_liquid_name("UNKNOWN") is False
assert is_known_liquid_name(" Unknown ") is False
def test_literal_none_string_is_unknown(self) -> None:
assert is_known_liquid_name("none") is False
assert is_known_liquid_name("None") is False
def test_real_liquid_name_is_known(self) -> None:
assert is_known_liquid_name("PBS") is True
assert is_known_liquid_name("Tris HCl") is True
assert is_known_liquid_name("Liquid_3") is True
class TestSameLiquidViaLiquids:
def test_well_and_tip_same_name_match(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, "PBS") is True
def test_well_and_tip_different_names_no_match(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, "Tris HCl") is False
def test_tip_unknown_returns_false(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, None) is False
assert same_liquid_via_liquids(well, "") is False
assert same_liquid_via_liquids(well, "unknown") is False
def test_well_empty_liquids_returns_false(self) -> None:
well = make_well("A1", liquid_name=None) # 不写 liquids
assert same_liquid_via_liquids(well, "PBS") is False
def test_well_unknown_literal_returns_false(self) -> None:
well = make_well("A1", "unknown")
assert same_liquid_via_liquids(well, "unknown") is False
class TestSameLiquidViaLiquidsPair:
def test_two_wells_same_name_match(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", "PBS")
assert same_liquid_via_liquids_pair(a, b) is True
def test_two_wells_different_names_no_match(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", "Tris HCl")
assert same_liquid_via_liquids_pair(a, b) is False
def test_either_well_empty_returns_false(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", liquid_name=None)
assert same_liquid_via_liquids_pair(a, b) is False
assert same_liquid_via_liquids_pair(b, a) is False
class TestCaptureTipLiquidName:
def test_known_name_returned(self) -> None:
well = make_well("A1", "PBS")
assert capture_tip_liquid_name(well) == "PBS"
def test_empty_well_returns_none(self) -> None:
well = make_well("A1", liquid_name=None)
assert capture_tip_liquid_name(well) is None
def test_unknown_literal_returns_none(self) -> None:
well = make_well("A1", "unknown")
assert capture_tip_liquid_name(well) is None
# ---------------------------------------------------------------------------
# T1T12 端到端测试(单通道 transfer_liquid 主循环)
#
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
# 若 PLR import 失败则整段 skiphelper 测试照常运行。
# ---------------------------------------------------------------------------
_skip_if_no_plr = pytest.mark.skipif(
not _PLR_AVAILABLE,
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
)
@_skip_if_no_plr
class TestSingleChannelTipReuse:
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
def test_T1_identity_hit_reuses_tip(self) -> None:
"""T1连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
lh = FakeLiquidHandler(channel_num=1)
src = make_well("S0", "PBS")
tgt = make_well("T0")
run(
lh.transfer_liquid(
sources=[src, src],
targets=[tgt, tgt],
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
# 2 次 transfer但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
assert call_names(lh).count("aspirate") == 2
assert call_names(lh).count("dispense") == 2
def test_T2_liquids_hit_across_plates(self) -> None:
"""T29 个独立 source well不同 PLR Well 对象)都装 PBS → identity 全 failliquids-keep 全命中。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
targets = [make_well(f"T{i}") for i in range(9)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 9,
dis_vols=[1] * 9,
)
)
# 9 个 source 物理上同液 → 整段共用 1 个 tip
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
assert call_names(lh).count("aspirate") == 9
assert call_names(lh).count("dispense") == 9
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
"""T3同 plate 上 A1-H1 都装 PBS8 个不同 Well 对象)→ identity 全 failliquids-keep 命中。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
targets = [make_well(f"T{i}") for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 8,
dis_vols=[1] * 8,
)
)
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
"""T4A1=PBSB1=Tris HCl → liquids 名不等,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
# 2 次完全独立的 transfer2 次 pick_up / 2 次 discard
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T5_empty_liquids_forces_tip_change(self) -> None:
"""T5source 从未调过 set_liquidsliquids 空)→ 视为未知,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1"), make_well("B1")] # 没装液体名
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T6_switch_off_disables_liquids_keep(self) -> None:
"""T6tip_reuse_by_liquid_name=FalseT2 场景退化为 identity-only强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
targets = [make_well(f"T{i}") for i in range(9)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 9,
dis_vols=[1] * 9,
)
)
# 关闭开关后 → 退化为 identity-only9 次独立换 tip
assert call_names(lh).count("pick_up_tips") == 9
assert call_names(lh).count("discard_tips") == 9
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
"""T8单 source 反复 aspirate/dispense → identity-keep 命中mix-style"""
lh = FakeLiquidHandler(channel_num=1)
src = make_well("S0", "Methanol")
tgt = make_well("T0")
run(
lh.transfer_liquid(
sources=[src, src, src],
targets=[tgt, tgt, tgt],
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1, 1],
dis_vols=[1, 1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
"""T10``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
"""T11aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
"""
lh = AspiratePopFakeLiquidHandler(channel_num=1)
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
targets = [make_well(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 3,
dis_vols=[1] * 3,
)
)
# 即使 aspirate 后 source.tracker.liquids 被 poppending_tip_name 已捕获 "PBS"
# → 下一轮 source 仍是 PBSaspirate 还没发生liquids-keep 命中
# → 整段 1 次 pick_up_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
# ---------------------------------------------------------------------------
# T7跨节点边界两个独立 transfer_liquid 调用,状态隔离)
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestCrossNodeBoundary:
"""T7两个 transfer_liquid 节点之间不复用 tip每次调用初始化 current_tip_liquid_name=None"""
def test_T7_two_calls_dont_share_tip_state(self) -> None:
lh = FakeLiquidHandler(channel_num=1)
src_a = make_well("A_src", "PBS")
tgt_a = make_well("A_tgt")
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
tgt_b = make_well("B_tgt")
run(
lh.transfer_liquid(
sources=[src_a],
targets=[tgt_a],
tip_racks=[],
use_channels=[0],
asp_vols=[1],
dis_vols=[1],
)
)
run(
lh.transfer_liquid(
sources=[src_b],
targets=[tgt_b],
tip_racks=[],
use_channels=[0],
asp_vols=[1],
dis_vols=[1],
)
)
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
# ---------------------------------------------------------------------------
# T98 通道段锚孔 liquids-keep
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestEightChannelSegmentTipReuse:
"""T98 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
def test_T9_two_segments_same_anchor_liquid(self) -> None:
lh = FakeLiquidHandler(channel_num=8)
# 16 个 source wells分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
targets = [make_well(f"T{i}") for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 16,
dis_vols=[1] * 16,
mix_times=0,
)
)
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
"""T9b段 1 锚孔 = PBS段 2 锚孔 = Tris → 段间强制换 tip。"""
lh = FakeLiquidHandler(channel_num=8)
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
sources = seg1 + seg2
targets = [make_well(f"T{i}") for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 16,
dis_vols=[1] * 16,
mix_times=0,
)
)
# 2 段不同液 → 2 次独立换 tip
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
# ---------------------------------------------------------------------------
# 配置开关默认值 / 实例字段读取
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestConfigDefault:
def test_default_switch_is_on(self) -> None:
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True测试 fixture 显式 default 一致)。"""
lh = FakeLiquidHandler()
assert lh._tip_reuse_by_liquid_name is True
def test_switch_off_takes_effect(self) -> None:
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
assert lh._tip_reuse_by_liquid_name is False

View File

@@ -0,0 +1,137 @@
"""P9 — ``_augment_states_with_liquid_history`` 单元测试OS→Cloud sync 链路 Phase C
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3 / §8 T4。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List
import pytest
from unilabos.resources.resource_tracker import _augment_states_with_liquid_history
# ---------------------------------------------------------------------------
# Fixtures纯 dataclass 模拟 PLR 资源树(避免引入 PLR 真实实例化)
# ---------------------------------------------------------------------------
@dataclass
class FakeTracker:
liquid_history: Any = field(default_factory=list)
@dataclass
class FakeResource:
name: str
tracker: Any = None
children: List["FakeResource"] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestAugmentStatesWithLiquidHistory:
def test_single_well_history_attached(self) -> None:
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
{"name": "Plasma", "volume": 100, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {"liquids": [], "pending_liquids": []}}
_augment_states_with_liquid_history(well, states)
assert "liquid_history" in states["well_A1"]
assert states["well_A1"]["liquid_history"] == [
{"name": "Plasma", "volume": 100, "action": "set"}
]
def test_recursive_walk_attaches_to_all_wells(self) -> None:
"""resource 树有多层时,每个有 tracker 的节点都会被并入 states。"""
wells = [
FakeResource(f"well_{i}", tracker=FakeTracker(liquid_history=[
{"name": f"L_{i}", "volume": i * 10, "action": "set"}
]))
for i in range(3)
]
plate = FakeResource("plate", children=wells)
deck = FakeResource("deck", children=[plate])
states: Dict[str, Any] = {
"deck": {"liquids": []},
"plate": {"liquids": []},
"well_0": {"liquids": []},
"well_1": {"liquids": []},
"well_2": {"liquids": []},
}
_augment_states_with_liquid_history(deck, states)
assert states["well_0"]["liquid_history"] == [{"name": "L_0", "volume": 0, "action": "set"}]
assert states["well_1"]["liquid_history"] == [{"name": "L_1", "volume": 10, "action": "set"}]
assert states["well_2"]["liquid_history"] == [{"name": "L_2", "volume": 20, "action": "set"}]
def test_no_tracker_node_skipped(self) -> None:
"""没有 tracker 的节点(如 deck 自身跳过state dict 不被污染。"""
deck = FakeResource("deck") # tracker=None
states: Dict[str, Any] = {"deck": {"some_field": 1}}
_augment_states_with_liquid_history(deck, states)
assert "liquid_history" not in states["deck"]
def test_existing_liquid_history_in_state_not_overwritten(self) -> None:
"""state 已经有 liquid_history 字段(例如 PLR 升级未来支持了)→ 不覆盖。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
{"name": "Plasma", "volume": 100, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {"liquid_history": ["preexisting"]}}
_augment_states_with_liquid_history(well, states)
assert states["well_A1"]["liquid_history"] == ["preexisting"]
def test_history_is_shallow_copied(self) -> None:
"""augment 后的 history 应是独立 list避免运行时 mutate 污染 dump 结果)。"""
original_history = [{"name": "X", "volume": 1, "action": "set"}]
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=original_history))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
# mutate runtime history 不应反映到 augmented state
original_history.append({"name": "Y", "volume": 2, "action": "set"})
assert len(states["well_A1"]["liquid_history"]) == 1
def test_node_not_in_states_silently_skipped(self) -> None:
"""resource 树中的节点 name 不在 ``states`` 字典里 → 静默跳过。"""
well = FakeResource("well_orphan", tracker=FakeTracker(liquid_history=[
{"name": "X", "volume": 1, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
# 不应该新增 well_orphan 键,也不应污染 well_A1
assert "well_orphan" not in states
assert "liquid_history" not in states["well_A1"]
def test_non_list_liquid_history_skipped(self) -> None:
"""tracker.liquid_history 非 list 时(异常情况)→ 跳过,不写入 state。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history="broken"))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
assert "liquid_history" not in states["well_A1"]
def test_empty_history_still_written(self) -> None:
"""tracker.liquid_history = [] 是合法状态 → 应写入空 list表示"未有任何液体操作")。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[]))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
assert states["well_A1"]["liquid_history"] == []

View File

@@ -0,0 +1,351 @@
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
6 条用例:
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi"
与 P6 等价PRCXI_* class_name
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
`target_class_name`**旧字段 `prcxi_class_name` 不存在**。
- `test_build_graph_model_level_slot_remap` —— P6.1.1``target_model`` 透传到
``_map_deck_slot`` 后改变 create_resource 的 slot同厂商不同型号 deck 物理布局不同)。
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph避免在
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
"""
from __future__ import annotations
import sys
import types
import warnings
from pathlib import Path
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
common.py 在模块级 import 这些库做可视化辅助build_protocol_graph 主路径不会真用到。
fake 模块只需要满足 ``from X import Y`` 的查找即可。
"""
if "matplotlib" not in sys.modules:
fake_matplotlib = types.ModuleType("matplotlib")
sys.modules["matplotlib"] = fake_matplotlib
if "matplotlib.pyplot" not in sys.modules:
fake_plt = types.ModuleType("matplotlib.pyplot")
sys.modules["matplotlib.pyplot"] = fake_plt
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz不可用时给个空 stub
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
def _to_agraph(_g): # type: ignore[no-untyped-def]
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
from unilabos.workflow import labware_mapping as lm # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
@pytest.fixture(autouse=True)
def _reset_mapping_cache():
"""每个用例后清 lru_cache避免跨用例污染。"""
yield
lm.reload_mapping()
# ==================== 公共 fixture最小 transfer_liquid 协议 ====================
def _minimal_labware_info() -> dict:
"""返回最小可用的 labware_infomutable每个 case 独立 build 一份)。
包含 tip rack + 24-tube rack + 96 wellplateslot 1/2/3覆盖 P6.1 主要 kind。
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
时通过 well-count 启发式well_n=3误判孔数与真实协议中 labware_defs 提供 num_wells
的行为对齐。
"""
return {
"tips": {
"slot": 1,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
"samples": {
"slot": 2,
"well": ["A1", "A2", "A3"],
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
"object": "source",
"num_wells": 24,
},
"plate_target": {
"slot": 3,
"well": ["A1", "A2", "A3"],
"labware": "opentrons_96_wellplate_300ul_pcr",
"object": "target",
"num_wells": 96,
},
}
def _minimal_protocol_steps() -> list:
"""最小 transfer_liquid 协议步骤asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
return [
{
"action": "transfer_liquid",
"parameters": {
"sources": "samples",
"targets": "plate_target",
"tip_racks": "tips",
"asp_vols": [200.0, 200.0, 200.0],
"dis_vols": [200.0, 200.0, 200.0],
},
"step_number": 1,
}
]
def _collect_create_resource_classes(graph) -> dict:
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
out: dict = {}
for _nid, node in graph.nodes.items():
if node.get("template_name") != "create_resource":
continue
param = node.get("param") or {}
slot = str(param.get("slot_on_deck") or "")
cls = str(param.get("class_name") or "")
if slot:
out[slot] = cls
return out
# ==================== 5 条核心用例 ====================
def test_build_graph_default_target_device_prcxi():
"""不传 target_device → 默认 "prcxi" → 与 P6 等价PRCXI_* class_name"""
labware_info = _minimal_labware_info()
g = build_protocol_graph(
labware_info=labware_info,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
classes = _collect_create_resource_classes(g)
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
def test_build_graph_explicit_target_device_prcxi():
"""显式传 target_device="prcxi" 应与默认完全等价。"""
labware_info_a = _minimal_labware_info()
labware_info_b = _minimal_labware_info()
g_default = build_protocol_graph(
labware_info=labware_info_a,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
g_prcxi = build_protocol_graph(
labware_info=labware_info_b,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
def test_build_graph_target_device_unknown_falls_back_to_default_section():
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价PRCXI_*)。
"""
labware_info_a = _minimal_labware_info()
labware_info_b = _minimal_labware_info()
g_prcxi = build_protocol_graph(
labware_info=labware_info_a,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
g_unknown = build_protocol_graph(
labware_info=labware_info_b,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="unknown_xxx",
)
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
assert any(
("未在 labware_mapping.yaml" in str(w.message))
or ("target_devices.default" in str(w.message))
for w in caught
)
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
"""同一 protocoltarget_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档P6.1.1 schema"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds:\n'
' - {pattern: "trash", kind: trash}\n'
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
' - {pattern: ".*", kind: plate}\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
' beckman:\n'
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
g_prcxi = build_protocol_graph(
labware_info=_minimal_labware_info(),
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
g_beckman = build_protocol_graph(
labware_info=_minimal_labware_info(),
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="beckman",
)
classes_prcxi = _collect_create_resource_classes(g_prcxi)
classes_beckman = _collect_create_resource_classes(g_beckman)
# 200 µLprcxi 走 300 档beckman 200 档已超 → 1000 档
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
# plate / tube rack 也按 target_device 输出对应厂商类
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
assert classes_beckman["2"] == "Beckman_24_TubeRack"
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
def test_field_renamed_target_class_name():
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
labware_info = _minimal_labware_info()
build_protocol_graph(
labware_info=labware_info,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
for lid, item in labware_info.items():
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
# ==================== P6.1.1 新增集成测试 ====================
def _labware_info_slot4_plate() -> dict:
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
return {
"plate_slot4": {
"slot": 4,
"well": ["A1"],
"labware": "opentrons_96_wellplate_300ul_pcr",
"object": "target",
"num_wells": 96,
},
}
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
"""P6.1.1target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
YAML 中 prcxi 厂商级 slot_remap 4→13模型 "4040" 显式覆盖 4→16。
同一份 labware_infoslot=4build 出的两份图slot_on_deck 应分别为 "13""16"
"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
' models:\n'
' "4040":\n'
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
g_default = build_protocol_graph(
labware_info=_labware_info_slot4_plate(),
protocol_steps=[],
workstation_name="PRCXI",
target_device="prcxi",
)
g_model_4040 = build_protocol_graph(
labware_info=_labware_info_slot4_plate(),
protocol_steps=[],
workstation_name="PRCXI",
target_device="prcxi",
target_model="4040",
)
classes_default = _collect_create_resource_classes(g_default)
classes_4040 = _collect_create_resource_classes(g_model_4040)
# 厂商级(无 model→ slot 4 → "13"
assert "13" in classes_default, f"未找到 slot 13实际生成的 slots: {list(classes_default)}"
assert "16" not in classes_default
# 模型 4040 → slot 4 → "16"
assert "16" in classes_4040, f"未找到 slot 16实际生成的 slots: {list(classes_4040)}"
assert "13" not in classes_4040
# class_name 不变rules 继承厂商级)
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"

View File

@@ -0,0 +1,369 @@
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
v2 设计要点(与本测试用例的映射)
-----------------------------------
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
well refs每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
(``labware`` → ``wells_identifier``)。
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
``_merged_targets_<idx>``runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
用例
----
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key51b9a5 主场景)。
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复cursor 推进)。
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合8 通道 cross-slot
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
``targets_identifier`` 入边params.targets 不再是 list。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == template_name
]
def _create_resource_by_slot(graph) -> Dict[str, str]:
"""slot_on_deck (str) -> create_resource 节点 ID。"""
out: Dict[str, str] = {}
for nid, node in graph.nodes.items():
if node.get("template_name") == "create_resource":
slot = str(node.get("param", {}).get("slot_on_deck") or "")
if slot:
out[slot] = nid
return out
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
return [e for e in graph.edges if e["target"] == target_id]
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
return [e for e in graph.edges if e["source"] == source_id]
# ==================== fixture构造跨板 labware + steps ====================
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
"""51b9a5 简化slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
return {
"l1": {
"slot": 1,
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
},
"plate_slot2": {
"slot": 2,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot3": {
"slot": 3,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot5": {
"slot": 5,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot6": {
"slot": 6,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"tiprack_12": {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
}
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
return [
{
"action": "transfer_liquid",
"parameters": {
"sources": "l1",
"targets": targets,
"tip_racks": "tiprack_12",
"asp_vols": dis_vols.copy(),
"dis_vols": dis_vols.copy(),
},
"step_number": 1,
}
]
# ==================== 用例 ====================
def test_emit_merged_set_liquid_basic():
"""51b9a5 主场景targets=[A,B,C,D] → 1 merged set_liquid 节点
+ 4 条入边(来自 4 个 distinct create_resource+ 1 条出边(去 transfer_liquid
"""
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
dis_vols = [8.3, 8.3, 8.3, 8.3]
g = build_protocol_graph(
labware_info=_cross_slot_labware_info(),
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
assert len(merged_nodes) == 1, (
f"应有且仅有 1 个 merged set_liquid_from_plate 节点v2 跨板聚合器);"
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
)
merged = merged_nodes[0]
merged_id = merged["id"]
# param.wells长度 4每元素的 parent 是对应 reagent_key
wells = merged.get("param", {}).get("wells") or []
assert len(wells) == 4
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
# well 字段映射到 reagent.well[0](都是 "A1"
for w, key in zip(wells, targets):
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
assert w["parent"] == key
# 入边4 条来自 distinct create_resource 节点slot 2/3/5/6target_port=wells_identifier
cr_by_slot = _create_resource_by_slot(g)
in_edges = _edges_to(g, merged_id)
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
assert in_sources == expected_sources, (
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
f" 实际 {in_sources} vs 期望 {expected_sources}"
)
# 出边1 条到 transfer_liquidtargets_identifier
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
assert len(transfer_nodes) == 1
transfer_id = transfer_nodes[0]["id"]
out_to_transfer = [
e for e in _edges_from(g, merged_id)
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
]
assert len(out_to_transfer) == 1, (
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
f" 实际 {len(out_to_transfer)}"
)
def test_emit_merged_set_liquid_repeat_key():
"""同 reagent_key 重复同板多孔targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
→ merged.wells 顺序 = [A/A1, A/A2, A/A3]cursor 推进取每个 well
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
dis_vols = [10.0, 20.0, 30.0]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
"cursor 应依次取 reagent.A.well[0/1/2]"
)
assert all(w["parent"] == "plate_slot2" for w in wells)
def test_emit_merged_set_liquid_mixed():
"""跨板 + 同板重复targets=[A,B,A,C] + reagent.A.well=[A1,A2]
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2"]
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
dis_vols = [10.0, 20.0, 30.0, 40.0]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
assert ids == [
("plate_slot2", "A1"),
("plate_slot3", "A1"),
("plate_slot2", "A2"),
("plate_slot5", "A1"),
]
def test_emit_merged_set_liquid_8ch():
"""与 P1 multi-channel 复合targets=[A]*8+[B]*8每列 8 通道)。
merged.wells 长度 16前 8 全 plate_slot2 的 8 个 well后 8 全 plate_slot3 的 8 个 well。
"""
labware = _cross_slot_labware_info()
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
dis_vols = [5.0] * 16
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
assert len(wells) == 16
# 前 8 全 plate_slot2后 8 全 plate_slot3满足 cross-slot × 8ch 列对齐约束)
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
# well 名顺序A1..H1 重复两遍
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
def test_transfer_liquid_targets_rewrite():
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边params.targets 不再是 list。"""
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
dis_vols = [8.3, 8.3, 8.3, 8.3]
g = build_protocol_graph(
labware_info=_cross_slot_labware_info(),
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
assert len(transfer_nodes) == 1
tnode = transfer_nodes[0]
transfer_id = tnode["id"]
# params.targetsv2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
tparams = tnode.get("param", {}) or {}
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
f"v2params.targets 不再是非空 list实际 {tparams.get('targets')!r}"
)
# targets_identifier 端口:只有 1 条入边
in_targets_edges = [
e for e in _edges_to(g, transfer_id)
if e.get("target_handle_key") == "targets_identifier"
]
assert len(in_targets_edges) == 1, (
f"v2transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid;"
f" 实际 {len(in_targets_edges)}"
)
# 这条入边的源端口必须是 output_wells
edge = in_targets_edges[0]
assert edge.get("source_handle_key") == "output_wells"
def test_str_targets_no_merged_node_emitted():
"""对照组targets 为 str单 reagent → 不插入 merged set_liquid_from_plate 节点。
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "l1",
"targets": "plate_slot2", # ← 单 str非 list
"tip_racks": "tiprack_12",
"asp_vols": [8.3, 8.3, 8.3],
"dis_vols": [8.3, 8.3, 8.3],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"

View File

@@ -0,0 +1,452 @@
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
设计要点
--------
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
解耦reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
前端的 human-readable 名称)。
- ``liquid_name`` 来源优先级Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
README 语义词 > 不写Stage 3 fallback 到 reagent_key
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
- 旧 JSON无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A
测试用例
--------
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key与 P8 前一致)。
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name
liquid_names[i] == "EDTA Plasma"
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 辅助 ====================
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == "set_liquid_from_plate"
]
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
for n in _set_liquid_nodes(graph):
if n.get("description") == f"Set liquid: {reagent_key}":
return n
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
def _merged_nodes(graph) -> List[Dict[str, Any]]:
return [
n for n in _set_liquid_nodes(graph)
if str(n.get("name", "")).startswith("_merged_targets_")
]
def _make_source_target_labware(
*,
source_key: str = "src_1",
source_liquid_name: str | None = None,
target_keys: List[str] | None = None,
target_liquid_names: Dict[str, str] | None = None,
) -> Dict[str, Dict[str, Any]]:
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
"""
info: Dict[str, Dict[str, Any]] = {}
source_entry: Dict[str, Any] = {
"slot": 1,
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
}
if source_liquid_name is not None:
source_entry["liquid_name"] = source_liquid_name
info[source_key] = source_entry
target_keys = target_keys or ["t_A"]
target_liquid_names = target_liquid_names or {}
for i, tk in enumerate(target_keys, start=1):
entry: Dict[str, Any] = {
"slot": 2 + i,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
}
if tk in target_liquid_names:
entry["liquid_name"] = target_liquid_names[tk]
info[tk] = entry
info["tiprack_12"] = {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
}
return info
# ==================== T1 缺省 fallback ====================
def test_per_plate_fallback_when_no_liquid_name():
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_keyP8 前行为)。"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["t_A"],
# 都不给 liquid_name
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["src_1"], (
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key"
f" 实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key"
f" 实际 {tgt_node['param']['liquid_names']}"
)
# ==================== T2 显式 liquid_name ====================
def test_per_plate_uses_explicit_liquid_name():
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="EDTA Plasma",
target_keys=["t_A"],
target_liquid_names={"t_A": "PBS Diluent"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
f"source per-plate 应使用 reagent.liquid_name实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
f"target per-plate 应使用 reagent.liquid_name实际 {tgt_node['param']['liquid_names']}"
)
# ==================== T3 空格 / 括号 ====================
def test_per_plate_preserves_spaces_and_special_chars():
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="Tris HCl pH 8.0 (1×)",
target_keys=["t_A"],
target_liquid_names={"t_A": "稀释液 A"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
)
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
# ==================== T4 merged 节点跨板部分有部分无 ====================
def test_merged_node_uses_explicit_liquid_name_per_dispense():
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
``reagent[key].liquid_name or key``:本例 3 个 target2 个有显式名、1 个无。
"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["t_A", "t_B", "t_C"],
target_liquid_names={
"t_A": "Plasma",
# t_B 无 liquid_name
"t_C": "Buffer X",
},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": ["t_A", "t_B", "t_C"],
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged = _merged_nodes(g)
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
liquid_names = merged[0]["param"]["liquid_names"]
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
f"merged 每 dispense 独立取 liquid_name or key实际 {liquid_names}"
)
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
def test_liquid_name_independent_of_reagent_key_normalization():
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key跨板去重
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
构造2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"
"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["samples", "samples_2"],
target_liquid_names={
"samples": "Bacterial Culture",
"samples_2": "Bacterial Culture",
},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": ["samples", "samples_2"],
"tip_racks": "tiprack_12",
"asp_vols": [5.0, 5.0],
"dis_vols": [5.0, 5.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged = _merged_nodes(g)
assert len(merged) == 1
liquid_names = merged[0]["param"]["liquid_names"]
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
f" liquid_name实际 {liquid_names}"
)
# 同时 reagent_key 仍是 samples / samples_2不变
wells = merged[0]["param"]["wells"]
parents = [w["parent"] for w in wells]
assert parents == ["samples", "samples_2"], (
f"merged wells.parent 应等于 list-targets reagent_keys实际 {parents}"
)
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
def test_both_source_and_target_per_plate_use_liquid_name():
"""str-targets 路径(无 mergedsource 和 target 都走 per-plate emit
各自独立取 ``liquid_name``。"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="Reagent A",
target_keys=["t_A"],
target_liquid_names={"t_A": "Reagent B"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A", # str-targets不触发 merged
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["Reagent A"]
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
def test_multi_well_reagent_replicates_liquid_name():
"""1 个 reagent 含 8 wellsmulti-channel 扩展场景)→ liquid_names 应是
``[liquid_name] * 8``,与 wells 长度一致。"""
labware: Dict[str, Dict[str, Any]] = {
"src_1": {
"slot": 1,
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
"liquid_name": "Mastermix",
},
"t_A": {
"slot": 3,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"tiprack_12": {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
}
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
liquid_names = src_node["param"]["liquid_names"]
assert liquid_names == ["Mastermix"] * 8, (
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
)
# 同时 wells / volumes 长度一致
assert len(src_node["param"]["wells"]) == 8
assert len(src_node["param"]["volumes"]) == 8

View File

@@ -0,0 +1,174 @@
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
跨板 fixP2 v2 §14把 plate name 作为 prefix 编码进 ``well_names`` 之后,
runtime 调用 ``plate.get_well("A5")`` 严格定位 welltrough plate 上不存在
``A5`` 会直接 IndexError使得这个隐藏多年的孔数推断 bug 浮出。
修复策略(方案 A
-----
hint 只用 ``item.get("labware", "")``**不再**拼上 ``labware_id``reagent_key
是业务名,不应参与孔板规格推断)。
测试矩阵
----
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
samples_96 + nunc_rectangular_agar_plate → hint 返回 Nonelabware string 不带孔数信息)。
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96
"custom_384_wellplate" → 384"nest_24_wellplate_2ml_pcr" → 24。
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
reagent blockslot 8 上 12 个 samples_XX 末尾含 6/24/96
``_apply_target_labware_class_auto_match`` 后samples_6/24 不再得到 trough class。
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import ( # noqa: E402
_apply_target_labware_class_auto_match,
_infer_plate_num_children_from_labware_hint,
_reconcile_slot_carrier_target_class,
)
# ==================== unithint 函数本身 ====================
@pytest.mark.parametrize(
"labware_id",
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
)
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
item = {
"slot": 8,
"well": ["A5"],
"labware": "nunc_rectangular_agar_plate",
"object": "target",
}
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
f"其末尾数字应当被忽略labware string 不含 96/384/etc 关键字)"
)
@pytest.mark.parametrize(
"labware_str,expected",
[
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
("custom_384_wellplate", 384),
("nest_24_wellplate_2ml_pcr", 24),
("custom_48_wellplate", 48),
("opentrons_12_wellplate_15ml", 12),
("nest_6_wellplate_5ml", 6),
("nunc_rectangular_agar_plate", None),
("", None),
],
)
def test_labware_string_well_count_inferred(labware_str, expected):
item = {"labware": labware_str}
assert (
_infer_plate_num_children_from_labware_hint("samples", item) == expected
), f"labware {labware_str!r} 应推断为 {expected!r}"
# ==================== integration模拟 Agar Plating ====================
def _agar_plating_reagent_block():
"""反推自 unilabos_data/req_workflow_upload.json12 列 × 9 reagent per step。
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
samples_87, samples_96, samples_105.
"""
info = {}
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
cols = [f"A{i + 1}" for i in range(12)]
for col_i, col in enumerate(cols):
for di in range(9):
n = col_i * 9 + di + 1
key = "samples" if n == 1 else f"samples_{n}"
info[key] = {
"slot": slot_for_idx[di],
"well": [col],
"labware": "nunc_rectangular_agar_plate",
"object": "target",
}
for i in range(12):
key = "sources" if i == 0 else f"sources_{i + 1}"
info[key] = {
"slot": 2,
"well": [cols[i]],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
}
info["tiprack_1"] = {
"slot": 1,
"well": None,
"labware": "opentrons_96_tiprack_10ul",
"object": "tiprack",
}
info["trash"] = {
"slot": 12,
"well": None,
"labware": "opentrons_1_trash_1100ml_fixed",
"object": "trash",
}
return info
def test_apply_does_not_classify_samples_6_as_trough():
"""集成回归Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
info = _agar_plating_reagent_block()
_apply_target_labware_class_auto_match(
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
)
_reconcile_slot_carrier_target_class(
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
)
slot8_keys = [
"samples_6", "samples_15", "samples_24", "samples_33",
"samples_42", "samples_51", "samples_60", "samples_69",
"samples_78", "samples_87", "samples_96", "samples_105",
]
for k in slot8_keys:
cls = info[k].get("target_class_name") or ""
assert "trough" not in cls.lower(), (
f"reagent {k} 被误识别为 trough class: {cls!r}"
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
)
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
"""labware string 含 96_wellplate 时应该正常识别为 96不被 fix 破坏。"""
item = {
"slot": 2,
"well": ["A1"],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
}
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96

View File

@@ -0,0 +1,379 @@
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §142026-05-22 plan
§14 设计要点
-----------------
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
导致**节点冗余**per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
§14 改造:在第二步循环**之前**预扫描 protocol_steps收集
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
**跳过** per-plate 节点创建。
测试用例
----
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets
节点数完全不变(回归防护)。
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响source
reagent_keys 不出现在 targets 字段中per-plate 节点恒在。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 辅助 ====================
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == template_name
]
def _set_liquid_nodes_split(graph):
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
return per_plate, merged
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
"""构造 labware_infosource 端 1 个 + 任意数量 target plates + tip rack。"""
info: Dict[str, Dict[str, Any]] = {}
source_keys = source_keys or ["src_1"]
for i, sk in enumerate(source_keys, start=1):
info[sk] = {
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
}
for i, tk in enumerate(target_keys, start=1):
info[tk] = {
"slot": 2 + i, # 错开 source 使用的 slot
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
}
info["tiprack_12"] = {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
}
return info
# ==================== 用例 ====================
def test_per_plate_skipped_when_covered_by_merged():
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
targets = ["t_A", "t_B", "t_C", "t_D"]
labware = _labware_with_targets(targets, source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": targets,
"tip_racks": "tiprack_12",
"asp_vols": [8.0] * 4,
"dis_vols": [8.0] * 4,
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
# merged 节点1 个
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
# per-plate 节点:仅 source 1 个src_1target 端被全部跳过
per_plate_names = {n.get("description", "") for n in per_plate}
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
for tk in targets:
assert tk not in per_plate_keys, (
f"§14target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
f" 实际 per_plate_keys={per_plate_keys}"
)
def test_per_plate_kept_when_also_referenced_by_str_targets():
"""R1 缓解t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
targets_list = ["t_A", "t_B", "t_C"]
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": targets_list,
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "t_A" in per_plate_keys, (
f"R1t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
f" 实际 per_plate_keys={per_plate_keys}"
)
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets应跳过"
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets应跳过"
# merged 节点数1仅 list-targets transfer #1 生成)
assert len(merged) == 1
def test_str_targets_protocol_unaffected():
"""单 slot 协议(全 str-targets→ 每个 target reagent_key 仍有 per-plate零回归"""
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_B",
"tip_racks": "tiprack_12",
"asp_vols": [20.0],
"dis_vols": [20.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
f"单 slot 协议每个 reagent_key含 source/target都应保留 per-plate"
f" 实际 {per_plate_keys}"
)
def test_51b9a5_style_node_count():
"""大规模场景N 个 list-targets transfers每个长度 M同 source 不同跨板)。
构造2 个 sourcesrc_A1、src_A2+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
2 个 transfer
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1跨 9 plate
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2跨 9 plate
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
"""
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
all_target_keys = target_keys_a1 + target_keys_a2
labware = _labware_with_targets(
all_target_keys,
source_keys=["src_A1", "src_A2"],
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_A1",
"targets": target_keys_a1,
"tip_racks": "tiprack_12",
"asp_vols": [8.3] * 9,
"dis_vols": [8.3] * 9,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_A2",
"targets": target_keys_a2,
"tip_racks": "tiprack_12",
"asp_vols": [8.3] * 9,
"dis_vols": [8.3] * 9,
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
# source 端2 个 per-plate
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
f"source 端必须有 src_A1 + src_A2 per-plate实际 {per_plate_keys}"
)
# target 端18 个全部被跳过
for tk in all_target_keys:
assert tk not in per_plate_keys, (
f"§14target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
f" 实际 per_plate_keys 包含 {tk}"
)
# 总节点数 == 2 + 2
assert len(per_plate) + len(merged) == 4, (
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
)
def test_source_per_plate_always_kept():
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
target_keys = ["t_A", "t_B", "t_C"]
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_X",
"targets": target_keys,
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_Y",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, _ = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "src_X" in per_plate_keys, "source src_X 必须有 per-platesource 不会被 §14 跳过)"
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"

View File

@@ -0,0 +1,534 @@
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
P6.1.1 schemav1.9
- 顶层 key 两段:``kinds`` / ``target_devices``**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖slot_remap / rules
- 旧 schema顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from unilabos.workflow import labware_mapping as lm
@pytest.fixture(autouse=True)
def _reset_lru_cache():
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
yield
lm.reload_mapping()
# ==================== slot_remap ====================
@pytest.mark.parametrize(
"raw,object_type,want",
[
("4", "", "13"),
("8", "", "14"),
("12", "trash", "16"),
("12", "source", "12"),
("1", "", "1"),
("", "", ""),
(4, "", "13"), # 非字符串入参也应规整
],
)
def test_remap_slot_basic(raw, object_type, want):
assert lm.remap_slot(raw, object_type) == want
def test_remap_slot_none_returns_empty():
assert lm.remap_slot(None) == ""
def test_remap_slot_passthrough_unknown():
assert lm.remap_slot("99") == "99"
# ==================== infer_kind ====================
def test_infer_kind_trash_priority():
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
assert lm.infer_kind("foo_trash_bar") == "trash"
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
def test_infer_kind_tiprack_before_tuberack():
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
def test_infer_kind_tube_rack_variants():
assert (
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
== "tube_rack"
)
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
def test_infer_kind_object_overrides_string():
"""object 字段优先:即使字符串看起来像 platetrash / tiprack 也能强制归类。"""
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
def test_infer_kind_default_plate():
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
def test_infer_kind_rack_without_tip_is_tube_rack():
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
def test_infer_kind_empty_hint_returns_plate():
assert lm.infer_kind("") == "plate"
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
# ==================== resolve_target_classtarget_device="prcxi" ====================
@pytest.mark.parametrize(
"vol,want",
[
(1, "PRCXI_10uL_Tips"),
(9, "PRCXI_10uL_Tips"),
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
(11, "PRCXI_300ul_Tips"),
(200, "PRCXI_300ul_Tips"),
(299.9, "PRCXI_300ul_Tips"),
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
(500, "PRCXI_1000uL_Tips"),
(1000, "PRCXI_1000uL_Tips"),
],
)
def test_resolve_tip_volume_buckets(vol, want):
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
def test_resolve_tube_rack_holes():
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
def test_resolve_plate_holes():
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
assert (
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
)
def test_resolve_plate_unknown_holes_returns_none():
"""48 孔板未在 YAML 列出 → None交给 PRCXI 模板打分匹配 fallback。"""
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
def test_resolve_trash_any():
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
# trash 规则未约束 hole_count / volume所以任意值都命中
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
# ==================== YAML 缺失 / 热加载 ====================
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
bogus = tmp_path / "no_such_labware_mapping.yaml"
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4") == "13"
assert (
lm.resolve_target_class("prcxi", "plate", 96, None)
== "PRCXI_BioER_96_wellplate"
)
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
"""YAML 解析失败也应回退到 builtin且打 warning。"""
bad = tmp_path / "labware_mapping.yaml"
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4") == "13"
assert any(
"labware_mapping.yaml 解析失败" in str(w.message)
or "labware_mapping.yaml 根不是 dict" in str(w.message)
for w in caught
)
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
"""临时 YAML 覆盖 + reload_mapping → 新规则生效且原规则失效P6.1.1 schema"""
tmp_yaml = tmp_path / "labware_mapping.yaml"
tmp_yaml.write_text(
'kinds:\n'
" - { pattern: 'trash', kind: trash }\n"
" - { pattern: '.*', kind: plate }\n"
'target_devices:\n'
' default:\n'
' slot_remap:\n'
' default: {"4": "99"}\n'
' by_object: {}\n'
' rules:\n'
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
' prcxi:\n'
' slot_remap:\n'
' default: {"4": "99"}\n'
' by_object: {}\n'
' rules:\n'
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
lm.reload_mapping()
assert lm.remap_slot("4") == "99"
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
# 新表里只有 96没有 384 → None
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
# tube_rack / tip_rack 在新表里没规则 → None
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
"""YAML 缺 `kinds` 段 → 该段使用 builtin其它段保留用户值P6.1.1 schema"""
partial = tmp_path / "labware_mapping.yaml"
partial.write_text(
'target_devices:\n'
' default:\n'
' slot_remap:\n'
' default: {"4": "88"}\n'
' by_object: {}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap:\n'
' default: {"4": "88"}\n'
' by_object: {}\n'
' rules: []\n', # 故意没有 kinds 段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# slot_remap 用 YAML 中的覆盖值
assert lm.remap_slot("4") == "88"
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
# ==================== P6.1 新增用例 ====================
def test_resolve_target_class_prcxi_tip_buckets():
"""PRCXI tip 量程档≤10 / <300 / 否则 1000与历史 _tip_prcxi_class_for_max_ul 等价)。"""
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
"""未声明的 target_device 自动回退到固定段 target_devices.default打 warning。
第一版 default 段内容按 prcxi 拷贝 → 断言caller 传 'tecan' 时,结果应等于查 default 段。"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
assert (
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
== lm.resolve_target_class("default", "tip_rack", 96, 200)
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
)
assert (
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
== lm.resolve_target_class("default", "plate", 96, None)
)
# 至少打 1 次 warning提示「未声明、已回退到 default 段」
assert any(
("未在 labware_mapping.yaml" in str(w.message))
or ("target_devices.default" in str(w.message))
for w in caught
)
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档P6.1.1 schema"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' prcxi:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' beckman:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# 同样的体积 200prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
# 同样的体积 15prcxi 已超出 10 档 → 300 档beckman 仍在 20 档
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
断言:把 default 段改成 Generic_Plate96prcxi 段保持 PRCXI_Plate96 时,
caller 传未声明的名字回退到 default 拿 Generic_Plate96传 prcxi 仍拿 PRCXI_Plate96。
"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n' # ← 独立改 default 段
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# caller 传未声明的 tecan → 走 default 段 → Generic_*
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_***不**受 default 影响)
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
# 显式传 "default" 也合法caller 可主动选择走 default 段)
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
"""旧 schemavendors / prcxi_class应被拒绝 + warning + 整段 fallback 到 builtinP6.1.1 schema"""
legacy = tmp_path / "labware_mapping.yaml"
legacy.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'vendors:\n' # ← 旧顶层 key
' opentrons:\n'
' rules:\n'
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate**不是**用户旧 YAML 中的 PRCXI_FooPlate
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
assert any(
("旧 schema" in str(w.message))
or ("vendors" in str(w.message))
or ("prcxi_class" in str(w.message))
for w in caught
)
def test_resolve_target_class_unknown_kind_returns_none():
"""target_device 存在、kind 不存在 → None。"""
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
# ==================== P6.1.1 新增用例slot_remap 按 device + model 分叉) ====================
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
"""型号级 slot_remap 优先级 > 厂商级。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
' rules: []\n'
' models:\n'
' "4040":\n'
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# device 级(不传 model→ "13"
assert lm.remap_slot("4", target_device="prcxi") == "13"
# model "4040" 覆盖 → "16"
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级rules 同理。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
' models:\n'
' "9320":\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules未声明 slot_remap
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
# model 9320 的 rules 覆盖 → PRCXI_PlateB
assert (
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
== "PRCXI_PlateB"
)
# 不传 model → 用厂商级 rules → PRCXI_PlateA
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
legacy = tmp_path / "labware_mapping.yaml"
legacy.write_text(
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
' default: {"4": "99"}\n'
' by_object: {}\n'
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# 整段走 builtin → "4" 仍然 → "13"builtin 值),**不是** YAML 顶层的 "99"
assert lm.remap_slot("4", target_device="prcxi") == "13"
assert any(
("顶层" in str(w.message) and "slot_remap" in str(w.message))
or ("旧 schema" in str(w.message))
for w in caught
)
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
"""未声明的 target_device → fallback 到 default.slot_remap + warning与 resolve_target_class 同语义)。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
assert any(
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
for w in caught
)
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remapmodel 名忽略)。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n', # 没有 prcxi 段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
"""target_devices.default.models 不被支持 → warning但 default.slot_remap 仍生效。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' models:\n' # ← default 段不支持 models
' "ghost":\n'
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99"
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
assert any(
("default" in str(w.message) and "models" in str(w.message))
for w in caught
)

View File

@@ -0,0 +1,178 @@
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
CLI 显式 ``-n/--workflow_name`` 永远最优先。
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
桩成可观察的捕获函数。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
# 让 import 走 Uni-Lab-OS 包根
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "unilabos"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture
def stub_upload(monkeypatch, tmp_path):
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``
返回 (helper, captured) 二元组:
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
并调用 ``upload_workflow``
- ``captured`` 是 dict记录 ``workflow_import`` 实际收到的 kwargs
以及 ``_convert_to_node_link`` 是否被调过。
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
package本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
delayed import。
"""
import types
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
def fake_workflow_import(**kwargs): # noqa: ANN003
captured["workflow_import_kwargs"] = kwargs
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
fake_web_pkg = types.ModuleType("unilabos.app.web")
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
from unilabos.workflow import wf_utils
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
captured["converted"] = True
# 返回最小合法 node-link 形态(不带 metadata模拟当前行为
return {"nodes": [], "edges": [], "workflow_uuid": ""}
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
wf_path = tmp_path / "transfer_actions_sample.json"
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
return helper, captured
# ==================== workflow_name fallback 链 ====================
def test_metadata_workflow_name_wins_over_filename(stub_upload):
"""P5 主路径transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs is not None and captured["converted"] is True
assert kwargs["name"] == "PCR Prep with Categories"
assert kwargs["workflow_name"] == "PCR Prep with Categories"
def test_cli_workflow_name_overrides_metadata(stub_upload):
"""CLI 显式 -n/--workflow_name 永远最优先。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "Metadata Wins By Default"},
"workflow": [],
"reagent": {},
}
helper(data, workflow_name="CLI Override Name")
kwargs = captured["workflow_import_kwargs"]
assert kwargs["name"] == "CLI Override Name"
assert kwargs["workflow_name"] == "CLI Override Name"
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
helper, captured = stub_upload
data = {"workflow": [], "reagent": {}} # 既无 metadata也无 workflow_name
helper(data)
kwargs = captured["workflow_import_kwargs"]
# 文件名由 fixture 固定为 transfer_actions_sample.json
assert kwargs["name"] == "transfer_actions_sample"
assert kwargs["workflow_name"] == "transfer_actions_sample"
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": " "}, # whitespace-only
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs["name"] == "transfer_actions_sample"
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
workflow_name``orig_metadata`` 为空时 fallback 到该字段。
"""
helper, captured = stub_upload
data = {
"nodes": [],
"edges": [],
"workflow_name": "Legacy Top Name",
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert captured["converted"] is False, "node-link 输入不应触发转换"
assert kwargs["name"] == "Legacy Top Name"
assert kwargs["workflow_name"] == "Legacy Top Name"
# ==================== tags fallback 链 ====================
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
"""P5 主路径metadata.tags 在 CLI 未传 tags 时被使用。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs["tags"] == ["Opentrons", "PCR"]
def test_cli_tags_override_metadata_tags(stub_upload):
"""CLI 显式 --tags 优先于 metadata.tags。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
"workflow": [],
"reagent": {},
}
helper(data, tags=["CLI", "Wins"])
kwargs = captured["workflow_import_kwargs"]
assert kwargs["tags"] == ["CLI", "Wins"]

View File

@@ -336,6 +336,27 @@ def parse_args():
default="",
help="Workflow description, used when publishing the workflow",
)
workflow_parser.add_argument(
"--target_device",
type=str,
default="prcxi",
help=(
"Target instrument name at vendor granularity (e.g. 'prcxi', 'beckman', 'tecan'). "
"Decides which target_devices.<name>.rules section in labware_mapping.yaml is used. "
"Unknown names fall back to target_devices.default. Default: 'prcxi'."
),
)
workflow_parser.add_argument(
"--target_model",
type=str,
default=None,
help=(
"Optional target instrument model name within the same vendor (e.g. '9320', '4040'). "
"Used to look up target_devices.<target_device>.models.<target_model>.slot_remap / "
".rules for model-specific deck layout or rule overrides. Falls back to the vendor-level "
"configuration when omitted or the model is not declared. Default: None."
),
)
return parser

View File

@@ -28,6 +28,15 @@ from pylabrobot.resources import (
)
from typing_extensions import TypedDict
from unilabos.devices.liquid_handling.liquid_history import (
LiquidHistoryEntry,
append_liquid_history as _append_liquid_history,
capture_tip_liquid_name as _capture_tip_liquid_name,
normalize_liquid_history as _normalize_liquid_history,
same_liquid_via_liquids as _same_liquid_via_liquids,
same_liquid_via_liquids_pair as _same_liquid_via_liquids_pair,
well_current_liquid_name as _well_current_liquid_name,
)
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.resources.resource_tracker import (
@@ -234,6 +243,13 @@ class LiquidHandlerMiddleware(LiquidHandler):
if spread == "":
spread = "custom"
# P9 — 在 super().aspirate 之前**预读**每个 source well 的液体名(用于 history 写入);
# super().aspirate 会消费 tracker.liquidsaspirate 后再读会拿不到液体身份。
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
liquid_names_before_aspirate: List[str] = [
_well_current_liquid_name(res) for res in resources
]
for i, res in enumerate(resources):
tracker = getattr(res, "tracker", None)
if tracker is None or getattr(tracker, "is_disabled", False):
@@ -263,9 +279,14 @@ class LiquidHandlerMiddleware(LiquidHandler):
try:
tracker.add_liquid(max(need - used, 1.0))
except Exception:
history = getattr(tracker, "liquid_history", None)
if isinstance(history, list):
history.append(("auto_init", max(fill_vol, need, 1.0)))
# P9 — 旧版 v2 tuple ``("auto_init", vol)`` 写入升级为 v3 dict
# 与 ``_append_liquid_history`` 写入形态保持一致。
_append_liquid_history(
res,
"auto_init",
float(max(fill_vol, need, 1.0)),
"auto_init",
)
if self._simulator:
try:
@@ -371,7 +392,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
else:
channels_to_use = use_channels
for resource, volume, channel in zip(resources, vols, channels_to_use):
for i, (resource, volume, channel) in enumerate(zip(resources, vols, channels_to_use)):
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
res_volumes.append(volume)
@@ -379,6 +400,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
EXTRA_SAMPLE_UUID: sample_uuid_value,
"volume": volume,
}
# P9 — aspirate history 写入 source wellvolume 取**负数**与 dispense/set 对称
# sum(history.volume) ≈ 残量name 取 aspirate 前预读的 liquid_name操作后 tracker
# 被 PLR 消费,此时读会拿不到液体身份)。
name_before = liquid_names_before_aspirate[i] if i < len(liquid_names_before_aspirate) else ""
_append_liquid_history(resource, name_before, -float(volume or 0.0), "aspirate")
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
@@ -523,6 +549,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
res_volumes.append(volume)
# P9 — dispense history 写入 target wellvolume 取**正数**
# name 从 target tracker.liquids 末项取PLR dispense 后 target tracker 顶层就是
# 本次新加的液体volume tracker bypass 路径下 name 可能为空字符串,符合预期。
target_liquid_name = _well_current_liquid_name(resource)
_append_liquid_history(resource, target_liquid_name, float(volume or 0.0), "dispense")
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
@@ -915,6 +946,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
backend_type = backend
self._simulator = simulator
self.group_info = dict()
# P10 v2 — Tip 复用判等开关;默认 onpop 出 kwargs 避免污染父类签名)。
# 详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.6。
self._tip_reuse_by_liquid_name: bool = bool(
kwargs.pop("tip_reuse_by_liquid_name", True)
)
super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs)
def post_init(self, ros_node: BaseROS2DeviceNode):
@@ -975,7 +1011,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
plr_history = getattr(plr_tracker, "liquid_history", None)
if (isinstance(local_history, list) and len(local_history) == 0
and isinstance(plr_history, list) and len(plr_history) > 0):
local_tracker.liquid_history = list(plr_history)
# P9 — 远端 history 归一为 v3 dictplr_history 可能仍是 v2 tuple
normalized_history = _normalize_liquid_history(plr_history)
local_tracker.liquid_history = normalized_history
elif (isinstance(local_history, list) and len(local_history) > 0
and isinstance(plr_history, list) and len(plr_history) == 0):
# 远端认为容器为空,重置本地 tracker 以保持同步
@@ -995,11 +1033,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
local_history = getattr(tracker, "liquid_history", None)
data = orig_dict.get("data") or {}
dict_history = data.get("liquid_history")
# P9 — 多形态升级v3 dict / v2 tuple / list[str] 全归一为 v3 dict 列表。
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
if isinstance(local_history, list) and len(local_history) == 0:
if isinstance(dict_history, list) and len(dict_history) > 0:
tracker.liquid_history = [
(name, float(vol)) for name, vol in dict_history
]
normalized_history = _normalize_liquid_history(dict_history)
tracker.liquid_history = normalized_history
elif isinstance(local_history, list) and len(local_history) > 0:
if isinstance(dict_history, list) and len(dict_history) == 0:
# 调用方认为容器为空,重置本地 tracker
@@ -1031,64 +1070,324 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
safe_volume = _clamp_volume(well, volume)
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
# P9 — set_liquid 是 history 的"播种"入口Stage 3 set_liquid_from_plate 节点会调到这里):
# 同时为 PLR tracker.liquids 写入 (name, vol) 和为扩展属性 tracker.liquid_history 写入
# 结构化 entry保证后续 OS→Cloud sync 能完整保留液体身份。
_append_liquid_history(well, liquid_name, safe_volume, "set")
res_volumes.append(safe_volume)
return SetLiquidReturn(
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore
)
def set_liquid_from_plate(
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
) -> SetLiquidFromPlateReturn:
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
如果 liquid_names 和 volumes 为空,但 plate well_names 不为空,直接返回 plate 和 wells。
"""
assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack) , f"plate must be a Plate, now: {type(plate)}"
plate: Union[Plate, TubeRack]
# 根据 well_names 获取对应的 Well 对象
def _resolve_wells_from_plate(
self,
plate: Union[Plate, TubeRack, ResourceSlot],
well_names: list[str],
) -> list[Well]:
"""旧签名兼容路径:plate + well_names → 顺序 Well 列表。"""
assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack), (
f"plate must be a Plate or TubeRack, now: {type(plate)}"
)
if issubclass(plate.__class__, Plate):
wells = [plate.get_well(name) for name in well_names]
elif issubclass(plate.__class__, TubeRack):
wells = [plate.get_tube(name) for name in well_names]
res_volumes = []
return [plate.get_well(name) for name in well_names] # type: ignore
return [plate.get_tube(name) for name in well_names] # type: ignore
# 如果 liquid_names 和 volumes 都为空,直接返回
def _coerce_well(self, w: Union[Well, Dict[str, Any]]) -> Well:
"""dict → PLR Well通过 self._ros_node.resource_tracker 同步解析Well 原样返回。
约定 dict 至少含 ``uuid`` 或 ``unilabos_uuid`` 字段,与
``_resolve_to_plr_resources`` 的入参 schema 对齐。
"""
if isinstance(w, Well):
return w
if isinstance(w, dict):
uid = w.get("uuid") or w.get("unilabos_uuid")
if uid is None:
raise TypeError(
f"dict 格式的 well 必须包含 uuid 或 unilabos_uuid 字段: {w!r}"
)
if not hasattr(self, "_ros_node") or self._ros_node is None:
raise ValueError(
"传入 dict 格式的 wells 时,需通过 post_init 注入 _ros_node"
"才能从物料系统按 uuid 解析为 PLR Well。"
)
matches = self._ros_node.resource_tracker.figure_resource(
{"uuid": uid}, try_mode=True
)
if not matches:
raise ValueError(
f"无法解析 well: uuid={uid!r} 未在 resource_tracker 中找到("
f"name={w.get('name')!r}, parent={w.get('parent')!r}"
)
return cast(Well, matches[0])
raise TypeError(f"无法解析 well: {w!r}")
def _set_liquid_grouped_by_plate(
self,
wells: list[Well],
liquid_names: list[str],
volumes: list[float],
) -> SetLiquidFromPlateReturn:
"""按 ``well.parent`` 分桶后多次 ``self.set_liquid``,最终按原顺序拼回 volumes。
作为 ``set_liquid_from_plate`` 的唯一执行路径(新旧两条入口都收敛到这里)。
"""
n = len(wells)
# 收集涉及的 plate 实例(按首次出现顺序),用于返回 plate 字段
plate_objs: List[Union[Plate, TubeRack]] = []
seen_plates: Set[str] = set()
for w in wells:
parent = getattr(w, "parent", None)
if parent is None:
continue
pname = getattr(parent, "name", None) or str(id(parent))
if pname in seen_plates:
continue
seen_plates.add(pname)
plate_objs.append(cast(Union[Plate, TubeRack], parent))
# 早返回liquid_names / volumes 均为空 → 仅回显 wells / plates
if not liquid_names and not volumes:
return SetLiquidFromPlateReturn(
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
volumes=res_volumes,
plate=ResourceTreeSet.from_plr_resources(plate_objs, known_newly_created=False).dump() if plate_objs else [], # type: ignore
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump() if wells else [], # type: ignore
volumes=[],
)
def _clamp_volume(resource: Union[Well, Container], volume: float) -> float:
# 防止初始化液量超过容器容量,导致后续 dispense 时 free volume 为负
clamped = max(float(volume), 0.0)
max_volume = getattr(resource, "max_volume", None)
if isinstance(max_volume, (int, float)) and max_volume > 0:
clamped = min(clamped, float(max_volume))
return clamped
if len(liquid_names) != n or len(volumes) != n:
raise ValueError(
f"set_liquid_from_plate: len(wells)={n}, len(liquid_names)={len(liquid_names)}, "
f"len(volumes)={len(volumes)} 三者必须等长"
)
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
safe_volume = _clamp_volume(well, volume)
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
res_volumes.append(safe_volume)
# 按 parent 分桶;记录原始 index 以便结果回拼
buckets: Dict[str, List[int]] = {}
for idx, w in enumerate(wells):
parent = getattr(w, "parent", None)
key = getattr(parent, "name", None) if parent is not None else None
key = key if key is not None else "_orphan"
buckets.setdefault(key, []).append(idx)
res_volumes: List[float] = [0.0] * n
# 按 plate 顺序串行 set_liquid避免设备物理碰撞 / 同板批量处理)
for plate_key, idxs in buckets.items():
sub_wells = [wells[i] for i in idxs]
sub_names = [liquid_names[i] for i in idxs]
sub_vols = [volumes[i] for i in idxs]
sub_ret = self.set_liquid(sub_wells, sub_names, sub_vols)
sub_ret_volumes = sub_ret.get("volumes", []) if isinstance(sub_ret, dict) else getattr(sub_ret, "volumes", [])
for local_idx, orig_idx in enumerate(idxs):
if local_idx < len(sub_ret_volumes):
res_volumes[orig_idx] = float(sub_ret_volumes[local_idx])
# 同步资源到 ROS每板独立 wells 列表,但 update_resource 一次性提交更高效)
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells})
task = ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True, **{"resources": wells}
)
submit_time = time.time()
while not task.done():
if time.time() - submit_time > 10:
self._ros_node.lab_logger().info(f"set_liquid_from_plate {plate} 超时")
self._ros_node.lab_logger().info(
f"set_liquid_from_plate (grouped) 超时, plates={list(buckets.keys())}"
)
break
time.sleep(0.01)
return SetLiquidFromPlateReturn(
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
plate=ResourceTreeSet.from_plr_resources(plate_objs, known_newly_created=False).dump() if plate_objs else [], # type: ignore
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
volumes=res_volumes,
)
def set_liquid_from_plate(
self,
wells: Optional[Sequence[Union[Well, Dict[str, Any]]]] = None,
liquid_names: Optional[list[str]] = None,
volumes: Optional[list[float]] = None,
*,
plate: Optional[Union[Plate, TubeRack, ResourceSlot]] = None,
well_names: Optional[list[str]] = None,
) -> SetLiquidFromPlateReturn:
"""按孔批量设定液体P3 框选化)。
优先路径(新签名,推荐):
set_liquid_from_plate(
wells=[well_obj_or_dict, ...],
liquid_names=["...", ...],
volumes=[v, ...],
)
``wells`` 中元素既可以是 PLR ``Well`` 实例,也可以是含 ``uuid`` 字段的 dict
(由 ``resource_tracker`` 同步解析);允许跨多 plate内部按 ``well.parent``
分桶后多次调用 :meth:`set_liquid`。
兼容路径(旧签名,仅在 ``wells`` 为 ``None`` 时启用):
set_liquid_from_plate(plate=plate, well_names=["A1","A2",...],
liquid_names=[...], volumes=[...])
Parameters
----------
wells
待设液的 Well 列表(含 PLR 实例或 dict 引用),跨板允许。
liquid_names
与 ``wells`` 等长的液体名列表。
volumes
与 ``wells`` 等长的体积列表µL内部会按容器容量上限 clamp。
plate, well_names
旧调用约定,仅当 ``wells`` 未传时生效。
"""
# ============================================================
# P3 框选化兼容修复:上游 ROS placeholder 在解析
# ``wells_identifier`` 边create_resource.labware → 本节点)时,
# 可能直接把单个 PLR Plate 资源 dict 写入 ``wells``,而非
# ``list[Well]``。多入边时只保留最后一条H7导致 §14 跨板
# merged 节点失去除最后 plate 外的入边。
#
# 检测此 schema 错位并按以下策略恢复:
# - 若 liquid_names 全相同 → 单 plate 场景wells 视为该 plate
# 走 plate + well_names 旧路径。
# - 若 liquid_names 含 distinct names§14 merged 跨板场景)→
# 按 liquid_names 逐个反查 resource_tracker 得到各自 plate
# 再用 well_names[i] 取 plate.get_well 构造跨板 wells 列表。
# ============================================================
if (
isinstance(wells, dict)
and "class" in wells
and well_names is not None
and (plate is None or (isinstance(plate, list) and len(plate) == 0))
):
# 判别单 plate vs 跨 plateliquid_names 是否含 distinct names
_ln = list(liquid_names or [])
_wn = list(well_names or [])
distinct_liquids = set(_ln) if _ln else set()
is_cross_plate = (
len(distinct_liquids) > 1
and len(_ln) == len(_wn)
and len(_wn) > 1
)
if is_cross_plate:
# 跨板 merged 场景:优先按 well_names 中的 "<plate_plr_name>/<well>" prefix
# 拆解逐个查 platecommon.py §14 fix 把 plate name 编码进 well_names
# 兜底:若 well_names 不含 "/",按 liquid_names 当 reagent_key 查(通常 miss
resolved_cross: list[Well] = []
cross_resolve_errors: list[str] = []
tracker = getattr(self, "_ros_node", None)
tracker = tracker.resource_tracker if tracker is not None else None
use_prefixed = all(isinstance(wn, str) and "/" in wn for wn in _wn)
for idx, (reagent_key, w_name) in enumerate(zip(_ln, _wn)):
try:
plate_instance = None
if use_prefixed and "/" in w_name:
# 主路径§14 fixwell_names[i] = "<plate_plr_name>/<well>"
plate_plr_name, real_well_name = w_name.rsplit("/", 1)
if tracker is not None:
figured = tracker.figure_resource(
{"name": plate_plr_name}, try_mode=True
)
if figured:
plate_instance = figured[0]
actual_well_name = real_well_name
else:
# 兜底legacy 形态well_names 是纯 well 名)
actual_well_name = w_name
if tracker is not None:
figured = tracker.figure_resource(
{"name": reagent_key}, try_mode=True
)
if figured:
plate_instance = figured[0]
else:
figured = tracker.figure_resource(
{"id": reagent_key}, try_mode=True
)
if figured:
plate_instance = figured[0]
if plate_instance is None:
cross_resolve_errors.append(
f"idx={idx} reagent_key={reagent_key!r} w_name={w_name!r}: resource_tracker miss"
)
continue
if not (
issubclass(plate_instance.__class__, Plate)
or issubclass(plate_instance.__class__, TubeRack)
):
cross_resolve_errors.append(
f"idx={idx} reagent_key={reagent_key!r}: not Plate/TubeRack (got {type(plate_instance).__name__})"
)
continue
if issubclass(plate_instance.__class__, Plate):
resolved_cross.append(plate_instance.get_well(actual_well_name))
else:
resolved_cross.append(plate_instance.get_tube(actual_well_name))
except Exception as _e:
cross_resolve_errors.append(
f"idx={idx} reagent_key={reagent_key!r} well={w_name!r}: {type(_e).__name__}: {_e}"
)
if len(resolved_cross) == len(_wn):
return self._set_liquid_grouped_by_plate(
resolved_cross,
_ln,
list(volumes or []),
)
# 跨板 fallback 解析失败 → 抛清晰错误,避免静默落回单 plate 单 well 错误降级。
# 触发原因通常是 legacy 工作流图common.py §14 fix 之前生成)的 well_names
# 缺少 "<plate_plr_name>/<well>" prefix导致 abstract 层无法跨板定位 plate。
raise ValueError(
"set_liquid_from_plate: 检测到 P2 v2 跨板 merged 节点"
f"liquid_names 含 {len(distinct_liquids)} 个 distinct names"
"但 well_names 解析失败 / 缺少 '<plate_plr_name>/<well>' prefix。"
"这通常是 LEGACY 工作流图(在 §14 well_names prefix fix 之前生成)。"
"请用最新版 common.py 重新转换 + 重新上传协议到 Cloud Lab。"
f"\n current well_names sample: {_wn[:3]}"
f"\n current liquid_names sample: {_ln[:3]}"
f"\n cross_resolve errors first3: {cross_resolve_errors[:3]}"
)
# 单 plate 兼容路径(或跨板解析失败 fallback
plate_data = wells
wells = None # 清空,让下面走旧路径
try:
if hasattr(self, "_ros_node") and self._ros_node is not None:
figured = self._ros_node.resource_tracker.figure_resource(
{"name": plate_data.get("name")}, try_mode=True
)
if figured:
plate = figured[0]
if plate is None or (isinstance(plate, list) and len(plate) == 0):
from unilabos.resources.resource_tracker import ResourceTreeSet
fallback_tree = ResourceTreeSet.from_raw_dict_list([plate_data])
plr_list = fallback_tree.to_plr_resources() if len(fallback_tree.trees) > 0 else []
if plr_list:
plate = plr_list[0]
except Exception:
pass
if wells is None:
if plate is None or well_names is None or (isinstance(plate, list) and len(plate) == 0):
raise ValueError(
"set_liquid_from_plate: 必须传 wells或同时传 plate + well_names"
)
resolved_wells = self._resolve_wells_from_plate(plate, well_names)
else:
resolved_wells = [self._coerce_well(w) for w in wells]
return self._set_liquid_grouped_by_plate(
resolved_wells,
list(liquid_names or []),
list(volumes or []),
)
# ---------------------------------------------------------------
# REMOVE LIQUID --------------------------------------------------
# ---------------------------------------------------------------
@@ -1579,28 +1878,33 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
# 辅助函数:
# - wrap=True: 返回 [value](用于 liquid_height 等列表参数)
# - wrap=False: 返回 value用于 mix_* 标量参数)
def safe_get(value, idx, default=None, wrap: bool = True):
if value is None:
return default
try:
if isinstance(value, (list, tuple)):
if len(value) == 0:
return default
item = value[idx % len(value)]
else:
item = value
return [item] if wrap else item
except Exception:
return default
# P10 v2 — 读取 tip 复用开关;测试 fixture 跳过 super().__init__ 时
# 用 getattr fallback 到 True保证默认行为一致。
tip_reuse_by_liquid_name = bool(getattr(self, "_tip_reuse_by_liquid_name", True))
if len(use_channels) != 8:
max_len = max(num_sources, num_targets, len_asp_vols, len_dis_vols)
prev_dropped = True # 循环开始前通道上无 tip
current_tip_liquid_name: Optional[str] = None # P10 v2tip 残液身份
for i in range(max_len):
# 辅助函数:
# - wrap=True: 返回 [value](用于 liquid_height 等列表参数)
# - wrap=False: 返回 value用于 mix_* 标量参数)
def safe_get(value, idx, default=None, wrap: bool = True):
if value is None:
return default
try:
if isinstance(value, (list, tuple)):
if len(value) == 0:
return default
item = value[idx % len(value)]
else:
item = value
return [item] if wrap else item
except Exception:
return default
# 动态构建参数字典,只传递实际提供的参数
kwargs = {
'sources': [sources[i%num_sources]],
@@ -1646,21 +1950,39 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
cur_source = sources[i % num_sources]
cur_target = targets[i % num_targets]
# drop: 仅当下一轮的 source 和 target 都相同时才保留 tip下一轮可以复用
# drop: identity-keep同 PLR Well 对象OR liquids-equivalence
# cur/next source ``tracker.liquids[-1]`` 同名)→ 任一命中即保留 tip。
drop_tip = True
if i < max_len - 1:
next_source = sources[(i + 1) % num_sources]
next_target = targets[(i + 1) % num_targets]
if cur_target is next_target and cur_source is next_source:
identity_keep = (cur_target is next_target) and (cur_source is next_source)
liquids_keep = (
tip_reuse_by_liquid_name
and _same_liquid_via_liquids_pair(cur_source, next_source)
)
if identity_keep or liquids_keep:
drop_tip = False
# pick_up: 仅当上一轮保留了 tip未 drop且 source 相同时才复用
# pick_up: identity-keep同 PLR Well 对象OR liquids-equivalence
# cur source ``tracker.liquids[-1]`` 与 tip 残液同名)→ 任一命中即复用 tip。
pick_up_tip = True
if i > 0 and not prev_dropped:
prev_source = sources[(i - 1) % num_sources]
if cur_source is prev_source:
identity_keep = (cur_source is prev_source)
liquids_keep = (
tip_reuse_by_liquid_name
and _same_liquid_via_liquids(cur_source, current_tip_liquid_name)
)
if identity_keep or liquids_keep:
pick_up_tip = False
# P10 v2 时序tip 残液名必须在 aspirate **之前**预读
# PLR aspirate 顶层归零时会 pop ``tracker.liquids`` 顶层)。
pending_tip_name: Optional[str] = None
if pick_up_tip:
pending_tip_name = _capture_tip_liquid_name(cur_source)
prev_dropped = drop_tip
kwargs['pick_up'] = pick_up_tip
@@ -1668,6 +1990,155 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
await self._transfer_base_method(**kwargs)
if pick_up_tip:
current_tip_liquid_name = pending_tip_name
if drop_tip:
current_tip_liquid_name = None
else:
# ---------------------------------------------------------------
# P1 v4 多通道分支use_channels=[0..7]sources / targets /
# asp_vols / dis_vols 长度均为 8 × MM 为列数 / 8 通道并发批次数)。
#
# 每段 8 个 wells + 8 个 vols 同时下发给 PLR一次完成 8 通道
# 并发 aspirate/dispense。M 段串行执行,对应 M 次 tip pickup /
# 8 通道 transfer 周期。
#
# flow_rates / blow_out_air_volume / blow_out_air_volume_before /
# liquid_height / delays / pre_aspirate_from_target 若长度也是 8M
# 则按段同切;若长度恰好为 M一段一值则 broadcast 到 8若长度
# 不足则按 idx % len 循环回退。
# ---------------------------------------------------------------
n_src_seg = max(1, num_sources // 8)
n_tgt_seg = max(1, num_targets // 8)
n_asp_seg = max(1, len_asp_vols // 8)
n_dis_seg = max(1, len_dis_vols // 8)
max_seg = max(n_src_seg, n_tgt_seg, n_asp_seg, n_dis_seg)
def _slice8(value, seg_idx):
"""从 ``value`` 抽出第 ``seg_idx`` 段(长度 8 的 list
- 若 ``value`` 长度是 8 的倍数 → 按段切 8 元素;
- 若 ``value`` 长度等于段数M → 取 ``value[seg_idx]`` 并 broadcast 到 8
- 单 scalar / 长度 1 → broadcast 到 8
- 其它 → 循环回退到 ``value[seg_idx % len(value)]`` 并 broadcast。
"""
if value is None:
return None
if isinstance(value, (int, float)):
return [float(value)] * 8
if not isinstance(value, (list, tuple)):
return None
if len(value) == 0:
return None
if len(value) % 8 == 0 and len(value) >= 8:
n = len(value) // 8
s = (seg_idx % n) * 8
return list(value[s : s + 8])
# 长度 == 段数 → 1 vol per segmentbroadcast 到 8 通道
item = value[seg_idx % len(value)]
return [item] * 8
prev_dropped = True
current_tip_liquid_name: Optional[str] = None # P10 v2tip 残液身份(段锚孔粒度)
for seg in range(max_seg):
src_slice = list(sources[(seg % n_src_seg) * 8 : (seg % n_src_seg + 1) * 8]) \
if num_sources >= 8 else [sources[seg % num_sources]] * 8
tgt_slice = list(targets[(seg % n_tgt_seg) * 8 : (seg % n_tgt_seg + 1) * 8]) \
if num_targets >= 8 else [targets[seg % num_targets]] * 8
asp_slice = asp_vols[(seg % n_asp_seg) * 8 : (seg % n_asp_seg + 1) * 8] \
if len_asp_vols >= 8 else [asp_vols[seg % len_asp_vols]] * 8
dis_slice = dis_vols[(seg % n_dis_seg) * 8 : (seg % n_dis_seg + 1) * 8] \
if len_dis_vols >= 8 else [dis_vols[seg % len_dis_vols]] * 8
kwargs = {
'sources': src_slice,
'targets': tgt_slice,
'tip_racks': tip_racks,
'use_channels': use_channels,
'asp_vols': asp_slice,
'dis_vols': dis_slice,
}
if asp_flow_rates is not None:
kwargs['asp_flow_rates'] = _slice8(asp_flow_rates, seg)
if dis_flow_rates is not None:
kwargs['dis_flow_rates'] = _slice8(dis_flow_rates, seg)
if offsets is not None:
kwargs['offsets'] = _slice8(offsets, seg)
if touch_tip is not None:
kwargs['touch_tip'] = bool(touch_tip)
if liquid_height is not None:
kwargs['liquid_height'] = _slice8(liquid_height, seg)
if blow_out_air_volume is not None:
kwargs['blow_out_air_volume'] = _slice8(blow_out_air_volume, seg)
if blow_out_air_volume_before is not None:
kwargs['blow_out_air_volume_before'] = _slice8(blow_out_air_volume_before, seg)
if spread is not None:
kwargs['spread'] = spread
# mix_* 仍按标量传递PLR 多通道 mix 用 use_channels 自动并发 8 wells
if mix_stage is not None:
kwargs['mix_stage'] = safe_get(mix_stage, seg, wrap=False)
if mix_times is not None:
kwargs['mix_times'] = safe_get(mix_times, seg, wrap=False)
if mix_vol is not None:
kwargs['mix_vol'] = safe_get(mix_vol, seg, wrap=False)
if mix_rate is not None:
kwargs['mix_rate'] = safe_get(mix_rate, seg, wrap=False)
if mix_liquid_height is not None:
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, seg, wrap=False)
if delays is not None:
kwargs['delays'] = _slice8(delays, seg)
if pre_aspirate_from_target is not None:
kwargs['pre_aspirate_from_target'] = _slice8(pre_aspirate_from_target, seg)
# 段间 tip 复用identity-keep同段锚孔 PLR Well 对象OR
# liquids-equivalence段锚孔 ``tracker.liquids[-1]`` 同名)→ 任一命中即保留 tip。
# 设计假设8 通道段内 8 wells 同液(由 P1 multi-channel-flatten 保证)。
cur_src_anchor = src_slice[0]
cur_tgt_anchor = tgt_slice[0]
drop_tip = True
if seg < max_seg - 1:
next_src_anchor = sources[((seg + 1) % n_src_seg) * 8] \
if num_sources >= 8 else sources[(seg + 1) % num_sources]
next_tgt_anchor = targets[((seg + 1) % n_tgt_seg) * 8] \
if num_targets >= 8 else targets[(seg + 1) % num_targets]
identity_keep = (cur_tgt_anchor is next_tgt_anchor) and (cur_src_anchor is next_src_anchor)
liquids_keep = (
tip_reuse_by_liquid_name
and _same_liquid_via_liquids_pair(cur_src_anchor, next_src_anchor)
)
if identity_keep or liquids_keep:
drop_tip = False
pick_up_tip = True
if seg > 0 and not prev_dropped:
prev_src_anchor = sources[((seg - 1) % n_src_seg) * 8] \
if num_sources >= 8 else sources[(seg - 1) % num_sources]
identity_keep = (cur_src_anchor is prev_src_anchor)
liquids_keep = (
tip_reuse_by_liquid_name
and _same_liquid_via_liquids(cur_src_anchor, current_tip_liquid_name)
)
if identity_keep or liquids_keep:
pick_up_tip = False
# P10 v2 时序tip 残液名必须在 aspirate **之前**预读
pending_tip_name: Optional[str] = None
if pick_up_tip:
pending_tip_name = _capture_tip_liquid_name(cur_src_anchor)
prev_dropped = drop_tip
kwargs['pick_up'] = pick_up_tip
kwargs['drop'] = drop_tip
await self._transfer_base_method(**kwargs)
if pick_up_tip:
current_tip_liquid_name = pending_tip_name
if drop_tip:
current_tip_liquid_name = None
return TransferLiquidReturn(
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
@@ -1703,22 +2174,68 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
delays = kwargs.get('delays')
pre_aspirate_from_target = kwargs.get('pre_aspirate_from_target')
# P1 v4 多通道:当 use_channels 长度 > 1如 8 通道)时,下层
# PLR aspirate/dispense 接受「N 个 resources + N 个 vols + N
# 个 use_channels」逐通道独立操作单通道时仍按 `[sources[0]]`
# / `[asp_vols[0]]` 单元素列表调用。
multi_channel = isinstance(use_channels, (list, tuple)) and len(use_channels) > 1
n_ch = len(use_channels) if multi_channel else 1
def _pad_to_n(lst, n, default=None):
"""把 list 截/扩到长度 nNone / 空列表返回 None。"""
if lst is None:
return None
if not isinstance(lst, (list, tuple)) or len(lst) == 0:
return None
if len(lst) >= n:
return list(lst[:n])
return list(lst) + [default if default is not None else lst[-1]] * (n - len(lst))
if multi_channel:
asp_resources = list(sources[:n_ch]) if len(sources) >= n_ch else list(sources)
dis_resources = list(targets[:n_ch]) if len(targets) >= n_ch else list(targets)
asp_vols_arg = list(asp_vols[:n_ch])
dis_vols_arg = list(dis_vols[:n_ch])
asp_flow_arg = _pad_to_n(asp_flow_rates, n_ch) if asp_flow_rates else None
dis_flow_arg = _pad_to_n(dis_flow_rates, n_ch) if dis_flow_rates else None
asp_liquid_h = _pad_to_n(liquid_height, n_ch) if liquid_height else None
dis_liquid_h = _pad_to_n(liquid_height, n_ch) if liquid_height else None
asp_offsets = _pad_to_n(offsets, n_ch) if offsets else None
dis_offsets = _pad_to_n(offsets, n_ch) if offsets else None
# mix 仍以 anchor well 调用,让 use_channels 在 PLR 内部并发列扩展
mix_src_anchor = [sources[0]]
mix_tgt_anchor = [targets[0]]
else:
asp_resources = [sources[0]]
dis_resources = [targets[0]]
asp_vols_arg = [asp_vols[0]]
dis_vols_arg = [dis_vols[0]]
asp_flow_arg = [asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None
dis_flow_arg = [dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None
asp_liquid_h = [liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None
dis_liquid_h = [liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None
asp_offsets = [offsets[0]] if offsets and len(offsets) > 0 else None
dis_offsets = [offsets[0]] if offsets and len(offsets) > 0 else None
mix_src_anchor = [sources[0]]
mix_tgt_anchor = [targets[0]]
tip = []
if pick_up:
tip.append(self._get_next_tip())
await self.pick_up_tips(tip,use_channels=use_channels)
blow_out_air_volume_before_vol = 0.0
if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0:
blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0)
blow_out_air_volume_vol = 0.0
if blow_out_air_volume is not None and len(blow_out_air_volume) > 0:
blow_out_air_volume_vol = float(blow_out_air_volume[0] or 0.0)
# P1 v4blow_before / blow_after 是每通道独立的,列表长度应为 n_ch。
# 标量化处理(取 first 非零)用于决定是否触发 before-aspirate下发到
# PLR 时仍按通道列表传递。
blow_before_list = _pad_to_n(blow_out_air_volume_before, n_ch) if blow_out_air_volume_before else None
blow_after_list = _pad_to_n(blow_out_air_volume, n_ch) if blow_out_air_volume else None
blow_out_air_volume_before_vol = float(blow_before_list[0] or 0.0) if blow_before_list else 0.0
blow_out_air_volume_vol = float(blow_after_list[0] or 0.0) if blow_after_list else 0.0
# PLR 的 blow_out_air_volume 是空气参数,不计入液体体积。
# before 空气通过单独预吸实现after 空气通过 blow_out_air_volume 参数实现。
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[sources[0]],
targets=mix_src_anchor,
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
@@ -1729,18 +2246,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if blow_out_air_volume_before_vol > 0:
source_tracker = getattr(sources[0], "tracker", None)
source_tracker_was_disabled = bool(getattr(source_tracker, "is_disabled", False))
try:
if source_tracker is not None and hasattr(source_tracker, "disable"):
source_tracker.disable()
await self.aspirate(
resources=[sources[0]],
vols=[0],
resources=asp_resources,
vols=[0] * len(asp_resources),
use_channels=use_channels,
flow_rates=None,
offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())],
offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())] * len(asp_resources),
liquid_height=None,
blow_out_air_volume=[blow_out_air_volume_before_vol],
blow_out_air_volume=(
blow_before_list if multi_channel
else [blow_out_air_volume_before_vol]
),
spread="custom",
)
finally:
@@ -1748,34 +2267,44 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
source_tracker.enable()
await self.aspirate(
resources=[sources[0]],
vols=[asp_vols[0]],
resources=asp_resources,
vols=asp_vols_arg,
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
flow_rates=asp_flow_arg,
offsets=asp_offsets,
liquid_height=asp_liquid_h,
blow_out_air_volume=(
[blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None
blow_after_list if (multi_channel and blow_after_list and any((v or 0) > 0 for v in blow_after_list))
else ([blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None)
),
spread=spread,
)
if delays is not None:
if delays is not None and len(delays) > 0:
await self.custom_delay(seconds=delays[0])
# 合并 before/after 空气体积逐通道dispense 时一次性吐回。
if multi_channel:
blow_for_dispense = [
float(((blow_after_list[k] if blow_after_list else 0) or 0)
+ ((blow_before_list[k] if blow_before_list else 0) or 0))
for k in range(n_ch)
]
else:
blow_for_dispense = [blow_out_air_volume_vol + blow_out_air_volume_before_vol]
await self.dispense(
resources=[targets[0]],
vols=[dis_vols[0]],
resources=dis_resources,
vols=dis_vols_arg,
use_channels=use_channels,
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
blow_out_air_volume=[blow_out_air_volume_vol+blow_out_air_volume_before_vol],
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
flow_rates=dis_flow_arg,
offsets=dis_offsets,
blow_out_air_volume=blow_for_dispense,
liquid_height=dis_liquid_h,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[0]],
targets=mix_tgt_anchor,
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,

View File

@@ -0,0 +1,221 @@
"""P9 — liquid_history schema v3 与 helper 函数。
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点set_liquid / aspirate /
dispense调用且由 ``resource_tracker._augment_states_with_liquid_history`` 在
serialize 链路使用。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
"""
from __future__ import annotations
from typing import Any, List, Tuple
from typing_extensions import TypedDict
# liquid_history 元素 schema v3
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
# 旧格式v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
class LiquidHistoryEntry(TypedDict, total=False):
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 ""
volume: float # 操作体积µLaspirate 为负dispense / set 为正)
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
timestamp: str # ISO8601 UTCOS runtime 写入时填,前端写入时可省略)
# liquid_history 单 well 上限:超过则滚动丢弃头部
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
LIQUID_HISTORY_MAX_ENTRIES = 1000
def well_current_liquid_name(well: Any) -> str:
"""从 ``well.tracker.liquids`` 末项读取当前液体名PLR ``Liquid`` enum / str / None 兼容)。
P9作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return ""
liquids = getattr(tracker, "liquids", None)
if not liquids:
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)]
try:
liquids = tracker.get_liquids() # type: ignore[attr-defined]
except Exception:
liquids = None
if not liquids:
return ""
last = liquids[-1]
if isinstance(last, (list, tuple)) and last:
candidate = last[0]
else:
candidate = last
if candidate is None:
return ""
name = getattr(candidate, "name", None)
if isinstance(name, str) and name:
return name
if isinstance(candidate, str):
return candidate
return ""
def append_liquid_history(
well: Any,
liquid_name: str,
volume: float,
action: str,
) -> None:
"""P9 — 统一写入 ``well.tracker.liquid_history``PLR 扩展属性)。
设计要点:
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
:class:`LiquidHistoryEntry` schema 一致。
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
``sum(history.volume)`` ≈ 当前残量)。
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return
history = getattr(tracker, "liquid_history", None)
if not isinstance(history, list):
history = []
try:
tracker.liquid_history = history # type: ignore[attr-defined]
except Exception:
return # tracker 拒绝写扩展属性(极少见);静默放弃
# 兼容修复PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
# 这里把历史就地归一为 tuple 形态,再 append tuple避免 unpack ValueError。
normalized_pairs: List[Tuple[str, float]] = []
for item in history:
if isinstance(item, (list, tuple)) and len(item) >= 2:
name_val = str(item[0] or "")
try:
vol_val = float(item[1])
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, dict):
name_val = str(item.get("name", ""))
try:
vol_val = float(item.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, str):
normalized_pairs.append((item, 0.0))
history[:] = normalized_pairs
entry = (str(liquid_name or ""), float(volume))
history.append(entry)
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
if overflow > 0:
del history[:overflow]
# ---------------------------------------------------------------------------
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper详见
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2
#
# 设计原则:
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"
# 而非 P9 扩展属性 ``liquid_history``P10 v2 因此不依赖 P9 是否落地。
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式aspirate 之前预读
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
# 的环境下独立运行。
# ---------------------------------------------------------------------------
def is_known_liquid_name(name: Any) -> bool:
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
if not name:
return False
if not isinstance(name, str):
return False
return name.strip().lower() not in {"unknown", "none"}
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip
"""
if not is_known_liquid_name(tip_liquid_name):
return False
well_name = well_current_liquid_name(well)
if not is_known_liquid_name(well_name):
return False
return well_name == tip_liquid_name
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
注:必须在 cur_well 的 aspirate **之前**调用aspirate 不改
``liquids[-1].name`` 只改顶层 vol或顶层归零时 pop故 cur/next 的判等
"将要被抽的那一层" 为准。
"""
cur_name = well_current_liquid_name(cur_well)
next_name = well_current_liquid_name(next_well)
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
return False
return cur_name == next_name
def capture_tip_liquid_name(source_well: Any) -> "str | None":
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
完成后 tip 上残留液体的身份。
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取PLR
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
同样的 "预读" 模式。
"""
name = well_current_liquid_name(source_well)
return name if is_known_liquid_name(name) else None
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
兼容输入:
- v3 dict ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
- v2 tuple ``[(name, vol), ...]`` → ``action="legacy"``
- list[str] ``["A", "B"]`` → ``volume=0, action="legacy"``
- 其它:丢弃该 entry
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
"""
if not isinstance(raw, list):
return []
result: List[Tuple[str, float]] = []
for entry in raw:
if isinstance(entry, dict):
try:
vol_val = float(entry.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry.get("name", "")), vol_val))
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
try:
vol_val = float(entry[1])
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry[0] or ""), vol_val))
elif isinstance(entry, str):
result.append((entry, 0.0))
# 其它类型静默丢弃
return result

View File

@@ -1191,9 +1191,21 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
return super().set_liquid(wells, liquid_names, volumes)
def set_liquid_from_plate(
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
self,
wells: Optional[Sequence[Union[Well, Dict[str, Any]]]] = None,
liquid_names: Optional[list[str]] = None,
volumes: Optional[list[float]] = None,
*,
plate: Optional[ResourceSlot] = None,
well_names: Optional[list[str]] = None,
) -> SetLiquidFromPlateReturn:
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
return super().set_liquid_from_plate(
wells=wells,
liquid_names=liquid_names,
volumes=volumes,
plate=plate,
well_names=well_names,
)
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
return super().set_group(group_name, wells, volumes)
@@ -1355,16 +1367,36 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
tip_rack = tip_racks[0]
else:
tip_rack = tip_racks[0].parent
# 小体积单通道 head 切换:仅当 caller 没显式指定多通道时才生效。
# P1 v4 多通道协议use_channels=[0..7])即便体积 ≤ 10uL 也应保留 8 通道,
# 避免把 dis_vols=[8.3]*8 这种「8 通道每孔 8.3uL」的展开退化为单通道串行。
small_vols = all(v <= 10.0 for v in _asp_list) and all(v <= 10.0 for v in _dis_list)
if small_vols and self._tip_rack_is_10ul_range(tip_rack):
_explicit_multi = isinstance(use_channels, (list, tuple)) and len(use_channels) > 1
if small_vols and self._tip_rack_is_10ul_range(tip_rack) and not _explicit_multi:
use_channels = [1]
mix_vol = max(min(mix_vol, 10), 0) if mix_vol is not None else None
# P2 v2跨板 transfer_liquid 场景下 sources / targets 列表里可能引用多个 plate
# v1 旧实现只取 [0] 会漏掉 slot 3/5/6 的位置同步)。这里改为遍历所有 source/target
# 的 parent plate按首次出现顺序去重——既保证跨板都能 update_pipetting_position
# 又避免同板多孔重复发送。详见 02-cross-slot-merge.md §3.3.2 / §9.5 step 5。
change_slots = []
change_slots.append(sources[0].parent)
change_slots.append(targets[0].parent)
seen_plates = set()
def _push_unique_plate(plate_obj):
if plate_obj is None:
return
pname = getattr(plate_obj, "name", None) or id(plate_obj)
if pname in seen_plates:
return
seen_plates.add(pname)
change_slots.append(plate_obj)
for src in sources:
_push_unique_plate(getattr(src, "parent", None))
for tgt in targets:
_push_unique_plate(getattr(tgt, "parent", None))
_push_unique_plate(tip_rack)
change_slots.append(tip_rack)
self.tip_height = tip_rack.children[0].get_size_z()
change_slots_positions = []

View File

@@ -850,9 +850,11 @@ liquid_handler:
plate: null
volumes: null
well_names: null
wells: null
handles: {}
placeholder_keys:
plate: unilabos_resources
wells: unilabos_resources
result: {}
schema:
description: ''
@@ -952,9 +954,89 @@ liquid_handler:
items:
type: string
type: array
wells:
items:
additionalProperties: false
properties:
category:
type: string
children:
items:
type: string
type: array
config:
type: string
data:
type: string
id:
type: string
name:
type: string
parent:
type: string
pose:
additionalProperties: false
properties:
orientation:
additionalProperties: false
properties:
w:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
- y
- z
- w
title: orientation
type: object
position:
additionalProperties: false
properties:
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
- y
- z
title: position
type: object
required:
- position
- orientation
title: pose
type: object
sample_id:
type: string
type:
type: string
title: well
type: object
type: array
required:
- plate
- well_names
- liquid_names
- volumes
type: object
@@ -9373,13 +9455,19 @@ liquid_handler.prcxi:
plate: null
volumes: null
well_names: null
wells: null
handles:
input:
- data_key: '@this.0@@@wells'
data_source: handle
data_type: resource
handler_key: wells_identifier
label: 待设定液体孔(框选)
- data_key: '@this.0@@@plate'
data_source: handle
data_type: resource
handler_key: input_plate
label: 待设定液体板
label: 待设定液体板(兼容 fallback
output:
- data_key: plate.@flatten
data_source: executor
@@ -9398,6 +9486,7 @@ liquid_handler.prcxi:
label: 各孔设定体积
placeholder_keys:
plate: unilabos_resources
wells: unilabos_resources
result: {}
schema:
description: ''
@@ -9497,9 +9586,89 @@ liquid_handler.prcxi:
items:
type: string
type: array
wells:
items:
additionalProperties: false
properties:
category:
type: string
children:
items:
type: string
type: array
config:
type: string
data:
type: string
id:
type: string
name:
type: string
parent:
type: string
pose:
additionalProperties: false
properties:
orientation:
additionalProperties: false
properties:
w:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
- y
- z
- w
title: orientation
type: object
position:
additionalProperties: false
properties:
x:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
y:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
z:
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
required:
- x
- y
- z
title: position
type: object
required:
- position
- orientation
title: pose
type: object
sample_id:
type: string
type:
type: string
title: well
type: object
type: array
required:
- plate
- well_names
- liquid_names
- volumes
type: object

View File

@@ -33,6 +33,42 @@ RETURN_UNILABOS_SAMPLES = "unilabos_samples"
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
def _augment_states_with_liquid_history(
resource: "PLRResource",
states: Dict[str, Any],
) -> None:
"""P9 — 把 Uni-Lab 在 PLR tracker 上挂的 ``liquid_history`` 扩展属性并入
``serialize_all_state()`` 返回的 well state dict。
PLR 原生 ``serialize_all_state()`` 只输出 ``{liquids, pending_liquids}``
会丢失 ``tracker.liquid_history``。本 helper 递归遍历资源树,把每个有 tracker 的
节点的 ``liquid_history`` 写入 ``states[node.name]["liquid_history"]``。
设计要点:
- 不可变:若 ``states[name]`` 已有 ``liquid_history`` 字段则不覆盖(向后兼容)。
- 列表浅拷贝:避免运行时 mutation 影响 dump 结果。
- 节点无 tracker / tracker 无 ``liquid_history`` 属性 → 跳过(不写默认 ``[]``
否则会污染非 well 节点 state
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3。
"""
def _walk(node: "PLRResource") -> None:
name = getattr(node, "name", None)
if isinstance(name, str) and name in states:
tracker = getattr(node, "tracker", None)
if tracker is not None:
history = getattr(tracker, "liquid_history", None)
if isinstance(history, list):
state = states[name]
if isinstance(state, dict) and "liquid_history" not in state:
state["liquid_history"] = list(history)
for child in getattr(node, "children", ()) or ():
_walk(child)
_walk(resource)
class LabSample(TypedDict):
sample_uuid: str
oss_path: str
@@ -577,6 +613,11 @@ class ResourceTreeSet(object):
serialized_data = resource.serialize()
all_states = resource.serialize_all_state()
# P9 — PLR 原生 serialize_all_state 只输出 {liquids, pending_liquids}
# 丢弃 Uni-Lab 在 tracker 上挂的扩展属性 liquid_history。在这里把它并回 state dict
# 以确保 OS→Cloud sync 链路完整保留液体历史。
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3。
_augment_states_with_liquid_history(resource, all_states)
# 根节点没有父节点,传入 None
root_instance = resource_plr_inner(serialized_data, None, all_states, uuid_list)

View File

@@ -26,7 +26,7 @@
"is_9320": true,
"timeout": 10,
"matrix_id": "",
"simulator": false,
"simulator": true,
"channel_num": 2,
"step_mode": false,
"calibration_points": {

View File

@@ -23,7 +23,7 @@
- 遍历所有 reagent按 slot 去重,为每个唯一的 slot 创建一个板子
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点minimized=true
- 生成参数:
res_id / 节点 name / display_name: {匹配后的 prcxi 类名}_slot_{槽位}
res_id / 节点 name / display_name: {匹配后的 target 类名}_slot_{槽位}
device_id: /PRCXI
class_name: 与 res_id 中类型一致(如 PRCXI 384/96 孔板注册类)
parent: /PRCXI/PRCXI_Deck
@@ -38,12 +38,18 @@
- 首先创建一个 Group 节点type="Group", minimized=true用于包含所有 set_liquid_from_plate 节点
- 遍历所有 reagent为每个试剂创建 set_liquid_from_plate 节点
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点minimized=true
- 生成参数:
- 生成参数P3 框选化,新主路径):
wells: [
{id, name, parent: labware_id, type: "well"},
...
]list[dict],每孔一个资源引用;前端通过 placeholder 框选 well 时回填 uuid
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 wells 数量一致)
volumes: [1e5, 1e5, 1e5](与 wells 数量一致,默认体积)
# 兼容字段(旧 runtime / 旧 schema fallback:
plate: [](通过连接传递,来自 create_resource 的 labware
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致)
volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积
- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate)
- 输入连接: create_resource (labware) -> set_liquid_from_plate (wells_identifier)
P3 §3.4.3 简化方案source_port 仍为 labwareplaceholder 内部把 labware.wells.@flatten 映射到 wells 字段
- 输出端口: output_wells用于连接 transfer_liquid
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联
@@ -69,7 +75,7 @@
物料流:
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
(slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier)
(slot=1) (cell_lines) (wells_identifier) (sources_identifier) (sources_identifier)
(slot=4) (Liquid_1) (targets_identifier) (targets_identifier)
==================== 端口映射 ====================
@@ -78,8 +84,8 @@ create_resource:
输出: labware
set_liquid_from_plate:
输入: input_plate
输出: output_plate, output_wells
输入: wells -> wells_identifierP3 主路径input_plate 作旧 schema fallback 仍存在)
输出: output_plate, output_wells, output_volumes
transfer_liquid:
输入: sources -> sources_identifier, targets -> targets_identifier
@@ -102,11 +108,24 @@ transfer_liquid:
import re
import uuid
import warnings
import networkx as nx
from networkx.drawing.nx_agraph import to_agraph
import matplotlib.pyplot as plt
from typing import Dict, List, Any, Tuple, Optional
from typing import Dict, List, Any, Set, Tuple, Optional
from unilabos.workflow.labware_mapping import (
infer_kind as _yaml_infer_kind,
remap_slot as _yaml_remap_slot,
resolve_target_class as _yaml_resolve_target_class,
)
# P6.1 默认目标仪器caller 不显式传 target_device 时使用。
# 注意:这里写 "prcxi" 是 P6 历史兜底(与原版 _tip_prcxi_class_for_max_ul、
# _apply_prcxi_labware_auto_match 走 PRCXI 模板的语义一致),与 YAML
# 顶层是否声明 prcxi 段无关。
DEFAULT_TARGET_DEVICE = "prcxi"
Json = Dict[str, Any]
@@ -142,14 +161,25 @@ PARAM_RENAME_MAPPING = {
}
def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
"""协议槽位 -> 实际 deck4→138→1412+trash→16其余不变。"""
s = "" if raw_slot is None else str(raw_slot).strip()
if not s:
return ""
if s == "12" and (object_type or "").strip().lower() == "trash":
return "16"
return {"4": "13", "8": "14"}.get(s, s)
def _map_deck_slot(
raw_slot: str,
object_type: str = "",
*,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> str:
"""协议槽位 -> 实际 deck默认 4→138→1412+trash→16其余不变。
P6.1.1``slot_remap`` 内嵌在 ``target_devices.<target_device>`` 下,
可由 ``target_devices.<target_device>.models.<target_model>.slot_remap`` 进一步覆盖。
转调 :func:`labware_mapping.remap_slot`,走 4 段 fallback 链model → device → default → builtin
"""
return _yaml_remap_slot(
raw_slot,
object_type,
target_device=target_device,
target_model=target_model,
)
def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
@@ -169,27 +199,16 @@ def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str:
def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
ot = (item.get("object") or "").strip().lower()
if ot == "trash":
return "trash"
if ot == "tiprack":
return "tip_rack"
lid = _labware_hint_text(labware_id, item)
if "trash" in lid:
return "trash"
# tiprack / tip + rack顺序在 tuberack 之前)
if "tiprack" in lid or ("tip" in lid and "rack" in lid):
return "tip_rack"
# 离心管架 / OpenTrons tuberack勿与 96 tiprack 混淆)
if "tuberack" in lid or "tube_rack" in lid:
return "tube_rack"
if "eppendorf" in lid and "rack" in lid:
return "tube_rack"
if "safelock" in lid and "rack" in lid:
return "tube_rack"
if "rack" in lid and "tip" not in lid:
return "tube_rack"
return "plate"
"""labware → ``plate / tip_rack / tube_rack / trash``。
P6转调 ``labware_mapping.infer_kind``,匹配规则由
``Uni-Lab-OS/labware_mapping.yaml`` 的 ``kinds`` 段声明(顺序敏感、首个命中胜出)。
object 字段(``trash`` / ``tiprack``)优先级保留在 YAML loader 内部。
"""
return _yaml_infer_kind(
_labware_hint_text(labware_id, item),
(item.get("object") or "") if isinstance(item, dict) else "",
)
def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int:
@@ -231,8 +250,15 @@ def _infer_plate_num_children_from_wells(wells: Any) -> Optional[int]:
def _infer_plate_num_children_from_labware_hint(labware_id: str, item: Dict[str, Any]) -> Optional[int]:
"""从 labware 命名(如 custom_384_wellplate、nest_96_wellplate解析孔数供模板匹配。"""
hint = _labware_hint_text(labware_id, item)
"""从 labware 命名(如 custom_384_wellplate、nest_96_wellplate解析孔数供模板匹配。
P6 hint bug 修复2026-05-22hint 只用 ``item["labware"]``**不**拼上
``labware_id``reagent_key 业务名,如 ``samples_6``、``samples_24`` 末尾数字
会被宽松正则 ``[_\\s](\\d+)[_\\s]`` 误识别为孔板规格,进而触发
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板,
最终把同 deck 槽位上所有 reagent 的 ``target_class_name`` unify 成错误的 trough class
"""
hint = str(item.get("labware", "") or "").lower()
m = re.search(
r"\b(1536|384|96|48|24|12|6)(\s*[-_]?\s*well|wellplate|_well_)",
hint,
@@ -291,20 +317,24 @@ def _flatten_transfer_vols(value: Any) -> List[float]:
return []
def _tip_prcxi_class_for_max_ul(max_ul: float) -> str:
"""按移液最大体积分档推介 PRCXI tip 类名≤10 µL → 10µL<300 → 300µL否则 1000µL。"""
if max_ul <= 10:
return "PRCXI_10uL_Tips"
if max_ul < 300:
return "PRCXI_300ul_Tips"
return "PRCXI_1000uL_Tips"
def _apply_tip_rack_class_from_transfer_volumes(
labware_info: Dict[str, Dict[str, Any]],
protocol_steps_refactored: List[Dict[str, Any]],
*,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> None:
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``prcxi_class_name``。"""
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``target_class_name``。
P6.1tip 量程档不再硬编码 PRCXI 三档,改为查
``labware_mapping.yaml`` 的 ``target_devices.<target_device>.rules``tip_rack
+ hole_count=96 + volume_max 闭区间。YAML 未命中时 fallback 到
``CLASS_NAMES_MAPPING['tip_rack']``(保守默认 PRCXI_300ul_Tips
P6.1.1``target_model`` 透传给 :func:`_yaml_resolve_target_class`
允许同厂商不同型号声明不同 tip 量程档(如 PRCXI 9320 与 4040 用同档,
Beckman i7 与 i5 可能用不同档)。
"""
tip_to_max_ul: Dict[str, float] = {}
for step in protocol_steps_refactored:
@@ -326,13 +356,17 @@ def _apply_tip_rack_class_from_transfer_volumes(
step_max = max(nums)
tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)
default_tip_cls = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
for tip_key, max_ul in tip_to_max_ul.items():
item = labware_info.get(tip_key)
if item is None:
continue
if _infer_reagent_kind(tip_key, item) != "tip_rack":
continue
item["prcxi_class_name"] = _tip_prcxi_class_for_max_ul(max_ul)
cls = _yaml_resolve_target_class(
target_device, "tip_rack", hole_count=96, volume=max_ul, target_model=target_model
)
item["target_class_name"] = cls if cls else default_tip_cls
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
@@ -377,13 +411,24 @@ def _match_score_prcxi_template(
return hole_diff * 1000 + vol_diff
def _apply_prcxi_labware_auto_match(
def _apply_target_labware_class_auto_match(
labware_info: Dict[str, Dict[str, Any]],
labware_defs: Optional[List[Dict[str, Any]]] = None,
*,
preserve_tip_rack_incoming_class: bool = True,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> None:
"""上传构建图前:按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名,写入 ``prcxi_class_name``。
"""上传构建图前:按孔数 + 容量将 reagent 条目匹配到目标仪器物料注册类名,写入 ``target_class_name``。
P6.1 流程:
1. 先查 ``labware_mapping.yaml`` 的 ``target_devices.<target_device>.rules``
(未声明的 target_device 由 :func:`_yaml_resolve_target_class` 自动 fallback
到固定段 ``target_devices.default``);命中直接采用。
2. YAML 未命中(孔数 / 体积超出表内规则覆盖范围)→ 走 ``prcxi_labware``
注册模板打分匹配 fallback并打 warning 提示「请补到映射表」。
若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。
``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或
@@ -394,19 +439,19 @@ def _apply_prcxi_labware_auto_match(
default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
# P6.1:模板 fallback 只在 prcxi_labware 可导入且非空时启用YAML 查表路径**始终**生效。
# 这样在最小 Python 环境(无 pylabrobotYAML 命中也能写入 target_class_name。
templates: List[Dict[str, Any]] = []
try:
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
templates = list(get_prcxi_labware_template_specs() or [])
except Exception:
return
templates = get_prcxi_labware_template_specs()
if not templates:
return
templates = []
def_map = _labware_def_index(labware_defs)
for labware_id, item in labware_info.items():
if item.get("prcxi_class_name"):
if item.get("target_class_name"):
continue
kind = _infer_reagent_kind(labware_id, item)
@@ -416,12 +461,12 @@ def _apply_prcxi_labware_auto_match(
if inc_s == default_prcxi_tip_class:
inc_s = ""
if inc_s:
item["prcxi_class_name"] = inc_s
item["target_class_name"] = inc_s
continue
explicit = item.get("class_name") or item.get("class")
if explicit and str(explicit).startswith("PRCXI_"):
item["prcxi_class_name"] = str(explicit)
item["target_class_name"] = str(explicit)
continue
extra = def_map.get(str(labware_id), {})
@@ -456,6 +501,20 @@ def _apply_prcxi_labware_auto_match(
if kind == "tip_rack" and child_max_volume_f is None:
child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0
# P6.1: 先查 labware_mapping.yaml命中直接采用跳过 PRCXI 模板打分匹配
# P6.1.1: target_model 透传,允许型号级 rules 覆盖
yaml_cls = _yaml_resolve_target_class(
target_device,
kind,
hole_count=num_children if kind != "trash" else None,
volume=child_max_volume_f,
target_model=target_model,
)
if yaml_cls:
item["target_class_name"] = yaml_cls
continue
# YAML 未命中fallback 到 PRCXI 模板打分匹配(保留历史行为)+ warning 提示补表
candidates = [t for t in templates if t["kind"] == kind]
if not candidates:
continue
@@ -473,21 +532,38 @@ def _apply_prcxi_labware_auto_match(
best = t
if best:
item["prcxi_class_name"] = best["class_name"]
item["target_class_name"] = best["class_name"]
warnings.warn(
f"labware {labware_id!r} (kind={kind}, holes={num_children}, vol={child_max_volume_f}) "
f"未在 labware_mapping.yaml 的 target_devices.{target_device}.rules / "
f"target_devices.default.rules 中命中,已用 PRCXI 模板兜底 {best['class_name']}"
f"建议在 labware_mapping.yaml 中补一条对应规则。"
)
def _reconcile_slot_carrier_prcxi_class(
def _reconcile_slot_carrier_target_class(
labware_info: Dict[str, Dict[str, Any]],
*,
preserve_tip_rack_incoming_class: bool = False,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> None:
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``prcxi_class_name``,避免先遍历到 96 板后槽位被错误绑定。
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``target_class_name``,避免先遍历到 96 板后槽位被错误绑定。
``preserve_tip_rack_incoming_class=True`` 时tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。"""
``preserve_tip_rack_incoming_class=True`` 时tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。
P6.1.1``target_device`` / ``target_model`` 透传给 :func:`_map_deck_slot`
保证「同槽位归并」按目标仪器型号的实际 deck 物理布局进行。
"""
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
for lid, item in labware_info.items():
ot = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), ot)
slot = _map_deck_slot(
str(item.get("slot", "")),
ot,
target_device=target_device,
target_model=target_model,
)
if not slot:
continue
by_slot.setdefault(str(slot), []).append((lid, item))
@@ -506,7 +582,7 @@ def _reconcile_slot_carrier_prcxi_class(
for lid, it in pairs_sorted:
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
continue
c = it.get("prcxi_class_name")
c = it.get("target_class_name")
if c:
best_cls = c
break
@@ -515,7 +591,7 @@ def _reconcile_slot_carrier_prcxi_class(
for lid, it in pairs:
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
continue
it["prcxi_class_name"] = best_cls
it["target_class_name"] = best_cls
# ---------------- Graph ----------------
@@ -737,6 +813,188 @@ def refactor_data(
return refactored_data
MERGED_TARGETS_SYNTHETIC_PREFIX = "_merged_targets_"
def _collect_set_liquid_coverage(
protocol_steps: List[Dict[str, Any]],
) -> Tuple[Set[str], Set[str]]:
"""P2 v2 §14预扫描 transfer_liquid 的 ``params.targets``,统计 reagent_key 覆盖关系。
输入要求:``protocol_steps`` 已经过 :func:`refactor_data` 标准化,每个 step 形如
``{"template_name": "transfer_liquid", "param": {"targets": ...}, ...}``。
Returns
-------
(covered_by_merged, referenced_by_str)
``covered_by_merged`` —— 所有出现在某个 ``list[str] targets`` 中的 reagent_keys。
``referenced_by_str`` —— 所有以 ``str`` 形态出现在 ``targets`` 中的 reagent_keys。
用途
----
第二步循环(``for labware_id, item in labware_info.items()``)根据这两个集合
判断某 target reagent_key 是否完全被 ``_emit_merged_set_liquid`` 接管:
若 ``key ∈ covered_by_merged ∧ key ∉ referenced_by_str``,则跳过 per-plate
``set_liquid_from_plate`` 节点(避免冗余)。
详见 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14。
"""
covered_by_merged: Set[str] = set()
referenced_by_str: Set[str] = set()
for step in protocol_steps:
if step.get("template_name") != "transfer_liquid":
continue
tgt = (step.get("param") or {}).get("targets")
if isinstance(tgt, list):
for t in tgt:
if isinstance(t, str) and t:
covered_by_merged.add(t)
elif isinstance(tgt, str) and tgt:
referenced_by_str.add(tgt)
return covered_by_merged, referenced_by_str
def _emit_merged_set_liquid(
G: "WorkflowGraph",
target_reagent_keys: List[str],
labware_info: Dict[str, Dict[str, Any]],
slot_to_create_resource: Dict[str, str],
*,
set_liquid_group_id: str,
merged_index: int,
target_device: str,
target_model: Optional[str],
) -> Tuple[str, str]:
"""P2 v2为含 ``list[str] targets`` 的 transfer_liquid 节点插入一个 merged
``set_liquid_from_plate`` 跨板聚合节点。
详见 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.2 / §9.5。
构造逻辑:
1. 按 ``target_reagent_keys`` 顺序遍历,逐 key 使用独立 cursor 取
``labware_info[key].well[cursor % len(wells)]`` 作为该 dispense 对应的 well 名;
wells 列表为空时退化为 ``key`` 本身(不带 ``/<well>`` 后缀)。
2. 把每个 dispense 的 ``{id, name, parent: key, type: "well"}`` 顺序压入
merged 节点的 ``param.wells``——这是 v2 的「顺序权威」载体(构造期固化)。
3. 多入边:对每个 distinct reagent_key 涉及的 plate从对应 ``create_resource``
节点连一条 ``labware → wells_identifier`` 入边(同 plate 不重复连接)。
4. 注册一个 synthetic str ``_merged_targets_<idx>``,供 caller 改写
``params.targets`` 与 ``resource_last_writer`` 映射。
Returns
-------
(synthetic_key, merged_node_id)
``synthetic_key`` —— 写入到 ``transfer_liquid.params.targets``str 形态),
以及 ``resource_last_writer[synthetic_key] = f"{merged_node_id}:output_wells"``。
``merged_node_id`` —— 新插入节点的 UUID。
"""
# 每个 reagent_key 一个 cursor按 dispense 顺序推进mod 处理同 well 重复 dispense
cursor: Dict[str, int] = {}
merged_wells: List[Dict[str, Any]] = []
liquid_names: List[str] = []
# P2 v2 §14 fix2026-05-22merged 节点的 well_names 用 "<plate_plr_name>/<well>" 形态
# 编码每个 dispense 对应的 PLR Plate 实例名,让 abstract 层 fallback 能定位跨板 plate
# (否则 ROS placeholder 的 wells_identifier 多入边只保留最后一个 plate导致跨板信息丢失
# plate_plr_name 复用 create_resource 节点的命名约定: f"{target_class_name}_slot_{mapped_slot}".
well_names_prefixed: List[str] = []
for key in target_reagent_keys:
info = labware_info.get(key) or {}
wells = info.get("well") or []
idx = cursor.get(key, 0)
if wells:
well_name = wells[idx % len(wells)]
ref_id = f"{key}/{well_name}"
else:
well_name = None
ref_id = key
cursor[key] = idx + 1
merged_wells.append({
"id": ref_id,
"name": ref_id,
"parent": key,
"type": "well",
})
# P82026-05-24reagent block 显式 ``liquid_name`` 字段优先,作为写入 PLR
# tracker / 前端的真实化学名;缺省时 fallback 到 reagent_key行为不变
# 详见 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4。
ln_value = info.get("liquid_name") or str(key)
liquid_names.append(ln_value)
# 计算 PLR Plate name 给 well_names prefix跨板 fallback 用)
object_type = info.get("object", "") or ""
mapped_slot = _map_deck_slot(
str(info.get("slot", "")),
object_type,
target_device=target_device,
target_model=target_model,
)
target_class = info.get("target_class_name") or ""
if target_class and mapped_slot and well_name:
plate_plr_name = f"{target_class}_slot_{mapped_slot}".replace(" ", "_")
well_names_prefixed.append(f"{plate_plr_name}/{well_name}")
elif well_name:
# target_class 未知时仅写 well 名abstract 层会走单 plate fallback
# 跨板信息丢失,但至少不破坏单 slot 协议)
well_names_prefixed.append(well_name)
else:
well_names_prefixed.append("")
merged_node_id = str(uuid.uuid4())
synthetic_key = f"{MERGED_TARGETS_SYNTHETIC_PREFIX}{merged_index}"
G.add_node(
merged_node_id,
template_name="set_liquid_from_plate",
resource_name="liquid_handler.prcxi",
name=synthetic_key,
display_name=f"MergedTargets({len(set(target_reagent_keys))}p×{len(merged_wells)}w)",
description=f"Merged set_liquid_from_plate: targets={target_reagent_keys}",
lab_node_type="Reagent",
footer="set_liquid_from_plate-liquid_handler.prcxi",
device_name=DEVICE_NAME_DEFAULT,
type=NODE_TYPE_DEFAULT,
parent_uuid=set_liquid_group_id,
minimized=True,
param={
"wells": merged_wells,
"liquid_names": liquid_names,
# volumes=0target plate 不预先注入液体,仅占位(同 per-plate set_liquid 行为)。
"volumes": [0] * len(merged_wells),
# 兼容字段:保留 plate/well_names 让旧 runtime / 旧前端可继续解析
"plate": [],
# 升级well_names 元素为 "<plate_plr_name>/<well>" 形态(含跨板 plate 定位信息),
# abstract 层 set_liquid_from_plate 的 schema_fallback 会按 "/" 拆解逐个查 plate。
"well_names": well_names_prefixed,
},
)
# 多入边:对每个 distinct plate 接一条 create_resource.labware → wells_identifier
seen_keys: set = set()
for key in target_reagent_keys:
if key in seen_keys:
continue
seen_keys.add(key)
info = labware_info.get(key) or {}
object_type = info.get("object", "") or ""
mapped_slot = _map_deck_slot(
str(info.get("slot", "")),
object_type,
target_device=target_device,
target_model=target_model,
)
cr_node = slot_to_create_resource.get(mapped_slot)
if cr_node:
G.add_edge(
cr_node,
merged_node_id,
source_port="labware",
target_port="wells_identifier",
)
return synthetic_key, merged_node_id
def build_protocol_graph(
labware_info: Dict[str, Dict[str, Any]],
protocol_steps: List[Dict[str, Any]],
@@ -744,6 +1002,8 @@ def build_protocol_graph(
action_resource_mapping: Optional[Dict[str, str]] = None,
labware_defs: Optional[List[Dict[str, Any]]] = None,
preserve_tip_rack_incoming_class: bool = False,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> WorkflowGraph:
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
@@ -755,9 +1015,16 @@ def build_protocol_graph(
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack都走模板匹配。
target_device: P6.1 新增。目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明的
名字由 :func:`labware_mapping.resolve_target_class` 自动 fallback 到固定段
``target_devices.default``。默认 ``"prcxi"``(与历史 P6 完全等价)。
target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``
决定查 ``target_devices.<target_device>.models.<target_model>`` 下的 ``slot_remap`` /
``rules`` 覆盖。``None`` 表示不区分型号,走厂商级配置。
会先 ``refactor_data`` 规范化步骤,再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
``tip_racks`` 写入 ``prcxi_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips````<300`` → ``PRCXI_300ul_Tips``
``tip_racks`` 写入 ``target_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips````<300`` → ``PRCXI_300ul_Tips``
否则 ``PRCXI_1000uL_Tips``);无有效体积的步骤不覆盖。
"""
G = WorkflowGraph()
@@ -765,24 +1032,38 @@ def build_protocol_graph(
slot_to_create_resource = {} # slot -> create_resource node_id
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
_apply_tip_rack_class_from_transfer_volumes(labware_info, protocol_steps)
_apply_tip_rack_class_from_transfer_volumes(
labware_info,
protocol_steps,
target_device=target_device,
target_model=target_model,
)
_apply_prcxi_labware_auto_match(
_apply_target_labware_class_auto_match(
labware_info,
labware_defs,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
target_device=target_device,
target_model=target_model,
)
_reconcile_slot_carrier_prcxi_class(
_reconcile_slot_carrier_target_class(
labware_info,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
target_device=target_device,
target_model=target_model,
)
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 target_class_name / object 会被其它条目盖住
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
for labware_id, item in labware_info.items():
object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
slot = _map_deck_slot(
str(item.get("slot", "")),
object_type,
target_device=target_device,
target_model=target_model,
)
if not slot:
continue
by_slot.setdefault(slot, []).append((labware_id, item))
@@ -795,25 +1076,25 @@ def build_protocol_graph(
tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
chosen_lid = ""
chosen_item: Dict[str, Any] = {}
prcxi_val: Optional[str] = None
target_class_val: Optional[str] = None
scan = tip_pairs if tip_pairs else pairs
for lid, it in scan:
c = it.get("prcxi_class_name")
c = it.get("target_class_name")
if c:
chosen_lid, chosen_item, prcxi_val = lid, it, str(c)
chosen_lid, chosen_item, target_class_val = lid, it, str(c)
break
if not chosen_lid and scan:
chosen_lid, chosen_item = scan[0]
pv = chosen_item.get("prcxi_class_name")
prcxi_val = str(pv) if pv else None
pv = chosen_item.get("target_class_name")
target_class_val = str(pv) if pv else None
labware = str(chosen_item.get("labware", "") or "")
slots_info[slot] = {
"labware": labware,
"labware_id": chosen_lid,
"object": chosen_item.get("object", "") or "",
"prcxi_class_name": prcxi_val,
"target_class_name": target_class_val,
}
# 创建 Group 节点,包含所有 create_resource 节点
@@ -838,7 +1119,7 @@ def build_protocol_graph(
node_id = str(uuid.uuid4())
object_type = info.get("object", "") or ""
ot_lo = str(object_type).strip().lower()
matched = info.get("prcxi_class_name")
matched = info.get("target_class_name")
if ot_lo == "trash":
res_type_name = "PRCXI_trash"
elif matched:
@@ -898,6 +1179,13 @@ def build_protocol_graph(
param=None,
)
# P2 v2 §14预扫描 list-targets / str-targets 覆盖关系,
# 第二步循环将跳过被 merged 节点完全接管的 target reagent_keys避免冗余 per-plate 节点)。
# 详见 product_designs/protocol_convert/02-cross-slot-merge.md §14。
set_liquid_covered_by_merged, set_liquid_referenced_by_str = _collect_set_liquid_coverage(
protocol_steps
)
set_liquid_index = 0
for labware_id, item in labware_info.items():
@@ -908,7 +1196,23 @@ def build_protocol_graph(
continue
object_type = item.get("object", "") or ""
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
# P2 v2 §14被 merged 节点完全接管的 target reagent_key 跳过 per-plate 创建。
# 仅当 object="target" ∧ key ∈ covered_by_merged ∧ key ∉ referenced_by_str 时才跳过;
# 共用 key被 list 与 str 双重引用)必须保留 per-plate否则 str transfer 失去 output_wells 来源R1 缓解)。
if (
object_type == "target"
and labware_id in set_liquid_covered_by_merged
and labware_id not in set_liquid_referenced_by_str
):
continue
slot = _map_deck_slot(
str(item.get("slot", "")),
object_type,
target_device=target_device,
target_model=target_model,
)
wells = item.get("well", [])
if not wells or not slot:
continue
@@ -918,14 +1222,33 @@ def build_protocol_graph(
well_count = len(wells)
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
# P82026-05-24reagent block 显式 ``liquid_name`` 字段优先于 reagent_key
# 用于写入 PLR tracker / 前端显示的真实化学名(保留空格 / 中文 / 括号等,
# **不** 经过 ``replace(" ", "_")``)。缺省时 fallback 到 ``res_id``(行为不变)。
# 详见 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4。
liquid_name_value = str(item.get("liquid_name") or res_id)
node_id = str(uuid.uuid4())
set_liquid_index += 1
prcxi_mat = item.get("prcxi_class_name")
if prcxi_mat:
sl_node_title = f"{prcxi_mat}_slot_{slot}_{res_id}"
target_class = item.get("target_class_name")
if target_class:
sl_node_title = f"{target_class}_slot_{slot}_{res_id}"
else:
sl_node_title = f"lab_{res_id.lower()}_slot_{slot}_{set_liquid_index}"
# P3 框选化:新主路径 = param.wellslist[dict],每孔一个资源引用),
# 端口 target_port="wells_identifier"。
# 旧字段plate / well_names仍写入 param 作 fallback便于旧 runtime / 旧 schema 解析。
well_resource_refs = [
{
"id": f"{labware_id}/{w}",
"name": f"{labware_id}/{w}",
"parent": labware_id,
"type": "well",
}
for w in wells
]
G.add_node(
node_id,
template_name="set_liquid_from_plate",
@@ -940,19 +1263,30 @@ def build_protocol_graph(
parent_uuid=set_liquid_group_id, # 指向 Group 节点
minimized=True, # 折叠显示
param={
"plate": [], # 通过连接传递
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
"liquid_names": [res_id] * well_count,
# P3 新主路径wells 框选化list[well_resource_ref]
"wells": well_resource_refs,
"liquid_names": [liquid_name_value] * well_count,
"volumes": [liquid_volume] * well_count,
# 兼容字段:保留 plate / well_names 以便旧 runtime / 旧前端继续工作;
# 新 yaml schema 已将 required 改为 [liquid_names, volumes]
"plate": [],
"well_names": wells,
},
)
# set_liquid_from_plate 之间不需要 ready 连接
# 物料流create_resource 的 labware -> set_liquid_from_plate 的 input_plate
# 物料流create_resource 的 labware -> set_liquid_from_plate 的 wells_identifier
# P3 §3.4.3 简化方案source_port 仍为 labware目标端口换为 wells_identifier
# placeholder 内部把 labware.wells.@flatten 映射到 wells 字段)
create_res_node_id = slot_to_create_resource.get(slot)
if create_res_node_id:
G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate")
G.add_edge(
create_res_node_id,
node_id,
source_port="labware",
target_port="wells_identifier",
)
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
resource_last_writer[labware_id] = f"{node_id}:output_wells"
@@ -1001,6 +1335,9 @@ def build_protocol_graph(
"liquid_height",
]
# P2 v2跨板 transfer_liquid 的 merged set_liquid_from_plate 节点计数器
merged_set_liquid_counter = 0
# 处理协议步骤
for step in protocol_steps:
node_id = str(uuid.uuid4())
@@ -1064,6 +1401,53 @@ def build_protocol_graph(
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
params["delays"] = normalized_delays
# use_channels 输入归一化P1 多通道意图透传):
# - 与 LiquidHandler.transfer_liquid 的 use_channels: Optional[List[int]] 入参对齐
# - None / 缺失 / 非 list 一律删除该 key让 runtime 走自动选头默认逻辑
# - 不参与 EXPAND_BY_WELLS_PARAMSuse_channels 是「这条 transfer 用哪些通道」的常量,
# 长度由通道数决定(单通道 [0]/[1]、8 通道 [0..7]),与 targets 的 wells 数无关。
if "use_channels" in params:
uc_value = params["use_channels"]
if uc_value is None:
params.pop("use_channels")
elif isinstance(uc_value, list):
try:
params["use_channels"] = [int(x) for x in uc_value]
except (TypeError, ValueError):
warnings.append(f"use_channels 列表中存在无法转换为 int 的值: {uc_value},已忽略")
params.pop("use_channels")
else:
warnings.append(
f"use_channels 期望 list[int],实际 {type(uc_value).__name__},已忽略"
)
params.pop("use_channels")
# ============================================================
# P2 v2 跨板聚合:当 params.targets 是 list[str] 时,插入一个
# merged set_liquid_from_plate 节点把跨板 wells 聚合成有序 List[Container]
# 然后改写 params.targets 为 synthetic str。详见
# product_designs/protocol_convert/02-cross-slot-merge.md §9.2。
# ============================================================
raw_targets = params.get("targets")
if (
isinstance(raw_targets, list)
and len(raw_targets) > 0
and all(isinstance(t, str) and t for t in raw_targets)
):
synth_key, merged_node_id = _emit_merged_set_liquid(
G,
raw_targets,
labware_info,
slot_to_create_resource,
set_liquid_group_id=set_liquid_group_id,
merged_index=merged_set_liquid_counter,
target_device=target_device,
target_model=target_model,
)
merged_set_liquid_counter += 1
params["targets"] = synth_key
resource_last_writer[synth_key] = f"{merged_node_id}:output_wells"
# 处理输入连接
for param_key, target_port in INPUT_PORT_MAPPING.items():
resource_name = params.get(param_key)
@@ -1081,9 +1465,21 @@ def build_protocol_graph(
targets_wells_count = 1
sources_wells_count = 1
# P2 v2synthetic merged targets key_merged_targets_<idx>)不在 labware_info 中,
# wells 数量从 dis_vols 长度推断,且不打「未在 reagent 中定义」warning。
targets_is_synthetic = (
isinstance(targets_name, str)
and targets_name.startswith(MERGED_TARGETS_SYNTHETIC_PREFIX)
)
if targets_name and targets_name in labware_info:
target_wells = labware_info[targets_name].get("well", [])
targets_wells_count = len(target_wells) if target_wells else 1
elif targets_is_synthetic:
# merged set_liquid 的 wells 长度 == dis_vols 长度(顺序权威由 Stage 3 构造期固化)
dis_vols_val = params.get("dis_vols")
if isinstance(dis_vols_val, list) and dis_vols_val:
targets_wells_count = len(dis_vols_val)
elif targets_name:
warnings.append(f"targets={targets_name} 未在 reagent 中定义")
@@ -1093,13 +1489,28 @@ def build_protocol_graph(
elif sources_name:
warnings.append(f"sources={sources_name} 未在 reagent 中定义")
# 检查 sources 和 targets 的 wells 数量是否匹配
if targets_wells_count != sources_wells_count and targets_name and sources_name:
# 检查 sources 和 targets 的 wells 数量是否匹配v2 跨板1:N 是合法的,跳过 warning
if (
targets_wells_count != sources_wells_count
and targets_name
and sources_name
and not targets_is_synthetic
and sources_wells_count not in (0, 1)
):
warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")
# 使用 targets 的 wells 数量来扩展参数
wells_count = targets_wells_count
# P1 多通道use_channels 存在且 len > 1multi 协议)时,
# asp_vols / dis_vols 等数组的长度已是 8 × MStage 2 复制完毕),
# 与 reagent.well 长度plate=8 / reservoir=1不一定相等——跳过 wells 长度对齐警告,
# 让长度由 use_channels × 列锚条目决定。
is_multi_channel = (
isinstance(params.get("use_channels"), list)
and len(params.get("use_channels", [])) > 1
)
# 扩展单值参数为数组(根据 targets 的 wells 数量)
for expand_param in EXPAND_BY_WELLS_PARAMS:
if expand_param in params:
@@ -1107,8 +1518,8 @@ def build_protocol_graph(
# 如果是单个值,扩展为数组
if not isinstance(value, list):
params[expand_param] = [value] * wells_count
# 如果已经是数组但长度不对,记录警告
elif len(value) != wells_count:
# 如果已经是数组但长度不对,记录警告multi 通道场景下跳过)
elif len(value) != wells_count and not is_multi_channel:
warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")
# 如果 sources/targets 已通过连接传递,将参数值改为空数组
@@ -1140,10 +1551,17 @@ def build_protocol_graph(
last_control_node_id = node_id
# 处理输出:更新 resource_last_writer
# P2 v2``step.param[param_key]`` 可能是 list[str](跨板 reagent_keys
# 此时为每个 reagent_key 注册 transfer_liquid 的下游 writer保留多 reagent
# 链式 transfer 的能力。
for param_key, output_port in OUTPUT_PORT_MAPPING.items():
resource_name = step.get("param", {}).get(param_key) # 使用原始参数值
if resource_name:
resource_last_writer[resource_name] = f"{node_id}:{output_port}"
raw_value = step.get("param", {}).get(param_key) # 使用原始参数值
if isinstance(raw_value, list):
for name in raw_value:
if isinstance(name, str) and name:
resource_last_writer[name] = f"{node_id}:{output_port}"
elif raw_value:
resource_last_writer[raw_value] = f"{node_id}:{output_port}"
return G

View File

@@ -21,11 +21,12 @@ JSON 工作流转换模块
"""
import json
import warnings
from os import PathLike
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
from unilabos.workflow.common import DEFAULT_TARGET_DEVICE, WorkflowGraph, build_protocol_graph
from unilabos.registry.registry import lab_registry
@@ -206,11 +207,40 @@ def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, A
return normalized
def _load_json_data(data: Union[str, PathLike, Dict[str, Any]]) -> Dict[str, Any]:
"""统一加载 JSON 输入。
支持三种形态:
1. ``str`` / ``PathLike`` 指向磁盘文件 → ``json.load``
2. ``str``(非文件路径)→ ``json.loads`` 解析为 dict
3. ``dict`` → 直接返回
抽出此 helper 是为了让 :func:`convert_from_json` 和
:func:`convert_json_to_workflow_envelope` 都能复用,
后者需要在传给 :func:`convert_from_json` **之前**先读出顶层
``metadata`` 段,而 :func:`convert_from_json` 自身的 schema 校验
不感知 ``metadata`` 字段。
"""
if isinstance(data, (str, PathLike)):
path = Path(data)
if path.exists():
with path.open("r", encoding="utf-8") as fp:
return json.load(fp)
if isinstance(data, str):
return json.loads(data)
raise FileNotFoundError(f"文件不存在: {data}")
if isinstance(data, dict):
return data
raise TypeError(f"不支持的数据类型: {type(data)}")
def convert_from_json(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION,
validate: bool = True,
preserve_tip_rack_incoming_class: bool = False,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> WorkflowGraph:
"""
从 JSON 数据或文件转换为 WorkflowGraph
@@ -224,6 +254,12 @@ def convert_from_json(
validate: 是否校验句柄配置,默认 True
preserve_tip_rack_incoming_class: True默认时仅 tip_rack 不跑模板、按传入类名/labware其它载体仍自动匹配。
False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。
target_device: P6.1 新增。目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明
的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。
target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``
决定 ``target_devices.<target_device>.models.<target_model>`` 段的 ``slot_remap`` /
``rules`` 覆盖。``None`` 表示走厂商级配置。
Returns:
WorkflowGraph: 构建好的工作流图
@@ -233,22 +269,9 @@ def convert_from_json(
FileNotFoundError: 文件不存在
json.JSONDecodeError: JSON 解析失败
"""
# 处理输入数据
if isinstance(data, (str, PathLike)):
path = Path(data)
if path.exists():
with path.open("r", encoding="utf-8") as fp:
json_data = json.load(fp)
elif isinstance(data, str):
json_data = json.loads(data)
else:
raise FileNotFoundError(f"文件不存在: {data}")
elif isinstance(data, dict):
json_data = data
else:
raise TypeError(f"不支持的数据类型: {type(data)}")
json_data = _load_json_data(data)
# 校验格式
# 校验格式``metadata`` 段为 P5 新增可选顶层字段,不参与校验)
if "workflow" not in json_data or "reagent" not in json_data:
raise ValueError(
"不支持的 JSON 格式。请使用标准格式:\n"
@@ -278,6 +301,8 @@ def convert_from_json(
action_resource_mapping=ACTION_RESOURCE_MAPPING,
labware_defs=labware_defs,
preserve_tip_rack_incoming_class=preserve,
target_device=target_device,
target_model=target_model,
)
# 校验句柄配置
@@ -296,6 +321,8 @@ def convert_json_to_node_link(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION,
preserve_tip_rack_incoming_class: bool = False,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> Dict[str, Any]:
"""
将 JSON 数据转换为 node-link 格式的字典
@@ -303,6 +330,8 @@ def convert_json_to_node_link(
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi"
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
Returns:
Dict: node-link 格式的工作流数据
@@ -311,6 +340,8 @@ def convert_json_to_node_link(
data,
workstation_name,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
target_device=target_device,
target_model=target_model,
)
return graph.to_node_link_dict()
@@ -319,6 +350,8 @@ def convert_json_to_workflow_list(
data: Union[str, PathLike, Dict[str, Any]],
workstation_name: str = DEFAULT_WORKSTATION,
preserve_tip_rack_incoming_class: bool = True,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
将 JSON 数据转换为工作流列表格式
@@ -326,6 +359,8 @@ def convert_json_to_workflow_list(
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串
workstation_name: 工作站名称,默认 "PRCXi"
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
Returns:
List: 工作流节点列表
@@ -334,5 +369,119 @@ def convert_json_to_workflow_list(
data,
workstation_name,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
target_device=target_device,
target_model=target_model,
)
return graph.to_dict()
# ==================== P5 — Workflow envelope ====================
def convert_json_to_workflow_envelope(
data: Union[str, PathLike, Dict[str, Any]],
*,
target_lab_uuid: str = "",
workflow_uuid: str = "",
workflow_name: Optional[str] = None,
name: Optional[str] = None,
tags: Optional[List[str]] = None,
workstation_name: str = DEFAULT_WORKSTATION,
preserve_tip_rack_incoming_class: bool = False,
target_device: str = DEFAULT_TARGET_DEVICE,
target_model: Optional[str] = None,
) -> Dict[str, Any]:
"""把 transfer_actions JSON 转换为带「外壳」的 Cloud Lab 上传格式。
与 :func:`convert_json_to_node_link` 的差异:本函数在 ``nodes / edges``
之外补齐了前端 / Cloud 上传接口期望的顶层字段
``target_lab_uuid`` / ``name`` / ``data.workflow_uuid`` /
``data.workflow_name`` / ``data.tags``),并保持 ``nodes / edges`` 字节级
与 :func:`convert_json_to_node_link` 完全一致。
参数优先级(自顶向下取首个非空):
1. 显式传入:``workflow_name`` / ``tags`` / ``name``。
2. 输入 JSON 顶层 ``metadata`` 段:``metadata.workflow_name`` /
``metadata.tags``(由 Stage 2 ``export_transfer_actions`` 写入)。
3. 回退:空字符串 / 空列表,并打 :mod:`warnings` warning。
UUID 类字段(``target_lab_uuid`` / ``workflow_uuid``**不**自动生成;
缺省保留空字符串,由调用方(前端 / 上传接口)写入。这样转换器输出
的同一份协议是字节稳定的,便于 batch diff 与回归。
Args:
data: JSON 文件路径、字典数据、或 JSON 字符串。
支持 P5 新增的顶层 ``metadata`` 字段,缺失时 fallback 空。
target_lab_uuid: 目标实验台 UUID默认空字符串。
workflow_uuid: 工作流 UUID默认空字符串后端持久化时生成
workflow_name: 工作流名称;缺省时取 ``metadata.workflow_name``。
name: 列表页面展示标题;缺省时镜像 ``workflow_name``。
tags: 工作流标签;缺省时取 ``metadata.tags``。
workstation_name: 透传给 :func:`convert_from_json`。
preserve_tip_rack_incoming_class: 透传给 :func:`convert_from_json`。
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
Returns:
外壳化的 dict::
{
"target_lab_uuid": str,
"name": str,
"data": {
"workflow_uuid": str,
"workflow_name": str,
"tags": List[str],
"nodes": [...],
"edges": [...]
}
}
"""
json_data = _load_json_data(data)
# 1) 解析 P5 新增的顶层 metadata 段
meta = json_data.get("metadata") if isinstance(json_data, dict) else None
if not isinstance(meta, dict):
meta = {}
resolved_name = workflow_name if workflow_name else str(meta.get("workflow_name") or "")
if tags is None:
meta_tags = meta.get("tags")
resolved_tags: List[str] = list(meta_tags) if isinstance(meta_tags, (list, tuple)) else []
else:
resolved_tags = list(tags)
if not resolved_name:
warnings.warn(
"convert_json_to_workflow_envelope: workflow_name 为空,"
"请检查 transfer_actions JSON 的 metadata.workflow_name 或显式传入 workflow_name"
)
if not resolved_tags:
warnings.warn(
"convert_json_to_workflow_envelope: tags 为空,"
"请检查 README.md 的 ## Categories 段或显式传入 tags"
)
# 2) 复用 convert_from_json 构图metadata 段对图构建透明)
graph = convert_from_json(
json_data,
workstation_name,
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
target_device=target_device,
target_model=target_model,
)
node_link = graph.to_node_link_dict()
# 3) 组装外壳name 默认镜像 workflow_name显式传入时覆盖
return {
"target_lab_uuid": target_lab_uuid,
"name": name if name is not None else resolved_name,
"data": {
"workflow_uuid": workflow_uuid,
"workflow_name": resolved_name,
"tags": resolved_tags,
"nodes": node_link.get("nodes", []),
"edges": node_link.get("edges", []),
},
}

View File

@@ -0,0 +1,443 @@
"""Opentrons → 目标仪器 物料映射表加载与查询P6 / P6.1 / P6.1.1)。
YAML 文件位置(默认):``Uni-Lab-OS/labware_mapping.yaml``(项目根,与
``pyproject.toml`` 同级,最显眼)。
模块对外暴露 4 个 API
- :func:`remap_slot` ← 替代 ``_map_deck_slot``
- :func:`infer_kind` ← 替代 ``_infer_reagent_kind`` 的字符串匹配链
- :func:`resolve_target_class` ← 替代 ``_tip_prcxi_class_for_max_ul`` +
``_apply_prcxi_labware_auto_match`` 的主路径
- :func:`reload_mapping` ← 测试 / 脚本中改 YAML 后清缓存重读
P6.1.1 关键约定(与 P6.1 不同):
- YAML 两段顶层 key``kinds`` / ``target_devices``。
顶层 ``slot_remap`` 段**已不支持**;检出 → warning + 整段 fallback 到 :data:`_BUILTIN_DEFAULT`。
- ``slot_remap`` 内嵌到 ``target_devices.<device>.slot_remap``,可由
``target_devices.<device>.models.<model>.slot_remap`` 进一步按型号覆盖。
- ``rules`` 同样支持型号级覆盖(``target_devices.<device>.models.<model>.rules``)。
- ``slot_remap`` 与 ``rules`` 共用同一条 4 段 fallback 链model → device → default → builtin
- ``target_devices.default`` **不支持** ``models`` 子段;若声明则 loader warning + 忽略。
"""
from __future__ import annotations
import re
import warnings
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
# 项目根的查找__file__ 在 Uni-Lab-OS/unilabos/workflow/labware_mapping.py
# 上溯三级到 Uni-Lab-OS/parents[0]=workflow, [1]=unilabos, [2]=Uni-Lab-OS
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "labware_mapping.yaml"
# P6.1兜底段名硬编码为常量。caller 传入的 target_device 在 target_devices
# 段下未声明时,自动 fallback 到这个段。
_DEFAULT_SECTION = "default"
# 默认 slot_remap与原 _map_deck_slot 硬编码一致。default + prcxi 段共享同一份。
_BUILTIN_DEFAULT_SLOT_REMAP: Dict[str, Any] = {
"default": {"4": "13", "8": "14"},
"by_object": {"trash": {"12": "16"}},
}
# default 段 + prcxi 段的共享规则列表(两段在 YAML 中各自独立,但第一版字节一致)。
_BUILTIN_DEFAULT_RULES: List[Dict[str, Any]] = [
{"kind": "tip_rack", "hole_count": 96, "volume_max": 10, "class_name": "PRCXI_10uL_Tips"},
{"kind": "tip_rack", "hole_count": 96, "volume_max": 299.9, "class_name": "PRCXI_300ul_Tips"},
{"kind": "tip_rack", "hole_count": 96, "class_name": "PRCXI_1000uL_Tips"},
{"kind": "tube_rack", "hole_count": 24, "class_name": "PRCXI_EP_Adapter"},
{"kind": "tube_rack", "hole_count": 10, "class_name": "PRCXI_EP_Adapter"},
{"kind": "plate", "hole_count": 96, "class_name": "PRCXI_BioER_96_wellplate"},
{"kind": "plate", "hole_count": 384, "class_name": "PRCXI_BioER_384_wellplate"},
{"kind": "trash", "class_name": "PRCXI_trash"},
]
def _builtin_device_section() -> Dict[str, Any]:
"""构造一个独立的 device 段slot_remap + rules 都是深拷贝),避免段间共享引用。"""
return {
"slot_remap": {
"default": dict(_BUILTIN_DEFAULT_SLOT_REMAP["default"]),
"by_object": {k: dict(v) for k, v in _BUILTIN_DEFAULT_SLOT_REMAP["by_object"].items()},
},
"rules": [dict(r) for r in _BUILTIN_DEFAULT_RULES],
}
# 内置兜底表:当 YAML 文件不存在 / 解析失败 / 检测到旧 schema 时退化使用。
# 与 YAML 文件保持同步。default 与 prcxi 段是两份独立的副本(语义独立、内容相同)。
_BUILTIN_DEFAULT: Dict[str, Any] = {
"kinds": [
{"pattern": "trash", "kind": "trash"},
{"pattern": r"tiprack|tip[_ ]?rack|opentrons_\d+_tiprack", "kind": "tip_rack"},
{"pattern": r"tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", "kind": "tube_rack"},
{"pattern": r"(?:^|[^a-z])rack(?:[^a-z]|$)", "kind": "tube_rack"},
{"pattern": r".*", "kind": "plate"},
],
"target_devices": {
_DEFAULT_SECTION: _builtin_device_section(),
"prcxi": _builtin_device_section(),
},
}
def _has_legacy_schema(data: Dict[str, Any]) -> bool:
"""检测旧 schema 痕迹:
- P6.1 旧 schema顶层 ``vendors`` 段,或任一 rule 含 ``prcxi_class``。
- P6.1.1 旧 schema**本期新增**):顶层 ``slot_remap`` 段(应内嵌到 target_devices 下)。
"""
if "vendors" in data:
return True
# P6.1.1:顶层 slot_remap 段被视为旧 schema
if "slot_remap" in data:
return True
td = data.get("target_devices")
if isinstance(td, dict):
for sect in td.values():
if not isinstance(sect, dict):
continue
for r in sect.get("rules") or []:
if isinstance(r, dict) and "prcxi_class" in r:
return True
# 也检查 models 内
models = sect.get("models") or {}
if isinstance(models, dict):
for m in models.values():
if not isinstance(m, dict):
continue
for r in m.get("rules") or []:
if isinstance(r, dict) and "prcxi_class" in r:
return True
return False
def _legacy_schema_reason(data: Dict[str, Any]) -> str:
"""生成具体的旧 schema 提示,便于用户定位升级点。"""
reasons: List[str] = []
if "vendors" in data:
reasons.append("顶层 `vendors` 段(应改为 `target_devices`")
if "slot_remap" in data:
reasons.append("顶层 `slot_remap` 段(应内嵌到 `target_devices.<device>.slot_remap`")
td = data.get("target_devices")
if isinstance(td, dict):
for sect_name, sect in td.items():
if not isinstance(sect, dict):
continue
for r in sect.get("rules") or []:
if isinstance(r, dict) and "prcxi_class" in r:
reasons.append(f"`target_devices.{sect_name}.rules` 中含旧字段 `prcxi_class`(应改为 `class_name`")
break
models = sect.get("models") or {}
if isinstance(models, dict):
for m_name, m in models.items():
if not isinstance(m, dict):
continue
for r in m.get("rules") or []:
if isinstance(r, dict) and "prcxi_class" in r:
reasons.append(
f"`target_devices.{sect_name}.models.{m_name}.rules` 中含旧字段 `prcxi_class`"
)
break
return "".join(reasons) if reasons else "未知"
@lru_cache(maxsize=4)
def _load_mapping(path: Optional[str] = None) -> Dict[str, Any]:
"""从 YAML 加载映射表;缺文件 / 解析失败 / 旧 schema 时退化到内置默认。
``path`` 缺省时取项目根 ``labware_mapping.yaml``。结果按路径缓存,
重复调用零成本;测试 / 脚本改 YAML 后通过 :func:`reload_mapping` 失效缓存。
P6.1.1 校验顺序:
1. 文件存在 + 可 parse + 根 dict
2. 旧 schema 检测(含 P6.1 `vendors` / `prcxi_class` + P6.1.1 顶层 `slot_remap`
→ 整段 fallback 到 :data:`_BUILTIN_DEFAULT`
3. 两段顶层 key 校验:``kinds`` / ``target_devices``
4. ``target_devices`` 下必含 :data:`_DEFAULT_SECTION` 段;缺则该段使用 builtin default 段
5. ``target_devices.default.models`` 不允许;若声明则 warning + 删除
"""
p = Path(path) if path else _DEFAULT_PATH
if not p.exists():
warnings.warn(f"labware_mapping.yaml 未找到:{p},使用内置默认表")
return _BUILTIN_DEFAULT
try:
with p.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
except Exception as e:
warnings.warn(f"labware_mapping.yaml 解析失败:{e},使用内置默认表")
return _BUILTIN_DEFAULT
if not isinstance(data, dict):
warnings.warn(f"labware_mapping.yaml 根不是 dict{type(data).__name__},使用内置默认表")
return _BUILTIN_DEFAULT
if _has_legacy_schema(data):
warnings.warn(
"labware_mapping.yaml 检测到旧 schema"
f"{_legacy_schema_reason(data)}。P6.1.1 不再支持;"
"请参考 product_designs/protocol_convert/06-labware-mapping-table.md §11.8 升级 schema。"
"本次加载整段使用内置默认表。"
)
return _BUILTIN_DEFAULT
for key in ("kinds", "target_devices"):
if key not in data or data[key] is None:
warnings.warn(f"labware_mapping.yaml 缺少 `{key}` 段;该段将使用内置默认")
data[key] = _BUILTIN_DEFAULT[key]
td = data.get("target_devices")
if not isinstance(td, dict) or _DEFAULT_SECTION not in td or td.get(_DEFAULT_SECTION) is None:
warnings.warn(
f"labware_mapping.yaml 缺少必需的 `target_devices.{_DEFAULT_SECTION}` 段;"
f"该段将使用内置默认。default 段是 P6.1 的兜底物料集,未来未声明的 "
f"target_device 都会回退到它。"
)
if not isinstance(td, dict):
td = {}
data["target_devices"] = td
td[_DEFAULT_SECTION] = _BUILTIN_DEFAULT["target_devices"][_DEFAULT_SECTION]
# P6.1.1target_devices.default 不支持 models 子段
default_sect = td.get(_DEFAULT_SECTION)
if isinstance(default_sect, dict) and "models" in default_sect:
warnings.warn(
f"labware_mapping.yaml: `target_devices.{_DEFAULT_SECTION}.models` 不被支持 —— "
"型号粒度差异必须落到具体仪器段。该子段将被忽略。"
)
# 副作用:从 cached data 中删除,避免后续解析误用
default_sect.pop("models", None)
return data
def reload_mapping(path: Optional[str] = None) -> None:
"""测试或脚本中修改 YAML 后重新加载(失效 lru_cache"""
_load_mapping.cache_clear()
if path is not None:
_load_mapping(str(path))
# ============================================================================
# 4 段 fallback helpermodel → device → default → builtin default
# ============================================================================
def _resolve_section(
field_name: str,
target_device: str,
target_model: Optional[str],
) -> Any:
"""4 段 fallback 链解析指定字段(``slot_remap`` / ``rules`` / ...)。
Args:
field_name: ``target_devices.<device>`` 或 ``models.<model>`` 下的字段名,
如 ``"slot_remap"`` / ``"rules"``。
target_device: caller 传入的目标仪器名(厂商粒度)。
target_model: caller 传入的目标型号名;``None`` 表示不区分型号、走厂商级。
Returns:
对应字段的值(保留原 dict / list 形态);找不到任何兜底也只返回 ``None``。
fallback 链:
1. ``target_devices.<target_device>.models.<target_model>.<field_name>``
—— 仅当 ``target_model`` 非空且 model 子段含该字段。
2. ``target_devices.<target_device>.<field_name>`` —— 厂商级。
3. ``target_devices.default.<field_name>`` —— 兜底段。
4. ``_BUILTIN_DEFAULT.target_devices.default.<field_name>`` —— 最终硬编码兜底。
warning 策略:
- caller 传未声明的 ``target_device`` 段(步骤 2 没拿到值且 device 段整体不存在)→ 单次 warning。
- caller 传未声明的 ``target_model``model 名不存在或 model 内缺该字段)→ **静默** fallback
(这是常见的"用厂商默认"用法,不应报噪音)。
- YAML 误删 default 段(步骤 3 也拿不到值)→ 单次 warning。
"""
td = _load_mapping().get("target_devices") or {}
builtin_td = _BUILTIN_DEFAULT["target_devices"]
device_sect = td.get(target_device) if isinstance(td, dict) else None
device_sect = device_sect if isinstance(device_sect, dict) else None
# Step 1: model 级
if target_model and device_sect is not None:
models = device_sect.get("models")
if isinstance(models, dict):
m = models.get(target_model)
if isinstance(m, dict) and m.get(field_name) is not None:
return m[field_name]
# model 名整体未声明 / 该字段缺失 → 静默 fallback
# Step 2: device 级
if device_sect is not None and device_sect.get(field_name) is not None:
return device_sect[field_name]
# Step 3: default 段
if target_device != _DEFAULT_SECTION and device_sect is None:
warnings.warn(
f"target_device {target_device!r} 未在 labware_mapping.yaml 的 target_devices 中声明,"
f"已回退到固定段 target_devices.{_DEFAULT_SECTION}"
f"请在 YAML 中补 target_devices.{target_device}.{field_name}"
)
default_sect = td.get(_DEFAULT_SECTION) if isinstance(td, dict) else None
if isinstance(default_sect, dict) and default_sect.get(field_name) is not None:
return default_sect[field_name]
# Step 4: builtin defaultYAML 误删 default 段时)
warnings.warn(
f"labware_mapping.yaml 缺少必需的 target_devices.{_DEFAULT_SECTION}.{field_name}"
f"本次解析整段使用内置默认表。"
)
builtin_default = builtin_td.get(_DEFAULT_SECTION) or {}
return builtin_default.get(field_name)
# ============================================================================
# 公开 API
# ============================================================================
def remap_slot(
raw_slot: Any,
object_type: str = "",
*,
target_device: str = "prcxi",
target_model: Optional[str] = None,
) -> str:
"""协议槽位 → 目标设备 deck 实际位置。等价于历史 ``_map_deck_slot``
1. 优先查 ``slot_remap.by_object[object_type][raw]``(如 ``trash`` 的 ``12 → 16``)。
2. 否则查 ``slot_remap.default[raw]``(如 ``4 → 13``、``8 → 14``)。
3. 否则原样返回。
P6.1.1``slot_remap`` 内嵌在 ``target_devices.<target_device>`` 下,
可由 ``target_devices.<target_device>.models.<target_model>.slot_remap`` 进一步覆盖。
走 :func:`_resolve_section` 的 4 段 fallback 链model → device → default → builtin
Args:
raw_slot: 协议中的原始槽位标识;接受 ``int`` / ``str`` / ``None``。
object_type: ``labware_info[id]['object']`` 的值(如 ``"trash"`` / ``"source"``)。
target_device: 目标仪器名(厂商粒度);默认 ``"prcxi"``。
target_model: 目标型号名(型号粒度);``None`` 表示不区分型号,走厂商级。
"""
s = "" if raw_slot is None else str(raw_slot).strip()
if not s:
return ""
cfg = _resolve_section("slot_remap", target_device, target_model) or {}
if not isinstance(cfg, dict):
return s
ot = (object_type or "").strip().lower()
by_obj = (cfg.get("by_object") or {}).get(ot) or {}
if s in by_obj:
return str(by_obj[s])
return str((cfg.get("default") or {}).get(s, s))
def infer_kind(labware_hint: str, object_type: str = "") -> str:
"""labware 字符串 + object 字段 → ``plate / tip_rack / tube_rack / trash`` 之一。
与历史 ``_infer_reagent_kind`` 行为对齐:
- ``object_type == "trash"`` → 直接 ``trash``。
- ``object_type == "tiprack"`` → 直接 ``tip_rack``。
- 否则按 YAML ``kinds`` 段顺序,对 ``lower(labware_hint)`` 做 ``re.search``
首个命中胜出。
- 全不命中 → ``plate``YAML 默认 ``.*`` 兜底也回到 plate
``kinds`` 段是**全局**的(与 target_device 无关P6.1.1 起依然保留在顶层。
"""
ot = (object_type or "").strip().lower()
if ot == "trash":
return "trash"
if ot == "tiprack":
return "tip_rack"
hint = (labware_hint or "").lower()
for rule in _load_mapping().get("kinds") or []:
pat = rule.get("pattern")
kd = rule.get("kind")
if not pat or not kd:
continue
try:
if re.search(pat, hint):
return str(kd)
except re.error:
warnings.warn(f"labware_mapping.yaml: kinds 规则正则不合法 {pat!r},跳过")
continue
return "plate"
def _match_rules(
rules: List[Dict[str, Any]],
kind: str,
hole_count: Optional[int],
volume: Optional[float],
) -> Optional[str]:
"""在给定 rules 列表内按 kind + hole_count + volume 找首个命中规则的 ``class_name``。
匹配规则(与 P6 完全相同的语义):
- ``rule.kind == kind``(严格相等)。
- ``rule.hole_count`` 缺失 OR 严格等于传入 ``hole_count``。
若传入 ``hole_count is None``,则只要 rule 也未约束 hole_count 即可视为不冲突。
- ``volume`` 范围rule 的 ``volume_min`` / ``volume_max`` 闭区间,二者均可省略。
若传入 ``volume is None``,则只要 rule 也未约束 volume 即可视为不冲突。
"""
for r in rules or []:
if r.get("kind") != kind:
continue
if "hole_count" in r and r["hole_count"] is not None:
if hole_count is None:
continue
try:
if int(r["hole_count"]) != int(hole_count):
continue
except (TypeError, ValueError):
continue
vmin = r.get("volume_min")
vmax = r.get("volume_max")
if vmin is not None or vmax is not None:
if volume is None:
continue
try:
vf = float(volume)
except (TypeError, ValueError):
continue
if vmin is not None and vf < float(vmin):
continue
if vmax is not None and vf > float(vmax):
continue
cls = r.get("class_name")
if cls:
return str(cls)
return None
def resolve_target_class(
target_device: str,
kind: str,
hole_count: Optional[int] = None,
volume: Optional[float] = None,
*,
target_model: Optional[str] = None,
) -> Optional[str]:
"""按 target_device (+ target_model) + kind + hole_count + volume 选首个命中的 ``class_name``。
P6.1.1 4 段 fallback 链(走 :func:`_resolve_section```field_name="rules"``
1. 查 ``target_devices.<target_device>.models.<target_model>.rules``,找到首个命中规则 → 返回 ``class_name``。
2. 若步骤 1 缺字段 → 查 ``target_devices.<target_device>.rules``。
3. 若 ``target_device`` 段不存在caller 传 YAML 未声明的名字)→
查 ``target_devices.default.rules`` + 单次 warning。
4. 若 ``default`` 段也不存在 → 走 :data:`_BUILTIN_DEFAULT` 的 default 段 + warning。
在最终命中的 rules 列表内仍未匹配到(孔数 / 体积超出覆盖范围)→ 返回 ``None``
交给上游 ``_apply_target_labware_class_auto_match`` 走 PRCXI 模板打分匹配 fallback。
"""
rules = _resolve_section("rules", target_device, target_model)
if not isinstance(rules, list):
rules = []
return _match_rules(rules, kind, hole_count, volume)

View File

@@ -17,21 +17,35 @@ def _is_node_link_format(data: Dict[str, Any]) -> bool:
return "nodes" in data and "edges" in data
def _convert_to_node_link(workflow_file: str, workflow_data: Dict[str, Any]) -> Dict[str, Any]:
def _convert_to_node_link(
workflow_file: str,
workflow_data: Dict[str, Any],
*,
target_device: str = "prcxi",
target_model: Optional[str] = None,
) -> Dict[str, Any]:
"""
将非 node-link 格式的工作流数据转换为 node-link 格式
Args:
workflow_file: 工作流文件路径(用于日志)
workflow_data: 原始工作流数据
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_json_to_node_link`。
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_json_to_node_link`。
Returns:
node-link 格式的工作流数据
"""
from unilabos.workflow.convert_from_json import convert_json_to_node_link
print_status(f"检测到非 node-link 格式,正在转换...", "info")
node_link_data = convert_json_to_node_link(workflow_data)
model_hint = f" target_model={target_model}" if target_model else ""
print_status(
f"检测到非 node-link 格式正在转换target_device={target_device}{model_hint}...",
"info",
)
node_link_data = convert_json_to_node_link(
workflow_data, target_device=target_device, target_model=target_model
)
print_status(f"转换完成", "success")
return node_link_data
@@ -42,6 +56,8 @@ def upload_workflow(
tags: Optional[List[str]] = None,
published: bool = False,
description: str = "",
target_device: str = "prcxi",
target_model: Optional[str] = None,
) -> Dict[str, Any]:
"""
上传工作流到服务器
@@ -58,6 +74,12 @@ def upload_workflow(
tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False
description: 工作流描述,发布时使用
target_device: P6.1 新增,目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明
的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。
target_model: P6.1.1 新增,同厂商内的型号名(如 ``"9320"`` / ``"4040"``
决定 ``target_devices.<target_device>.models.<target_model>`` 段的 ``slot_remap`` /
``rules`` 覆盖。``None`` 表示走厂商级配置。
Returns:
Dict: API响应数据
@@ -77,18 +99,36 @@ def upload_workflow(
print_status(f"工作流文件JSON解析失败: {e}", "error")
return {"code": -1, "message": f"JSON解析失败: {e}"}
# P5先把原始 transfer_actions JSON 的顶层 metadata 段抠出来,避免后续
# _convert_to_node_link 转换后丢失 metadata.workflow_name / metadata.tags。
# 兼容:旧 node-link 文件没有 metadata 段时为空 dict。
orig_metadata = workflow_data.get("metadata") if isinstance(workflow_data, dict) else None
if not isinstance(orig_metadata, dict):
orig_metadata = {}
# 从 JSON 文件中提取 description 和 tags作为 fallback
# tags fallback 链CLI 显式 > metadata.tagsP5> 顶层 tags旧字段> 空列表
if not description and "description" in workflow_data:
description = workflow_data["description"]
print_status(f"从文件中读取 description", "info")
if not tags and "tags" in workflow_data:
tags = workflow_data["tags"]
print_status(f"从文件中读取 tags: {tags}", "info")
if not tags:
meta_tags = orig_metadata.get("tags")
if isinstance(meta_tags, (list, tuple)) and meta_tags:
tags = list(meta_tags)
print_status(f"从 metadata.tags 读取 tags: {tags}", "info")
elif "tags" in workflow_data:
tags = workflow_data["tags"]
print_status(f"从文件顶层读取 tags: {tags}", "info")
# 自动检测并转换格式
if not _is_node_link_format(workflow_data):
try:
workflow_data = _convert_to_node_link(workflow_file, workflow_data)
workflow_data = _convert_to_node_link(
workflow_file,
workflow_data,
target_device=target_device,
target_model=target_model,
)
except Exception as e:
print_status(f"工作流格式转换失败: {e}", "error")
return {"code": -1, "message": f"格式转换失败: {e}"}
@@ -97,10 +137,29 @@ def upload_workflow(
nodes = workflow_data.get("nodes", [])
edges = workflow_data.get("edges", [])
workflow_uuid_val = workflow_data.get("workflow_uuid", str(uuid.uuid4()))
wf_name_from_file = workflow_data.get("workflow_name", os.path.basename(workflow_file).replace(".json", ""))
# 工作流名称 fallback 链(优先级自顶向下,取首个非空):
# 1. CLI 显式 -n/--workflow_name
# 2. P5 顶层 metadata.workflow_nametransfer_actions JSON 主路径)
# 3. 转换后 workflow_data 顶层 workflow_name旧 node-link 形态遗留字段)
# 4. 文件名(去 .json 后缀)兜底
meta_wf_name = str(orig_metadata.get("workflow_name") or "").strip()
legacy_top_name = str(workflow_data.get("workflow_name") or "").strip()
fallback_filename = os.path.basename(workflow_file).replace(".json", "")
wf_name_from_file = meta_wf_name or legacy_top_name or fallback_filename
# 确定工作流名称
final_name = workflow_name or wf_name_from_file
if not workflow_name:
if meta_wf_name:
print_status(f"使用 metadata.workflow_name: {meta_wf_name}", "info")
elif legacy_top_name:
print_status(f"使用文件顶层 workflow_name旧字段: {legacy_top_name}", "info")
else:
print_status(
f"metadata.workflow_name 与顶层 workflow_name 均为空,回退到文件名: {fallback_filename}",
"warning",
)
print_status(f"正在上传工作流: {final_name}", "info")
print_status(f" - 节点数量: {len(nodes)}", "info")
@@ -108,6 +167,9 @@ def upload_workflow(
print_status(f" - 标签: {tags or []}", "info")
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
print_status(f" - 发布状态: {published}", "info")
print_status(f" - 目标仪器: {target_device}", "info")
if target_model:
print_status(f" - 目标型号: {target_model}", "info")
# 调用 http_client 上传
result = http_client.workflow_import(
@@ -137,15 +199,27 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
处理 workflow_upload 子命令
Args:
args_dict: 命令行参数字典
args_dict: 命令行参数字典
- P6.1 新增 ``target_device`` key缺省 ``"prcxi"``)。
- P6.1.1 新增 ``target_model`` key缺省 ``None``)。
"""
workflow_file = args_dict.get("workflow_file")
workflow_name = args_dict.get("workflow_name")
tags = args_dict.get("tags", [])
published = args_dict.get("published", False)
description = args_dict.get("description", "")
target_device = args_dict.get("target_device") or "prcxi"
target_model = args_dict.get("target_model") or None
if workflow_file:
upload_workflow(workflow_file, workflow_name, tags, published, description)
upload_workflow(
workflow_file,
workflow_name,
tags,
published,
description,
target_device=target_device,
target_model=target_model,
)
else:
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")