Compare commits

..

7 Commits

Author SHA1 Message Date
hanhua
882c33bd22 update action
update action
2026-01-15 10:05:03 +08:00
ZiWei
ad21644db0 fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑 2026-01-14 20:15:05 +08:00
Xuwznln
9dfd58e9af fix parent_uuid fetch when bind_parent_id == node_name 2026-01-14 14:17:29 +08:00
Xuwznln
31c9f9a172 物料更新也是用父节点进行报送 2026-01-13 20:21:37 +08:00
Xuwznln
02cd8de4c5 Add None conversion for tube rack etc. 2026-01-13 17:49:11 +08:00
Xuwznln
a66603ec1c Add set_liquid example. 2026-01-12 22:24:01 +08:00
Xuwznln
ec015e16cd Add create_resource and test_resource example. 2026-01-12 21:17:28 +08:00
25 changed files with 1133 additions and 2583 deletions

View File

@@ -848,7 +848,7 @@ class MessageProcessor:
device_action_groups[key_add].append(item["uuid"]) device_action_groups[key_add].append(item["uuid"])
logger.info( logger.info(
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}" f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}"
) )
else: else:
# 正常update # 正常update
@@ -863,12 +863,11 @@ class MessageProcessor:
device_action_groups[key] = [] device_action_groups[key] = []
device_action_groups[key].append(item["uuid"]) device_action_groups[key].append(item["uuid"])
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}") logger.trace(f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
logger.trace(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, {resource_uuid_list}")
# 为每个(device_id, action)创建独立的更新线程 # 为每个(device_id, action)创建独立的更新线程
for (device_id, actual_action), items in device_action_groups.items(): for (device_id, actual_action), items in device_action_groups.items():
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}") logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
def _notify_resource_tree(dev_id, act, item_list): def _notify_resource_tree(dev_id, act, item_list):
try: try:

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ class Base(ABC):
self._type = typ self._type = typ
self._data_type = data_type self._data_type = data_type
self._node: Optional[Node] = None self._node: Optional[Node] = None
def _get_node(self) -> Node: def _get_node(self) -> Node:
if self._node is None: if self._node is None:
try: try:
@@ -66,7 +66,7 @@ class Base(ABC):
# 直接以字符串形式处理 # 直接以字符串形式处理
if isinstance(nid, str): if isinstance(nid, str):
nid = nid.strip() nid = nid.strip()
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)' # 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
# 提取括号内的内容 # 提取括号内的内容
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid) match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
@@ -116,16 +116,16 @@ class Base(ABC):
def read(self) -> Tuple[Any, bool]: def read(self) -> Tuple[Any, bool]:
"""读取节点值,返回(值, 是否出错)""" """读取节点值,返回(值, 是否出错)"""
pass pass
@abstractmethod @abstractmethod
def write(self, value: Any) -> bool: def write(self, value: Any) -> bool:
"""写入节点值,返回是否出错""" """写入节点值,返回是否出错"""
pass pass
@property @property
def type(self) -> NodeType: def type(self) -> NodeType:
return self._type return self._type
@property @property
def node_id(self) -> str: def node_id(self) -> str:
return self._node_id return self._node_id
@@ -210,15 +210,15 @@ class Method(Base):
super().__init__(client, name, node_id, NodeType.METHOD, data_type) super().__init__(client, name, node_id, NodeType.METHOD, data_type)
self._parent_node_id = parent_node_id self._parent_node_id = parent_node_id
self._parent_node = None self._parent_node = None
def _get_parent_node(self) -> Node: def _get_parent_node(self) -> Node:
if self._parent_node is None: if self._parent_node is None:
try: try:
# 处理父节点ID使用与_get_node相同的解析逻辑 # 处理父节点ID使用与_get_node相同的解析逻辑
import re import re
nid = self._parent_node_id nid = self._parent_node_id
# 如果已经是 NodeId 对象,直接使用 # 如果已经是 NodeId 对象,直接使用
try: try:
from opcua.ua import NodeId as UaNodeId from opcua.ua import NodeId as UaNodeId
@@ -227,16 +227,16 @@ class Method(Base):
return self._parent_node return self._parent_node
except Exception: except Exception:
pass pass
# 字符串处理 # 字符串处理
if isinstance(nid, str): if isinstance(nid, str):
nid = nid.strip() nid = nid.strip()
# 处理包含类名的格式 # 处理包含类名的格式
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid) match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
if match_wrapped: if match_wrapped:
nid = match_wrapped.group(2).strip() nid = match_wrapped.group(2).strip()
# 常见短格式 # 常见短格式
if re.match(r'^ns=\d+;[is]=', nid): if re.match(r'^ns=\d+;[is]=', nid):
self._parent_node = self._client.get_node(nid) self._parent_node = self._client.get_node(nid)
@@ -271,7 +271,7 @@ class Method(Base):
def write(self, value: Any) -> bool: def write(self, value: Any) -> bool:
"""方法节点不支持写入操作""" """方法节点不支持写入操作"""
return True return True
def call(self, *args) -> Tuple[Any, bool]: def call(self, *args) -> Tuple[Any, bool]:
"""调用方法,返回(返回值, 是否出错)""" """调用方法,返回(返回值, 是否出错)"""
try: try:
@@ -285,7 +285,7 @@ class Method(Base):
class Object(Base): class Object(Base):
def __init__(self, client: Client, name: str, node_id: str): def __init__(self, client: Client, name: str, node_id: str):
super().__init__(client, name, node_id, NodeType.OBJECT, None) super().__init__(client, name, node_id, NodeType.OBJECT, None)
def read(self) -> Tuple[Any, bool]: def read(self) -> Tuple[Any, bool]:
"""对象节点不支持直接读取操作""" """对象节点不支持直接读取操作"""
return None, True return None, True
@@ -293,7 +293,7 @@ class Object(Base):
def write(self, value: Any) -> bool: def write(self, value: Any) -> bool:
"""对象节点不支持直接写入操作""" """对象节点不支持直接写入操作"""
return True return True
def get_children(self) -> Tuple[List[Node], bool]: def get_children(self) -> Tuple[List[Node], bool]:
"""获取子节点列表,返回(子节点列表, 是否出错)""" """获取子节点列表,返回(子节点列表, 是否出错)"""
try: try:
@@ -301,4 +301,4 @@ class Object(Base):
return children, False return children, False
except Exception as e: except Exception as e:
print(f"获取对象 {self._name} 的子节点失败: {e}") print(f"获取对象 {self._name} 的子节点失败: {e}")
return [], True return [], True

View File

@@ -176,40 +176,7 @@ class BioyondV1RPC(BaseRequest):
return {} return {}
print(f"add material data: {response['data']}") print(f"add material data: {response['data']}")
return response.get("data", {})
# 自动更新缓存
data = response.get("data", {})
if data:
if isinstance(data, str):
# 如果返回的是字符串通常是ID
mat_id = data
name = params.get("name")
else:
# 如果返回的是字典尝试获取name和id
name = data.get("name") or params.get("name")
mat_id = data.get("id")
if name and mat_id:
self.material_cache[name] = mat_id
print(f"已自动更新缓存: {name} -> {mat_id}")
# 处理返回数据中的 details (如果有)
# 有些 API 返回结构可能直接包含 details或者在 data 字段中
details = data.get("details", []) if isinstance(data, dict) else []
if not details and isinstance(data, dict):
details = data.get("detail", [])
if details:
for detail in details:
d_name = detail.get("name")
# 尝试从不同字段获取 ID
d_id = detail.get("id") or detail.get("detailMaterialId")
if d_name and d_id:
self.material_cache[d_name] = d_id
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
return data
def query_matial_type_id(self, data) -> list: def query_matial_type_id(self, data) -> list:
"""查找物料typeid""" """查找物料typeid"""
@@ -236,7 +203,7 @@ class BioyondV1RPC(BaseRequest):
params={ params={
"apiKey": self.api_key, "apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(), "requestTime": self.get_current_time_iso8601(),
"data": 0, "data": {},
}) })
if not response or response['code'] != 1: if not response or response['code'] != 1:
return [] return []
@@ -306,14 +273,6 @@ class BioyondV1RPC(BaseRequest):
if not response or response['code'] != 1: if not response or response['code'] != 1:
return {} return {}
# 自动更新缓存 - 移除被删除的物料
for name, mid in list(self.material_cache.items()):
if mid == material_id:
del self.material_cache[name]
print(f"已从缓存移除物料: {name}")
break
return response.get("data", {}) return response.get("data", {})
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict: def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
@@ -1144,10 +1103,6 @@ class BioyondV1RPC(BaseRequest):
for detail_material in detail_materials: for detail_material in detail_materials:
detail_name = detail_material.get("name") detail_name = detail_material.get("name")
detail_id = detail_material.get("detailMaterialId") detail_id = detail_material.get("detailMaterialId")
if not detail_id:
# 尝试其他可能的字段
detail_id = detail_material.get("id")
if detail_name and detail_id: if detail_name and detail_id:
self.material_cache[detail_name] = detail_id self.material_cache[detail_name] = detail_id
print(f"加载detail材料: {detail_name} -> ID: {detail_id}") print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
@@ -1168,14 +1123,6 @@ class BioyondV1RPC(BaseRequest):
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}") print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
return material_id return material_id
# 如果缓存中没有,尝试刷新缓存
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
self.refresh_material_cache()
if material_name_or_id in self.material_cache:
material_id = self.material_cache[material_name_or_id]
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
return material_id
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值") print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
return material_name_or_id return material_name_or_id

View File

@@ -4,7 +4,6 @@ import time
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from typing_extensions import TypedDict from typing_extensions import TypedDict
import requests import requests
import pint
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
@@ -44,41 +43,6 @@ class BioyondDispensingStation(BioyondWorkstation):
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}} # 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
self.order_completion_status = {} self.order_completion_status = {}
# 初始化 pint 单位注册表
self.ureg = pint.UnitRegistry()
# 化合物信息
self.compound_info = {
"MolWt": {
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
"134": 292.34 * self.ureg.g / self.ureg.mol,
},
"FuncGroup": {
"MDA": "Amine",
"TDA": "Amine",
"PAPP": "Amine",
"BTDA": "Anhydride",
"BPDA": "Anhydride",
"6FAP": "Amine",
"MPDA": "Amine",
"SIDA": "Amine",
"PMDA": "Anhydride",
"ODA": "Amine",
"4,4'-ODA": "Amine",
"134": "Amine",
}
}
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]: def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
"""项目接口通用POST调用 """项目接口通用POST调用
@@ -154,22 +118,20 @@ class BioyondDispensingStation(BioyondWorkstation):
ratio = json.loads(ratio) ratio = json.loads(ratio)
except Exception: except Exception:
ratio = {} ratio = {}
root = str(Path(__file__).resolve().parents[3])
if root not in sys.path:
sys.path.append(root)
try:
mod = importlib.import_module("tem.compute")
except Exception as e:
raise BioyondException(f"无法导入计算模块: {e}")
try: try:
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
mt = float(m_tot) if isinstance(m_tot, str) else m_tot mt = float(m_tot) if isinstance(m_tot, str) else m_tot
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
except Exception as e: except Exception as e:
raise BioyondException(f"参数解析失败: {e}") raise BioyondException(f"参数解析失败: {e}")
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
# 2. 调用内部计算方法
res = self._generate_experiment_design(
ratio=ratio,
wt_percent=wp,
m_tot=mt,
titration_percent=tp
)
# 3. 构造返回结果
out = { out = {
"solutions": res.get("solutions", []), "solutions": res.get("solutions", []),
"titration": res.get("titration", {}), "titration": res.get("titration", {}),
@@ -178,248 +140,11 @@ class BioyondDispensingStation(BioyondWorkstation):
"return_info": json.dumps(res, ensure_ascii=False) "return_info": json.dumps(res, ensure_ascii=False)
} }
return out return out
except BioyondException: except BioyondException:
raise raise
except Exception as e: except Exception as e:
raise BioyondException(str(e)) raise BioyondException(str(e))
def _generate_experiment_design(
self,
ratio: dict,
wt_percent: float = 0.25,
m_tot: float = 70,
titration_percent: float = 0.03,
) -> dict:
"""内部方法:生成实验设计
根据FuncGroup自动区分二胺和二酐每种二胺单独配溶液严格按照ratio顺序投料。
参数:
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
wt_percent: 固体重量百分比
m_tot: 反应混合物总质量(g)
titration_percent: 滴定溶液百分比
返回:
包含实验设计详细参数的字典
"""
# 溶剂密度
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
# 二酐溶解度
solubility = 0.02 * self.ureg.g / self.ureg.ml
# 投入固体时最小溶剂体积
V_min = 30 * self.ureg.ml
m_tot = m_tot * self.ureg.g
# 保持ratio中的顺序
compound_names = list(ratio.keys())
compound_ratios = list(ratio.values())
# 验证所有化合物是否在 compound_info 中定义
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
if undefined_compounds:
available = list(self.compound_info["MolWt"].keys())
raise ValueError(
f"以下化合物未在 compound_info 中定义: {undefined_compounds}"
f"可用的化合物: {available}"
)
# 获取各化合物的分子量和官能团类型
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
# 记录化合物信息用于调试
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
# 按原始顺序分离二胺和二酐
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
if not diamine_compounds or not anhydride_compounds:
raise ValueError(
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
)
# 计算加权平均分子量 (基于摩尔比)
total_molar_ratio = sum(compound_ratios)
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
# 取最后一个二酐用于滴定
titration_anhydride = anhydride_compounds[-1]
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
# 二胺溶液配制参数 - 每种二胺单独配制
diamine_solutions = []
total_diamine_volume = 0 * self.ureg.ml
# 计算反应物的总摩尔量
n_reactant = m_tot * wt_percent / weighted_molecular_weight
for name, ratio_val, mw, order_index in diamine_compounds:
# 跳过 SIDA
if name == "SIDA":
continue
# 计算该二胺需要的摩尔数
n_diamine_needed = n_reactant * ratio_val
# 二胺溶液配制参数 (每种二胺固定配制参数)
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
m_solvent_for_this = ρ_solvent * V_solvent_for_this
# 计算该二胺溶液的浓度
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
# 计算需要移取的溶液体积
V_diamine_needed = n_diamine_needed / c_diamine
diamine_solutions.append({
"name": name,
"order": order_index,
"solid_mass": m_diamine_solid.magnitude,
"solvent_volume": V_solvent_for_this.magnitude,
"concentration": c_diamine.magnitude,
"volume_needed": V_diamine_needed.magnitude,
"molar_ratio": ratio_val
})
total_diamine_volume += V_diamine_needed
# 按原始顺序排序
diamine_solutions.sort(key=lambda x: x["order"])
# 计算滴定二酐的质量
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
m_titration_10 = m_titration_anhydride * titration_percent
# 计算其他固体二酐的质量 (按顺序)
solid_anhydride_masses = []
for name, ratio_val, mw, order_index in solid_anhydrides:
mass = n_reactant * ratio_val * mw
solid_anhydride_masses.append({
"name": name,
"order": order_index,
"mass": mass.magnitude,
"molar_ratio": ratio_val
})
# 按原始顺序排序
solid_anhydride_masses.sort(key=lambda x: x["order"])
# 计算溶剂用量
total_diamine_solution_mass = sum(
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
) * self.ureg.ml
# 预估滴定溶剂量、计算补加溶剂量
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
# 检查最小溶剂体积要求
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
m_tot_min = V_min / total_liquid_volume * m_tot
# 如果需要,按比例放大
scale_factor = 1.0
if m_tot_min > m_tot:
scale_factor = (m_tot_min / m_tot).magnitude
m_titration_90 *= scale_factor
m_titration_10 *= scale_factor
m_solvent_add *= scale_factor
m_solvent_titration *= scale_factor
# 更新二胺溶液用量
for sol in diamine_solutions:
sol["volume_needed"] *= scale_factor
# 更新固体二酐用量
for anhydride in solid_anhydride_masses:
anhydride["mass"] *= scale_factor
m_tot = m_tot_min
# 生成投料顺序
feeding_order = []
# 1. 固体二酐 (按顺序)
for anhydride in solid_anhydride_masses:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "solid_anhydride",
"name": anhydride["name"],
"amount": anhydride["mass"],
"order": anhydride["order"]
})
# 2. 二胺溶液 (按顺序)
for sol in diamine_solutions:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "diamine_solution",
"name": sol["name"],
"amount": sol["volume_needed"],
"order": sol["order"]
})
# 3. 主要二酐粉末
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "main_anhydride",
"name": titration_name,
"amount": m_titration_90.magnitude,
"order": titration_anhydride[3]
})
# 4. 补加溶剂
if m_solvent_add > 0:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "additional_solvent",
"name": "溶剂",
"amount": m_solvent_add.magnitude,
"order": 999
})
# 5. 滴定二酐溶液
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "titration_anhydride",
"name": f"{titration_name} 滴定液",
"amount": m_titration_10.magnitude,
"titration_solvent": m_solvent_titration.magnitude,
"order": titration_anhydride[3]
})
# 返回实验设计结果
results = {
"total_mass": m_tot.magnitude,
"scale_factor": scale_factor,
"solutions": diamine_solutions,
"solids": solid_anhydride_masses,
"titration": {
"name": titration_name,
"main_portion": m_titration_90.magnitude,
"titration_portion": m_titration_10.magnitude,
"titration_solvent": m_solvent_titration.magnitude,
},
"solvents": {
"additional_solvent": m_solvent_add.magnitude,
"total_liquid_volume": total_liquid_volume.magnitude
},
"feeding_order": feeding_order,
"minimum_required_mass": m_tot_min.magnitude
}
return results
# 90%10%小瓶投料任务创建方法 # 90%10%小瓶投料任务创建方法
def create_90_10_vial_feeding_task(self, def create_90_10_vial_feeding_task(self,
order_name: str = None, order_name: str = None,
@@ -1236,108 +961,6 @@ class BioyondDispensingStation(BioyondWorkstation):
'actualVolume': actual_volume 'actualVolume': actual_volume
} }
def _simplify_report(self, report) -> Dict[str, Any]:
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
if not isinstance(report, dict):
return report
data = report.get('data', {})
if not isinstance(data, dict):
return report
# 提取关键信息
simplified = {
'name': data.get('name'),
'code': data.get('code'),
'requester': data.get('requester'),
'workflowName': data.get('workflowName'),
'workflowStep': data.get('workflowStep'),
'requestTime': data.get('requestTime'),
'startPreparationTime': data.get('startPreparationTime'),
'completeTime': data.get('completeTime'),
'useTime': data.get('useTime'),
'status': data.get('status'),
'statusName': data.get('statusName'),
}
# 提取物料信息(简化版)
pre_intakes = data.get('preIntakes', [])
if pre_intakes and isinstance(pre_intakes, list):
first_intake = pre_intakes[0]
sample_materials = first_intake.get('sampleMaterials', [])
# 简化物料信息
simplified_materials = []
for material in sample_materials:
if isinstance(material, dict):
mat_info = {
'materialName': material.get('materialName'),
'materialTypeName': material.get('materialTypeName'),
'materialCode': material.get('materialCode'),
'materialLocation': material.get('materialLocation'),
}
# 解析parameters中的关键信息如密度、加料历史等
params_str = material.get('parameters', '{}')
try:
params = json.loads(params_str) if isinstance(params_str, str) else params_str
if isinstance(params, dict):
# 只保留关键参数
if 'density' in params:
mat_info['density'] = params['density']
if 'feedingHistory' in params:
mat_info['feedingHistory'] = params['feedingHistory']
if 'liquidVolume' in params:
mat_info['liquidVolume'] = params['liquidVolume']
if 'm_diamine_tot' in params:
mat_info['m_diamine_tot'] = params['m_diamine_tot']
if 'wt_diamine' in params:
mat_info['wt_diamine'] = params['wt_diamine']
except:
pass
simplified_materials.append(mat_info)
simplified['sampleMaterials'] = simplified_materials
# 提取extraProperties中的实际值
extra_props = first_intake.get('extraProperties', {})
if isinstance(extra_props, dict):
simplified_extra = {}
for key, value in extra_props.items():
try:
parsed_value = json.loads(value) if isinstance(value, str) else value
simplified_extra[key] = parsed_value
except:
simplified_extra[key] = value
simplified['extraProperties'] = simplified_extra
return {
'data': simplified,
'code': report.get('code'),
'message': report.get('message'),
'timestamp': report.get('timestamp')
}
def scheduler_start(self) -> dict:
"""启动调度器 - 启动Bioyond工作站的任务调度器开始执行队列中的任务
Returns:
dict: 包含return_info的字典return_info为整型(1=成功)
Raises:
BioyondException: 调度器启动失败时抛出异常
"""
result = self.hardware_interface.scheduler_start()
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
if result != 1:
error_msg = "启动调度器失败: 有未处理错误调度无法启动。请检查Bioyond系统状态。"
self.hardware_interface._logger.error(error_msg)
raise BioyondException(error_msg)
return {"return_info": result}
# 等待多个任务完成并获取实验报告 # 等待多个任务完成并获取实验报告
def wait_for_multiple_orders_and_get_reports(self, def wait_for_multiple_orders_and_get_reports(self,
batch_create_result: str = None, batch_create_result: str = None,
@@ -1379,12 +1002,7 @@ class BioyondDispensingStation(BioyondWorkstation):
# 验证batch_create_result参数 # 验证batch_create_result参数
if not batch_create_result or batch_create_result == "": if not batch_create_result or batch_create_result == "":
raise BioyondException( raise BioyondException("batch_create_result参数为空请确保从batch_create节点正确连接handle")
"batch_create_result参数为空请确保:\n"
"1. batch_create节点与wait节点之间正确连接了handle\n"
"2. batch_create节点成功执行并返回了结果\n"
"3. 检查上游batch_create任务是否成功创建了订单"
)
# 解析batch_create_result JSON对象 # 解析batch_create_result JSON对象
try: try:
@@ -1413,17 +1031,7 @@ class BioyondDispensingStation(BioyondWorkstation):
# 验证提取的数据 # 验证提取的数据
if not order_codes: if not order_codes:
self.hardware_interface._logger.error( raise BioyondException("batch_create_result中未找到order_codes字段或为空")
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
)
raise BioyondException(
"batch_create_result中未找到order_codes或为空。\n"
"可能的原因:\n"
"1. batch_create任务执行失败检查任务是否报错\n"
"2. 物料配置问题(如'物料样品板分配失败'\n"
"3. Bioyond系统状态异常\n"
f"请检查batch_create任务的执行结果"
)
if not order_ids: if not order_ids:
raise BioyondException("batch_create_result中未找到order_ids字段或为空") raise BioyondException("batch_create_result中未找到order_ids字段或为空")
@@ -1506,8 +1114,6 @@ class BioyondDispensingStation(BioyondWorkstation):
self.hardware_interface._logger.info( self.hardware_interface._logger.info(
f"成功获取任务 {order_code} 的实验报告" f"成功获取任务 {order_code} 的实验报告"
) )
# 简化报告,去除冗余信息
report = self._simplify_report(report)
reports.append({ reports.append({
"order_code": order_code, "order_code": order_code,

View File

@@ -6,7 +6,6 @@ Bioyond Workstation Implementation
""" """
import time import time
import traceback import traceback
import threading
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
import json import json
@@ -30,90 +29,6 @@ from unilabos.devices.workstation.bioyond_studio.config import (
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
class ConnectionMonitor:
"""Bioyond连接监控器"""
def __init__(self, workstation, check_interval=30):
self.workstation = workstation
self.check_interval = check_interval
self._running = False
self._thread = None
self._last_status = "unknown"
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
self._thread.start()
logger.info("Bioyond连接监控器已启动")
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=2)
logger.info("Bioyond连接监控器已停止")
def _monitor_loop(self):
while self._running:
try:
# 使用 lightweight API 检查连接
# query_matial_type_list 是比较快的查询
start_time = time.time()
result = self.workstation.hardware_interface.material_type_list()
status = "online" if result else "offline"
msg = "Connection established" if status == "online" else "Failed to get material type list"
if status != self._last_status:
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
self._publish_event(status, msg)
self._last_status = status
# 发布心跳 (可选,或者只在状态变更时发布)
# self._publish_event(status, msg)
except Exception as e:
logger.error(f"Bioyond连接检查异常: {e}")
if self._last_status != "error":
self._publish_event("error", str(e))
self._last_status = "error"
time.sleep(self.check_interval)
def _publish_event(self, status, message):
try:
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
event_data = {
"status": status,
"message": message,
"timestamp": datetime.now().isoformat()
}
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
# 这里假设通用事件发布机制,使用 String 类型的 topic
# 话题: /<namespace>/events/device_status
ns = self.workstation._ros_node.namespace
topic = f"{ns}/events/device_status"
# 使用 ROS2DeviceNode 的发布功能
# 如果没有预定义的 publisher需要动态创建
# 注意workstation base node 可能没有自动创建 arbitrary publishers 的机制
# 这里我们先尝试用 String json 发布
# 在 ROS2DeviceNode 中通常需要先 create_publisher
# 为了简单起见,我们检查是否已有 publisher没有则创建
if not hasattr(self.workstation, "_device_status_pub"):
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
String, topic, 10
)
self.workstation._device_status_pub.publish(
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
)
except Exception as e:
logger.error(f"发布设备状态事件失败: {e}")
class BioyondResourceSynchronizer(ResourceSynchronizer): class BioyondResourceSynchronizer(ResourceSynchronizer):
"""Bioyond资源同步器 """Bioyond资源同步器
@@ -324,18 +239,13 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...") logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
# 导入物料默认参数配置 # 导入物料默认参数配置
from .config import MATERIAL_DEFAULT_PARAMETERS, MATERIAL_TYPE_PARAMETERS from .config import MATERIAL_DEFAULT_PARAMETERS
# 合并参数配置:物料名称参数 + typeId参数转换为 type:<uuid> 格式)
merged_params = MATERIAL_DEFAULT_PARAMETERS.copy()
for type_id, params in MATERIAL_TYPE_PARAMETERS.items():
merged_params[f"type:{type_id}"] = params
bioyond_material = resource_plr_to_bioyond( bioyond_material = resource_plr_to_bioyond(
[resource], [resource],
type_mapping=self.workstation.bioyond_config["material_type_mappings"], type_mapping=self.workstation.bioyond_config["material_type_mappings"],
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
material_params=merged_params material_params=MATERIAL_DEFAULT_PARAMETERS
)[0] )[0]
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...") logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
@@ -558,18 +468,13 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
return material_bioyond_id return material_bioyond_id
# 转换为 Bioyond 格式 # 转换为 Bioyond 格式
from .config import MATERIAL_DEFAULT_PARAMETERS, MATERIAL_TYPE_PARAMETERS from .config import MATERIAL_DEFAULT_PARAMETERS
# 合并参数配置:物料名称参数 + typeId参数转换为 type:<uuid> 格式)
merged_params = MATERIAL_DEFAULT_PARAMETERS.copy()
for type_id, params in MATERIAL_TYPE_PARAMETERS.items():
merged_params[f"type:{type_id}"] = params
bioyond_material = resource_plr_to_bioyond( bioyond_material = resource_plr_to_bioyond(
[resource], [resource],
type_mapping=self.workstation.bioyond_config["material_type_mappings"], type_mapping=self.workstation.bioyond_config["material_type_mappings"],
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
material_params=merged_params material_params=MATERIAL_DEFAULT_PARAMETERS
)[0] )[0]
# ⚠️ 关键:创建物料时不设置 locations让 Bioyond 系统暂不分配库位 # ⚠️ 关键:创建物料时不设置 locations让 Bioyond 系统暂不分配库位
@@ -679,44 +584,6 @@ class BioyondWorkstation(WorkstationBase):
集成Bioyond物料管理的工作站实现 集成Bioyond物料管理的工作站实现
""" """
def _publish_task_status(
self,
task_id: str,
task_type: str,
status: str,
result: dict = None,
progress: float = 0.0,
task_code: str = None
):
"""发布任务状态事件"""
try:
if not getattr(self, "_ros_node", None):
return
event_data = {
"task_id": task_id,
"task_code": task_code,
"task_type": task_type,
"status": status,
"progress": progress,
"timestamp": datetime.now().isoformat()
}
if result:
event_data["result"] = result
topic = f"{self._ros_node.namespace}/events/task_status"
if not hasattr(self, "_task_status_pub"):
self._task_status_pub = self._ros_node.create_publisher(
String, topic, 10
)
self._task_status_pub.publish(
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
)
except Exception as e:
logger.error(f"发布任务状态事件失败: {e}")
def __init__( def __init__(
self, self,
bioyond_config: Optional[Dict[str, Any]] = None, bioyond_config: Optional[Dict[str, Any]] = None,
@@ -765,16 +632,13 @@ class BioyondWorkstation(WorkstationBase):
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]), "host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"]) "port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
} }
self.http_service = None # 将在 post_init 启动 self.http_service = None # 将在 post_init 启动
self.connection_monitor = None # 将在 post_init 启动
logger.info(f"Bioyond工作站初始化完成") logger.info(f"Bioyond工作站初始化完成")
def __del__(self): def __del__(self):
"""析构函数:清理资源,停止 HTTP 服务""" """析构函数:清理资源,停止 HTTP 服务"""
try: try:
if hasattr(self, 'connection_monitor') and self.connection_monitor:
self.connection_monitor.stop()
if hasattr(self, 'http_service') and self.http_service is not None: if hasattr(self, 'http_service') and self.http_service is not None:
logger.info("正在停止 HTTP 报送服务...") logger.info("正在停止 HTTP 报送服务...")
self.http_service.stop() self.http_service.stop()
@@ -784,13 +648,6 @@ class BioyondWorkstation(WorkstationBase):
def post_init(self, ros_node: ROS2WorkstationNode): def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node self._ros_node = ros_node
# 启动连接监控
try:
self.connection_monitor = ConnectionMonitor(self)
self.connection_monitor.start()
except Exception as e:
logger.error(f"启动连接监控失败: {e}")
# 启动 HTTP 报送接收服务(现在 device_id 已可用) # 启动 HTTP 报送接收服务(现在 device_id 已可用)
if hasattr(self, '_http_service_config'): if hasattr(self, '_http_service_config'):
try: try:
@@ -1157,15 +1014,7 @@ class BioyondWorkstation(WorkstationBase):
workflow_id = self._get_workflow(actual_workflow_name) workflow_id = self._get_workflow(actual_workflow_name)
if workflow_id: if workflow_id:
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况 self.workflow_sequence.append(workflow_id)
if isinstance(self.workflow_sequence, list):
self.workflow_sequence.append(workflow_id)
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
self._cached_workflow_sequence.append(workflow_id)
else:
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
return False
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}") print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
return True return True
return False return False
@@ -1366,22 +1215,6 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理步骤完成逻辑 # TODO: 根据实际业务需求处理步骤完成逻辑
# 例如:更新数据库、触发后续流程等 # 例如:更新数据库、触发后续流程等
# 发布任务状态事件 (running/progress update)
self._publish_task_status(
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
task_code=data.get('orderCode'),
task_type="bioyond_step",
status="running",
progress=0.5, # 步骤完成视为任务进行中
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
)
# 更新物料信息
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
logger.info(f"[步骤完成报送] 触发物料同步...")
self.resource_synchronizer.sync_from_external()
return { return {
"processed": True, "processed": True,
"step_id": data.get('stepId'), "step_id": data.get('stepId'),
@@ -1416,17 +1249,6 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理通量完成逻辑 # TODO: 根据实际业务需求处理通量完成逻辑
# 发布任务状态事件
self._publish_task_status(
task_id=data.get('orderCode'),
task_code=data.get('orderCode'),
task_type="bioyond_sample",
status="running",
progress=0.7,
result={"sample_id": data.get('sampleId'), "status": status_desc}
)
return { return {
"processed": True, "processed": True,
"sample_id": data.get('sampleId'), "sample_id": data.get('sampleId'),
@@ -1466,32 +1288,6 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理任务完成逻辑 # TODO: 根据实际业务需求处理任务完成逻辑
# 例如:更新物料库存、生成报表等 # 例如:更新物料库存、生成报表等
# 映射状态到事件状态
event_status = "completed"
if str(data.get('status')) in ["-11", "-12"]:
event_status = "error"
elif str(data.get('status')) == "30":
event_status = "completed"
else:
event_status = "running" # 其他状态视为运行中(或根据实际定义)
# 发布任务状态事件
self._publish_task_status(
task_id=data.get('orderCode'),
task_code=data.get('orderCode'),
task_type="bioyond_order",
status=event_status,
progress=1.0 if event_status in ["completed", "error"] else 0.9,
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
)
# 更新物料信息
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
if event_status == "completed":
logger.info(f"[任务完成报送] 触发物料同步...")
self.resource_synchronizer.sync_from_external()
return { return {
"processed": True, "processed": True,
"order_code": data.get('orderCode'), "order_code": data.get('orderCode'),

View File

@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
# 验证必需字段 # 验证必需字段
if 'brand' in request_data: if 'brand' in request_data:
if request_data['brand'] == "bioyond": # 奔曜 if request_data['brand'] == "bioyond": # 奔曜
material_data = request_data["text"] error_msg = request_data["text"]
logger.info(f"收到奔曜物料变更报送: {material_data}") logger.info(f"收到奔曜错误处理报送: {error_msg}")
return HttpResponse( return HttpResponse(
success=True, success=True,
message=f"物料变更报送已收到: {material_data}", message=f"错误处理报送已收到: {error_msg}",
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}", acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
data=None data=None
) )
else: else:

View File

@@ -5,6 +5,229 @@ bioyond_dispensing_station:
- bioyond_dispensing_station - bioyond_dispensing_station
class: class:
action_value_mappings: action_value_mappings:
auto-brief_step_parameters:
feedback: {}
goal: {}
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
data:
type: object
required:
- data
type: object
result: {}
required:
- goal
title: brief_step_parameters参数
type: object
type: UniLabJsonCommand
auto-compute_experiment_design:
feedback: {}
goal: {}
goal_default:
m_tot: '70'
ratio: null
titration_percent: '0.03'
wt_percent: '0.25'
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
m_tot:
default: '70'
type: string
ratio:
type: object
titration_percent:
default: '0.03'
type: string
wt_percent:
default: '0.25'
type: string
required:
- ratio
type: object
result:
properties:
feeding_order:
items: {}
title: Feeding Order
type: array
return_info:
title: Return Info
type: string
solutions:
items: {}
title: Solutions
type: array
solvents:
additionalProperties: true
title: Solvents
type: object
titration:
additionalProperties: true
title: Titration
type: object
required:
- solutions
- titration
- solvents
- feeding_order
- return_info
title: ComputeExperimentDesignReturn
type: object
required:
- goal
title: compute_experiment_design参数
type: object
type: UniLabJsonCommand
auto-process_order_finish_report:
feedback: {}
goal: {}
goal_default:
report_request: null
used_materials: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
report_request:
type: string
used_materials:
type: string
required:
- report_request
- used_materials
type: object
result: {}
required:
- goal
title: process_order_finish_report参数
type: object
type: UniLabJsonCommand
auto-project_order_report:
feedback: {}
goal: {}
goal_default:
order_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
order_id:
type: string
required:
- order_id
type: object
result: {}
required:
- goal
title: project_order_report参数
type: object
type: UniLabJsonCommand
auto-query_resource_by_name:
feedback: {}
goal: {}
goal_default:
material_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
material_name:
type: string
required:
- material_name
type: object
result: {}
required:
- goal
title: query_resource_by_name参数
type: object
type: UniLabJsonCommand
auto-transfer_materials_to_reaction_station:
feedback: {}
goal: {}
goal_default:
target_device_id: null
transfer_groups: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
target_device_id:
type: string
transfer_groups:
type: array
required:
- target_device_id
- transfer_groups
type: object
result: {}
required:
- goal
title: transfer_materials_to_reaction_station参数
type: object
type: UniLabJsonCommand
auto-workflow_sample_locations:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result: {}
required:
- goal
title: workflow_sample_locations参数
type: object
type: UniLabJsonCommand
batch_create_90_10_vial_feeding_tasks: batch_create_90_10_vial_feeding_tasks:
feedback: {} feedback: {}
goal: goal:
@@ -171,99 +394,6 @@ bioyond_dispensing_station:
title: BatchCreateDiamineSolutionTasks title: BatchCreateDiamineSolutionTasks
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
compute_experiment_design:
feedback: {}
goal:
m_tot: m_tot
ratio: ratio
titration_percent: titration_percent
wt_percent: wt_percent
goal_default:
m_tot: '70'
ratio: ''
titration_percent: '0.03'
wt_percent: '0.25'
handles:
output:
- data_key: solutions
data_source: executor
data_type: array
handler_key: solutions
io_type: sink
label: Solution Data From Python
- data_key: titration
data_source: executor
data_type: object
handler_key: titration
io_type: sink
label: Titration Data From Calculation Node
- data_key: solvents
data_source: executor
data_type: object
handler_key: solvents
io_type: sink
label: Solvents Data From Calculation Node
- data_key: feeding_order
data_source: executor
data_type: array
handler_key: feeding_order
io_type: sink
label: Feeding Order Data From Calculation Node
result:
feeding_order: feeding_order
return_info: return_info
solutions: solutions
solvents: solvents
titration: titration
schema:
description: 计算实验设计输出solutions/titration/solvents/feeding_order用于后续节点。
properties:
feedback: {}
goal:
properties:
m_tot:
default: '70'
description: 总质量(g)
type: string
ratio:
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
type: string
titration_percent:
default: '0.03'
description: 滴定比例(10%部分)
type: string
wt_percent:
default: '0.25'
description: 目标固含质量分数
type: string
required:
- ratio
type: object
result:
properties:
feeding_order:
type: array
return_info:
type: string
solutions:
type: array
solvents:
type: object
titration:
type: object
required:
- solutions
- titration
- solvents
- feeding_order
- return_info
title: ComputeExperimentDesign_Result
type: object
required:
- goal
title: ComputeExperimentDesign
type: object
type: UniLabJsonCommand
create_90_10_vial_feeding_task: create_90_10_vial_feeding_task:
feedback: {} feedback: {}
goal: goal:
@@ -490,89 +620,6 @@ bioyond_dispensing_station:
title: DispenStationSolnPrep title: DispenStationSolnPrep
type: object type: object
type: DispenStationSolnPrep type: DispenStationSolnPrep
scheduler_start:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
schema:
description: 启动调度器 - 启动Bioyond配液站的任务调度器开始执行队列中的任务
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
return_info:
description: 调度器启动结果成功返回1失败返回0
type: integer
required:
- return_info
title: scheduler_start结果
type: object
required:
- goal
title: scheduler_start参数
type: object
type: UniLabJsonCommand
transfer_materials_to_reaction_station:
feedback: {}
goal:
target_device_id: target_device_id
transfer_groups: transfer_groups
goal_default:
target_device_id: ''
transfer_groups: ''
handles: {}
placeholder_keys:
target_device_id: unilabos_devices
result: {}
schema:
description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。
properties:
feedback: {}
goal:
properties:
target_device_id:
description: 目标反应站设备ID从设备列表中选择所有转移组都使用同一个目标设备
type: string
transfer_groups:
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
items:
properties:
materials:
description: 物料名称手动输入系统将通过RPC查询验证
type: string
target_sites:
description: 目标库位(手动输入,如"A01"
type: string
target_stack:
description: 目标堆栈名称(从列表选择)
enum:
- 堆栈1左
- 堆栈1右
- 站内试剂存放堆栈
type: string
required:
- materials
- target_stack
- target_sites
type: object
type: array
required:
- target_device_id
- transfer_groups
type: object
result: {}
required:
- goal
title: transfer_materials_to_reaction_station参数
type: object
type: UniLabJsonCommand
wait_for_multiple_orders_and_get_reports: wait_for_multiple_orders_and_get_reports:
feedback: {} feedback: {}
goal: goal:

View File

@@ -9278,7 +9278,13 @@ liquid_handler.prcxi:
z: 0.0 z: 0.0
sample_id: '' sample_id: ''
type: '' type: ''
handles: {} handles:
input:
- data_key: wells
data_source: handle
data_type: resource
handler_key: input_wells
label: InputWells
placeholder_keys: placeholder_keys:
wells: unilabos_resources wells: unilabos_resources
result: {} result: {}

View File

@@ -4,88 +4,213 @@ reaction_station.bioyond:
- reaction_station_bioyond - reaction_station_bioyond
class: class:
action_value_mappings: action_value_mappings:
add_time_constraint: auto-create_order:
feedback: {} feedback: {}
goal: goal: {}
duration: duration
end_point: end_point
end_step_key: end_step_key
start_point: start_point
start_step_key: start_step_key
goal_default: goal_default:
duration: 0 json_str: null
end_point: 0
end_step_key: ''
start_point: 0
start_step_key: ''
handles: {} handles: {}
placeholder_keys: {}
result: {} result: {}
schema: schema:
description: 添加时间约束 - 在两个工作流之间添加时间约束 description: ''
properties: properties:
feedback: {} feedback: {}
goal: goal:
properties: properties:
duration: json_str:
description: 时间(秒)
type: integer
end_point:
default: Start
description: 终点计时点 (Start=开始前, End=结束后)
enum:
- Start
- End
type: string
end_step_key:
description: 终点步骤Key (可选, 默认为空则自动选择)
type: string
start_point:
default: Start
description: 起点计时点 (Start=开始前, End=结束后)
enum:
- Start
- End
type: string
start_step_key:
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
type: string type: string
required: required:
- duration - json_str
type: object type: object
result: {} result: {}
required: required:
- goal - goal
title: add_time_constraint参数 title: create_order参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
clean_all_server_workflows: auto-hard_delete_merged_workflows:
feedback: {} feedback: {}
goal: {} goal: {}
goal_default: {} goal_default:
workflow_ids: null
handles: {} handles: {}
result: placeholder_keys: {}
code: code result: {}
message: message
schema: schema:
description: 清空服务端所有非核心工作流 (保留核心流程) description: ''
properties: properties:
feedback: {} feedback: {}
goal: goal:
properties: {}
required: []
type: object
result:
properties: properties:
code: workflow_ids:
description: 操作结果代码(1表示成功) items:
type: integer type: string
message: type: array
description: 结果描述 required:
type: string - workflow_ids
type: object type: object
result: {}
required: required:
- goal - goal
title: clean_all_server_workflows参数 title: hard_delete_merged_workflows参数
type: object
type: UniLabJsonCommand
auto-merge_workflow_with_parameters:
feedback: {}
goal: {}
goal_default:
json_str: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
json_str:
type: string
required:
- json_str
type: object
result: {}
required:
- goal
title: merge_workflow_with_parameters参数
type: object
type: UniLabJsonCommand
auto-process_temperature_cutoff_report:
feedback: {}
goal: {}
goal_default:
report_request: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
report_request:
type: string
required:
- report_request
type: object
result: {}
required:
- goal
title: process_temperature_cutoff_report参数
type: object
type: UniLabJsonCommand
auto-process_web_workflows:
feedback: {}
goal: {}
goal_default:
web_workflow_json: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
web_workflow_json:
type: string
required:
- web_workflow_json
type: object
result: {}
required:
- goal
title: process_web_workflows参数
type: object
type: UniLabJsonCommand
auto-skip_titration_steps:
feedback: {}
goal: {}
goal_default:
preintake_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
preintake_id:
type: string
required:
- preintake_id
type: object
result: {}
required:
- goal
title: skip_titration_steps参数
type: object
type: UniLabJsonCommand
auto-wait_for_multiple_orders_and_get_reports:
feedback: {}
goal: {}
goal_default:
batch_create_result: null
check_interval: 10
timeout: 7200
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
batch_create_result:
type: string
check_interval:
default: 10
type: integer
timeout:
default: 7200
type: integer
required: []
type: object
result: {}
required:
- goal
title: wait_for_multiple_orders_and_get_reports参数
type: object
type: UniLabJsonCommand
auto-workflow_step_query:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result: {}
required:
- goal
title: workflow_step_query参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
drip_back: drip_back:
@@ -122,19 +247,13 @@ reaction_station.bioyond:
description: 观察时间(分钟) description: 观察时间(分钟)
type: string type: string
titration_type: titration_type:
description: 是否滴定(NO=否, YES=是) description: 是否滴定(1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
torque_variation: torque_variation:
description: 是否观察 (NO=否, YES=是) description: 是否观察 (1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
volume: volume:
description: 分液公式(mL) description: 分液公式(μL)
type: string type: string
required: required:
- volume - volume
@@ -234,19 +353,13 @@ reaction_station.bioyond:
description: 观察时间(分钟) description: 观察时间(分钟)
type: string type: string
titration_type: titration_type:
description: 是否滴定(NO=否, YES=是) description: 是否滴定(1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
torque_variation: torque_variation:
description: 是否观察 (NO=否, YES=是) description: 是否观察 (1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
volume: volume:
description: 分液公式(mL) description: 分液公式(μL)
type: string type: string
required: required:
- volume - volume
@@ -290,7 +403,7 @@ reaction_station.bioyond:
label: Solvents Data From Calculation Node label: Solvents Data From Calculation Node
result: {} result: {}
schema: schema:
description: 液体投料-溶剂。可以直接提供volume(mL),或通过solvents对象自动从additional_solvent(mL)计算volume。 description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。
properties: properties:
feedback: {} feedback: {}
goal: goal:
@@ -310,21 +423,15 @@ reaction_station.bioyond:
description: 观察时间(分钟),默认360 description: 观察时间(分钟),默认360
type: string type: string
titration_type: titration_type:
default: 'NO' default: '1'
description: 是否滴定(NO=否, YES=是),默认NO description: 是否滴定(1=否, 2=是),默认1
enum:
- 'NO'
- 'YES'
type: string type: string
torque_variation: torque_variation:
default: 'YES' default: '2'
description: 是否观察 (NO=否, YES=是),默认YES description: 是否观察 (1=否, 2=是),默认2
enum:
- 'NO'
- 'YES'
type: string type: string
volume: volume:
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 description: 分液量(μL)。可直接提供,或通过solvents参数自动计算
type: string type: string
required: required:
- assign_material_name - assign_material_name
@@ -397,21 +504,15 @@ reaction_station.bioyond:
description: 观察时间(分钟),默认90 description: 观察时间(分钟),默认90
type: string type: string
titration_type: titration_type:
default: 'YES' default: '2'
description: 是否滴定(NO=否, YES=是),默认YES description: 是否滴定(1=否, 2=是),默认2
enum:
- 'NO'
- 'YES'
type: string type: string
torque_variation: torque_variation:
default: 'YES' default: '2'
description: 是否观察 (NO=否, YES=是),默认YES description: 是否观察 (1=否, 2=是),默认2
enum:
- 'NO'
- 'YES'
type: string type: string
volume_formula: volume_formula:
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
type: string type: string
x_value: x_value:
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
@@ -459,19 +560,13 @@ reaction_station.bioyond:
description: 观察时间(分钟) description: 观察时间(分钟)
type: string type: string
titration_type: titration_type:
description: 是否滴定(NO=否, YES=是) description: 是否滴定(1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
torque_variation: torque_variation:
description: 是否观察 (NO=否, YES=是) description: 是否观察 (1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
volume_formula: volume_formula:
description: 分液公式(mL) description: 分液公式(μL)
type: string type: string
required: required:
- volume_formula - volume_formula
@@ -585,35 +680,6 @@ reaction_station.bioyond:
title: reactor_taken_out参数 title: reactor_taken_out参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
scheduler_start:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
schema:
description: 启动调度器 - 启动Bioyond工作站的任务调度器开始执行队列中的任务
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
return_info:
description: 调度器启动结果成功返回1失败返回0
type: integer
required:
- return_info
title: scheduler_start结果
type: object
required:
- goal
title: scheduler_start参数
type: object
type: UniLabJsonCommand
solid_feeding_vials: solid_feeding_vials:
feedback: {} feedback: {}
goal: goal:
@@ -640,11 +706,7 @@ reaction_station.bioyond:
description: 物料名称(用于获取试剂瓶位ID) description: 物料名称(用于获取试剂瓶位ID)
type: string type: string
material_id: material_id:
description: 粉末类型IDSalt=盐21分钟Flour=面粉27分钟BTDA=BTDA38分钟 description: 粉末类型ID1=盐21分钟2=面粉27分钟3=BTDA38分钟
enum:
- Salt
- Flour
- BTDA
type: string type: string
temperature: temperature:
description: 温度设定(°C) description: 温度设定(°C)
@@ -653,10 +715,7 @@ reaction_station.bioyond:
description: 观察时间(分钟) description: 观察时间(分钟)
type: string type: string
torque_variation: torque_variation:
description: 是否观察 (NO=否, YES=是) description: 是否观察 (1=否, 2=是)
enum:
- 'NO'
- 'YES'
type: string type: string
required: required:
- assign_material_name - assign_material_name
@@ -674,16 +733,6 @@ reaction_station.bioyond:
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation
protocol_type: [] protocol_type: []
status_types: status_types:
average_viscosity: Float64
force: Float64
in_temperature: Float64
out_temperature: Float64
pt100_temperature: Float64
sensor_average_temperature: Float64
setting_temperature: Float64
speed: Float64
target_temperature: Float64
viscosity: Float64
workflow_sequence: String workflow_sequence: String
type: python type: python
config_info: [] config_info: []
@@ -716,19 +765,34 @@ reaction_station.reactor:
- reactor - reactor
- reaction_station_bioyond - reaction_station_bioyond
class: class:
action_value_mappings: {} action_value_mappings:
auto-update_metrics:
feedback: {}
goal: {}
goal_default:
payload: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
payload:
type: object
required:
- payload
type: object
result: {}
required:
- goal
title: update_metrics参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor
status_types: status_types: {}
average_viscosity: Float64
force: Float64
in_temperature: Float64
out_temperature: Float64
pt100_temperature: Float64
sensor_average_temperature: Float64
setting_temperature: Float64
speed: Float64
target_temperature: Float64
viscosity: Float64
type: python type: python
config_info: [] config_info: []
description: 反应站子设备-反应器 description: 反应站子设备-反应器

View File

@@ -124,17 +124,32 @@ class Registry:
"output": [ "output": [
{ {
"handler_key": "labware", "handler_key": "labware",
"label": "Labware",
"data_type": "resource", "data_type": "resource",
"data_source": "handle", "label": "Labware",
"data_key": "liquid", "data_source": "executor",
} "data_key": "created_resource_tree.@flatten",
},
{
"handler_key": "liquid_slots",
"data_type": "resource",
"label": "LiquidSlots",
"data_source": "executor",
"data_key": "liquid_input_resource_tree.@flatten",
},
{
"handler_key": "materials",
"data_type": "resource",
"label": "AllMaterials",
"data_source": "executor",
"data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten",
},
] ]
}, },
"placeholder_keys": { "placeholder_keys": {
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择 "res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择 "device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择 "parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
"class_name": "unilabos_class",
}, },
}, },
"test_latency": { "test_latency": {
@@ -186,7 +201,17 @@ class Registry:
"resources": "unilabos_resources", "resources": "unilabos_resources",
}, },
"goal_default": {}, "goal_default": {},
"handles": {}, "handles": {
"input": [
{
"handler_key": "input_resources",
"data_type": "resource",
"label": "InputResources",
"data_source": "handle",
"data_key": "resources", # 不为空
},
]
},
}, },
}, },
}, },

View File

@@ -20,17 +20,6 @@ BIOYOND_PolymerStation_Liquid_Vial:
icon: '' icon: ''
init_param_schema: {} init_param_schema: {}
version: 1.0.0 version: 1.0.0
BIOYOND_PolymerStation_Measurement_Vial:
category:
- bottles
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
type: pylabrobot
description: 聚合站-测量小瓶(测密度)
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Reactor: BIOYOND_PolymerStation_Reactor:
category: category:
- bottles - bottles

View File

@@ -193,20 +193,3 @@ def BIOYOND_PolymerStation_Flask(
barcode=barcode, barcode=barcode,
model="BIOYOND_PolymerStation_Flask", model="BIOYOND_PolymerStation_Flask",
) )
def BIOYOND_PolymerStation_Measurement_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建测量小瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Measurement_Vial",
)

View File

@@ -49,17 +49,20 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01B03 "测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01B03
} }
self.warehouse_locations = { self.warehouse_locations = {
"堆栈1左": Coordinate(-200.0, 450.0, 0.0), # 左侧位置 "堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2350.0, 450.0, 0.0), # 右侧位置 "堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(730.0, 390.0, 0.0), "站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0), # "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0), "站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
"测量小瓶仓库(测密度)": Coordinate(940.0, 530.0, 0.0), "测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
} }
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck): class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__( def __init__(
self, self,
@@ -141,7 +144,6 @@ class BIOYOND_YB_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items(): for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck: def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name) by=BIOYOND_YB_Deck(name=name)
by.setup() by.setup()

View File

@@ -46,55 +46,41 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
) )
def bioyond_warehouse_density_vial(name: str) -> WareHouse: def bioyond_warehouse_density_vial(name: str) -> WareHouse:
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行 """创建测量小瓶仓库(测密度) A01B03"""
布局(从下到上,从左到右):
| A03 | B03 | ← 顶部
| A02 | B02 | ← 中部
| A01 | B01 | ← 底部
"""
return warehouse_factory( return warehouse_factory(
name=name, name=name,
num_items_x=2, # 2列(A, B num_items_x=3, # 3列(01-03
num_items_y=3, # 3行(01-03从下到上 num_items_y=2, # 2行(A-B
num_items_z=1, # 1层 num_items_z=1, # 1层
dx=10.0, dx=10.0,
dy=10.0, dy=10.0,
dz=10.0, dz=10.0,
item_dx=40.0, # 列间距A到B的横向距离 item_dx=40.0,
item_dy=40.0, # 行间距01到02到03的竖向距离 item_dy=40.0,
item_dz=50.0, item_dz=50.0,
# ⭐ 竖向warehouse槽位尺寸也是竖向的小瓶已经是正方形无需调整 # 用更小的 resource_size 来表现 "小点的孔位"
resource_size_x=30.0, resource_size_x=30.0,
resource_size_y=30.0, resource_size_y=30.0,
resource_size_z=12.0, resource_size_z=12.0,
category="warehouse", category="warehouse",
col_offset=0, col_offset=0,
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局 layout="row-major",
) )
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse: def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行 """创建BioYond站内试剂存放堆栈A01A02, 1行×2列"""
布局(竖向,从下到上):
| A02 | ← 顶部
| A01 | ← 底部
"""
return warehouse_factory( return warehouse_factory(
name=name, name=name,
num_items_x=1, # 1列 num_items_x=2, # 2列01-02
num_items_y=2, # 2行(01-02从下到上 num_items_y=1, # 1行(A
num_items_z=1, # 1层 num_items_z=1, # 1层
dx=10.0, dx=10.0,
dy=10.0, dy=10.0,
dz=10.0, dz=10.0,
item_dx=96.0, # 列间距这里只有1列不重要 item_dx=137.0,
item_dy=137.0, # 行间距A01到A02的竖向距离 item_dy=96.0,
item_dz=120.0, item_dz=120.0,
# ⭐ 竖向warehouse交换槽位尺寸使槽位框也是竖向的
resource_size_x=86.0, # 原来的 resource_size_y
resource_size_y=127.0, # 原来的 resource_size_x
resource_size_z=25.0,
category="warehouse", category="warehouse",
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
) )
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse: def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:

View File

@@ -27,7 +27,7 @@ class RegularContainer(Container):
def get_regular_container(name="container"): def get_regular_container(name="container"):
r = RegularContainer(name=name) r = RegularContainer(name=name)
r.category = "container" r.category = "container"
return RegularContainer(name=name) return r
# #
# class RegularContainer(object): # class RegularContainer(object):

View File

@@ -779,22 +779,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if not locations: if not locations:
logger.debug(f"[物料位置] {unique_name} 没有location信息跳过warehouse放置") logger.debug(f"[物料位置] {unique_name} 没有location信息跳过warehouse放置")
# ⭐ 预先检查如果物料的任何location在竖向warehouse中提前交换尺寸
# 这样可以避免多个location时尺寸不一致的问题
needs_size_swap = False
for loc in locations:
wh_name_check = loc.get("whName")
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
needs_size_swap = True
break
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
original_x = plr_material.size_x
original_y = plr_material.size_y
plr_material.size_x = original_y
plr_material.size_y = original_x
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse预先交换尺寸: {original_x}×{original_y}{plr_material.size_x}×{plr_material.size_y}")
for loc in locations: for loc in locations:
wh_name = loc.get("whName") wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
@@ -816,6 +800,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1) z = loc.get("z", 1) # 层号 (1-based, 通常为1)
@@ -824,23 +809,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if wh_name == "堆栈1右": if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4 y = y - 4 # 将5-8映射到1-4
# 特殊处理向warehouse站内试剂存放堆栈、测量小瓶仓库 # 特殊处理对于1行×N列的横向warehouse站内试剂存放堆栈)
# 这些warehouse使用 vertical-col-major 布局 # Bioyond的y坐标表示线性位置序号而不是列号
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]: if warehouse.num_items_y == 1:
# vertical-col-major 布局的坐标映射: # 1行warehouse: 直接用y作为线性索引
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向) idx = y - 1
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上 logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
# vertical-col-major 中: row=0 对应底部row=n-1 对应顶部
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
# 索引计算: idx = row * num_cols + col
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
layer_idx = z - 1
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
# 普通横向warehouse的处理
else: else:
# 多行warehouse: 根据 layout 使用不同的索引计算 # 多行warehouse: 根据 layout 使用不同的索引计算
row_idx = x - 1 # x表示行: 转为0-based row_idx = x - 1 # x表示行: 转为0-based
@@ -864,7 +838,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if 0 <= idx < warehouse.capacity: if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder): if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
# 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material warehouse[idx] = plr_material
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})") logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
else: else:
@@ -1038,24 +1011,11 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}") logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
# 🎯 处理物料默认参数和单位 # 🎯 处理物料默认参数和单位
# 优先级: typeId参数 > 物料名称参数 > 默认值 # 检查是否有该物料名称的默认参数配置
default_unit = "" # 默认单位 default_unit = "" # 默认单位
material_parameters = {} material_parameters = {}
# 1⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取key 格式为 "type:<typeId>" if material_name in material_params:
type_params_key = f"type:{type_id}"
if type_params_key in material_params:
params_config = material_params[type_params_key].copy()
# 提取 unit 字段(如果有)
if "unit" in params_config:
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
# 2⃣ 其次检查是否有该物料名称的默认参数配置
elif material_name in material_params:
params_config = material_params[material_name].copy() params_config = material_params[material_name].copy()
# 提取 unit 字段(如果有) # 提取 unit 字段(如果有)
@@ -1064,7 +1024,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
# 剩余的字段放入 Parameters # 剩余的字段放入 Parameters
material_parameters = params_config material_parameters = params_config
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}") logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
# 转换为 JSON 字符串 # 转换为 JSON 字符串
parameters_json = json.dumps(material_parameters) if material_parameters else "{}" parameters_json = json.dumps(material_parameters) if material_parameters else "{}"
@@ -1191,11 +1151,7 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni
if resource_class_config["type"] == "pylabrobot": if resource_class_config["type"] == "pylabrobot":
resource_plr = RESOURCE(name=resource_config["name"]) resource_plr = RESOURCE(name=resource_config["name"])
if resource_type != ResourcePLR: if resource_type != ResourcePLR:
tree_sets = ResourceTreeSet.from_plr_resources([resource_plr]) tree_sets = ResourceTreeSet.from_plr_resources([resource_plr], known_newly_created=True)
# r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None))
# # r = resource_plr_to_ulab(resource_plr=resource_plr)
# if resource_config.get("position") is not None:
# r["position"] = resource_config["position"]
r = tree_sets.dump() r = tree_sets.dump()
else: else:
r = resource_plr r = resource_plr

View File

@@ -50,46 +50,13 @@ class Bottle(Well):
self.barcode = barcode self.barcode = barcode
def serialize(self) -> dict: def serialize(self) -> dict:
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
# We temporarily unset it to avoid AttributeError in super().serialize().
_barcode = self.barcode
self.barcode = None
try:
data = super().serialize()
finally:
self.barcode = _barcode
return { return {
**data, **super().serialize(),
"diameter": self.diameter, "diameter": self.diameter,
"height": self.height, "height": self.height,
"barcode": self.barcode, "barcode": self.barcode,
} }
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
# Extract barcode before calling parent deserialize to avoid type error
barcode_data = data.pop("barcode", None)
# Call parent deserialize
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
# Set barcode as string (not as Barcode object)
if barcode_data:
if isinstance(barcode_data, str):
instance.barcode = barcode_data
elif isinstance(barcode_data, dict):
# If it's a dict (Barcode serialized format), extract the data field
instance.barcode = barcode_data.get("data", "")
else:
instance.barcode = ""
# Set additional attributes
instance.diameter = data.get("diameter", instance._size_x)
instance.height = data.get("height", instance._size_z)
return instance
T = TypeVar("T", bound=ResourceHolder) T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder) S = TypeVar("S", bound=ResourceHolder)

View File

@@ -1,7 +1,7 @@
import inspect import inspect
import traceback import traceback
import uuid import uuid
from pydantic import BaseModel, field_serializer, field_validator from pydantic import BaseModel, field_serializer, field_validator, ValidationError
from pydantic import Field from pydantic import Field
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
@@ -147,20 +147,24 @@ class ResourceDictInstance(object):
if not content.get("extra"): # MagicCode if not content.get("extra"): # MagicCode
content["extra"] = {} content["extra"] = {}
if "position" in content: if "position" in content:
pose = content.get("pose",{}) pose = content.get("pose", {})
if "position" not in pose : if "position" not in pose:
if "position" in content["position"]: if "position" in content["position"]:
pose["position"] = content["position"]["position"] pose["position"] = content["position"]["position"]
else: else:
pose["position"] = {"x": 0, "y": 0, "z": 0} pose["position"] = {"x": 0, "y": 0, "z": 0}
if "size" not in pose: if "size" not in pose:
pose["size"] = { pose["size"] = {
"width": content["config"].get("size_x", 0), "width": content["config"].get("size_x", 0),
"height": content["config"].get("size_y", 0), "height": content["config"].get("size_y", 0),
"depth": content["config"].get("size_z", 0) "depth": content["config"].get("size_z", 0),
} }
content["pose"] = pose content["pose"] = pose
return ResourceDictInstance(ResourceDict.model_validate(content)) try:
res_dict = ResourceDict.model_validate(content)
return ResourceDictInstance(res_dict)
except ValidationError as err:
raise err
def get_plr_nested_dict(self) -> Dict[str, Any]: def get_plr_nested_dict(self) -> Dict[str, Any]:
"""获取资源实例的嵌套字典表示""" """获取资源实例的嵌套字典表示"""
@@ -322,7 +326,7 @@ class ResourceTreeSet(object):
) )
@classmethod @classmethod
def from_plr_resources(cls, resources: List["PLRResource"]) -> "ResourceTreeSet": def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
""" """
从plr资源创建ResourceTreeSet 从plr资源创建ResourceTreeSet
""" """
@@ -339,6 +343,8 @@ class ResourceTreeSet(object):
} }
if source in replace_info: if source in replace_info:
return replace_info[source] return replace_info[source]
elif source is None:
return ""
else: else:
print("转换pylabrobot的时候出现未知类型", source) print("转换pylabrobot的时候出现未知类型", source)
return source return source
@@ -349,7 +355,8 @@ class ResourceTreeSet(object):
if not uid: if not uid:
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
res.unilabos_uuid = uid res.unilabos_uuid = uid
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}") if not known_newly_created:
logger.warning(f"{res}没有uuid请设置后再传入默认填充{uid}\n{traceback.format_exc()}")
# 获取unilabos_extra默认为空字典 # 获取unilabos_extra默认为空字典
extra = getattr(res, "unilabos_extra", {}) extra = getattr(res, "unilabos_extra", {})
@@ -448,7 +455,13 @@ class ResourceTreeSet(object):
from pylabrobot.utils.object_parsing import find_subclass from pylabrobot.utils.object_parsing import find_subclass
# 类型映射 # 类型映射
TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"} TYPE_MAP = {
"plate": "Plate",
"well": "Well",
"deck": "Deck",
"container": "RegularContainer",
"tip_spot": "TipSpot",
}
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
"""一次遍历收集 name_to_uuid, all_states 和 name_to_extra""" """一次遍历收集 name_to_uuid, all_states 和 name_to_extra"""
@@ -608,16 +621,6 @@ class ResourceTreeSet(object):
""" """
return [tree.root_node for tree in self.trees] return [tree.root_node for tree in self.trees]
@property
def root_nodes_uuid(self) -> List[ResourceDictInstance]:
"""
获取所有树的根节点
Returns:
所有根节点的资源实例列表
"""
return [tree.root_node.res_content.uuid for tree in self.trees]
@property @property
def all_nodes(self) -> List[ResourceDictInstance]: def all_nodes(self) -> List[ResourceDictInstance]:
""" """
@@ -928,6 +931,33 @@ class DeviceNodeResourceTracker(object):
return self._traverse_and_process(resource, process) return self._traverse_and_process(resource, process)
def loop_find_with_uuid(self, resource, target_uuid: str):
"""
递归遍历资源树,根据 uuid 查找并返回对应的资源
Args:
resource: 资源对象可以是list、dict或实例
target_uuid: 要查找的uuid
Returns:
找到的资源对象未找到则返回None
"""
found_resource = None
def process(res):
nonlocal found_resource
if found_resource is not None:
return 0 # 已找到,跳过后续处理
current_uuid = self._get_resource_attr(res, "uuid", "unilabos_uuid")
if current_uuid and current_uuid == target_uuid:
found_resource = res
logger.trace(f"找到资源UUID: {target_uuid}")
return 1
return 0
self._traverse_and_process(resource, process)
return found_resource
def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int: def loop_set_extra(self, resource, name_to_extra_map: Dict[str, dict]) -> int:
""" """
递归遍历资源树,根据 name 设置所有节点的 extra 递归遍历资源树,根据 name 设置所有节点的 extra
@@ -1113,7 +1143,7 @@ class DeviceNodeResourceTracker(object):
for key in keys_to_remove: for key in keys_to_remove:
self.resource2parent_resource.pop(key, None) self.resource2parent_resource.pop(key, None)
logger.debug(f"成功移除资源: {resource}") logger.trace(f"[ResourceTracker] 成功移除资源: {resource}")
return True return True
def clear_resource(self): def clear_resource(self):

View File

@@ -42,10 +42,6 @@ def warehouse_factory(
if layout == "row-major": if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值 # 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy y = dy + row * item_dy
elif layout == "vertical-col-major":
# 竖向warehouse: row=0 对应顶部y小row=n-1 对应底部y大
# 但标签 01 应该在底部,所以使用反向映射
y = dy + (num_items_y - row - 1) * item_dy
else: else:
# 列优先保持原逻辑row=0 对应较大的 y # 列优先保持原逻辑row=0 对应较大的 y
y = dy + (num_items_y - row - 1) * item_dy y = dy + (num_items_y - row - 1) * item_dy
@@ -70,14 +66,6 @@ def warehouse_factory(
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04 # 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
# locations[0] 对应 row=0, y最大前端顶部→ 应该是 A01 # locations[0] 对应 row=0, y最大前端顶部→ 应该是 A01
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)] keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
elif layout == "vertical-col-major":
# ⭐ 竖向warehouse专用布局
# 字母(A,B,C...)对应列(横向, x方向),数字(01,02,03...)对应行(竖向, y方向从下到上)
# locations 生成顺序: row→col (row=0,col=0 → row=0,col=1 → row=1,col=0 → ...)
# 其中 row=0 对应底部(y大)row=n-1 对应顶部(y小)
# 标签中 01 对应底部(row=0)02 对应中间(row=1)03 对应顶部(row=2)
# 标签顺序: A01,B01,A02,B02,A03,B03
keys = [f"{LETTERS[col]}{row + 1 + col_offset:02d}" for row in range(len_y) for col in range(len_x)]
else: else:
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02 # 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]

View File

@@ -159,10 +159,14 @@ _msg_converter: Dict[Type, Any] = {
else Pose() else Pose()
), ),
config=json.dumps(x.get("config", {})), config=json.dumps(x.get("config", {})),
data=json.dumps(x.get("data", {})), data=json.dumps(obtain_data_with_uuid(x)),
), ),
} }
def obtain_data_with_uuid(x: dict):
data = x.get("data", {})
data["unilabos_uuid"] = x.get("uuid", None)
return data
def json_or_yaml_loads(data: str) -> Any: def json_or_yaml_loads(data: str) -> Any:
try: try:

View File

@@ -392,9 +392,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
parent_resource = self.resource_tracker.figure_resource( parent_resource = self.resource_tracker.figure_resource(
{"name": bind_parent_id} {"name": bind_parent_id}
) )
for r in rts.root_nodes: for r in rts.root_nodes:
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
r.res_content.parent_uuid = parent_resource.unilabos_uuid r.res_content.parent_uuid = parent_resource.unilabos_uuid
else:
for r in rts.root_nodes:
r.res_content.parent_uuid = self.uuid
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer): if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
# noinspection PyTypeChecker # noinspection PyTypeChecker
@@ -430,11 +433,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}) })
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) uuid_maps = json.loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) plr_instances = rts.to_plr_resources()
for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
rts: ResourceTreeSet = ResourceTreeSet.from_plr_resources(plr_instances)
self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
final_response = { final_response = {
"created_resources": rts.dump(), "created_resource_tree": rts.dump(),
"liquid_input_resources": [], "liquid_input_resource_tree": [],
} }
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
# 如果driver自己就有assign的方法那就使用driver自己的assign方法 # 如果driver自己就有assign的方法那就使用driver自己的assign方法
@@ -460,7 +466,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
try: try:
if len(rts.root_nodes) == 1 and parent_resource is not None: if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = rts.to_plr_resources()[0] plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate): if isinstance(plr_instance, Plate):
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
@@ -485,7 +491,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = [] input_wells = []
for r in LIQUID_INPUT_SLOT: for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r]) input_wells.append(plr_instance.children[r])
final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump() final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
res.response = json.dumps(final_response) res.response = json.dumps(final_response)
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param: if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
other_calling_param["slot"] = int(other_calling_param["slot"]) other_calling_param["slot"] = int(other_calling_param["slot"])
@@ -619,7 +625,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) # type: ignore ) # type: ignore
raw_nodes = json.loads(response.response) raw_nodes = json.loads(response.response)
tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes) tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes)
self.lab_logger().trace(f"获取资源结果: {len(tree_set.trees)} 个资源树 {tree_set.root_nodes}") self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
return tree_set return tree_set
async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR": async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR":
@@ -653,61 +659,71 @@ class BaseROS2DeviceNode(Node, Generic[T]):
def transfer_to_new_resource( def transfer_to_new_resource(
self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any] self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]
): ) -> Optional["ResourcePLR"]:
parent_uuid = tree.root_node.res_content.parent_uuid parent_uuid = tree.root_node.res_content.parent_uuid
if parent_uuid: if not parent_uuid:
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) self.lab_logger().warning(
if parent_resource is None: f"物料{plr_resource} parent未知挂载到当前节点下额外参数{additional_add_params}"
)
return None
if parent_uuid == self.uuid:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{self.identifier},额外参数:{additional_add_params}"
)
return None
parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid)
if parent_resource is None:
self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在"
)
else:
try:
# 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
extra = getattr(plr_resource, "unilabos_extra", {})
if len(extra):
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
if "update_resource_site" in extra:
additional_add_params["site"] = extra["update_resource_site"]
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
if ordering_dict:
site = list(ordering_dict.keys()).index(site)
additional_params["spot"] = site
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning( self.lab_logger().warning(
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_uuid}不存在" f"物料{plr_resource}请求挂载{parent_resource},额外参数:{additional_params}"
) )
else:
try:
# 特殊兼容所有plr的物料的assign方法和create_resource append_resource后期同步
additional_params = {}
extra = getattr(plr_resource, "unilabos_extra", {})
if len(extra):
self.lab_logger().info(f"发现物料{plr_resource}额外参数: " + str(extra))
if "update_resource_site" in extra:
additional_add_params["site"] = extra["update_resource_site"]
site = additional_add_params.get("site", None)
spec = inspect.signature(parent_resource.assign_child_resource)
if "spot" in spec.parameters:
ordering_dict: Dict[str, Any] = getattr(parent_resource, "_ordering")
if ordering_dict:
site = list(ordering_dict.keys()).index(site)
additional_params["spot"] = site
old_parent = plr_resource.parent
if old_parent is not None:
# plr并不支持同一个deck的加载和卸载
self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载")
old_parent.unassign_child_resource(plr_resource)
self.lab_logger().warning(
f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}"
)
# ⭐ assign 之前,需要从 resources 列表中移除 # ⭐ assign 之前,需要从 resources 列表中移除
# 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源 # 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源
# 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children # 如果不移除figure_resource 会找到两次:一次在 resources一次在 parent 的 children
resource_id = id(plr_resource) resource_id = id(plr_resource)
for i, r in enumerate(self.resource_tracker.resources): for i, r in enumerate(self.resource_tracker.resources):
if id(r) == resource_id: if id(r) == resource_id:
self.resource_tracker.resources.pop(i) self.resource_tracker.resources.pop(i)
self.lab_logger().debug( self.lab_logger().debug(
f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)" f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)"
) )
break break
parent_resource.assign_child_resource(plr_resource, location=None, **additional_params) parent_resource.assign_child_resource(plr_resource, location=None, **additional_params)
func = getattr(self.driver_instance, "resource_tree_transfer", None) func = getattr(self.driver_instance, "resource_tree_transfer", None)
if callable(func): if callable(func):
# 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了 # 分别是 物料的原来父节点当前物料的状态物料的新父节点此时物料已经重新assign了
func(old_parent, plr_resource, parent_resource) func(old_parent, plr_resource, parent_resource)
except Exception as e: return parent_resource
self.lab_logger().warning( except Exception as e:
f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}" self.lab_logger().warning(
) f"物料{plr_resource}请求挂载{tree.root_node.res_content.name}的父节点{parent_resource}[{parent_uuid}]失败!\n{traceback.format_exc()}"
)
async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response): async def s2c_resource_tree(self, req: SerialCommand_Request, res: SerialCommand_Response):
""" """
@@ -722,7 +738,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
def _handle_add( def _handle_add(
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
) -> Dict[str, Any]: ) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
""" """
处理资源添加操作的内部函数 处理资源添加操作的内部函数
@@ -734,15 +750,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Returns: Returns:
操作结果字典 操作结果字典
""" """
parents = [] # 放的是被变更的物料 / 被变更的物料父级
for plr_resource, tree in zip(plr_resources, tree_set.trees): for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.resource_tracker.add_resource(plr_resource) self.resource_tracker.add_resource(plr_resource)
self.transfer_to_new_resource(plr_resource, tree, additional_add_params) parent = self.transfer_to_new_resource(plr_resource, tree, additional_add_params)
if parent is not None:
parents.append(parent)
else:
parents.append(plr_resource)
func = getattr(self.driver_instance, "resource_tree_add", None) func = getattr(self.driver_instance, "resource_tree_add", None)
if callable(func): if callable(func):
func(plr_resources) func(plr_resources)
return {"success": True, "action": "add"} return {"success": True, "action": "add"}, parents
def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]: def _handle_remove(resources_uuid: List[str]) -> Dict[str, Any]:
""" """
@@ -777,11 +798,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if plr_resource.parent is not None: if plr_resource.parent is not None:
plr_resource.parent.unassign_child_resource(plr_resource) plr_resource.parent.unassign_child_resource(plr_resource)
self.resource_tracker.remove_resource(plr_resource) self.resource_tracker.remove_resource(plr_resource)
self.lab_logger().info(f"移除物料 {plr_resource} 及其子节点") self.lab_logger().info(f"[资源同步] 移除物料 {plr_resource} 及其子节点")
for other_plr_resource in other_plr_resources: for other_plr_resource in other_plr_resources:
self.resource_tracker.remove_resource(other_plr_resource) self.resource_tracker.remove_resource(other_plr_resource)
self.lab_logger().info(f"移除物料 {other_plr_resource} 及其子节点") self.lab_logger().info(f"[资源同步] 移除物料 {other_plr_resource} 及其子节点")
return { return {
"success": True, "success": True,
@@ -813,11 +834,16 @@ class BaseROS2DeviceNode(Node, Generic[T]):
original_instance: ResourcePLR = self.resource_tracker.figure_resource( original_instance: ResourcePLR = self.resource_tracker.figure_resource(
{"uuid": tree.root_node.res_content.uuid}, try_mode=False {"uuid": tree.root_node.res_content.uuid}, try_mode=False
) )
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
old_name = original_instance.name
new_name = plr_resource.name
parent_appended = False
# Update操作中包含改名需要先remove再add # Update操作中包含改名需要先remove再add,这里更新父节点即可
if original_instance.name != plr_resource.name: if not not_same_parent and old_name != new_name:
old_name = original_instance.name
new_name = plr_resource.name
self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}") self.lab_logger().info(f"物料改名操作:{old_name} -> {new_name}")
# 收集所有相关的uuid包括子节点 # 收集所有相关的uuid包括子节点
@@ -826,12 +852,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
_handle_add([original_instance], tree_set, additional_add_params) _handle_add([original_instance], tree_set, additional_add_params)
self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}") self.lab_logger().info(f"物料改名完成:{old_name} -> {new_name}")
original_instances.append(original_parent_resource)
parent_appended = True
# 常规更新:不涉及改名 # 常规更新:不涉及改名
original_parent_resource = original_instance.parent
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
self.lab_logger().info( self.lab_logger().info(
f"物料{original_instance} 原始父节点{original_parent_resource_uuid} " f"物料{original_instance} 原始父节点{original_parent_resource_uuid} "
f"目标父节点{target_parent_resource_uuid} 更新" f"目标父节点{target_parent_resource_uuid} 更新"
@@ -842,13 +866,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501 original_instance.unilabos_extra = getattr(plr_resource, "unilabos_extra") # type: ignore # noqa: E501
# 如果父节点变化,需要重新挂载 # 如果父节点变化,需要重新挂载
if ( if not_same_parent:
original_parent_resource_uuid != target_parent_resource_uuid parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
and original_parent_resource is not None original_instances.append(parent)
): parent_appended = True
self.transfer_to_new_resource(original_instance, tree, additional_add_params)
else: else:
# 判断是否变更了resource_site # 判断是否变更了resource_site,重新登记
target_site = original_instance.unilabos_extra.get("update_resource_site") target_site = original_instance.unilabos_extra.get("update_resource_site")
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else [] site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
@@ -856,7 +879,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
site_index = sites.index(original_instance) site_index = sites.index(original_instance)
site_name = site_names[site_index] site_name = site_names[site_index]
if site_name != target_site: if site_name != target_site:
self.transfer_to_new_resource(original_instance, tree, additional_add_params) parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
if parent is not None:
original_instances.append(parent)
parent_appended = True
# 加载状态 # 加载状态
original_instance.load_all_state(states) original_instance.load_all_state(states)
@@ -864,7 +890,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().info( self.lab_logger().info(
f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}" f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count}"
) )
original_instances.append(original_instance) if not parent_appended:
original_instances.append(original_instance)
# 调用driver的update回调 # 调用driver的update回调
func = getattr(self.driver_instance, "resource_tree_update", None) func = getattr(self.driver_instance, "resource_tree_update", None)
@@ -881,8 +908,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
action = i.get("action") # remove, add, update action = i.get("action") # remove, add, update
resources_uuid: List[str] = i.get("data") # 资源数据 resources_uuid: List[str] = i.get("data") # 资源数据
additional_add_params = i.get("additional_add_params", {}) # 额外参数 additional_add_params = i.get("additional_add_params", {}) # 额外参数
self.lab_logger().info( self.lab_logger().trace(
f"[Resource Tree Update] Processing {action} operation, " f"resources count: {len(resources_uuid)}" f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
) )
tree_set = None tree_set = None
if action in ["add", "update"]: if action in ["add", "update"]:
@@ -894,8 +921,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if tree_set is None: if tree_set is None:
raise ValueError("tree_set不能为None") raise ValueError("tree_set不能为None")
plr_resources = tree_set.to_plr_resources() plr_resources = tree_set.to_plr_resources()
result = _handle_add(plr_resources, tree_set, additional_add_params) result, parents = _handle_add(plr_resources, tree_set, additional_add_params)
new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) parents: List[Optional["ResourcePLR"]] = [i for i in parents if i is not None]
# de_dupe_parents = list(set(parents))
# Fix unhashable type error for WareHouse
de_dupe_parents = []
_seen_ids = set()
for p in parents:
if id(p) not in _seen_ids:
_seen_ids.add(id(p))
de_dupe_parents.append(p)
new_tree_set = ResourceTreeSet.from_plr_resources(de_dupe_parents) # 去重
for tree in new_tree_set.trees:
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
@@ -914,7 +953,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0])
result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params)
if not BasicConfig.no_update_feedback: if not BasicConfig.no_update_feedback:
new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) # 去重
for tree in new_tree_set.trees:
if tree.root_node.res_content.uuid_parent is None and self.node_name != "host_node":
tree.root_node.res_content.parent_uuid = self.uuid
r = SerialCommand.Request() r = SerialCommand.Request()
r.command = json.dumps( r.command = json.dumps(
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
@@ -934,15 +976,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 返回处理结果 # 返回处理结果
result_json = {"results": results, "total": len(data)} result_json = {"results": results, "total": len(data)}
res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder) res.response = json.dumps(result_json, ensure_ascii=False, cls=TypeEncoder)
self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations") # self.lab_logger().info(f"[Resource Tree Update] Completed processing {len(data)} operations")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
error_msg = f"Invalid JSON format: {str(e)}" error_msg = f"Invalid JSON format: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}") self.lab_logger().error(f"[资源同步] {error_msg}")
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
except Exception as e: except Exception as e:
error_msg = f"Unexpected error: {str(e)}" error_msg = f"Unexpected error: {str(e)}"
self.lab_logger().error(f"[Resource Tree Update] {error_msg}") self.lab_logger().error(f"[资源同步] {error_msg}")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False) res.response = json.dumps({"success": False, "error": error_msg}, ensure_ascii=False)
@@ -1263,7 +1305,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name) ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}") self.lab_logger().debug(f"任务 {ACTION.__name__} 接收到原始目标: {str(action_kwargs)[:1000]}")
self.lab_logger().trace(f"任务 {ACTION.__name__} 接收到原始目标: {action_kwargs}")
error_skip = False error_skip = False
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过 # 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name not in ["create_resource_detailed", "create_resource"]: if action_name not in ["create_resource_detailed", "create_resource"]:
@@ -1279,9 +1322,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 批量查询资源 # 批量查询资源
queried_resources = [] queried_resources = []
for resource_data in resource_inputs: for resource_data in resource_inputs:
plr_resource = await self.get_resource_with_dir( unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
resource_id=resource_data["id"], with_children=True if unilabos_uuid is None:
) plr_resource = await self.get_resource_with_dir(
resource_id=resource_data["id"], with_children=True
)
else:
resource_tree = await self.get_resource([unilabos_uuid])
plr_resource = resource_tree.to_plr_resources()[0]
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"] plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
queried_resources.append(plr_resource) queried_resources.append(plr_resource)
@@ -1330,9 +1378,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
execution_success = True execution_success = True
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error( error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
)
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1352,8 +1399,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
except Exception as _: except Exception as _:
execution_error = traceback.format_exc() execution_error = traceback.format_exc()
error( error(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
) trace(
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
future.add_done_callback(_handle_future_exception) future.add_done_callback(_handle_future_exception)
@@ -1421,7 +1469,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for r in rs: for r in rs:
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象 res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
else: else:
res = self.resource_tracker.parent_resource(r) res = self.resource_tracker.parent_resource(rs)
if id(res) not in seen: if id(res) not in seen:
seen.add(id(res)) seen.add(id(res))
unique_resources.append(res) unique_resources.append(res)
@@ -1497,8 +1545,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_data = function_args[arg_name] resource_data = function_args[arg_name]
if isinstance(resource_data, dict) and "id" in resource_data: if isinstance(resource_data, dict) and "id" in resource_data:
try: try:
converted_resource = self._convert_resource_sync(resource_data) function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
function_args[arg_name] = converted_resource
except Exception as e: except Exception as e:
self.lab_logger().error( self.lab_logger().error(
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
@@ -1512,12 +1559,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
resource_list = function_args[arg_name] resource_list = function_args[arg_name]
if isinstance(resource_list, list): if isinstance(resource_list, list):
try: try:
converted_resources = [] uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
for resource_data in resource_list: function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
if isinstance(resource_data, dict) and "id" in resource_data:
converted_resource = self._convert_resource_sync(resource_data)
converted_resources.append(converted_resource)
function_args[arg_name] = converted_resources
except Exception as e: except Exception as e:
self.lab_logger().error( self.lab_logger().error(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
@@ -1530,20 +1573,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
) )
def _convert_resource_sync(self, resource_data: Dict[str, Any]): def _convert_resources_sync(self, *uuids: str) -> List["ResourcePLR"]:
"""同步转换资源数据为实例""" """同步转换资源 UUID 为实例
# 创建资源查询请求
r = SerialCommand.Request()
r.command = json.dumps(
{
"id": resource_data.get("id", None),
"uuid": resource_data.get("uuid", None),
"with_children": True,
}
)
# 同步调用资源查询服务 Args:
future = self._resource_clients["resource_get"].call_async(r) *uuids: 一个或多个资源 UUID
Returns:
单个 UUID 时返回单个资源实例,多个 UUID 时返回资源实例列表
"""
if not uuids:
raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
command=json.dumps(
{
"data": {"data": uuids_list, "with_children": True},
"action": "get",
}
)
))
# 等待结果使用while循环每次sleep 0.05秒最多等待30秒 # 等待结果使用while循环每次sleep 0.05秒最多等待30秒
timeout = 30.0 timeout = 30.0
@@ -1553,27 +1603,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
elapsed += 0.05 elapsed += 0.05
if not future.done(): if not future.done():
raise Exception(f"资源查询超时: {resource_data}") raise Exception(f"资源查询超时: {uuids_list}")
response = future.result() response = future.result()
if response is None: if response is None:
raise Exception(f"资源查询返回空结果: {resource_data}") raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
plr_resource = tree_set.to_plr_resources()[0] if not len(tree_set.trees):
raise Exception(f"资源查询返回空树: {raw_data}")
plr_resources = tree_set.to_plr_resources()
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) figured_resources: List[ResourcePLR] = []
if len(res) == 0: for plr_resource, tree in zip(plr_resources, tree_set.trees):
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例") res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
return plr_resource if len(res) == 0:
elif len(res) == 1: self.lab_logger().warning(f"资源转换未能索引到实例: {tree.root_node.res_content},返回新建实例")
return res[0] figured_resources.append(plr_resource)
else: elif len(res) == 1:
raise ValueError(f"资源转换得到多个实例: {res}") figured_resources.append(res[0])
else:
raise ValueError(f"资源转换得到多个实例: {res}")
mapped_plr_resources = []
for uuid in uuids_list:
for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r)
break
return mapped_plr_resources
async def _execute_driver_command_async(self, string: str): async def _execute_driver_command_async(self, string: str):
try: try:

View File

@@ -23,6 +23,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialComma
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer
from unilabos.resources.graphio import initialize_resource from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
@@ -361,8 +362,7 @@ class HostNode(BaseROS2DeviceNode):
request.command = "" request.command = ""
future = sclient.call_async(request) future = sclient.call_async(request)
# Use timeout for result as well # Use timeout for result as well
future.result(timeout_sec=5.0) future.result()
self.lab_logger().debug(f"[Host Node] Re-register completed for {device_namespace}")
except Exception as e: except Exception as e:
# Gracefully handle destruction during shutdown # Gracefully handle destruction during shutdown
if "destruction was requested" in str(e) or self._shutting_down: if "destruction was requested" in str(e) or self._shutting_down:
@@ -586,11 +586,10 @@ class HostNode(BaseROS2DeviceNode):
) )
try: try:
new_li = [] assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response: for i in response:
res = json.loads(i) res = json.loads(i)
new_li.append(res) return res
return {"resources": new_li, "liquid_input_resources": new_li}
except Exception as ex: except Exception as ex:
pass pass
_n = "\n" _n = "\n"
@@ -795,7 +794,8 @@ class HostNode(BaseROS2DeviceNode):
assign_sample_id(action_kwargs) assign_sample_id(action_kwargs)
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}") self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
action_client.wait_for_server() action_client.wait_for_server()
goal_uuid_obj = UUID(uuid=list(u.bytes)) goal_uuid_obj = UUID(uuid=list(u.bytes))
@@ -1133,11 +1133,11 @@ class HostNode(BaseROS2DeviceNode):
接收序列化的 ResourceTreeSet 数据并进行处理 接收序列化的 ResourceTreeSet 数据并进行处理
""" """
self.lab_logger().info(f"[Host Node-Resource] Resource tree add request received")
try: try:
# 解析请求数据 # 解析请求数据
data = json.loads(request.command) data = json.loads(request.command)
action = data["action"] action = data["action"]
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
data = data["data"] data = data["data"]
if action == "add": if action == "add":
await self._resource_tree_action_add_callback(data, response) await self._resource_tree_action_add_callback(data, response)
@@ -1243,7 +1243,7 @@ class HostNode(BaseROS2DeviceNode):
data = json.loads(request.command) data = json.loads(request.command)
if "uuid" in data and data["uuid"] is not None: if "uuid" in data and data["uuid"] is not None:
http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"])
elif "id" in data and data["id"].startswith("/"): elif "id" in data:
http_req = http_client.resource_get(data["id"], data["with_children"]) http_req = http_client.resource_get(data["id"], data["with_children"])
else: else:
raise ValueError("没有使用正确的物料 id 或 uuid") raise ValueError("没有使用正确的物料 id 或 uuid")
@@ -1453,10 +1453,16 @@ class HostNode(BaseROS2DeviceNode):
} }
def test_resource( def test_resource(
self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot] self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
) -> TestResourceReturn: ) -> TestResourceReturn:
if resources is None:
resources = []
if devices is None:
devices = []
if resource is None:
resource = RegularContainer("test_resource传入None")
return { return {
"resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(), "resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
"devices": [device, *devices], "devices": [device, *devices],
} }
@@ -1508,7 +1514,7 @@ class HostNode(BaseROS2DeviceNode):
# 构建服务地址 # 构建服务地址
srv_address = f"/srv{namespace}/s2c_resource_tree" srv_address = f"/srv{namespace}/s2c_resource_tree"
self.lab_logger().info(f"[Host Node-Resource] Notifying {device_id} for resource tree {action} operation") self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
# 创建服务客户端 # 创建服务客户端
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
@@ -1543,9 +1549,7 @@ class HostNode(BaseROS2DeviceNode):
time.sleep(0.05) time.sleep(0.05)
response = future.result() response = future.result()
self.lab_logger().info( self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
f"[Host Node-Resource] Resource tree {action} notification completed for {device_id}"
)
return True return True
except Exception as e: except Exception as e:

View File

@@ -14,11 +14,7 @@
], ],
"type": "device", "type": "device",
"class": "reaction_station.bioyond", "class": "reaction_station.bioyond",
"position": { "position": {"x": 0, "y": 3800, "z": 0},
"x": 0,
"y": 1100,
"z": 0
},
"config": { "config": {
"config": { "config": {
"api_key": "DE9BDDA0", "api_key": "DE9BDDA0",
@@ -61,10 +57,6 @@
"BIOYOND_PolymerStation_TipBox": [ "BIOYOND_PolymerStation_TipBox": [
"枪头盒", "枪头盒",
"3a143890-9d51-60ac-6d6f-6edb43c12041" "3a143890-9d51-60ac-6d6f-6edb43c12041"
],
"BIOYOND_PolymerStation_Measurement_Vial": [
"测量小瓶",
"b1fc79c9-5864-4f05-8052-6ed3abc18a97"
] ]
} }
}, },
@@ -74,9 +66,6 @@
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
} }
}, },
"size_x": 2700.0,
"size_y": 1080.0,
"size_z": 2000.0,
"protocol_type": [] "protocol_type": []
}, },
"data": {} "data": {}
@@ -88,11 +77,7 @@
"parent": "reaction_station_bioyond", "parent": "reaction_station_bioyond",
"type": "device", "type": "device",
"class": "reaction_station.reactor", "class": "reaction_station.reactor",
"position": { "position": {"x": 1150, "y": 380, "z": 0},
"x": 1150,
"y": 380,
"z": 0
},
"config": {}, "config": {},
"data": {} "data": {}
}, },
@@ -103,11 +88,7 @@
"parent": "reaction_station_bioyond", "parent": "reaction_station_bioyond",
"type": "device", "type": "device",
"class": "reaction_station.reactor", "class": "reaction_station.reactor",
"position": { "position": {"x": 1365, "y": 380, "z": 0},
"x": 1365,
"y": 380,
"z": 0
},
"config": {}, "config": {},
"data": {} "data": {}
}, },
@@ -118,11 +99,7 @@
"parent": "reaction_station_bioyond", "parent": "reaction_station_bioyond",
"type": "device", "type": "device",
"class": "reaction_station.reactor", "class": "reaction_station.reactor",
"position": { "position": {"x": 1580, "y": 380, "z": 0},
"x": 1580,
"y": 380,
"z": 0
},
"config": {}, "config": {},
"data": {} "data": {}
}, },
@@ -133,11 +110,7 @@
"parent": "reaction_station_bioyond", "parent": "reaction_station_bioyond",
"type": "device", "type": "device",
"class": "reaction_station.reactor", "class": "reaction_station.reactor",
"position": { "position": {"x": 1790, "y": 380, "z": 0},
"x": 1790,
"y": 380,
"z": 0
},
"config": {}, "config": {},
"data": {} "data": {}
}, },
@@ -148,11 +121,7 @@
"parent": "reaction_station_bioyond", "parent": "reaction_station_bioyond",
"type": "device", "type": "device",
"class": "reaction_station.reactor", "class": "reaction_station.reactor",
"position": { "position": {"x": 2010, "y": 380, "z": 0},
"x": 2010,
"y": 380,
"z": 0
},
"config": {}, "config": {},
"data": {} "data": {}
}, },
@@ -165,7 +134,7 @@
"class": "BIOYOND_PolymerReactionStation_Deck", "class": "BIOYOND_PolymerReactionStation_Deck",
"position": { "position": {
"x": 0, "x": 0,
"y": 1100, "y": 0,
"z": 0 "z": 0
}, },
"config": { "config": {