feat: RNA add guided siRNA manual load gate

- Expose siRNA order and material handles for manual-confirm load workflows.
- Gate scheduler start on explicit material-load confirmation before calling Bioyond RPC.
- Improve lazy API config diagnostics and Sirna warehouse/material resource handling.
This commit is contained in:
yxz321
2026-05-08 11:24:56 +08:00
parent 18c3263e92
commit 98c27cde40
4 changed files with 425 additions and 96 deletions

View File

@@ -12,7 +12,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated, Any, Dict, Iterable, List, Literal, Optional, Tuple
from urllib import error, request
from uuid import UUID
from uuid import NAMESPACE_URL, UUID, uuid5
try:
from typing_extensions import TypedDict
@@ -38,7 +38,15 @@ except Exception: # pragma: no cover - 允许无 pylabrobot 依赖时导入轻
_SIRNA_DECK_CLASS = None
try:
from unilabos.registry.decorators import NodeType, action, device, not_action
from unilabos.registry.decorators import (
ActionInputHandle,
ActionOutputHandle,
DataSource,
NodeType,
action,
device,
not_action,
)
_REGISTRY_IMPORT_ERROR: Optional[Exception] = None
except Exception as exc: # pragma: no cover - 允许无完整依赖时导入轻量 helper
_REGISTRY_IMPORT_ERROR = exc
@@ -46,6 +54,23 @@ except Exception as exc: # pragma: no cover - 允许无完整依赖时导入轻
class NodeType: # type: ignore[no-redef]
MANUAL_CONFIRM = "manual_confirm"
class DataSource: # type: ignore[no-redef]
HANDLE = "handle"
EXECUTOR = "executor"
class _FallbackActionHandle: # pragma: no cover - lightweight import-only fallback
def __init__(self, **kwargs: Any) -> None:
self.__dict__.update(kwargs)
def to_registry_dict(self) -> Dict[str, Any]:
return dict(self.__dict__)
class ActionInputHandle(_FallbackActionHandle): # type: ignore[no-redef]
pass
class ActionOutputHandle(_FallbackActionHandle): # type: ignore[no-redef]
pass
def device(*args: Any, **kwargs: Any):
def decorator(cls):
return cls
@@ -64,6 +89,15 @@ except Exception as exc: # pragma: no cover - 允许无完整依赖时导入轻
def not_action(func):
return func
try:
from unilabos.registry.placeholder_type import DeviceSlot, ResourceSlot
except Exception: # pragma: no cover - 允许无 pylabrobot 依赖时导入轻量 helper
class ResourceSlot: # type: ignore[no-redef]
pass
class DeviceSlot(str): # type: ignore[no-redef]
pass
try:
from unilabos.devices.workstation.workstation_base import WorkstationBase
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
@@ -244,7 +278,18 @@ class BioyondSirnaStation(BioyondWorkstation):
logger.info(f" - API Host: {self.bioyond_config.get('api_host', '')}")
logger.info(f" - Workflow 映射数量: {len(self.bioyond_config.get('workflow_mappings', {}))}")
self._lazy_frontend_init = deck is None or not self._has_required_api_config(self.bioyond_config)
missing_api_keys = self._missing_api_config_keys(self.bioyond_config)
if missing_api_keys:
logger.warning(
"BioyondSirnaStation 缺少 Bioyond API 配置 %s,进入延迟初始化模式。"
"动作可在调用前通过 config 重新配置或在 goal 中传入 api_host/api_key 后再使用。"
"缺失项可来自 graph 节点 config或环境变量 "
"BIOYOND_SIRNA_API_HOST / BIOYOND_SIRNA_EXP1_API_HOST 与 "
"BIOYOND_SIRNA_API_KEY / BIOYOND_SIRNA_EXP1_API_KEY。",
missing_api_keys,
)
self._lazy_frontend_init = deck is None or bool(missing_api_keys)
if self._lazy_frontend_init:
WorkstationBase.__init__(self, deck=deck)
self.is_running = False
@@ -256,9 +301,10 @@ class BioyondSirnaStation(BioyondWorkstation):
self._http_service_config = self.bioyond_config.get("http_service_config", {})
if "workflow_mappings" in self.bioyond_config:
self.workflow_mappings = dict(self.bioyond_config.get("workflow_mappings") or {})
if deck is None:
logger.warning(
"BioyondSirnaStation 以延迟模式初始化:缺少 deck 或 api_host/api_key"
"设备会先注册动作,首次调用动作时再检查 Bioyond API 配置"
"BioyondSirnaStation deck 未提供graph 节点缺少 config.deck"
"Bioyond 后台同步与 HTTP 服务暂不启动;动作将在补齐 deck 后才能完成 RPC 调用"
)
else:
super().__init__(bioyond_config=self.bioyond_config, deck=deck)
@@ -269,10 +315,30 @@ class BioyondSirnaStation(BioyondWorkstation):
def post_init(self, ros_node: Any) -> None:
if getattr(self, "_lazy_frontend_init", False):
WorkstationBase.post_init(self, ros_node)
logger.warning("BioyondSirnaStation 延迟模式跳过 Bioyond 后台同步和 HTTP 服务启动")
logger.warning(
"BioyondSirnaStation 处于延迟模式:跳过 Bioyond 后台同步和 HTTP 服务启动。"
"首次调用动作时会重新检查 api_host/api_key 与环境变量。"
)
return
super().post_init(ros_node)
@staticmethod
def _missing_api_config_keys(config: Dict[str, Any]) -> List[str]:
missing: List[str] = []
if BioyondSirnaStation._is_blank_value(config.get("api_host")):
missing.append("api_host")
if BioyondSirnaStation._is_blank_value(config.get("api_key")):
missing.append("api_key")
return missing
@staticmethod
def _is_blank_value(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
return False
def fetch_workflow_list(
self,
workflow_type: int = 0,
@@ -339,6 +405,58 @@ class BioyondSirnaStation(BioyondWorkstation):
},
feedback_interval=300,
description="提交小核酸实验1报告基因检测",
handles=[
ActionOutputHandle(
key="order_ids",
data_type="bioyond_order_ids",
label="实验ID",
data_key="order_ids",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="order_code",
data_type="bioyond_order_code",
label="订单编号",
data_key="order_code",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="order_name",
data_type="bioyond_order_name",
label="订单名称",
data_key="order_name",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="target_device",
data_type="device_id",
label="目标设备",
data_key="target_device",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
# TEMP frontend-compat key: material/resource name.
key="resource",
data_type="resource",
label="待装载物料",
data_key="resource",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
# TEMP frontend-compat key: display as material/resource name.
key="coin_cell_code", data_type="array",
label="物料名称",
data_key="coin_cell_code",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
# TEMP frontend-compat key: display as mount/position.
key="mount_resource", data_type="resource",
label="库位",
data_key="mount_resource",
data_source=DataSource.EXECUTOR,
),
],
)
def submit_experiment_1(
self,
@@ -385,6 +503,11 @@ class BioyondSirnaStation(BioyondWorkstation):
order_name = optional_params.get("order_name", "")
parameter_overrides = optional_params.get("parameter_overrides", "")
auto_register_materials = optional_params.get("auto_register_materials", True)
target_device = (
self._kwarg_text(kwargs, "unilabos_device_id")
or self._kwarg_text(kwargs, "device_id")
or "bioyond_sirna_station"
)
del timeout_seconds, assignee_user_ids, kwargs
rpc = self._require_hardware_interface("create_order")
@@ -454,6 +577,7 @@ class BioyondSirnaStation(BioyondWorkstation):
"order_code": resolved_order_code,
"order_name": resolved_order_name,
"order_ids": order_ids,
"target_device": target_device,
"workflow": workflow,
"sample_throughput": int(sample_throughput),
"payload": order_payload,
@@ -461,11 +585,18 @@ class BioyondSirnaStation(BioyondWorkstation):
"create_order_result": parsed_result,
"materials": material_records,
"materials_by_type": confirmation_data.get("materials_by_type", {}),
"manual_load_tables": self._build_manual_load_tables(
confirmation_data.get("materials_by_type", {})
),
"manual_load_probe": self._build_manual_load_probe(
confirmation_data.get("materials_by_type", {})
),
"suggested_locations": suggested_locations,
"start_experiment": start_experiment_info,
"confirmation_message": confirmation_data.get("confirmation_message", ""),
"registration_result": registration_result,
}
result.update(result["manual_load_probe"])
return result
def _parse_parameter_overrides_text(self, text: str) -> Dict[str, Any]:
@@ -508,16 +639,73 @@ class BioyondSirnaStation(BioyondWorkstation):
@action(
always_free=True,
node_type=NodeType.MANUAL_CONFIRM,
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
placeholder_keys={
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm",
},
goal_default={
"materials_loaded": False,
"timeout_seconds": 3600,
"assignee_user_ids": [],
},
feedback_interval=300,
description="启动小核酸实验调度",
description="请核对并装载实验物料;勾选装载确认后方可启动调度",
handles=[
ActionInputHandle(
key="target_device",
data_type="device_id",
label="目标设备",
data_key="target_device",
data_source=DataSource.HANDLE,
io_type="source",
),
ActionInputHandle(
key="resource",
data_type="resource",
label="待装载物料",
data_key="resource",
data_source=DataSource.HANDLE,
io_type="source",
),
ActionInputHandle(
# TEMP frontend-compat key: material/resource name.
key="coin_cell_code",
data_type="array",
label="物料名称",
data_key="coin_cell_code",
data_source=DataSource.HANDLE,
io_type="source",
),
ActionInputHandle(
# TEMP frontend-compat key: mount/position.
key="mount_resource",
data_type="resource",
label="库位",
data_key="mount_resource",
data_source=DataSource.HANDLE,
io_type="source",
),
# Order metadata for scheduler start.
ActionInputHandle(
key="order_ids", data_type="bioyond_order_ids",
label="实验ID", data_key="order_ids",
data_source=DataSource.HANDLE,
io_type="source",
),
],
)
def start_experiment(
self,
target_device: DeviceSlot = "",
resource: Optional[List[ResourceSlot]] = None,
coin_cell_code: Optional[List[str]] = None,
mount_resource: Optional[List[ResourceSlot]] = None,
submit_experiment_result: Optional[Dict[str, Any]] = None,
order_id: str = "",
order_ids: Optional[List[str]] = None,
materials_loaded: bool = False,
api_host: str = "",
api_key: str = "",
ready_signal: str = "READY",
@@ -525,11 +713,33 @@ class BioyondSirnaStation(BioyondWorkstation):
assignee_user_ids: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""启动 Bioyond 调度器。"""
del timeout_seconds, assignee_user_ids, kwargs
"""Guided manual-load checkpoint that gates ``rpc.scheduler_start()``."""
del target_device, timeout_seconds, assignee_user_ids, kwargs
self._update_runtime_api_config(api_host=api_host, api_key=api_key)
self._require_ready_signal(ready_signal)
start_info = self._resolve_start_experiment_info(submit_experiment_result, order_id, order_ids)
category_arrays = {
"materials_loaded": (
"物料",
self._as_manual_gate(materials_loaded),
[resource, coin_cell_code, mount_resource],
),
}
gates: Dict[str, Dict[str, Any]] = {}
missing_labels: List[str] = []
for gate_key, (label, ticked, arrays) in category_arrays.items():
required = any(bool(arr) for arr in arrays)
gates[gate_key] = {"label": label, "required": required, "ticked": bool(ticked)}
if required and not ticked:
missing_labels.append(label)
if missing_labels:
raise RuntimeError(
f"以下分类装载尚未确认,无法启动调度: {', '.join(missing_labels)}"
)
start_info = self._resolve_start_experiment_info(
submit_experiment_result, order_id, order_ids
)
rpc = self._require_hardware_interface("scheduler_start")
logger.info("正在启动小核酸调度器")
result = rpc.scheduler_start()
@@ -538,6 +748,7 @@ class BioyondSirnaStation(BioyondWorkstation):
"return_info": result,
"scheduler_start_result": result,
"start_experiment": start_info,
"gates": gates,
"confirmation_message": "调度器启动成功" if result == 1 else "调度器启动失败,请检查 LIMS 状态",
})
@@ -557,18 +768,29 @@ class BioyondSirnaStation(BioyondWorkstation):
def _initialize_hardware_interface_from_config(self) -> Any:
self._apply_env_api_config(self.bioyond_config)
if not self._has_required_api_config(self.bioyond_config):
raise RuntimeError(
"Bioyond RPC 客户端未初始化,且缺少 api_host/api_key。"
"请在前端节点 config 中配置 api_host、api_key或在动作参数中传入 api_host、api_key"
)
missing = self._missing_api_config_keys(self.bioyond_config)
if missing:
api_host_present = "api_host" not in missing
api_key_present = "api_key" not in missing
lines = [
f"无法调用 Bioyond RPC缺少 {missing}(站点处于延迟初始化模式,构造时未提供完整 API 配置)。",
f" - 当前 api_host: {'已配置' if api_host_present else '<缺失>'}",
f" - 当前 api_key: {'已配置' if api_key_present else '<缺失>'}",
"请按以下任一方式补齐后重试:",
" 1) 在前端节点 config 中填入 api_host / api_key 并重新下发 graph",
" 2) 在动作 goal 参数中直接传入 api_host / api_keyreset / start_experiment 已支持);",
" 3) 设置环境变量后重启 edge",
" BIOYOND_SIRNA_API_HOST 或 BIOYOND_SIRNA_EXP1_API_HOST",
" BIOYOND_SIRNA_API_KEY 或 BIOYOND_SIRNA_EXP1_API_KEY",
]
raise RuntimeError("\n".join(lines))
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
return self.hardware_interface
def _has_required_api_config(self, config: Dict[str, Any]) -> bool:
return not self._is_blank(config.get("api_host")) and not self._is_blank(config.get("api_key"))
return not self._missing_api_config_keys(config)
def _apply_env_api_config(self, config: Dict[str, Any]) -> None:
env_pairs = {
@@ -1051,6 +1273,18 @@ class BioyondSirnaStation(BioyondWorkstation):
payload["received_ready_signal"] = DEFAULT_READY_SIGNAL
return payload
@staticmethod
def _as_manual_gate(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y", "on", "checked"}:
return True
if normalized in {"false", "0", "no", "n", "off", "unchecked", ""}:
return False
return bool(value)
def _resolve_start_experiment_info(
self,
submit_experiment_result: Optional[Dict[str, Any]],
@@ -1327,6 +1561,108 @@ class BioyondSirnaStation(BioyondWorkstation):
grouped[mode].append(mat)
return grouped
def _build_manual_load_tables(
self,
materials_by_type: Dict[str, List[Dict[str, Any]]],
) -> Dict[str, Dict[str, List[str]]]:
"""Derive sibling-array tables per Bioyond material category.
Output shape matches the production sibling-array handles on
``submit_experiment_1``: ``manual_load_tables.<Mode>.<column>`` where
``<column>`` is ``material_name | material_code | location | quantity``
and each value is a row-aligned ``List[str]``.
"""
tables: Dict[str, Dict[str, List[str]]] = {}
for mode in ("Sample", "Consumables", "Reagent"):
rows = (materials_by_type or {}).get(mode, []) or []
tables[mode] = {
"material_name": [str(row.get("materialName") or "") for row in rows],
"material_code": [str(row.get("materialCode") or "") for row in rows],
"location": [
str(row.get("locationShowName") or row.get("locationCode") or "")
for row in rows
],
"quantity": [str(row.get("quantity") or "") for row in rows],
}
return tables
@staticmethod
def _manual_load_resource_stub(
row: Dict[str, Any],
*,
name: str,
kind: str,
index: int,
) -> Dict[str, Any]:
location_label = str(row.get("locationShowName") or row.get("locationCode") or "")
display_name = name or location_label or f"{kind}_{index + 1}"
stable_key = f"bioyond-sirna-manual-load:{kind}:{index}:{display_name}"
return {
"id": display_name,
"uuid": str(uuid5(NAMESPACE_URL, stable_key)),
"name": display_name,
"description": "Bioyond 手动装载临时资源",
"schema": {},
"model": {},
"icon": "",
"parent_uuid": None,
"type": "resource",
"class": "",
"config": {
"materialId": str(row.get("materialId") or ""),
"materialCode": str(row.get("materialCode") or ""),
"materialName": str(row.get("materialName") or ""),
"locationId": str(row.get("locationId") or ""),
"locationCode": str(row.get("locationCode") or ""),
"locationShowName": str(row.get("locationShowName") or ""),
},
"data": {
"display_name": display_name,
"locationShowName": str(row.get("locationShowName") or ""),
"locationCode": str(row.get("locationCode") or ""),
},
"extra": {
"bioyond_manual_load_kind": kind,
"bioyond_location_display": location_label,
},
"machine_name": "",
}
def _build_manual_load_probe(
self,
materials_by_type: Dict[str, List[Dict[str, Any]]],
) -> Dict[str, List[Any]]:
"""构造临时前端兼容的手动装载表格值。
当前临时使用 ``coin_cell_code`` 表示物料名称,``mount_resource`` 表示库位。
因为 ``mount_resource`` 声明为 ``data_type="resource"``,这里要把
``locationShowName/locationCode`` 放到资源形状的 ``name`` 字段上,而不是
直接返回字符串列表。前端支持通用 action-input 表格后应替换为 Sirna 自有 key。
"""
rows: List[Dict[str, Any]] = []
for mode in ("Sample", "Consumables", "Reagent"):
rows.extend((materials_by_type or {}).get(mode, []) or [])
material_names = [
str(row.get("materialName") or row.get("materialCode") or "") for row in rows
]
location_names = [
str(row.get("locationShowName") or row.get("locationCode") or "") for row in rows
]
return {
"resource": material_names,
"coin_cell_code": material_names,
"mount_resource": [
self._manual_load_resource_stub(
row,
name=location_names[index],
kind="location",
index=index,
)
for index, row in enumerate(rows)
],
"mount_resource_label": location_names,
}
def _register_materials_to_tree(self, material_records: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Register Bioyond materials to UniLabOS resource tree after user confirmation."""
from unilabos.resources.bioyond.sirna_materials import get_material_class_by_type_code

View File

@@ -4,8 +4,8 @@ Defines PyLabRobot resource classes for Bioyond Sirna station materials.
Each class is decorated with @resource for AST-based registry discovery.
"""
from typing import Optional
from collections import OrderedDict
from pylabrobot.resources import Plate, TipRack, Container
from unilabos.registry.decorators import resource
@@ -19,20 +19,15 @@ from unilabos.registry.decorators import resource
class BioyondSirna_G3_200ul_TipRack(TipRack):
"""G3-200ul tip rack for Sirna liquid handling."""
def __init__(
self,
name: str,
with_tips: bool = True,
):
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=64.0,
model="bioyond_sirna_g3_200ul_tip_rack",
with_tips=with_tips,
ordering=OrderedDict(), # Empty ordering to satisfy PyLabRobot requirement
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_200ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
@@ -43,20 +38,15 @@ class BioyondSirna_G3_200ul_TipRack(TipRack):
class BioyondSirna_G3_50ul_TipRack(TipRack):
"""G3-50ul tip rack for Sirna liquid handling."""
def __init__(
self,
name: str,
with_tips: bool = True,
):
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=64.0,
model="bioyond_sirna_g3_50ul_tip_rack",
with_tips=with_tips,
ordering=OrderedDict(), # Empty ordering to satisfy PyLabRobot requirement
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_50ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
@@ -67,20 +57,15 @@ class BioyondSirna_G3_50ul_TipRack(TipRack):
class BioyondSirna_384WellPlate(Plate):
"""384-well plate for Sirna reporter gene detection."""
def __init__(
self,
name: str,
lid: Optional[object] = None,
):
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=14.35,
lid=lid,
model="bioyond_sirna_384_well_plate",
plate_type="skirted",
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_384_well_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
@@ -91,20 +76,15 @@ class BioyondSirna_384WellPlate(Plate):
class BioyondSirna_CellCulturePlate(Plate):
"""Cell culture plate for Sirna experiments."""
def __init__(
self,
name: str,
lid: Optional[object] = None,
):
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=14.35,
lid=lid,
model="bioyond_sirna_cell_culture_plate",
plate_type="skirted",
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_cell_culture_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
@@ -115,19 +95,13 @@ class BioyondSirna_CellCulturePlate(Plate):
class BioyondSirna_ReagentTrough(Container):
"""Reagent trough for Sirna station reagents (RiboGreen, etc.)."""
def __init__(
self,
name: str,
max_volume: float = 300000.0, # 300mL default
):
super().__init__(
name=name,
size_x=127.76,
size_y=85.48,
size_z=44.0,
max_volume=max_volume,
model="bioyond_sirna_reagent_trough",
)
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 44.0)
kwargs.setdefault("max_volume", 300000.0)
kwargs.setdefault("model", "bioyond_sirna_reagent_trough")
super().__init__(*args, **kwargs)
# Material type code mapping for dynamic instantiation

View File

@@ -4,8 +4,19 @@ from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_reso
from unilabos.resources.warehouse import WareHouse, warehouse_factory
def bioyond_warehouse_numeric_stack(name: str, rows: int = 10, columns: int = 17) -> WareHouse:
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。"""
def bioyond_warehouse_numeric_stack(
name: str,
rows: int = 10,
columns: int = 17,
bioyond_axis: str = "xy_row_col",
) -> WareHouse:
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。
bioyond_axis: 仓库级别的 Bioyond 坐标轴约定,供 graphio 的坐标映射使用。
- "xy_row_col" (default): Bioyond x→row, y→col (reaction/peptide 历史约定).
- "xy_col_row": Bioyond x→col, y→row (Sirna live API 实测约定).
未设置时 graphio 回退到默认 "xy_row_col",其他调用方保持原行为。
"""
num_items_x = columns
num_items_y = rows
num_items_z = 1
@@ -33,7 +44,7 @@ def bioyond_warehouse_numeric_stack(name: str, rows: int = 10, columns: int = 17
for row in range(num_items_y)
for col in range(num_items_x)
]
return WareHouse(
warehouse = WareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
@@ -45,23 +56,25 @@ def bioyond_warehouse_numeric_stack(name: str, rows: int = 10, columns: int = 17
sites={key: holder for key, holder in zip(keys, holders.values())},
category="warehouse",
)
warehouse.bioyond_axis = bioyond_axis
return warehouse
# ================ 小核酸工作站相关堆栈 ================
def bioyond_warehouse_sirna_g3_liquid_handler(name: str = "G3移液站") -> WareHouse:
"""创建小核酸 G3 移液站库位堆栈1 行 x 14 列。"""
return bioyond_warehouse_numeric_stack(name, rows=1, columns=14)
return bioyond_warehouse_numeric_stack(name, rows=1, columns=14, bioyond_axis="xy_col_row")
def bioyond_warehouse_sirna_automation_stack(name: str = "自动化堆栈") -> WareHouse:
"""创建小核酸自动化堆栈10 行 x 17 列。"""
return bioyond_warehouse_numeric_stack(name, rows=10, columns=17)
return bioyond_warehouse_numeric_stack(name, rows=10, columns=17, bioyond_axis="xy_col_row")
def bioyond_warehouse_sirna_centrifuge_balance_plate_stack(name: str = "离心机配平板堆栈") -> WareHouse:
"""创建小核酸离心机配平板堆栈2 行 x 1 列。"""
return bioyond_warehouse_numeric_stack(name, rows=2, columns=1)
return bioyond_warehouse_numeric_stack(name, rows=2, columns=1, bioyond_axis="xy_col_row")
# ================ 反应站相关堆栈 ================

View File

@@ -869,6 +869,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 仓库级别的轴约定覆盖:部分工作站 (Sirna 实测) 的 Bioyond 返回 x=列/y=行,
# 与上面的默认 "xy_row_col" 相反。warehouse.bioyond_axis="xy_col_row" 时交换 x/y。
bioyond_axis = getattr(warehouse, "bioyond_axis", "xy_row_col")
if bioyond_axis == "xy_col_row":
x, y = y, x
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4