mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-24 21:58:53 +00:00
feat: RNA refine Bioyond siRNA Experiment 1 submission
This commit is contained in:
@@ -10,10 +10,21 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
from typing import Annotated, Any, Dict, Iterable, List, Literal, Optional, Tuple
|
||||||
from urllib import error, request
|
from urllib import error, request
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
except ImportError: # pragma: no cover - 仅用于轻量环境导入
|
||||||
|
from typing import TypedDict # type: ignore
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pydantic import Field
|
||||||
|
except Exception: # pragma: no cover - 仅用于无 pydantic 的轻量环境导入
|
||||||
|
def Field(*args: Any, **kwargs: Any) -> Dict[str, Any]:
|
||||||
|
return kwargs
|
||||||
|
|
||||||
if __package__ in {None, ""}:
|
if __package__ in {None, ""}:
|
||||||
repo_root = Path(__file__).resolve().parents[5]
|
repo_root = Path(__file__).resolve().parents[5]
|
||||||
if str(repo_root) not in sys.path:
|
if str(repo_root) not in sys.path:
|
||||||
@@ -67,6 +78,23 @@ WORKFLOW_LIST_ENDPOINT = "/api/lims/workflow/work-flow-list"
|
|||||||
SUPPORTED_WORKFLOW_TYPES = {0, 1, 2}
|
SUPPORTED_WORKFLOW_TYPES = {0, 1, 2}
|
||||||
DEFAULT_READY_SIGNAL = "READY"
|
DEFAULT_READY_SIGNAL = "READY"
|
||||||
DEFAULT_RESET_OPERATIONS = ("scheduler_reset", "reset_order_status", "reset_location")
|
DEFAULT_RESET_OPERATIONS = ("scheduler_reset", "reset_order_status", "reset_location")
|
||||||
|
DEFAULT_SIRNA_MATERIAL_TYPE_MAPPINGS = {
|
||||||
|
"bioyond_sirna_g3_200ul_tip_rack": ["G3-200ul枪头盒", ""],
|
||||||
|
"bioyond_sirna_g3_50ul_tip_rack": ["G3-50ul枪头盒", ""],
|
||||||
|
"bioyond_sirna_384_well_plate": ["384孔板", ""],
|
||||||
|
"bioyond_sirna_cell_culture_plate": ["细胞培养板", ""],
|
||||||
|
"bioyond_sirna_reagent_trough": ["试剂槽RiboGreen", ""],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Experiment1RequiredParams(TypedDict):
|
||||||
|
sample_throughput: Annotated[int, Field(description="样品通量(1-96,必填),表示一次实验处理的样品数量。")]
|
||||||
|
|
||||||
|
|
||||||
|
class Experiment1OptionalParams(TypedDict, total=False):
|
||||||
|
order_name: Annotated[str, Field(description="订单名称(可选,自动生成)")]
|
||||||
|
parameter_overrides: Annotated[str, Field(description="参数覆盖(文本格式)")]
|
||||||
|
auto_register_materials: Annotated[bool, Field(default=True, description="是否自动注册物料(默认True)")]
|
||||||
|
|
||||||
|
|
||||||
def _utc_now_iso8601_ms() -> str:
|
def _utc_now_iso8601_ms() -> str:
|
||||||
@@ -90,6 +118,15 @@ def _workflow_list_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_default_sirna_material_type_mappings(config: Dict[str, Any]) -> None:
|
||||||
|
configured = config.get("material_type_mappings")
|
||||||
|
if not isinstance(configured, dict):
|
||||||
|
configured = {}
|
||||||
|
merged = dict(DEFAULT_SIRNA_MATERIAL_TYPE_MAPPINGS)
|
||||||
|
merged.update(configured)
|
||||||
|
config["material_type_mappings"] = merged
|
||||||
|
|
||||||
|
|
||||||
def load_sirna_config(config_path: str | Path) -> Dict[str, Any]:
|
def load_sirna_config(config_path: str | Path) -> Dict[str, Any]:
|
||||||
"""从 JSON 文件读取小核酸站配置。"""
|
"""从 JSON 文件读取小核酸站配置。"""
|
||||||
path = Path(config_path)
|
path = Path(config_path)
|
||||||
@@ -197,6 +234,7 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
if bioyond_config:
|
if bioyond_config:
|
||||||
merged_config.update(bioyond_config)
|
merged_config.update(bioyond_config)
|
||||||
merged_config.update(kwargs)
|
merged_config.update(kwargs)
|
||||||
|
_apply_default_sirna_material_type_mappings(merged_config)
|
||||||
self._apply_env_api_config(merged_config)
|
self._apply_env_api_config(merged_config)
|
||||||
|
|
||||||
self.protocol_type = protocol_type
|
self.protocol_type = protocol_type
|
||||||
@@ -294,53 +332,80 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
always_free=True,
|
always_free=True,
|
||||||
node_type=NodeType.MANUAL_CONFIRM,
|
node_type=NodeType.MANUAL_CONFIRM,
|
||||||
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
||||||
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
|
goal_default={
|
||||||
|
"optional_params": {"auto_register_materials": True},
|
||||||
|
"timeout_seconds": 3600,
|
||||||
|
"assignee_user_ids": [],
|
||||||
|
},
|
||||||
feedback_interval=300,
|
feedback_interval=300,
|
||||||
description="提交小核酸实验",
|
description="提交小核酸实验1(报告基因检测)",
|
||||||
)
|
)
|
||||||
def submit_experiment(
|
def submit_experiment_1(
|
||||||
self,
|
self,
|
||||||
workflow_name: str = "",
|
required_params: Experiment1RequiredParams,
|
||||||
sub_workflow_name: str = "",
|
optional_params: Optional[Experiment1OptionalParams] = None,
|
||||||
order_code: str = "",
|
|
||||||
order_name: str = "",
|
|
||||||
sample_throughput: int = 4,
|
|
||||||
parameter_overrides: Optional[Dict[str, Any]] = None,
|
|
||||||
include_all_task_displayable: bool = False,
|
|
||||||
reset_before_create: bool = True,
|
|
||||||
reset_operations: Optional[
|
|
||||||
List[Literal["scheduler_reset", "reset_order_status", "reset_location"]]
|
|
||||||
] = None,
|
|
||||||
reset_order_id: str = "",
|
|
||||||
reset_location_id: str = "",
|
|
||||||
cleanup_order_code: str = "",
|
|
||||||
api_host: str = "",
|
|
||||||
api_key: str = "",
|
|
||||||
ready_signal: str = "READY",
|
|
||||||
timeout_seconds: int = 3600,
|
timeout_seconds: int = 3600,
|
||||||
assignee_user_ids: Optional[List[str]] = None,
|
assignee_user_ids: Optional[List[str]] = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""实时查询 LIMS 工作流和步骤参数,构造并提交 create-order。"""
|
"""提交小核酸实验1(报告基因检测)到 Bioyond LIMS。
|
||||||
|
|
||||||
|
自动查询实验1工作流参数,使用 API 默认值填充所有参数,
|
||||||
|
创建订单并分配物料,最后将物料注册到 UniLabOS 资源树。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
required_params: 必填参数组
|
||||||
|
sample_throughput: 样品通量(1-96,必填),表示一次实验处理的样品数量。
|
||||||
|
optional_params: 可选参数组
|
||||||
|
order_name: 订单名称(可选,自动生成)
|
||||||
|
parameter_overrides: 参数覆盖(文本格式)
|
||||||
|
auto_register_materials: 是否自动注册物料(默认True)
|
||||||
|
timeout_seconds: 超时时间(秒,框架参数)
|
||||||
|
assignee_user_ids: 分配用户ID列表(框架参数)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含以下字段的字典:
|
||||||
|
- success (bool): 是否成功
|
||||||
|
- order_code (str): 订单编号
|
||||||
|
- order_name (str): 订单名称
|
||||||
|
- order_ids (List[str]): 订单ID列表
|
||||||
|
- materials (List[Dict]): 物料记录列表
|
||||||
|
- materials_by_type (Dict): 按类型分组的物料
|
||||||
|
- confirmation_message (str): 确认消息
|
||||||
|
- registration_result (Dict): 物料注册结果
|
||||||
|
"""
|
||||||
|
if isinstance(required_params, dict):
|
||||||
|
sample_throughput = required_params.get("sample_throughput")
|
||||||
|
else:
|
||||||
|
sample_throughput = required_params
|
||||||
|
sample_throughput = int(sample_throughput)
|
||||||
|
|
||||||
|
if optional_params is None:
|
||||||
|
optional_params = {}
|
||||||
|
order_name = optional_params.get("order_name", "")
|
||||||
|
parameter_overrides = optional_params.get("parameter_overrides", "")
|
||||||
|
auto_register_materials = optional_params.get("auto_register_materials", True)
|
||||||
|
|
||||||
del timeout_seconds, assignee_user_ids, kwargs
|
del 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)
|
|
||||||
rpc = self._require_hardware_interface("create_order")
|
rpc = self._require_hardware_interface("create_order")
|
||||||
workflow = self._resolve_experiment_workflow(
|
|
||||||
rpc,
|
# 自动解析实验1工作流(无需用户指定workflow_name)
|
||||||
workflow_name=workflow_name,
|
workflow = self._resolve_experiment_1_workflow(rpc)
|
||||||
sub_workflow_name=sub_workflow_name,
|
|
||||||
)
|
|
||||||
step_data = rpc.workflow_step_query(workflow["sub_workflow_id"])
|
step_data = rpc.workflow_step_query(workflow["sub_workflow_id"])
|
||||||
|
|
||||||
|
# Parse parameter_overrides from text format
|
||||||
|
parsed_overrides = self._parse_parameter_overrides_text(parameter_overrides)
|
||||||
|
|
||||||
param_values, parameter_template = self._build_param_values_from_step_data(
|
param_values, parameter_template = self._build_param_values_from_step_data(
|
||||||
step_data,
|
step_data,
|
||||||
parameter_overrides=parameter_overrides or [],
|
parameter_overrides=parsed_overrides,
|
||||||
include_all_task_displayable=include_all_task_displayable,
|
include_all_task_displayable=True,
|
||||||
)
|
)
|
||||||
if not param_values:
|
if not param_values:
|
||||||
raise RuntimeError("未从 LIMS 子工作流参数中提取到 create_order paramValues")
|
raise RuntimeError("未从 LIMS 子工作流参数中提取到 create_order paramValues")
|
||||||
|
|
||||||
resolved_order_code, resolved_order_name = self._build_bioyond_order_identity(order_code, order_name)
|
resolved_order_code, resolved_order_name = self._build_bioyond_order_identity("", order_name)
|
||||||
order_payload = [
|
order_payload = [
|
||||||
{
|
{
|
||||||
"orderCode": resolved_order_code,
|
"orderCode": resolved_order_code,
|
||||||
@@ -352,17 +417,7 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
reset_result = None
|
logger.info(f"正在提交小核酸实验1: {resolved_order_name} ({resolved_order_code})")
|
||||||
if reset_before_create:
|
|
||||||
reset_result = self._run_reset_operations(
|
|
||||||
rpc,
|
|
||||||
reset_operations=reset_operations,
|
|
||||||
reset_order_id=reset_order_id,
|
|
||||||
reset_location_id=reset_location_id,
|
|
||||||
cleanup_order_code=cleanup_order_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"正在提交小核酸实验: {resolved_order_name} ({resolved_order_code})")
|
|
||||||
raw_result = rpc.create_order(json.dumps(copy.deepcopy(order_payload), ensure_ascii=False))
|
raw_result = rpc.create_order(json.dumps(copy.deepcopy(order_payload), ensure_ascii=False))
|
||||||
parsed_result = self._parse_lims_result(raw_result)
|
parsed_result = self._parse_lims_result(raw_result)
|
||||||
material_records = self._extract_create_order_materials(parsed_result)
|
material_records = self._extract_create_order_materials(parsed_result)
|
||||||
@@ -376,6 +431,24 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
"order_name": resolved_order_name,
|
"order_name": resolved_order_name,
|
||||||
"workflow": workflow,
|
"workflow": workflow,
|
||||||
}
|
}
|
||||||
|
confirmation_data = self._format_create_order_confirmation(
|
||||||
|
order_code=resolved_order_code,
|
||||||
|
order_name=resolved_order_name,
|
||||||
|
workflow=workflow,
|
||||||
|
order_ids=order_ids,
|
||||||
|
material_records=material_records,
|
||||||
|
suggested_locations=suggested_locations,
|
||||||
|
)
|
||||||
|
|
||||||
|
registration_result = None
|
||||||
|
if auto_register_materials and material_records:
|
||||||
|
try:
|
||||||
|
registration_result = self._register_materials_to_tree(material_records)
|
||||||
|
logger.info(f"物料注册完成: {len(registration_result.get('registered', []))} 个物料已添加到资源树")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"物料注册失败: {e}")
|
||||||
|
registration_result = {"error": str(e)}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"success": self._create_result_success(parsed_result, order_ids, material_records),
|
"success": self._create_result_success(parsed_result, order_ids, material_records),
|
||||||
"order_code": resolved_order_code,
|
"order_code": resolved_order_code,
|
||||||
@@ -383,23 +456,54 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
"order_ids": order_ids,
|
"order_ids": order_ids,
|
||||||
"workflow": workflow,
|
"workflow": workflow,
|
||||||
"sample_throughput": int(sample_throughput),
|
"sample_throughput": int(sample_throughput),
|
||||||
"reset_result": reset_result,
|
|
||||||
"payload": order_payload,
|
"payload": order_payload,
|
||||||
"parameter_template": parameter_template,
|
"parameter_template": parameter_template,
|
||||||
"create_order_result": parsed_result,
|
"create_order_result": parsed_result,
|
||||||
"materials": material_records,
|
"materials": material_records,
|
||||||
|
"materials_by_type": confirmation_data.get("materials_by_type", {}),
|
||||||
"suggested_locations": suggested_locations,
|
"suggested_locations": suggested_locations,
|
||||||
"start_experiment": start_experiment_info,
|
"start_experiment": start_experiment_info,
|
||||||
"confirmation_message": self._format_create_order_confirmation(
|
"confirmation_message": confirmation_data.get("confirmation_message", ""),
|
||||||
order_code=resolved_order_code,
|
"registration_result": registration_result,
|
||||||
order_name=resolved_order_name,
|
|
||||||
workflow=workflow,
|
|
||||||
order_ids=order_ids,
|
|
||||||
material_records=material_records,
|
|
||||||
suggested_locations=suggested_locations,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
return self._with_ready_signal(result)
|
return result
|
||||||
|
|
||||||
|
def _parse_parameter_overrides_text(self, text: str) -> Dict[str, Any]:
|
||||||
|
"""Parse parameter overrides from text format.
|
||||||
|
|
||||||
|
Supports two formats:
|
||||||
|
1. Key-value pairs: "key1=value1,key2=value2"
|
||||||
|
2. JSON string: '{"key1": "value1", "key2": "value2"}'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Parameter overrides as text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of parameter overrides
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
# Try JSON format first
|
||||||
|
if text.startswith("{"):
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"无法解析 JSON 格式的参数覆盖: {text}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Parse key=value,key=value format
|
||||||
|
result = {}
|
||||||
|
for pair in text.split(","):
|
||||||
|
pair = pair.strip()
|
||||||
|
if "=" not in pair:
|
||||||
|
continue
|
||||||
|
key, value = pair.split("=", 1)
|
||||||
|
result[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
always_free=True,
|
always_free=True,
|
||||||
@@ -1164,31 +1268,188 @@ class BioyondSirnaStation(BioyondWorkstation):
|
|||||||
order_ids: List[str],
|
order_ids: List[str],
|
||||||
material_records: List[Dict[str, Any]],
|
material_records: List[Dict[str, Any]],
|
||||||
suggested_locations: List[Dict[str, str]],
|
suggested_locations: List[Dict[str, str]],
|
||||||
) -> str:
|
) -> Dict[str, Any]:
|
||||||
|
"""Format create order confirmation message with grouped materials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'confirmation_message' (str) and 'materials_by_type' (dict)
|
||||||
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
f"实验已提交: {order_name} ({order_code})",
|
f"实验已提交: {order_name} ({order_code})",
|
||||||
f"工作流: {workflow.get('workflow_name', '')} / {workflow.get('sub_workflow_name', '')}",
|
f"工作流: {workflow.get('workflow_name', '')} / {workflow.get('sub_workflow_name', '')}",
|
||||||
]
|
]
|
||||||
if order_ids:
|
if order_ids:
|
||||||
lines.append(f"实验ID: {', '.join(order_ids)}")
|
lines.append(f"实验ID: {', '.join(order_ids)}")
|
||||||
|
|
||||||
|
# Group materials by type for better readability
|
||||||
|
grouped = {}
|
||||||
if material_records:
|
if material_records:
|
||||||
lines.append("所需物料与建议库位:")
|
lines.append("\n实验物料分配确认:")
|
||||||
for index, record in enumerate(material_records, 1):
|
grouped = self._group_materials_by_type(material_records)
|
||||||
name = record.get("materialName") or "未命名物料"
|
|
||||||
code = record.get("materialCode") or "-"
|
for mode in ["Sample", "Consumables", "Reagent"]:
|
||||||
quantity = record.get("quantity") or "-"
|
materials = grouped.get(mode, [])
|
||||||
material_type = record.get("materialTypeName") or record.get("materialTypeMode") or "-"
|
if materials:
|
||||||
location = record.get("locationShowName") or record.get("locationCode") or "-"
|
lines.append(f"\n【{mode}】")
|
||||||
lines.append(f"{index}. {name} [{code}], {quantity}, {material_type}, 建议库位 {location}")
|
for i, mat in enumerate(materials, 1):
|
||||||
|
name = mat.get("materialName") or "未命名物料"
|
||||||
|
code = mat.get("materialCode") or "-"
|
||||||
|
quantity = mat.get("quantity") or "-"
|
||||||
|
location = mat.get("locationShowName") or mat.get("locationCode") or "-"
|
||||||
|
lines.append(f" {i}. {name} ({code})")
|
||||||
|
lines.append(f" 数量: {quantity}, 位置: {location}")
|
||||||
else:
|
else:
|
||||||
lines.append("所需物料与建议库位: LIMS 未返回预分配记录")
|
lines.append("所需物料与建议库位: LIMS 未返回预分配记录")
|
||||||
|
|
||||||
if suggested_locations:
|
if suggested_locations:
|
||||||
lines.append("库位汇总:")
|
lines.append("\n库位汇总:")
|
||||||
for index, location in enumerate(suggested_locations, 1):
|
for index, location in enumerate(suggested_locations, 1):
|
||||||
material = location.get("material_name") or "未命名物料"
|
material = location.get("material_name") or "未命名物料"
|
||||||
code = location.get("location_code") or location.get("location_id") or "-"
|
code = location.get("location_code") or location.get("location_id") or "-"
|
||||||
lines.append(f"{index}. {material} -> {code}")
|
lines.append(f"{index}. {material} -> {code}")
|
||||||
return "\n".join(lines)
|
|
||||||
|
return {
|
||||||
|
"confirmation_message": "\n".join(lines),
|
||||||
|
"materials_by_type": grouped,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _group_materials_by_type(self, materials: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""Group materials by materialTypeMode (Sample/Consumables/Reagent)."""
|
||||||
|
grouped: Dict[str, List[Dict[str, Any]]] = {
|
||||||
|
"Sample": [],
|
||||||
|
"Consumables": [],
|
||||||
|
"Reagent": [],
|
||||||
|
}
|
||||||
|
for mat in materials:
|
||||||
|
mode = mat.get("materialTypeMode", "Unknown")
|
||||||
|
if mode not in grouped:
|
||||||
|
grouped[mode] = []
|
||||||
|
grouped[mode].append(mat)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
deck = getattr(self, "deck", None)
|
||||||
|
if deck is None:
|
||||||
|
logger.warning("deck 未初始化,跳过 resource tree 注册")
|
||||||
|
return {"registered": [], "skipped": material_records, "reason": "no_deck"}
|
||||||
|
|
||||||
|
registered = []
|
||||||
|
skipped = []
|
||||||
|
for mat in material_records:
|
||||||
|
type_code = mat.get("materialTypeCode", "")
|
||||||
|
resource_class = get_material_class_by_type_code(type_code)
|
||||||
|
if resource_class is None:
|
||||||
|
logger.warning(f"未知 materialTypeCode {type_code},跳过: {mat.get('materialName')}")
|
||||||
|
skipped.append(mat)
|
||||||
|
continue
|
||||||
|
|
||||||
|
location_code = mat.get("locationShowName") or mat.get("locationCode") or ""
|
||||||
|
if not location_code:
|
||||||
|
skipped.append(mat)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
warehouse, idx = self._resolve_location_to_warehouse(location_code)
|
||||||
|
except (ValueError, RuntimeError) as exc:
|
||||||
|
logger.warning(f"解析库位 {location_code} 失败: {exc}")
|
||||||
|
skipped.append(mat)
|
||||||
|
continue
|
||||||
|
|
||||||
|
material_code = mat.get("materialCode") or f"mat_{type_code}_{location_code}"
|
||||||
|
plr_resource = resource_class(name=material_code)
|
||||||
|
plr_resource.unilabos_extra = {
|
||||||
|
"material_bioyond_id": mat.get("materialId", ""),
|
||||||
|
"material_bioyond_name": mat.get("materialName", ""),
|
||||||
|
"material_bioyond_type": mat.get("materialTypeName", ""),
|
||||||
|
"material_bioyond_type_code": type_code,
|
||||||
|
"material_bioyond_type_mode": mat.get("materialTypeMode", ""),
|
||||||
|
"location_code": location_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
warehouse[idx] = plr_resource
|
||||||
|
registered.append({
|
||||||
|
"material_code": material_code,
|
||||||
|
"material_name": mat.get("materialName", ""),
|
||||||
|
"location_code": location_code,
|
||||||
|
"warehouse": warehouse.name,
|
||||||
|
"index": idx,
|
||||||
|
})
|
||||||
|
except (IndexError, TypeError) as exc:
|
||||||
|
logger.warning(f"放置物料 {material_code} 到 {warehouse.name}[{idx}] 失败: {exc}")
|
||||||
|
skipped.append(mat)
|
||||||
|
|
||||||
|
self._publish_resource_tree_update()
|
||||||
|
return {"registered": registered, "skipped": skipped}
|
||||||
|
|
||||||
|
def _resolve_location_to_warehouse(self, location_code: str) -> Tuple[Any, int]:
|
||||||
|
"""Map Bioyond location code to (warehouse, index).
|
||||||
|
|
||||||
|
Location codes come from API response (e.g., "10-1", "4-13").
|
||||||
|
The deck defines warehouses with known row/col layouts:
|
||||||
|
- G3移液站: row 1, cols 1-14 (code "1-X" where X <= 14 and in G3 range)
|
||||||
|
- 自动化堆栈: rows 1-10, cols 1-17 (code "Y-X")
|
||||||
|
- 离心机配平板堆栈: rows 1-2, cols 1-1
|
||||||
|
|
||||||
|
Since both G3移液站 and 自动化堆栈 share row-based codes, we rely on
|
||||||
|
the deck warehouse configuration to determine which warehouse owns which
|
||||||
|
row/col range. The live API allocates materials using the same site codes
|
||||||
|
as defined in the deck setup.
|
||||||
|
"""
|
||||||
|
deck = getattr(self, "deck", None)
|
||||||
|
if deck is None:
|
||||||
|
raise RuntimeError("deck 未初始化")
|
||||||
|
|
||||||
|
parts = location_code.replace("-", "-").split("-")
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"无法解析库位代码: {location_code!r}")
|
||||||
|
row = int(parts[0])
|
||||||
|
col = int(parts[1])
|
||||||
|
|
||||||
|
# Try to find the matching warehouse by checking site keys
|
||||||
|
for child in deck.children:
|
||||||
|
warehouse = child
|
||||||
|
if not hasattr(warehouse, "sites"):
|
||||||
|
continue
|
||||||
|
site_key = f"{row}-{col}"
|
||||||
|
sites = warehouse.sites if hasattr(warehouse, "sites") else {}
|
||||||
|
if isinstance(sites, dict) and site_key in sites:
|
||||||
|
idx = list(sites.keys()).index(site_key)
|
||||||
|
return warehouse, idx
|
||||||
|
|
||||||
|
# Fallback: use row/col based heuristic matching deck dimensions
|
||||||
|
for child in deck.children:
|
||||||
|
warehouse = child
|
||||||
|
num_sites = getattr(warehouse, "num_items", 0) or len(getattr(warehouse, "children", []))
|
||||||
|
if num_sites == 0:
|
||||||
|
continue
|
||||||
|
num_cols = getattr(warehouse, "_num_items_y", None) or getattr(warehouse, "num_items_y", 1)
|
||||||
|
num_rows = getattr(warehouse, "_num_items_x", None) or getattr(warehouse, "num_items_x", 1)
|
||||||
|
if 1 <= row <= num_rows and 1 <= col <= num_cols:
|
||||||
|
idx = (row - 1) * num_cols + (col - 1)
|
||||||
|
if idx < num_sites:
|
||||||
|
return warehouse, idx
|
||||||
|
|
||||||
|
raise ValueError(f"未找到与库位 {location_code} 匹配的 warehouse")
|
||||||
|
|
||||||
|
def _publish_resource_tree_update(self) -> None:
|
||||||
|
"""Trigger ROS2 resource tree update for frontend refresh."""
|
||||||
|
ros_node = getattr(self, "_ros_node", None)
|
||||||
|
if ros_node is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if hasattr(ros_node, "update_resource"):
|
||||||
|
deck = getattr(self, "deck", None)
|
||||||
|
if deck is not None:
|
||||||
|
ros_node.update_resource(
|
||||||
|
resource_name=deck.name,
|
||||||
|
resource_data=deck.serialize() if hasattr(deck, "serialize") else {},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"resource tree 更新失败 (非阻塞): {exc}")
|
||||||
|
|
||||||
def _record_name(self, record: Optional[Dict[str, Any]]) -> str:
|
def _record_name(self, record: Optional[Dict[str, Any]]) -> str:
|
||||||
if not isinstance(record, dict):
|
if not isinstance(record, dict):
|
||||||
|
|||||||
152
unilabos/resources/bioyond/sirna_materials.py
Normal file
152
unilabos/resources/bioyond/sirna_materials.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Sirna Station Material Resource Definitions
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="bioyond_sirna_g3_200ul_tip_rack",
|
||||||
|
category=["labware", "tip_rack"],
|
||||||
|
description="G3-200ul枪头盒 for Sirna station",
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="bioyond_sirna_g3_50ul_tip_rack",
|
||||||
|
category=["labware", "tip_rack"],
|
||||||
|
description="G3-50ul枪头盒 for Sirna station",
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="bioyond_sirna_384_well_plate",
|
||||||
|
category=["labware", "plate"],
|
||||||
|
description="384孔板 for Sirna assays",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="bioyond_sirna_cell_culture_plate",
|
||||||
|
category=["labware", "plate"],
|
||||||
|
description="细胞培养板 for Sirna cell culture",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="bioyond_sirna_reagent_trough",
|
||||||
|
category=["labware", "trough"],
|
||||||
|
description="试剂槽 for Sirna reagents",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Material type code mapping for dynamic instantiation
|
||||||
|
MATERIAL_TYPE_CODE_TO_CLASS = {
|
||||||
|
"0016": BioyondSirna_G3_200ul_TipRack,
|
||||||
|
"0017": BioyondSirna_G3_50ul_TipRack,
|
||||||
|
"0015": BioyondSirna_384WellPlate,
|
||||||
|
"0001": BioyondSirna_CellCulturePlate,
|
||||||
|
"0006": BioyondSirna_ReagentTrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_material_class_by_type_code(type_code: str):
|
||||||
|
"""Get resource class by Bioyond material type code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type_code: Bioyond materialTypeCode (e.g., "0016", "0017")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resource class or None if not found
|
||||||
|
"""
|
||||||
|
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)
|
||||||
Reference in New Issue
Block a user