mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 01:59:59 +00:00
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:
@@ -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,10 +301,11 @@ 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 {})
|
||||
logger.warning(
|
||||
"BioyondSirnaStation 以延迟模式初始化:缺少 deck 或 api_host/api_key,"
|
||||
"设备会先注册动作,首次调用动作时再检查 Bioyond API 配置。"
|
||||
)
|
||||
if deck is None:
|
||||
logger.warning(
|
||||
"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_key(reset / 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ================ 反应站相关堆栈 ================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user