diff --git a/AGENTS.md b/AGENTS.md index 2f9efa06..48d01bec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 subcommand(P6.1 新增 --target_device;P6.1.1 新增 --target_model) unilab workflow_upload -f -n --tags tag1 tag2 +unilab workflow_upload -f --target_device prcxi # P6.1 默认;同上 P6 行为 +unilab workflow_upload -f --target_device prcxi --target_model 9320 # P6.1.1:型号粒度 +unilab workflow_upload -f --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.` — 按目标仪器组织的规则段,内含三个字段: + - `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13`、`8 → 14`、`12+trash → 16`)。 + - `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max` → `class_name`」规则,首个命中胜出。 + - `models.` — 可选的型号粒度覆盖(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 ` 时命中。可在 `models.` 下声明同厂商不同型号的差异。 +- **4 段 fallback 链**(`slot_remap` / `rules` 共用): + 1. `target_devices..models..`(caller 同时传 device + model) + 2. `target_devices..`(厂商级;步骤 1 缺字段时静默 fallback) + 3. `target_devices.default.`(caller 传未声明 device,或步骤 2 缺字段;打 warning) + 4. `_BUILTIN_DEFAULT.target_devices.default.`(YAML 误删 default 段时的最后兜底) +- **CLI 用法**: + - P6.1:`unilab workflow_upload -f --target_device prcxi` + (`--target_device` snake-case,默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。 + - P6.1.1:可加 `--target_model `(snake,可省略,默认 `None`)。 + 例:`unilab workflow_upload -f --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 dispense),Stage 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_` 并注册 `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 ...` 一切照旧;跨 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 diff --git a/labware_mapping.yaml b/labware_mapping.yaml new file mode 100644 index 00000000..d811a7d4 --- /dev/null +++ b/labware_mapping.yaml @@ -0,0 +1,140 @@ +# Opentrons → 目标仪器 物料映射表(P6.1.1) +# +# 两段顶层 key(P6.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.: # 厂商段(必填) +# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段) +# rules: [...] # 厂商级规则(缺失 → 继承 default 段) +# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号) +# : # 型号子段 +# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级) +# rules: [...] # 型号级覆盖(缺失 → 继承厂商级) +# +# 段名约定: +# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。 +# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。 +# target_devices. : 具体仪器段(prcxi / beckman / tecan ...)。 +# +# 解析链(remap_slot / resolve_target_class 共用,字段级 fallback): +# 1. target_devices..models.. (caller 同时传 device + model) +# 2. target_devices.. (caller 传 device,或步骤 1 缺字段) +# 3. target_devices.default. (caller 传未声明 device,或步骤 2 缺字段) +# 4. _BUILTIN_DEFAULT.target_devices.default. (YAML 误删 default 段时的最后兜底) +# +# 编辑建议: +# 1. 顺序敏感:kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。 +# 2. volume_min / volume_max 是闭区间(µL)。任一字段可省略;都省略 = 不限制体积。 +# 3. notes 仅作注释,不参与匹配。 +# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。 +# 5. 同厂商不同型号:在 target_devices..models. 下显式覆盖差异字段; +# 没声明的字段自动继承厂商级。 +# 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 } + # 「 含 '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 mL(10 位兼容 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 } + + # ───────────────────────────────────────────────────────────────────────── + # prcxi:PRCXI 仪器专用段。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 rack(PRCXI 量程档:≤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 mL(10 位兼容 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 重映射 diff --git a/tests/devices/liquid_handling/test_liquid_history.py b/tests/devices/liquid_handling/test_liquid_history.py new file mode 100644 index 00000000..f75654ff --- /dev/null +++ b/tests/devices/liquid_handling/test_liquid_history.py @@ -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, +) + + +# --------------------------------------------------------------------------- +# Fixtures:DummyTracker / 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" diff --git a/tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py b/tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py new file mode 100644 index 00000000..50b41532 --- /dev/null +++ b/tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py @@ -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`` 的 monkeypatch(dump 直接返回输入列表)。 +""" +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_liquid(per-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-occurrence(plate_slot2 第 1 次出现于 idx=0,plate_slot3 idx=1,plate_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,不匹配 + ) diff --git a/tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py b/tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py new file mode 100644 index 00000000..f3e0a5fb --- /dev/null +++ b/tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py @@ -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 + + +# --------------------------------------------------------------------------- +# Fixtures:DummyTracker / 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 测试用的 base:PLR 可用时是 ``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 + + +# --------------------------------------------------------------------------- +# T1–T12 端到端测试(单通道 transfer_liquid 主循环) +# +# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。 +# 若 PLR import 失败则整段 skip,helper 测试照常运行。 +# --------------------------------------------------------------------------- + +_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: + """T2:9 个独立 source well(不同 PLR Well 对象)都装 PBS → identity 全 fail,liquids-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 都装 PBS(8 个不同 Well 对象)→ identity 全 fail,liquids-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: + """T4:A1=PBS,B1=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 次完全独立的 transfer:2 次 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: + """T5:source 从未调过 set_liquids(liquids 空)→ 视为未知,强制换 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: + """T6:tip_reuse_by_liquid_name=False,T2 场景退化为 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-only,9 次独立换 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: + """T11:aspirate 顶层归零 → 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 被 pop,pending_tip_name 已捕获 "PBS" + # → 下一轮 source 仍是 PBS(aspirate 还没发生),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 + + +# --------------------------------------------------------------------------- +# T9:8 通道段锚孔 liquids-keep +# --------------------------------------------------------------------------- + + +@_skip_if_no_plr +class TestEightChannelSegmentTipReuse: + """T9:8 通道分段,连续两段 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 diff --git a/tests/resources/test_resource_tracker_history.py b/tests/resources/test_resource_tracker_history.py new file mode 100644 index 00000000..293a788b --- /dev/null +++ b/tests/resources/test_resource_tracker_history.py @@ -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"] == [] diff --git a/tests/workflow/test_build_protocol_graph_target_device.py b/tests/workflow/test_build_protocol_graph_target_device.py new file mode 100644 index 00000000..9bfe984d --- /dev/null +++ b/tests/workflow/test_build_protocol_graph_target_device.py @@ -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_info(mutable,每个 case 独立 build 一份)。 + + 包含 tip rack + 24-tube rack + 96 wellplate(slot 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): + """同一 protocol,target_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 µL:prcxi 走 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.1:target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。 + + YAML 中 prcxi 厂商级 slot_remap 4→13;模型 "4040" 显式覆盖 4→16。 + 同一份 labware_info(slot=4)build 出的两份图,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" diff --git a/tests/workflow/test_common_cross_slot_v2.py b/tests/workflow/test_common_cross_slot_v2.py new file mode 100644 index 00000000..0778001f --- /dev/null +++ b/tests/workflow/test_common_cross_slot_v2.py @@ -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_``(runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。 + +用例 +---- +- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key(51b9a5 主场景)。 +- ``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/6),target_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_liquid(targets_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.targets:v2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串 + # (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合) + tparams = tnode.get("param", {}) or {} + assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], ( + f"v2:params.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"v2:transfer_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 聚合节点" diff --git a/tests/workflow/test_common_liquid_name_from_reagent.py b/tests/workflow/test_common_liquid_name_from_reagent.py new file mode 100644 index 00000000..8600304f --- /dev/null +++ b/tests/workflow/test_common_liquid_name_from_reagent.py @@ -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: "`` 反查 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_key(P8 前行为)。""" + 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 个 target,2 个有显式名、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 路径(无 merged)下,source 和 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 wells(multi-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 diff --git a/tests/workflow/test_common_plate_num_children_hint.py b/tests/workflow/test_common_plate_num_children_hint.py new file mode 100644 index 00000000..c9116ef3 --- /dev/null +++ b/tests/workflow/test_common_plate_num_children_hint.py @@ -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 模板。 + +跨板 fix(P2 v2 §14)把 plate name 作为 prefix 编码进 ``well_names`` 之后, +runtime 调用 ``plate.get_well("A5")`` 严格定位 well,trough 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 返回 None(labware 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 block(slot 8 上 12 个 samples_X,X 末尾含 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, +) + + +# ==================== unit:hint 函数本身 ==================== + + +@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.json:12 列 × 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 diff --git a/tests/workflow/test_common_set_liquid_dedup.py b/tests/workflow/test_common_set_liquid_dedup.py new file mode 100644 index 00000000..b7fca408 --- /dev/null +++ b/tests/workflow/test_common_set_liquid_dedup.py @@ -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`` §14(2026-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_info:source 端 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_1);target 端被全部跳过 + 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"§14:target 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"R1:t_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 个 source(src_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"§14:target 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-plate(source 不会被 §14 跳过)" + assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate" diff --git a/tests/workflow/test_labware_mapping.py b/tests/workflow/test_labware_mapping.py new file mode 100644 index 00000000..b8099593 --- /dev/null +++ b/tests/workflow/test_labware_mapping.py @@ -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 schema(v1.9): +- 顶层 key 两段:``kinds`` / ``target_devices``(**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.`` 内) +- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段** +- ``target_devices..models.`` 是可选的型号粒度覆盖(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 字段优先:即使字符串看起来像 plate,trash / 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_class(target_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() + + # 同样的体积 200:prcxi 走 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" + # 同样的体积 15:prcxi 已超出 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_Plate96,prcxi 段保持 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): + """旧 schema(vendors / prcxi_class)应被拒绝 + warning + 整段 fallback 到 builtin(P6.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_remap(model 名忽略)。""" + 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 + ) diff --git a/tests/workflow/test_wf_utils_workflow_name.py b/tests/workflow/test_wf_utils_workflow_name.py new file mode 100644 index 00000000..51653e65 --- /dev/null +++ b/tests/workflow/test_wf_utils_workflow_name.py @@ -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"] diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 8de9a75f..07cdf7f7 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -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..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..models..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 diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 6c8e7ff4..1ca790f1 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -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.liquids,aspirate 后再读会拿不到液体身份。 + # 详见 ``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 well:volume 取**负数**与 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 well:volume 取**正数**; + # 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 复用判等开关;默认 on(pop 出 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 dict(plr_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 跨 plate:liquid_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 中的 "/" prefix + # 拆解逐个查 plate(common.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 fix):well_names[i] = "/" + 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 + # 缺少 "/" prefix,导致 abstract 层无法跨板定位 plate。 + raise ValueError( + "set_liquid_from_plate: 检测到 P2 v2 跨板 merged 节点" + f"(liquid_names 含 {len(distinct_liquids)} 个 distinct names)," + "但 well_names 解析失败 / 缺少 '/' 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 v2:tip 残液身份 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 × M(M 为列数 / 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 segment,broadcast 到 8 通道 + item = value[seg_idx % len(value)] + return [item] * 8 + + prev_dropped = True + current_tip_liquid_name: Optional[str] = None # P10 v2:tip 残液身份(段锚孔粒度) + 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 截/扩到长度 n;None / 空列表返回 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 v4:blow_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, diff --git a/unilabos/devices/liquid_handling/liquid_history.py b/unilabos/devices/liquid_handling/liquid_history.py new file mode 100644 index 00000000..6149119b --- /dev/null +++ b/unilabos/devices/liquid_handling/liquid_history.py @@ -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 # 操作体积(µL;aspirate 为负,dispense / set 为正) + action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init" + timestamp: str # ISO8601 UTC(OS 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 diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index fa28218c..233d0c28 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -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 = [] diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 06afc044..1762738b 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -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 diff --git a/unilabos/resources/resource_tracker.py b/unilabos/resources/resource_tracker.py index 2ba81b6d..52f8656f 100644 --- a/unilabos/resources/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -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) diff --git a/unilabos/test/experiments/prcxi_9320_slim.json b/unilabos/test/experiments/prcxi_9320_slim.json index 73ccd4a2..4c4ea49f 100644 --- a/unilabos/test/experiments/prcxi_9320_slim.json +++ b/unilabos/test/experiments/prcxi_9320_slim.json @@ -26,7 +26,7 @@ "is_9320": true, "timeout": 10, "matrix_id": "", - "simulator": false, + "simulator": true, "channel_num": 2, "step_mode": false, "calibration_points": { diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 005491ed..7f0bd30c 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -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 仍为 labware;placeholder 内部把 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_identifier(P3 主路径;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: - """协议槽位 -> 实际 deck:4→13,8→14,12+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→13,8→14,12+trash→16,其余不变。 + + P6.1.1:``slot_remap`` 内嵌在 ``target_devices.`` 下, + 可由 ``target_devices..models..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-22):hint 只用 ``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.1:tip 量程档不再硬编码 PRCXI 三档,改为查 + ``labware_mapping.yaml`` 的 ``target_devices..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..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 环境(无 pylabrobot)下,YAML 命中也能写入 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`` 本身(不带 ``/`` 后缀)。 + 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_``,供 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 fix(2026-05-22):merged 节点的 well_names 用 "/" 形态 + # 编码每个 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", + }) + # P8(2026-05-24):reagent 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=0:target plate 不预先注入液体,仅占位(同 per-plate set_liquid 行为)。 + "volumes": [0] * len(merged_wells), + # 兼容字段:保留 plate/well_names 让旧 runtime / 旧前端可继续解析 + "plate": [], + # 升级:well_names 元素为 "/" 形态(含跨板 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..rules`` 段;未声明的 + 名字由 :func:`labware_mapping.resolve_target_class` 自动 fallback 到固定段 + ``target_devices.default``。默认 ``"prcxi"``(与历史 P6 完全等价)。 + target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``); + 决定查 ``target_devices..models.`` 下的 ``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 + # P8(2026-05-24):reagent 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.wells(list[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_PARAMS:use_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 v2:synthetic merged targets key(_merged_targets_)不在 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 > 1(multi 协议)时, + # asp_vols / dis_vols 等数组的长度已是 8 × M(Stage 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 diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py index 56b8c160..949ecd3b 100644 --- a/unilabos/workflow/convert_from_json.py +++ b/unilabos/workflow/convert_from_json.py @@ -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..rules`` 段;未声明 + 的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。 + target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``); + 决定 ``target_devices..models.`` 段的 ``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", []), + }, + } diff --git a/unilabos/workflow/labware_mapping.py b/unilabos/workflow/labware_mapping.py new file mode 100644 index 00000000..ee7b0552 --- /dev/null +++ b/unilabos/workflow/labware_mapping.py @@ -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..slot_remap``,可由 + ``target_devices..models..slot_remap`` 进一步按型号覆盖。 +- ``rules`` 同样支持型号级覆盖(``target_devices..models..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..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.1:target_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 helper:model → 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.`` 或 ``models.`` 下的字段名, + 如 ``"slot_remap"`` / ``"rules"``。 + target_device: caller 传入的目标仪器名(厂商粒度)。 + target_model: caller 传入的目标型号名;``None`` 表示不区分型号、走厂商级。 + + Returns: + 对应字段的值(保留原 dict / list 形态);找不到任何兜底也只返回 ``None``。 + + fallback 链: + + 1. ``target_devices..models..`` + —— 仅当 ``target_model`` 非空且 model 子段含该字段。 + 2. ``target_devices..`` —— 厂商级。 + 3. ``target_devices.default.`` —— 兜底段。 + 4. ``_BUILTIN_DEFAULT.target_devices.default.`` —— 最终硬编码兜底。 + + 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 default(YAML 误删 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_devices..models..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..models..rules``,找到首个命中规则 → 返回 ``class_name``。 + 2. 若步骤 1 缺字段 → 查 ``target_devices..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) diff --git a/unilabos/workflow/wf_utils.py b/unilabos/workflow/wf_utils.py index 6332f1d5..b539f114 100644 --- a/unilabos/workflow/wf_utils.py +++ b/unilabos/workflow/wf_utils.py @@ -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..rules`` 段;未声明 + 的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。 + target_model: P6.1.1 新增,同厂商内的型号名(如 ``"9320"`` / ``"4040"``); + 决定 ``target_devices..models.`` 段的 ``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.tags(P5)> 顶层 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_name(transfer_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")