Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
This commit is contained in:
Junhan Chang
2025-09-30 17:23:13 +08:00
committed by GitHub
parent 709eb0d91c
commit cfc1ee6e79
29 changed files with 21729 additions and 110 deletions

View File

@@ -41,7 +41,7 @@
"HydrogenateProtocol", "HydrogenateProtocol",
"RecrystallizeProtocol" "RecrystallizeProtocol"
], ],
"station_resource": { "deck": {
"data": { "data": {
"_resource_child_name": "deck", "_resource_child_name": "deck",
"_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck"

View File

@@ -0,0 +1,69 @@
{
"nodes": [
{
"id": "reaction_station_bioyond",
"name": "reaction_station_bioyond",
"parent": null,
"children": [
"Bioyond_Deck"
],
"type": "device",
"class": "workstation.bioyond",
"config": {
"bioyond_config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier"
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
},
"data": {}
},
{
"id": "Bioyond_Deck",
"name": "Bioyond_Deck",
"sample_id": null,
"children": [
],
"parent": "reaction_station_bioyond",
"type": "deck",
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_PolymerReactionStation_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
]
}

View File

@@ -0,0 +1,198 @@
{
"data": [
{
"id": "3a1c67a9-aed7-b94d-9e24-bfdf10c8baa9",
"typeName": "烧杯",
"code": "0006-00160",
"barCode": "",
"name": "ODA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 695374.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-1ade-5fe1-cc04b24b171c",
"typeName": "烧杯",
"code": "0006-00161",
"barCode": "",
"name": "MPDA",
"quantity": 120000.00000000000000000000000,
"lockQuantity": 681618.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-2864-6783-2cee4e701ba6",
"typeName": "试剂瓶",
"code": "0004-00041",
"barCode": "",
"name": "NMP",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 380000.00000000000000000000000,
"unit": "微升",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0001",
"x": 1,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c67a9-aed9-32c7-5809-3ba1b8db1aa1",
"typeName": "试剂瓶",
"code": "0004-00042",
"barCode": "",
"name": "PGME",
"quantity": 300000.00000000000000000000000,
"lockQuantity": 337892.00000000000000000000000,
"unit": "",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa3b-9fab-ca72-febc-b7c304476c78",
"whid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"whName": "站内试剂存放堆栈",
"code": "0003-0002",
"x": 1,
"y": 2,
"z": 1,
"quantity": 0
}
],
"detail": []
},
{
"id": "3a1c68c8-0574-d748-725e-97a2e549f085",
"typeName": "样品板",
"code": "0001-00004",
"barCode": "",
"name": "0917",
"quantity": 1.0000000000000000000000000000,
"lockQuantity": 4.0000000000000000000000000000,
"unit": "块",
"status": 1,
"isUse": false,
"locations": [
{
"id": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
"whid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"whName": "堆栈1",
"code": "0001-0009",
"x": 2,
"y": 1,
"z": 1,
"quantity": 0
}
],
"detail": [
{
"id": "3a1c68c8-0574-69a1-9858-4637e0193451",
"detailMaterialId": "3a1c68c8-0574-3630-bd42-bbf3623c5208",
"code": null,
"name": "SIDA",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 1,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-8d51-3191-a31f5be421e5",
"detailMaterialId": "3a1c68c8-0574-3b20-9ad7-90755f123d53",
"code": null,
"name": "BTDA-2",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 2,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-da80-735b-53ae2197a360",
"detailMaterialId": "3a1c68c8-0574-f2e4-33b3-90d813567939",
"code": null,
"name": "BTDA-DD",
"quantity": "300000",
"lockQuantity": "28",
"unit": "微升",
"x": 1,
"y": 1,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e717-1b1b-99891f875455",
"detailMaterialId": "3a1c68c8-0574-a0ef-e636-68cdc98960e2",
"code": null,
"name": "BTDA-3",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 3,
"z": 1,
"associateId": null
},
{
"id": "3a1c68c8-0574-e9bd-6cca-5e261b4f89cb",
"detailMaterialId": "3a1c68c8-0574-9d11-5115-283e8e5510b1",
"code": null,
"name": "BTDA-1",
"quantity": "300000",
"lockQuantity": "4",
"unit": "微升",
"x": 2,
"y": 1,
"z": 1,
"associateId": null
}
]
}
],
"code": 1,
"message": "",
"timestamp": 1758560573511
}

View File

@@ -0,0 +1,48 @@
import pytest
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_Electrolyte_6VialCarrier, BIOYOND_Electrolyte_1BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
def test_bottle_carrier() -> "BottleCarrier":
print("创建载架...")
# 创建6瓶载架
bottle_carrier = BIOYOND_Electrolyte_6VialCarrier("powder_carrier_01")
print(f"6瓶载架: {bottle_carrier.name}, 位置数: {len(bottle_carrier.sites)}")
# 创建1烧杯载架
beaker_carrier = BIOYOND_Electrolyte_1BottleCarrier("solution_carrier_01")
print(f"1烧杯载架: {beaker_carrier.name}, 位置数: {len(beaker_carrier.sites)}")
# 创建瓶子和烧杯
powder_bottle = BIOYOND_PolymerStation_Solid_Vial("powder_bottle_01")
solution_beaker = BIOYOND_PolymerStation_Solution_Beaker("solution_beaker_01")
reagent_bottle = BIOYOND_PolymerStation_Reagent_Bottle("reagent_bottle_01")
print(f"\n创建的物料:")
print(f"粉末瓶: {powder_bottle.name} - {powder_bottle.diameter}mm x {powder_bottle.height}mm, {powder_bottle.max_volume}μL")
print(f"溶液烧杯: {solution_beaker.name} - {solution_beaker.diameter}mm x {solution_beaker.height}mm, {solution_beaker.max_volume}μL")
print(f"试剂瓶: {reagent_bottle.name} - {reagent_bottle.diameter}mm x {reagent_bottle.height}mm, {reagent_bottle.max_volume}μL")
# 测试放置容器
print(f"\n测试放置容器...")
# 通过载架的索引操作来放置容器
# bottle_carrier[0] = powder_bottle # 放置粉末瓶到第一个位置
print(f"粉末瓶已放置到6瓶载架的位置 0")
# beaker_carrier[0] = solution_beaker # 放置烧杯到第一个位置
print(f"溶液烧杯已放置到1烧杯载架的位置 0")
# 验证放置结果
print(f"\n验证放置结果:")
bottle_at_0 = bottle_carrier[0].resource
beaker_at_0 = beaker_carrier[0].resource
if bottle_at_0:
print(f"位置 0 的瓶子: {bottle_at_0.name}")
if beaker_at_0:
print(f"位置 0 的烧杯: {beaker_at_0.name}")
print("\n载架设置完成!")

View File

@@ -0,0 +1,35 @@
import pytest
import json
import os
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.registry.registry import lab_registry
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
lab_registry.setup()
type_mapping = {
"烧杯": "BIOYOND_PolymerStation_1FlaskCarrier",
"试剂瓶": "BIOYOND_PolymerStation_1BottleCarrier",
"样品板": "BIOYOND_PolymerStation_6VialCarrier",
}
@pytest.fixture
def bioyond_materials() -> list[dict]:
print("加载 BioYond 物料数据...")
print(os.getcwd())
with open("bioyond_materials.json", "r", encoding="utf-8") as f:
data = json.load(f)["data"]
print(f"加载了 {len(data)} 条物料数据")
return data
def test_bioyond_to_plr(bioyond_materials) -> list[dict]:
deck = BIOYOND_PolymerReactionStation_Deck("test_deck")
print("将 BioYond 物料数据转换为 PLR 格式...")
output = resource_bioyond_to_plr(bioyond_materials, type_mapping=type_mapping, deck=deck)
print(deck.summary())
print([resource.serialize() for resource in output])
print([resource.serialize_all_state() for resource in output])

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "NEWARE_BATTERY_TEST_SYSTEM",
"name": "Neware Battery Test System",
"parent": null,
"type": "device",
"class": "neware_battery_test_system",
"position": {
"x": 620.6111111111111,
"y": 171,
"z": 0
},
"config": {
"ip": "127.0.0.1",
"port": 502,
"machine_id": 1,
"devtype": "27",
"timeout": 20,
"size_x": 500.0,
"size_y": 500.0,
"size_z": 2000.0
},
"data": {},
"children": []
}
],
"links": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
# experiment_workflow.py
"""
实验流程主程序
"""
import json
from bioyond_rpc import BioyondV1RPC
from config import API_CONFIG, WORKFLOW_MAPPINGS
def run_experiment():
"""运行实验流程"""
# 初始化Bioyond客户端
config = {
**API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS
}
Bioyond = BioyondV1RPC(config)
print("\n============= 多工作流参数测试(简化接口+材料缓存)=============")
# 显示可用的材料名称前20个
available_materials = Bioyond.get_available_materials()
print(f"可用材料名称前20个: {available_materials[:20]}")
print(f"总共有 {len(available_materials)} 个材料可用\n")
# 1. 反应器放入
print("1. 添加反应器放入工作流,带参数...")
Bioyond.reactor_taken_in(
assign_material_name="BTDA-DD",
cutoff="10000",
temperature="-10"
)
# 2. 液体投料-烧杯 (第一个)
print("2. 添加液体投料-烧杯,带参数...")
Bioyond.liquid_feeding_beaker(
volume="34768.7",
assign_material_name="ODA",
time="0",
torque_variation="1",
titrationType="1",
temperature=-10
)
# 3. 液体投料-烧杯 (第二个)
print("3. 添加液体投料-烧杯,带参数...")
Bioyond.liquid_feeding_beaker(
volume="34080.9",
assign_material_name="MPDA",
time="5",
torque_variation="2",
titrationType="1",
temperature=0
)
# 4. 液体投料-小瓶非滴定
print("4. 添加液体投料-小瓶非滴定,带参数...")
Bioyond.liquid_feeding_vials_non_titration(
volumeFormula="639.5",
assign_material_name="SIDA",
titration_type="1",
time="0",
torque_variation="1",
temperature=-10
)
# 5. 液体投料溶剂
print("5. 添加液体投料溶剂,带参数...")
Bioyond.liquid_feeding_solvents(
assign_material_name="NMP",
volume="19000",
titration_type="1",
time="5",
torque_variation="2",
temperature=-10
)
# 6-8. 固体进料小瓶 (三个)
print("6. 添加固体进料小瓶,带参数...")
Bioyond.solid_feeding_vials(
material_id="3",
time="180",
torque_variation="2",
assign_material_name="BTDA-1",
temperature=-10.00
)
print("7. 添加固体进料小瓶,带参数...")
Bioyond.solid_feeding_vials(
material_id="3",
time="180",
torque_variation="2",
assign_material_name="BTDA-2",
temperature=25.00
)
print("8. 添加固体进料小瓶,带参数...")
Bioyond.solid_feeding_vials(
material_id="3",
time="480",
torque_variation="2",
assign_material_name="BTDA-3",
temperature=25.00
)
# 液体投料滴定(第一个)
print("9. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="1000",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
# 液体投料滴定(第二个)
print("10. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="500",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
# 液体投料滴定(第三个)
print("11. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="500",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
print("12. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="500",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
print("13. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="500",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
print("14. 添加液体投料滴定,带参数...") # ODPA
Bioyond.liquid_feeding_titration(
volume_formula="500",
assign_material_name="BTDA-DD",
titration_type="1",
time="360",
torque_variation="2",
temperature="25.00"
)
print("15. 添加液体投料溶剂,带参数...")
Bioyond.liquid_feeding_solvents(
assign_material_name="PGME",
volume="16894.6",
titration_type="1",
time="360",
torque_variation="2",
temperature=25.00
)
# 16. 反应器取出
print("16. 添加反应器取出工作流...")
Bioyond.reactor_taken_out()
# 显示当前工作流序列
sequence = Bioyond.get_workflow_sequence()
print("\n当前工作流执行顺序:")
print(sequence)
# 执行process_and_execute_workflow合并工作流并创建任务
print("\n4. 执行process_and_execute_workflow...")
result = Bioyond.process_and_execute_workflow(
workflow_name="test3_86",
task_name="实验3_86"
)
# 显示执行结果
print("\n5. 执行结果:")
if isinstance(result, str):
try:
result_dict = json.loads(result)
if result_dict.get("success"):
print("任务创建成功!")
print(f"- 工作流: {result_dict.get('workflow', {}).get('name')}")
print(f"- 工作流ID: {result_dict.get('workflow', {}).get('id')}")
print(f"- 任务结果: {result_dict.get('task')}")
else:
print(f"任务创建失败: {result_dict.get('error')}")
except:
print(f"结果解析失败: {result}")
else:
if result.get("success"):
print("任务创建成功!")
print(f"- 工作流: {result.get('workflow', {}).get('name')}")
print(f"- 工作流ID: {result.get('workflow', {}).get('id')}")
print(f"- 任务结果: {result.get('task')}")
else:
print(f"任务创建失败: {result.get('error')}")
# 可选:启动调度器
# Bioyond.scheduler_start()
return Bioyond
def prepare_materials(bioyond):
"""准备实验材料(可选)"""
# 样品板材料数据定义
material_data_yp_1 = {
"typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e",
"name": "样品板-1",
"unit": "",
"quantity": 1,
"details": [
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BPDA-DD-1",
"quantity": 1,
"x": 1,
"y": 1,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "PEPA",
"quantity": 1,
"x": 1,
"y": 2,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BPDA-DD-2",
"quantity": 1,
"x": 1,
"y": 3,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BPDA-1",
"quantity": 1,
"x": 2,
"y": 1,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "PMDA",
"quantity": 1,
"x": 2,
"y": 2,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BPDA-2",
"quantity": 1,
"x": 2,
"y": 3,
"Parameters": "{\"molecular\": 1}"
}
],
"Parameters": "{}"
}
material_data_yp_2 = {
"typeId": "3a142339-80de-8f25-6093-1b1b1b6c322e",
"name": "样品板-2",
"unit": "",
"quantity": 1,
"details": [
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BPDA-DD",
"quantity": 1,
"x": 1,
"y": 1,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "SIDA",
"quantity": 1,
"x": 1,
"y": 2,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BTDA-1",
"quantity": 1,
"x": 2,
"y": 1,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BTDA-2",
"quantity": 1,
"x": 2,
"y": 2,
"Parameters": "{\"molecular\": 1}"
},
{
"typeId": "3a14233a-84a3-088d-6676-7cb4acd57c64",
"name": "BTDA-3",
"quantity": 1,
"x": 2,
"y": 3,
"Parameters": "{\"molecular\": 1}"
}
],
"Parameters": "{}"
}
# 烧杯材料数据定义
beaker_materials = [
{
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
"name": "PDA-1",
"unit": "微升",
"quantity": 1,
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
},
{
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
"name": "TFDB",
"unit": "微升",
"quantity": 1,
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
},
{
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
"name": "ODA",
"unit": "微升",
"quantity": 1,
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
},
{
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
"name": "MPDA",
"unit": "微升",
"quantity": 1,
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
},
{
"typeId": "3a14233b-f0a9-ba84-eaa9-0d4718b361b6",
"name": "PDA-2",
"unit": "微升",
"quantity": 1,
"parameters": "{\"DeviceMaterialType\":\"NMP\"}"
}
]
# 如果需要可以在这里调用add_material方法添加材料
# 例如:
# result = bioyond.add_material(json.dumps(material_data_yp_1))
# print(f"添加材料结果: {result}")
return {
"sample_plates": [material_data_yp_1, material_data_yp_2],
"beakers": beaker_materials
}
if __name__ == "__main__":
# 运行主实验流程
bioyond_client = run_experiment()
# 可选:准备材料数据
# materials = prepare_materials(bioyond_client)
# print(f"\n准备的材料数据: {materials}")

View File

@@ -0,0 +1,400 @@
"""
Bioyond工作站实现
Bioyond Workstation Implementation
集成Bioyond物料管理的工作站示例
"""
import traceback
from typing import Dict, Any, List, Optional, Union
import json
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger
from unilabos.resources.graphio import resource_bioyond_to_plr
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS
class BioyondResourceSynchronizer(ResourceSynchronizer):
"""Bioyond资源同步器
负责与Bioyond系统进行物料数据的同步
"""
def __init__(self, workstation: 'BioyondWorkstation'):
super().__init__(workstation)
self.bioyond_api_client = None
self.sync_interval = 60 # 默认60秒同步一次
self.last_sync_time = 0
self.initialize()
def initialize(self) -> bool:
"""初始化Bioyond资源同步器"""
try:
self.bioyond_api_client = self.workstation.hardware_interface
if self.bioyond_api_client is None:
logger.error("Bioyond API客户端未初始化")
return False
# 设置同步间隔
self.sync_interval = self.workstation.bioyond_config.get("sync_interval", 600)
logger.info("Bioyond资源同步器初始化完成")
return True
except Exception as e:
logger.error(f"Bioyond资源同步器初始化失败: {e}")
return False
def sync_from_external(self) -> bool:
"""从Bioyond系统同步物料数据"""
try:
if self.bioyond_api_client is None:
logger.error("Bioyond API客户端未初始化")
return False
bioyond_data = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}')
if not bioyond_data:
logger.warning("从Bioyond获取的物料数据为空")
return False
# 转换为UniLab格式
unilab_resources = resource_bioyond_to_plr(bioyond_data, type_mapping=self.workstation.bioyond_config["material_type_mappings"], deck=self.workstation.deck)
logger.info(f"从Bioyond同步了 {len(unilab_resources)} 个资源")
return True
except Exception as e:
logger.error(f"从Bioyond同步物料数据失败: {e}")
traceback.print_exc()
return False
def sync_to_external(self, resource: Any) -> bool:
"""将本地物料数据变更同步到Bioyond系统"""
try:
if self.bioyond_api_client is None:
logger.error("Bioyond API客户端未初始化")
return False
# 调用入库、出库操作
# bioyond_format_data = self._convert_resource_to_bioyond_format(resource)
# success = await self.bioyond_api_client.update_material(bioyond_format_data)
#
# if success
except:
pass
def handle_external_change(self, change_info: Dict[str, Any]) -> bool:
"""处理Bioyond系统的变更通知"""
try:
# 这里可以实现对Bioyond变更的处理逻辑
logger.info(f"处理Bioyond变更通知: {change_info}")
return True
except Exception as e:
logger.error(f"处理Bioyond变更通知失败: {e}")
return False
class BioyondWorkstation(WorkstationBase):
"""Bioyond工作站
集成Bioyond物料管理的工作站实现
"""
def __init__(
self,
bioyond_config: Optional[Dict[str, Any]] = None,
deck: Optional[Any] = None,
*args,
**kwargs,
):
# 初始化父类
super().__init__(
# 桌子
deck=deck,
*args,
**kwargs,
)
self.deck.warehouses = {}
for resource in self.deck.children:
if isinstance(resource, WareHouse):
self.deck.warehouses[resource.name] = resource
self._create_communication_module(bioyond_config)
self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
# TODO: self._ros_node里面拿属性
logger.info(f"Bioyond工作站初始化完成")
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
#self.deck = create_a_coin_cell_deck()
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
"""创建Bioyond通信模块"""
self.bioyond_config = config or {
**API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS
}
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
return None
def _register_supported_workflows(self):
"""注册Bioyond支持的工作流"""
from unilabos.devices.workstation.workstation_base import WorkflowInfo
# Bioyond物料同步工作流
self.supported_workflows["bioyond_sync"] = WorkflowInfo(
name="bioyond_sync",
description="从Bioyond系统同步物料",
parameters={
"sync_type": {"type": "string", "default": "full", "options": ["full", "incremental"]},
"force_sync": {"type": "boolean", "default": False}
}
)
# Bioyond物料更新工作流
self.supported_workflows["bioyond_update"] = WorkflowInfo(
name="bioyond_update",
description="将本地物料变更同步到Bioyond",
parameters={
"material_ids": {"type": "list", "default": []},
"sync_all": {"type": "boolean", "default": True}
}
)
logger.info(f"注册了 {len(self.supported_workflows)} 个Bioyond工作流")
async def execute_bioyond_sync_workflow(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""执行Bioyond同步工作流"""
try:
sync_type = parameters.get("sync_type", "full")
force_sync = parameters.get("force_sync", False)
logger.info(f"开始执行Bioyond同步工作流: {sync_type}")
# 获取物料管理模块
material_manager = self.material_management
if sync_type == "full":
# 全量同步
success = await material_manager.sync_from_bioyond()
else:
# 增量同步(这里可以实现增量同步逻辑)
success = await material_manager.sync_from_bioyond()
if success:
result = {
"status": "success",
"message": f"Bioyond同步完成: {sync_type}",
"synced_resources": len(material_manager.plr_resources)
}
else:
result = {
"status": "failed",
"message": "Bioyond同步失败"
}
logger.info(f"Bioyond同步工作流执行完成: {result['status']}")
return result
except Exception as e:
logger.error(f"Bioyond同步工作流执行失败: {e}")
return {
"status": "error",
"message": str(e)
}
async def execute_bioyond_update_workflow(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
"""执行Bioyond更新工作流"""
try:
material_ids = parameters.get("material_ids", [])
sync_all = parameters.get("sync_all", True)
logger.info(f"开始执行Bioyond更新工作流: sync_all={sync_all}")
# 获取物料管理模块
material_manager = self.material_management
if sync_all:
# 同步所有物料
success_count = 0
for resource in material_manager.plr_resources.values():
success = await material_manager.sync_to_bioyond(resource)
if success:
success_count += 1
else:
# 同步指定物料
success_count = 0
for material_id in material_ids:
resource = material_manager.find_material_by_id(material_id)
if resource:
success = await material_manager.sync_to_bioyond(resource)
if success:
success_count += 1
result = {
"status": "success",
"message": f"Bioyond更新完成",
"updated_resources": success_count,
"total_resources": len(material_ids) if not sync_all else len(material_manager.plr_resources)
}
logger.info(f"Bioyond更新工作流执行完成: {result['status']}")
return result
except Exception as e:
logger.error(f"Bioyond更新工作流执行失败: {e}")
return {
"status": "error",
"message": str(e)
}
def load_bioyond_data_from_file(self, file_path: str) -> bool:
"""从文件加载Bioyond数据用于测试"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
bioyond_data = json.load(f)
# 获取物料管理模块
material_manager = self.material_management
# 转换为UniLab格式
if isinstance(bioyond_data, dict) and "data" in bioyond_data:
unilab_resources = material_manager.resource_bioyond_container_to_ulab(bioyond_data)
else:
unilab_resources = material_manager.resource_bioyond_to_ulab(bioyond_data)
# 分配到Deck
import asyncio
asyncio.create_task(material_manager._assign_resources_to_deck(unilab_resources))
logger.info(f"从文件 {file_path} 加载了 {len(unilab_resources)} 个Bioyond资源")
return True
except Exception as e:
logger.error(f"从文件加载Bioyond数据失败: {e}")
return False
# 使用示例
def create_bioyond_workstation_example():
"""创建Bioyond工作站示例"""
# 配置参数
device_id = "bioyond_workstation_001"
# 子资源配置
children = {
"plate_1": {
"name": "plate_1",
"type": "plate",
"position": {"x": 100, "y": 100, "z": 0},
"config": {
"size_x": 127.76,
"size_y": 85.48,
"size_z": 14.35,
"model": "Generic 96 Well Plate"
}
}
}
# Bioyond配置
bioyond_config = {
"base_url": "http://bioyond.example.com/api",
"api_key": "your_api_key_here",
"sync_interval": 60, # 60秒同步一次
"timeout": 30
}
# Deck配置
deck_config = {
"size_x": 1000.0,
"size_y": 1000.0,
"size_z": 100.0,
"model": "BioyondDeck"
}
# 创建工作站
workstation = BioyondWorkstation(
station_resource=deck_config,
bioyond_config=bioyond_config,
deck_config=deck_config,
)
return workstation
if __name__ == "__main__":
# 创建示例工作站
#workstation = create_bioyond_workstation_example()
# 从文件加载测试数据
#workstation.load_bioyond_data_from_file("bioyond_test_yibin.json")
# 获取状态
#status = workstation.get_bioyond_status()
#print("Bioyond工作站状态:", status)
# 创建测试数据 - 使用resource_bioyond_container_to_ulab函数期望的格式
# 读取 bioyond_resources_unilab_output3 copy.json 文件
from unilabos.resources.graphio import resource_ulab_to_plr, convert_resources_to_type
from Bioyond_wuliao import *
from typing import List
from pylabrobot.resources import Resource as PLRResource
import json
from pylabrobot.resources.deck import Deck
from pylabrobot.resources.coordinate import Coordinate
with open("./bioyond_test_yibin3_unilab_result_corr.json", "r", encoding="utf-8") as f:
bioyond_resources_unilab = json.load(f)
print(f"成功读取 JSON 文件,包含 {len(bioyond_resources_unilab)} 个资源")
ulab_resources = convert_resources_to_type(bioyond_resources_unilab, List[PLRResource])
print(f"转换结果类型: {type(ulab_resources)}")
print(f"转换结果长度: {len(ulab_resources) if ulab_resources else 0}")
deck = Deck(size_x=2000,
size_y=653.5,
size_z=900)
Stack0 = Stack(name="Stack0", location=Coordinate(0, 100, 0))
Stack1 = Stack(name="Stack1", location=Coordinate(100, 100, 0))
Stack2 = Stack(name="Stack2", location=Coordinate(200, 100, 0))
Stack3 = Stack(name="Stack3", location=Coordinate(300, 100, 0))
Stack4 = Stack(name="Stack4", location=Coordinate(400, 100, 0))
Stack5 = Stack(name="Stack5", location=Coordinate(500, 100, 0))
deck.assign_child_resource(Stack1, Stack1.location)
deck.assign_child_resource(Stack2, Stack2.location)
deck.assign_child_resource(Stack3, Stack3.location)
deck.assign_child_resource(Stack4, Stack4.location)
deck.assign_child_resource(Stack5, Stack5.location)
Stack0.assign_child_resource(ulab_resources[0], Stack0.location)
Stack1.assign_child_resource(ulab_resources[1], Stack1.location)
Stack2.assign_child_resource(ulab_resources[2], Stack2.location)
Stack3.assign_child_resource(ulab_resources[3], Stack3.location)
Stack4.assign_child_resource(ulab_resources[4], Stack4.location)
Stack5.assign_child_resource(ulab_resources[5], Stack5.location)
from unilabos.resources.graphio import convert_resources_from_type
from unilabos.app.web.client import http_client
resources = convert_resources_from_type([deck], [PLRResource])
print(resources)
http_client.remote_addr = "https://uni-lab.bohrium.com/api/v1"
#http_client.auth = "9F05593C"
http_client.auth = "ED634D1C"
http_client.resource_add(resources, database_process_later=False)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -112,17 +112,17 @@ class ResourceSynchronizer(ABC):
self.workstation = workstation self.workstation = workstation
@abstractmethod @abstractmethod
async def sync_from_external(self) -> bool: def sync_from_external(self) -> bool:
"""从外部系统同步物料到本地deck""" """从外部系统同步物料到本地deck"""
pass pass
@abstractmethod @abstractmethod
async def sync_to_external(self, plr_resource: PLRResource) -> bool: def sync_to_external(self, plr_resource: PLRResource) -> bool:
"""将本地物料同步到外部系统""" """将本地物料同步到外部系统"""
pass pass
@abstractmethod @abstractmethod
async def handle_external_change(self, change_info: Dict[str, Any]) -> bool: def handle_external_change(self, change_info: Dict[str, Any]) -> bool:
"""处理外部系统的变更通知""" """处理外部系统的变更通知"""
pass pass
@@ -147,17 +147,15 @@ class WorkstationBase(ABC):
def __init__( def __init__(
self, self,
station_resource: PLRResource, deck: Deck,
*args, *args,
**kwargs, # 必须有kwargs **kwargs, # 必须有kwargs
): ):
# 基本配置
print(station_resource)
self.deck_config = station_resource
# PLR 物料系统 # PLR 物料系统
self.deck: Optional[Deck] = None self.deck: Optional[Deck] = deck
self.plr_resources: Dict[str, PLRResource] = {} self.plr_resources: Dict[str, PLRResource] = {}
self.resource_synchronizer = None # type: Optional[ResourceSynchronizer]
# 硬件接口 # 硬件接口
self.hardware_interface: Union[Any, str] = None self.hardware_interface: Union[Any, str] = None
@@ -173,46 +171,7 @@ class WorkstationBase(ABC):
def post_init(self, ros_node: ROS2WorkstationNode) -> None: def post_init(self, ros_node: ROS2WorkstationNode) -> None:
# 初始化物料系统 # 初始化物料系统
self._ros_node = ros_node self._ros_node = ros_node
self._initialize_material_system() self._ros_node.update_resource([self.deck])
def _initialize_material_system(self):
"""初始化物料系统 - 使用 graphio 转换"""
pass
def _create_complete_resource_config(self) -> Dict[str, Any]:
"""创建完整的资源配置 - 合并 deck_config 和 children"""
# 创建主 deck 配置
return {}
def _normalize_child_resource(self, resource_id: str, config: Dict[str, Any], parent_id: str) -> Dict[str, Any]:
"""标准化子资源配置"""
return {
"id": resource_id,
"name": config.get("name", resource_id),
"type": config.get("type", "container"),
"position": self._normalize_position(config.get("position", {})),
"config": config.get("config", {}),
"data": config.get("data", {}),
"children": [], # 简化版本:只支持一层子资源
"parent": parent_id,
}
def _normalize_position(self, position: Any) -> Dict[str, float]:
"""标准化位置信息"""
if isinstance(position, dict):
return {
"x": float(position.get("x", 0)),
"y": float(position.get("y", 0)),
"z": float(position.get("z", 0)),
}
elif isinstance(position, (list, tuple)) and len(position) >= 2:
return {
"x": float(position[0]),
"y": float(position[1]),
"z": float(position[2]) if len(position) > 2 else 0.0,
}
else:
return {"x": 0.0, "y": 0.0, "z": 0.0}
def _build_resource_mappings(self, deck: Deck): def _build_resource_mappings(self, deck: Deck):
"""递归构建资源映射""" """递归构建资源映射"""
@@ -296,14 +255,14 @@ class WorkstationBase(ABC):
"""按类型查找资源""" """按类型查找资源"""
return [res for res in self.plr_resources.values() if isinstance(res, resource_type)] return [res for res in self.plr_resources.values() if isinstance(res, resource_type)]
async def sync_with_external_system(self) -> bool: def sync_with_external_system(self) -> bool:
"""与外部物料系统同步""" """与外部物料系统同步"""
if not self.resource_synchronizer: if not self.resource_synchronizer:
logger.info(f"工作站 {self._ros_node.device_id} 没有配置资源同步器") logger.info(f"工作站 {self._ros_node.device_id} 没有配置资源同步器")
return True return True
try: try:
success = await self.resource_synchronizer.sync_from_external() success = self.resource_synchronizer.sync_from_external()
if success: if success:
logger.info(f"工作站 {self._ros_node.device_id} 外部同步成功") logger.info(f"工作站 {self._ros_node.device_id} 外部同步成功")
else: else:
@@ -391,5 +350,5 @@ class WorkstationBase(ABC):
class ProtocolNode(WorkstationBase): class ProtocolNode(WorkstationBase):
def __init__(self, station_resource: Optional[PLRResource], *args, **kwargs): def __init__(self, deck: Optional[PLRResource], *args, **kwargs):
super().__init__(station_resource, *args, **kwargs) super().__init__(deck, *args, **kwargs)

View File

@@ -149,6 +149,22 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
) )
self._send_response(error_response) self._send_response(error_response)
def do_OPTIONS(self):
"""处理OPTIONS请求 - CORS预检请求"""
try:
# 发送CORS响应头
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.send_header('Access-Control-Max-Age', '86400')
self.end_headers()
except Exception as e:
logger.error(f"OPTIONS请求处理失败: {e}")
self.send_response(500)
self.end_headers()
def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: def _handle_step_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理步骤完成报送统一LIMS协议规范""" """处理步骤完成报送统一LIMS协议规范"""
try: try:
@@ -206,7 +222,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
# 验证data字段内容 # 验证data字段内容
data = request_data['data'] data = request_data['data']
data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'Status'] data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status']
if data_missing_fields := [field for field in data_required_fields if field not in data]: if data_missing_fields := [field for field in data_required_fields if field not in data]:
return HttpResponse( return HttpResponse(
success=False, success=False,
@@ -227,7 +243,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"0": "待生产", "2": "进样", "10": "开始", "0": "待生产", "2": "进样", "10": "开始",
"20": "完成", "-2": "异常停止", "-3": "人工停止" "20": "完成", "-2": "异常停止", "-3": "人工停止"
} }
status_desc = status_names.get(str(data['Status']), f"状态{data['Status']}") status_desc = status_names.get(str(data['status']), f"状态{data['status']}")
return HttpResponse( return HttpResponse(
success=True, success=True,
@@ -380,6 +396,21 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
"""处理物料变更报送""" """处理物料变更报送"""
try: try:
# 验证必需字段 # 验证必需字段
if 'brand' in request_data:
if request_data['brand'] == "bioyond": # 奔曜
error_msg = request_data["text"]
logger.info(f"收到奔曜错误处理报送: {error_msg}")
return HttpResponse(
success=True,
message=f"错误处理报送已收到: {error_msg}",
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
data=None
)
else:
return HttpResponse(
success=False,
message=f"缺少厂家信息brand字段"
)
required_fields = ['workstation_id', 'timestamp', 'resource_id', 'change_type'] required_fields = ['workstation_id', 'timestamp', 'resource_id', 'change_type']
if missing_fields := [field for field in required_fields if field not in request_data]: if missing_fields := [field for field in required_fields if field not in request_data]:
return HttpResponse( return HttpResponse(
@@ -407,7 +438,29 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse: def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse:
"""处理错误处理报送""" """处理错误处理报送"""
try: try:
# 验证必需字段 # 检查是否为奔曜格式的错误报送
if 'brand' in request_data and str(request_data['brand']).lower() == "bioyond":
# 奔曜格式处理
if 'text' not in request_data:
return HttpResponse(
success=False,
message="奔曜格式缺少text字段"
)
error_data = request_data["text"]
logger.info(f"收到奔曜错误处理报送: {error_data}")
# 调用工作站的处理方法
result = self.workstation.handle_external_error(error_data)
return HttpResponse(
success=True,
message=f"错误处理报送已收到: 任务{error_data.get('task', 'unknown')}, 错误代码{error_data.get('code', 'unknown')}",
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_data.get('task', 'unknown')}",
data=result
)
else:
# 标准格式处理
required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message'] required_fields = ['workstation_id', 'timestamp', 'error_type', 'error_message']
if missing_fields := [field for field in required_fields if field not in request_data]: if missing_fields := [field for field in required_fields if field not in request_data]:
return HttpResponse( return HttpResponse(
@@ -548,13 +601,19 @@ class WorkstationHTTPService:
"""停止HTTP服务""" """停止HTTP服务"""
try: try:
if self.running and self.server: if self.running and self.server:
logger.info("正在停止工作站HTTP报送服务...")
self.running = False self.running = False
self.server.shutdown()
self.server.server_close()
# 停止serve_forever循环
self.server.shutdown()
# 等待服务器线程结束
if self.server_thread and self.server_thread.is_alive(): if self.server_thread and self.server_thread.is_alive():
self.server_thread.join(timeout=5.0) self.server_thread.join(timeout=5.0)
# 关闭服务器套接字
self.server.server_close()
logger.info("工作站HTTP报送服务已停止") logger.info("工作站HTTP报送服务已停止")
except Exception as e: except Exception as e:
@@ -563,11 +622,13 @@ class WorkstationHTTPService:
def _run_server(self): def _run_server(self):
"""运行HTTP服务器""" """运行HTTP服务器"""
try: try:
while self.running: # 使用serve_forever()让服务持续运行
self.server.handle_request() self.server.serve_forever()
except Exception as e: except Exception as e:
if self.running: # 只在非正常停止时记录错误 if self.running: # 只在非正常停止时记录错误
logger.error(f"HTTP服务运行错误: {e}") logger.error(f"HTTP服务运行错误: {e}")
finally:
logger.info("HTTP服务器线程已退出")
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -603,3 +664,49 @@ __all__ = [
'MaterialChangeReport', 'MaterialChangeReport',
'TaskExecutionReport' 'TaskExecutionReport'
] ]
if __name__ == "__main__":
# 简单测试HTTP服务
class DummyWorkstation:
device_id = "WS-001"
def process_step_finish_report(self, report_request):
return {"processed": True}
def process_sample_finish_report(self, report_request):
return {"processed": True}
def process_order_finish_report(self, report_request, used_materials):
return {"processed": True}
def process_material_change_report(self, report_data):
return {"processed": True}
def handle_external_error(self, error_data):
return {"handled": True}
workstation = DummyWorkstation()
http_service = WorkstationHTTPService(workstation)
try:
http_service.start()
print(f"测试服务器已启动: {http_service.service_url}")
print("按 Ctrl+C 停止服务器")
print("服务将持续运行等待接收HTTP请求...")
# 保持服务器运行 - 使用更好的等待机制
try:
while http_service.is_running:
time.sleep(1)
except KeyboardInterrupt:
print("\n接收到停止信号...")
except KeyboardInterrupt:
print("\n正在停止服务器...")
http_service.stop()
print("服务器已停止")
except Exception as e:
print(f"服务器运行错误: {e}")
http_service.stop()

View File

@@ -0,0 +1,344 @@
neware_battery_test_system:
category:
- neware_battery_test_system
class:
action_value_mappings:
auto-post_init:
feedback: {}
goal: {}
goal_default:
ros_node: null
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
ros_node:
type: string
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-print_status_summary:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: print_status_summary参数
type: object
type: UniLabJsonCommand
auto-test_connection:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: test_connection参数
type: object
type: UniLabJsonCommand
export_status_json:
feedback: {}
goal:
filepath: filepath
goal_default:
filepath: bts_status.json
handles: {}
result:
return_info: return_info
success: success
schema:
description: 导出当前状态数据到JSON文件
properties:
feedback: {}
goal:
properties:
filepath:
default: bts_status.json
description: 输出JSON文件路径
type: string
required: []
type: object
result:
properties:
return_info:
description: 导出操作结果信息
type: string
success:
description: 导出是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
type: object
type: UniLabJsonCommand
get_device_summary:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
schema:
description: 获取设备级别的摘要统计信息
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
return_info:
description: 设备摘要信息JSON格式
type: string
success:
description: 查询是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
type: object
type: UniLabJsonCommand
get_plate_status:
feedback: {}
goal:
plate_num: plate_num
goal_default:
plate_num: 1
handles: {}
result:
return_info: return_info
success: success
schema:
description: 获取指定盘(1或2)的电池状态信息
properties:
feedback: {}
goal:
properties:
plate_num:
description: 盘号 (1 或 2)
maximum: 2
minimum: 1
type: integer
required:
- plate_num
type: object
result:
properties:
return_info:
description: 盘状态信息JSON格式
type: string
success:
description: 查询是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
type: object
type: UniLabJsonCommand
print_status_summary_action:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
schema:
description: 打印通道状态摘要信息到控制台
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
return_info:
description: 打印操作结果信息
type: string
success:
description: 打印是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
type: object
type: UniLabJsonCommand
query_plate_action:
feedback: {}
goal:
string: plate_id
goal_default:
string: ''
handles: {}
result: {}
schema:
description: ''
properties:
feedback:
properties: {}
required: []
title: StrSingleInput_Feedback
type: object
goal:
properties:
string:
type: string
required:
- string
title: StrSingleInput_Goal
type: object
result:
properties:
return_info:
type: string
success:
type: boolean
required:
- return_info
- success
title: StrSingleInput_Result
type: object
required:
- goal
title: StrSingleInput
type: object
type: StrSingleInput
test_connection_action:
feedback: {}
goal: {}
goal_default: {}
handles: {}
result:
return_info: return_info
success: success
schema:
description: 测试与电池测试系统的TCP连接
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
return_info:
description: 连接测试结果信息
type: string
success:
description: 连接测试是否成功
type: boolean
required:
- return_info
- success
type: object
required:
- goal
type: object
type: UniLabJsonCommand
module: unilabos.devices.battery.neware_battery_test_system:NewareBatteryTestSystem
status_types:
channel_status: dict
connection_info: dict
device_summary: dict
plate_status: dict
status: str
total_channels: int
type: python
config_info: []
description: 新威电池测试系统驱动支持720个通道的电池测试状态监控和数据导出。通过TCP通信实现远程控制包含完整的物料管理系统支持2盘电池的状态映射和监控。
handles: []
icon: ''
init_param_schema:
config:
properties:
devtype:
type: string
ip:
type: string
machine_id:
default: 1
type: integer
port:
type: integer
size_x:
default: 500.0
type: number
size_y:
default: 500.0
type: number
size_z:
default: 2000.0
type: number
timeout:
type: integer
required: []
type: object
data:
properties:
channel_status:
type: object
connection_info:
type: object
device_summary:
type: object
plate_status:
type: object
status:
type: string
total_channels:
type: integer
required:
- status
- channel_status
- connection_info
- total_channels
- plate_status
- device_summary
type: object
version: 1.0.0

View File

@@ -6034,10 +6034,108 @@ workstation:
init_param_schema: init_param_schema:
config: config:
properties: properties:
station_resource: deck:
type: string type: string
required: required:
- station_resource - deck
type: object
data:
properties: {}
required: []
type: object
version: 1.0.0
workstation.bioyond:
category:
- work_station
class:
action_value_mappings:
auto-execute_bioyond_sync_workflow:
feedback: {}
goal: {}
goal_default:
parameters: null
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
parameters:
type: object
required:
- parameters
type: object
result: {}
required:
- goal
title: execute_bioyond_sync_workflow参数
type: object
type: UniLabJsonCommandAsync
auto-execute_bioyond_update_workflow:
feedback: {}
goal: {}
goal_default:
parameters: null
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
parameters:
type: object
required:
- parameters
type: object
result: {}
required:
- goal
title: execute_bioyond_update_workflow参数
type: object
type: UniLabJsonCommandAsync
auto-load_bioyond_data_from_file:
feedback: {}
goal: {}
goal_default:
file_path: null
handles: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
file_path:
type: string
required:
- file_path
type: object
result: {}
required:
- goal
title: load_bioyond_data_from_file参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.workstation.bioyond_studio.station:BioyondWorkstation
status_types: {}
type: python
config_info: []
description: ''
handles: []
icon: 反应站.webp
init_param_schema:
config:
properties:
bioyond_config:
type: string
deck:
type: string
required: []
type: object type: object
data: data:
properties: {} properties: {}

View File

@@ -0,0 +1,36 @@
BIOYOND_PolymerStation_1BottleCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_1BottleCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_1BottleCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerStation_1FlaskCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_1FlaskCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_1FlaskCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerStation_6VialCarrier:
category:
- bottle_carriers
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6VialCarrier
type: pylabrobot
description: BIOYOND_PolymerStation_6VialCarrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -0,0 +1,24 @@
BIOYOND_PolymerReactionStation_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck
type: pylabrobot
description: BIOYOND PolymerReactionStation Deck
handles: []
icon: '反应站.webp'
init_param_schema: {}
registry_type: resource
version: 1.0.0
BIOYOND_PolymerPreparationStation_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck
type: pylabrobot
description: BIOYOND PolymerPreparationStation Deck
handles: []
icon: '配液站.webp'
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

View File

@@ -0,0 +1,217 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.bottles import BIOYOND_PolymerStation_Solid_Vial, BIOYOND_PolymerStation_Solution_Beaker, BIOYOND_PolymerStation_Reagent_Bottle
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 30.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="BIOYOND_Electrolyte_6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
for i in range(6):
carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{i+1}")
return carrier
def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 100.0
# 烧杯尺寸
beaker_diameter = 80.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_Electrolyte_1BottleCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
return carrier
def BIOYOND_PolymerStation_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 30.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="BIOYOND_PolymerStation_6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
for i in range(6):
carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{ordering[i]}")
return carrier
def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1BottleCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1")
return carrier
def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 70.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="BIOYOND_PolymerStation_1FlaskCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_bottle_1")
return carrier

View File

@@ -0,0 +1,56 @@
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
def BIOYOND_PolymerStation_Solid_Vial(
name: str,
diameter: float = 20.0,
height: float = 100.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建粉末瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Solid_Vial",
)
def BIOYOND_PolymerStation_Solution_Beaker(
name: str,
diameter: float = 60.0,
height: float = 70.0,
max_volume: float = 200000.0, # 200mL
barcode: str = None,
) -> Bottle:
"""创建溶液烧杯"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Solution_Beaker",
)
def BIOYOND_PolymerStation_Reagent_Bottle(
name: str,
diameter: float = 70.0,
height: float = 120.0,
max_volume: float = 500000.0, # 500mL
barcode: str = None,
) -> Bottle:
"""创建试剂瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Reagent_Bottle",
)

View File

@@ -0,0 +1,68 @@
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling
class BIOYOND_PolymerReactionStation_Deck(Deck):
def __init__(
self,
name: str = "PolymerReactionStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库
self.warehouses = {
"堆栈1": bioyond_warehouse_1x4x4("堆栈1"),
"堆栈2": bioyond_warehouse_1x4x4("堆栈2"),
"站内试剂存放堆栈": bioyond_warehouse_liquid_and_lid_handling("站内试剂存放堆栈"),
}
self.warehouse_locations = {
"堆栈1": Coordinate(0.0, 430.0, 0.0),
"堆栈2": Coordinate(2550.0, 430.0, 0.0),
"站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0),
}
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__(
self,
name: str = "PolymerPreparationStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库
self.warehouses = {
"io_warehouse_left": bioyond_warehouse_1x4x4("io_warehouse_left"),
"io_warehouse_right": bioyond_warehouse_1x4x4("io_warehouse_right"),
"solutions": bioyond_warehouse_1x4x2("warehouse_solutions"),
"liquid_and_lid_handling": bioyond_warehouse_liquid_and_lid_handling("warehouse_liquid_and_lid_handling"),
}
self.warehouse_locations = {
"io_warehouse_left": Coordinate(0.0, 650.0, 0.0),
"io_warehouse_right": Coordinate(2550.0, 650.0, 0.0),
"solutions": Coordinate(1915.0, 900.0, 0.0),
"liquid_and_lid_handling": Coordinate(1330.0, 490.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])

View File

@@ -0,0 +1,54 @@
from unilabos.resources.warehouse import WareHouse, warehouse_factory
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
"""创建BioYond 4x1x4仓库"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=4,
num_items_z=4,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
"""创建BioYond 4x1x2仓库"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=4,
num_items_z=2,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
removed_positions=None
)
def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse:
"""创建BioYond开关盖加液模块台面"""
return warehouse_factory(
name=name,
num_items_x=2,
num_items_y=5,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
removed_positions=None
)

View File

@@ -4,6 +4,7 @@ import json
from typing import Union, Any, Dict from typing import Union, Any, Dict
import numpy as np import numpy as np
import networkx as nx import networkx as nx
from pylabrobot.resources import ResourceHolder
from unilabos_msgs.msg import Resource from unilabos_msgs.msg import Resource
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
@@ -480,7 +481,54 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
return r return r
def initialize_resource(resource_config: dict) -> list[dict]: def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: dict = {}, deck: Any = None) -> list[dict]:
"""
将 bioyond 物料格式转换为 ulab 物料格式
Args:
bioyond_materials: bioyond 系统的物料查询结果列表
type_mapping: 物料类型映射字典,格式 {bioyond_type: plr_class_name}
location_id_mapping: 库位 ID 到名称的映射字典,格式 {location_id: location_name}
Returns:
pylabrobot 格式的物料列表
"""
plr_materials = []
for material in bioyond_materials:
className = type_mapping.get(material.get("typeName"), "RegularContainer") if type_mapping else "RegularContainer"
plr_material: ResourcePLR = initialize_resource({"name": material["name"], "class": className}, resource_type=ResourcePLR)
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
# 处理子物料detail
if material.get("detail") and len(material["detail"]) > 0:
child_ids = []
for detail in material["detail"]:
number = (detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y + \
(detail.get("x", 0) - 1) * plr_material.num_items_x + \
(detail.get("y", 0) - 1)
bottle = plr_material[number]
bottle.code = detail.get("code", "")
bottle.tracker.liquids = [(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)]
plr_materials.append(plr_material)
if deck and hasattr(deck, "warehouses"):
for loc in material.get("locations", []):
if hasattr(deck, "warehouses") and loc.get("whName") in deck.warehouses:
warehouse = deck.warehouses[loc["whName"]]
idx = (loc.get("y", 0) - 1) * warehouse.num_items_x * warehouse.num_items_y + \
(loc.get("x", 0) - 1) * warehouse.num_items_x + \
(loc.get("z", 0) - 1)
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
warehouse[idx] = plr_material
return plr_materials
def initialize_resource(resource_config: dict, resource_type: Any = None) -> Union[list[dict], ResourcePLR]:
"""Initializes a resource based on its configuration. """Initializes a resource based on its configuration.
If the config is detailed, then do nothing; If the config is detailed, then do nothing;
@@ -512,11 +560,14 @@ def initialize_resource(resource_config: dict) -> list[dict]:
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:
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, parent_name=resource_config.get("parent", None))
# r = resource_plr_to_ulab(resource_plr=resource_plr) # r = resource_plr_to_ulab(resource_plr=resource_plr)
if resource_config.get("position") is not None: if resource_config.get("position") is not None:
r["position"] = resource_config["position"] r["position"] = resource_config["position"]
r = tree_to_list([r]) r = tree_to_list([r])
else:
r = resource_plr
elif resource_class_config["type"] == "unilabos": elif resource_class_config["type"] == "unilabos":
res_instance: RegularContainer = RESOURCE(id=resource_config["name"]) res_instance: RegularContainer = RESOURCE(id=resource_config["name"])
res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"}) res_instance.ulr_resource = convert_to_ros_msg(Resource, {k:v for k,v in resource_config.items() if k != "class"})

View File

@@ -0,0 +1,357 @@
"""
自动化液体处理工作站物料类定义 - 简化版
Automated Liquid Handling Station Resource Classes - Simplified Version
"""
from __future__ import annotations
from typing import Dict, Optional
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.container import Container
from pylabrobot.resources.resource_holder import ResourceHolder
from pylabrobot.resources import Resource as ResourcePLR
class Bottle(Container):
"""瓶子类 - 简化版,不追踪瓶盖"""
def __init__(
self,
name: str,
diameter: float,
height: float,
max_volume: float,
size_x: float = 0.0,
size_y: float = 0.0,
size_z: float = 0.0,
barcode: Optional[str] = "",
category: str = "container",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=diameter,
size_y=diameter,
size_z=height,
max_volume=max_volume,
category=category,
model=model,
)
self.diameter = diameter
self.height = height
self.barcode = barcode
def serialize(self) -> dict:
return {
**super().serialize(),
"diameter": self.diameter,
"height": self.height,
"barcode": self.barcode,
}
from string import ascii_uppercase as LETTERS
from typing import Dict, List, Optional, Type, TypeVar, Union, Sequence, Tuple
import pylabrobot
from pylabrobot.resources.resource_holder import ResourceHolder
T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder)
class ItemizedCarrier(ResourcePLR):
"""Base class for all carriers."""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
num_items_x: int = 0,
num_items_y: int = 0,
num_items_z: int = 0,
sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None,
category: Optional[str] = "carrier",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
model=model,
)
self.num_items = len(sites)
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
if isinstance(sites, dict):
sites = sites or {}
self.sites: List[Optional[ResourcePLR]] = list(sites.values())
self._ordering = sites
self.child_locations: Dict[str, Coordinate] = {}
self.child_size: Dict[str, dict] = {}
for spot, resource in sites.items():
if resource is not None and getattr(resource, "location", None) is None:
raise ValueError(f"resource {resource} has no location")
if resource is not None:
self.child_locations[spot] = resource.location
self.child_size[spot] = {"width": resource._size_x, "height": resource._size_y, "depth": resource._size_z}
else:
self.child_locations[spot] = Coordinate.zero()
self.child_size[spot] = {"width": 0, "height": 0, "depth": 0}
elif isinstance(sites, list):
# deserialize时走这里还需要根据 self.sites 索引children
self.child_locations = {site["label"]: Coordinate(**site["position"]) for site in sites}
self.child_size = {site["label"]: site["size"] for site in sites}
self.sites = [site["occupied_by"] for site in sites]
self._ordering = {site["label"]: site["position"] for site in sites}
else:
print("sites:", sites)
@property
def capacity(self):
"""The number of sites on this carrier."""
return len(self.sites)
def __len__(self) -> int:
"""Return the number of sites on this carrier."""
return len(self.sites)
def assign_child_resource(
self,
resource: ResourcePLR,
location: Optional[Coordinate],
reassign: bool = True,
spot: Optional[int] = None,
):
idx = spot
# 如果只给 location根据坐标和 deserialize 后的 self.sites持有names来寻找 resource 该摆放的位置
if spot is not None:
idx = spot
else:
for i, site in enumerate(self.sites):
site_location = list(self.child_locations.values())[i]
if type(site) == str and site == resource.name:
idx = i
break
if site_location == location:
idx = i
break
if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists")
super().assign_child_resource(resource, location=location, reassign=reassign)
self.sites[idx] = resource
def assign_resource_to_site(self, resource: ResourcePLR, spot: int):
if self.sites[spot] is not None and not isinstance(self.sites[spot], ResourceHolder):
raise ValueError(f"spot {spot} already has a resource, {resource}")
self.assign_child_resource(resource, location=self.child_locations.get(str(spot)), spot=spot)
def unassign_child_resource(self, resource: ResourcePLR):
found = False
for spot, res in enumerate(self.sites):
if res == resource:
self.sites[spot] = None
found = True
break
if not found:
raise ValueError(f"Resource {resource} is not assigned to this carrier")
if hasattr(resource, "unassign"):
resource.unassign()
def __getitem__(
self,
identifier: Union[str, int, Sequence[int], Sequence[str], slice, range],
) -> Union[List[T], T]:
"""Get the items with the given identifier.
This is a convenience method for getting the items with the given identifier. It is equivalent
to :meth:`get_items`, but adds support for slicing and supports single items in the same
functional call. Note that the return type will always be a list, even if a single item is
requested.
Examples:
Getting the items with identifiers "A1" through "E1":
>>> items["A1:E1"]
[<Item A1>, <Item B1>, <Item C1>, <Item D1>, <Item E1>]
Getting the items with identifiers 0 through 4 (note that this is the same as above):
>>> items[range(5)]
[<Item A1>, <Item B1>, <Item C1>, <Item D1>, <Item E1>]
Getting items with a slice (note that this is the same as above):
>>> items[0:5]
[<Item A1>, <Item B1>, <Item C1>, <Item D1>, <Item E1>]
Getting a single item:
>>> items[0]
[<Item A1>]
"""
if isinstance(identifier, str):
if ":" in identifier: # multiple # TODO: deprecate this, use `"A1":"E1"` instead (slice)
return self.get_items(identifier)
return self.get_item(identifier) # single
if isinstance(identifier, int):
return self.get_item(identifier)
if isinstance(identifier, (slice, range)):
start, stop = identifier.start, identifier.stop
if isinstance(identifier.start, str):
start = list(self._ordering.keys()).index(identifier.start)
elif identifier.start is None:
start = 0
if isinstance(identifier.stop, str):
stop = list(self._ordering.keys()).index(identifier.stop)
elif identifier.stop is None:
stop = self.num_items
identifier = list(range(start, stop, identifier.step or 1))
return self.get_items(identifier)
if isinstance(identifier, (list, tuple)):
return self.get_items(identifier)
raise TypeError(f"Invalid identifier type: {type(identifier)}")
def get_item(self, identifier: Union[str, int, Tuple[int, int]]) -> T:
"""Get the item with the given identifier.
Args:
identifier: The identifier of the item. Either a string, an integer, or a tuple. If an
integer, it is the index of the item in the list of items (counted from 0, top to bottom, left
to right). If a string, it uses transposed MS Excel style notation, e.g. "A1" for the first
item, "B1" for the item below that, etc. If a tuple, it is (row, column).
Raises:
IndexError: If the identifier is out of range. The range is 0 to self.num_items-1 (inclusive).
"""
if isinstance(identifier, tuple):
row, column = identifier
identifier = LETTERS[row] + str(column + 1) # standard transposed-Excel style notation
if isinstance(identifier, str):
try:
identifier = list(self._ordering.keys()).index(identifier)
except ValueError as e:
raise IndexError(
f"Item with identifier '{identifier}' does not exist on " f"resource '{self.name}'."
) from e
if not 0 <= identifier < self.capacity:
raise IndexError(
f"Item with identifier '{identifier}' does not exist on " f"resource '{self.name}'."
)
# Cast child to item type. Children will always be `T`, but the type checker doesn't know that.
return self.sites[identifier]
def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> List[T]:
"""Get the items with the given identifier.
Args:
identifier: Deprecated. Use `identifiers` instead. # TODO(deprecate-ordered-items)
identifiers: The identifiers of the items. Either a string range or a list of integers. If a
string, it uses transposed MS Excel style notation. Regions of items can be specified using
a colon, e.g. "A1:H1" for the first column. If a list of integers, it is the indices of the
items in the list of items (counted from 0, top to bottom, left to right).
Examples:
Getting the items with identifiers "A1" through "E1":
>>> items.get_items("A1:E1")
[<Item A1>, <Item B1>, <Item C1>, <Item D1>, <Item E1>]
Getting the items with identifiers 0 through 4:
>>> items.get_items(range(5))
[<Item A1>, <Item B1>, <Item C1>, <Item D1>, <Item E1>]
"""
if isinstance(identifiers, str):
identifiers = pylabrobot.utils.expand_string_range(identifiers)
return [self.get_item(i) for i in identifiers]
def __setitem__(self, idx: Union[int, str], resource: Optional[ResourcePLR]):
"""Assign a resource to this carrier."""
if resource is None: # setting to None
assigned_resource = self[idx]
if assigned_resource is not None:
self.unassign_child_resource(assigned_resource)
else:
idx = list(self._ordering.keys()).index(idx) if isinstance(idx, str) else idx
self.assign_resource_to_site(resource, spot=idx)
def __delitem__(self, idx: int):
"""Unassign a resource from this carrier."""
assigned_resource = self[idx]
if assigned_resource is not None:
self.unassign_child_resource(assigned_resource)
def get_resources(self) -> List[ResourcePLR]:
"""Get all resources assigned to this carrier."""
return [resource for resource in self.sites.values() if resource is not None]
def __eq__(self, other):
return super().__eq__(other) and self.sites == other.sites
def get_free_sites(self) -> List[int]:
return [spot for spot, resource in self.sites.items() if resource is None]
def serialize(self):
return {
**super().serialize(),
"num_items_x": self.num_items_x,
"num_items_y": self.num_items_y,
"num_items_z": self.num_items_z,
"sites": [{
"label": str(identifier),
"visible": True if self[identifier] is not None else False,
"occupied_by": self[identifier].name
if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else
self[identifier] if isinstance(self[identifier], str) else None,
"position": {"x": location.x, "y": location.y, "z": location.z},
"size": self.child_size[identifier],
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]
} for identifier, location in self.child_locations.items()]
}
class BottleCarrier(ItemizedCarrier):
"""瓶载架 - 直接继承自 TubeCarrier"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
sites: Optional[Dict[Union[int, str], ResourceHolder]] = None,
category: str = "bottle_carrier",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
sites=sites,
category=category,
model=model,
)

View File

@@ -0,0 +1,104 @@
from typing import Dict, Optional, List, Union
from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
from unilabos.resources.itemized_carrier import ItemizedCarrier, ResourcePLR
def warehouse_factory(
name: str,
num_items_x: int = 4,
num_items_y: int = 1,
num_items_z: int = 4,
dx: float = 137.0,
dy: float = 96.0,
dz: float = 120.0,
item_dx: float = 10.0,
item_dy: float = 10.0,
item_dz: float = 10.0,
removed_positions: Optional[List[int]] = None,
empty: bool = False,
category: str = "warehouse",
model: Optional[str] = None,
):
# 创建16个板架位 (4层 x 4位置)
locations = []
for layer in range(num_items_z): # 4层
for row in range(num_items_y): # 4行
for col in range(num_items_x): # 1列 (每层4x1=4个位置)
# 计算位置
x = dx + col * item_dx
y = dy + (num_items_y - row - 1) * item_dy
z = dz + (num_items_z - layer - 1) * item_dz
locations.append(Coordinate(x, y, z))
if removed_positions:
locations = [loc for i, loc in enumerate(locations) if i not in removed_positions]
sites = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=127.0,
resource_size_y=86.0,
name_prefix=name,
)
return WareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z,
num_items_x = num_items_x,
num_items_y = num_items_y,
num_items_z = num_items_z,
# ordered_items=ordered_items,
# ordering=ordering,
sites=sites,
category=category,
model=model,
)
class WareHouse(ItemizedCarrier):
"""堆栈载体类 - 可容纳16个板位的载体4层x4行x1列"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
num_items_x: int,
num_items_y: int,
num_items_z: int,
sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None,
category: str = "warehouse",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
# ordered_items=ordered_items,
# ordering=ordering,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
sites=sites,
category=category,
model=model,
)
def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder:
if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1):
raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col))
site_index = layer * 4 + row * 1 + col
return self.sites[site_index]
def add_rack_to_position(self, row: int, col: int, layer: int, rack) -> None:
site = self.get_site_by_layer_position(row, col, layer)
site.assign_child_resource(rack)
def get_rack_at_position(self, row: int, col: int, layer: int):
site = self.get_site_by_layer_position(row, col, layer)
return site.resource

View File

@@ -296,14 +296,14 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
try: try:
# 创建实例额外补充一个给protocol node的字段后面考虑取消 # 创建实例额外补充一个给protocol node的字段后面考虑取消
data["children"] = self.children data["children"] = self.children
station_resource_dict = data.get("station_resource") deck_dict = data.get("deck")
if station_resource_dict: if deck_dict:
from pylabrobot.resources import Deck, Resource from pylabrobot.resources import Deck, Resource
plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker) plrc = PyLabRobotCreator(Deck, self.children, self.resource_tracker)
station_resource = plrc.create_instance(station_resource_dict) deck = plrc.create_instance(deck_dict)
data["station_resource"] = station_resource data["deck"] = deck
else: else:
data["station_resource"] = None data["deck"] = None
self.device_instance = super(WorkstationNodeCreator, self).create_instance(data) self.device_instance = super(WorkstationNodeCreator, self).create_instance(data)
self.post_create() self.post_create()
return self.device_instance return self.device_instance