From 143104e9e3d48fdbaafe963ef48f56d4dc81d45a Mon Sep 17 00:00:00 2001 From: dijkstra402 Date: Thu, 18 Dec 2025 11:11:13 +0800 Subject: [PATCH] Add battery resources, bioyond_cell device registry, and fix file path resolution --- new_cellconfig3c.json | 101 ++ unilabos/app/main.py | 27 + .../bioyond_cell/2025092701.xlsx | Bin 0 -> 10229 bytes .../bioyond_cell/bioyond_cell_workstation.py | 1477 +++++++++++++++++ .../bioyond_cell/bioyond_yihua_YB.json} | 121 +- .../bioyond_cell/material_template.xlsx | Bin 0 -> 10915 bytes .../bioyond_cell/solid_materials.csv | 7 + .../workstation/bioyond_studio/bioyond_rpc.py | 488 +----- .../workstation/bioyond_studio/config.py | 409 +++-- .../bioyond_studio/dispensing_station.py | 876 +--------- .../bioyond_studio/reaction_station.py | 689 +------- .../workstation/bioyond_studio/station.py | 1079 +----------- .../coin_cell_assembly/YB_YH_materials.py | 639 +++++++ .../button_battery_station.py | 1289 -------------- .../coin_cell_assembly/coin_cell_assembly.py | 477 ++++-- .../coin_cell_assembly_1105.csv | 64 + .../coin_cell_assembly_a.csv | 64 + .../coin_cell_assembly/new_cellconfig3c.json | 39 + .../devices/workstation/workstation_base.py | 4 +- .../workstation/workstation_http_service.py | 291 ++-- unilabos/registry/devices/bioyond_cell.yaml | 1285 ++++++++++++++ .../devices/coin_cell_workstation.yaml | 751 +++++++++ unilabos/resources/battery/__init__.py | 4 + unilabos/resources/battery/bottle_carriers.py | 45 + unilabos/resources/battery/electrode_sheet.py | 67 + unilabos/resources/battery/magazine.py | 152 ++ 26 files changed, 5755 insertions(+), 4690 deletions(-) create mode 100644 new_cellconfig3c.json create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py rename unilabos/devices/workstation/{coin_cell_assembly/new_cellconfig4.json => bioyond_studio/bioyond_cell/bioyond_yihua_YB.json} (99%) create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx create mode 100644 unilabos/devices/workstation/bioyond_studio/bioyond_cell/solid_materials.csv create mode 100644 unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py delete mode 100644 unilabos/devices/workstation/coin_cell_assembly/button_battery_station.py create mode 100644 unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_1105.csv create mode 100644 unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_a.csv create mode 100644 unilabos/devices/workstation/coin_cell_assembly/new_cellconfig3c.json create mode 100644 unilabos/registry/devices/bioyond_cell.yaml create mode 100644 unilabos/registry/devices/coin_cell_workstation.yaml create mode 100644 unilabos/resources/battery/__init__.py create mode 100644 unilabos/resources/battery/bottle_carriers.py create mode 100644 unilabos/resources/battery/electrode_sheet.py create mode 100644 unilabos/resources/battery/magazine.py diff --git a/new_cellconfig3c.json b/new_cellconfig3c.json new file mode 100644 index 00000000..2ee2dacc --- /dev/null +++ b/new_cellconfig3c.json @@ -0,0 +1,101 @@ + +{ + "nodes": [ + { + "id": "bioyond_cell_workstation", + "name": "配液分液工站", + "parent": null, + "children": [ + "YB_Bioyond_Deck" + ], + "type": "device", + "class": "bioyond_cell", + "config": { + "deck": { + "data": { + "_resource_child_name": "YB_Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "YB_Bioyond_Deck", + "name": "YB_Bioyond_Deck", + "children": [], + "parent": "bioyond_cell_workstation", + "type": "deck", + "class": "BIOYOND_YB_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_YB_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "BatteryStation", + "name": "扣电工作站", + "parent": null, + "children": [ + "coin_cell_deck" + ], + "type": "device", + "class":"coincellassemblyworkstation_device", + "config": { + "deck": { + "data": { + "_resource_child_name": "YB_YH_Deck", + "_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck" + } + }, + "protocol_type": [] + }, + "position": { + "size": {"height": 1450, "width": 1450, "depth": 2100}, + "position": { + "x": -1500, + "y": 0, + "z": 0 + } + } + }, + { + "id": "YB_YH_Deck", + "name": "YB_YH_Deck", + "children": [], + "parent": "BatteryStation", + "type": "deck", + "class": "CoincellDeck", + "config": { + "type": "CoincellDeck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + } + ] + } + + + + + + \ No newline at end of file diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 4fb47766..afd6068a 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -367,10 +367,37 @@ def main(): graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json) else: if not os.path.isfile(file_path): + # 尝试从 main.py 向上两级目录查找 temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) if os.path.isfile(temp_file_path): print_status(f"使用相对路径{temp_file_path}", "info") file_path = temp_file_path + else: + # 尝试在 working_dir 中查找 + working_dir_file_path = os.path.join(working_dir, file_path) + if os.path.isfile(working_dir_file_path): + print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info") + file_path = working_dir_file_path + else: + # 尝试使用文件名在 working_dir 中查找 + file_name = os.path.basename(file_path) + working_dir_file_path = os.path.join(working_dir, file_name) + if os.path.isfile(working_dir_file_path): + print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info") + file_path = working_dir_file_path + # 最终检查文件是否存在 + if not os.path.isfile(file_path): + print_status( + f"无法找到设备加载文件: {file_path}\n" + f"已尝试在以下位置查找:\n" + f" 1. 原始路径: {args_dict.get('graph', BasicConfig.startup_json_path)}\n" + f" 2. 相对路径: {os.path.abspath(str(os.path.join(__file__, '..', '..', args_dict.get('graph', BasicConfig.startup_json_path) or '')))}\n" + f" 3. 工作目录: {os.path.join(working_dir, args_dict.get('graph', BasicConfig.startup_json_path) or '')}\n" + f" 4. 工作目录(仅文件名): {os.path.join(working_dir, os.path.basename(args_dict.get('graph', BasicConfig.startup_json_path) or ''))}\n" + f"请使用 -g 参数指定正确的文件路径,或在工作目录 {working_dir} 中放置文件", + "error" + ) + os._exit(1) if file_path.endswith(".json"): graph, resource_tree_set, resource_links = read_node_link_json(file_path) else: diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..70a55ffe88b9b3673066fa7b88fbe509218a5ecc GIT binary patch literal 10229 zcmeHN^;=YHyB-+2JEcP@2_*%j(-G-z7<%X$x}_Uw5d@TO=@gLe1_9~rt}{E%*>3kf zf5G>yAJ(k7*1Df-=3USI+)uo3YPd)d&%{+LfDJ4XEZDuV=wq zI@7(I+h~RuXD3H;kOd|e;UU%6j|8UyG(JK?xUE9lfg4Y>%ivk4T5~hHq*FyDH300Q3pjiC5?&8F}$f-b`NhYlmCzJsZ?Bm2|e z&i^Ime=r+=`s+n;AbB7sPVj;M15JO^i#cZ6QEPU|O_0eQ9B0a%0dGpo$NIYqDN&~n zHI&fwQrArPlrwNej$00nhYMbkB&vTpc&tIKUH-1N78y6*K`L@rrrnR$x?rd9>{%r9 z+K1^g;jp3~*?~_P9(+lhrPVt9h3a%H_%sq7NVHDnunQP;64xk#7s=iXJntNoL3!Z_ z%oe_EylUj(1(jO#L2Y|-=~!a&BnD$NFJorJwokaT)js6Xx0QMKZoTcjp*03lKGwQ> zzjmxxvxMg`KYfVeiSl;4Bw19#kLV#T)!y zas989>G-BT5e)(yr#7svnQ1nXevQ;>BzZFD=f@?e{6PuN?ai_GJ`8I!oxZVrQWe5;$E%yqf5v_q zS*6uIqf`?$oiWlc=ylTMzUVR9)nt*$v=OMDK-e5MiJ?j(*^j4{e!bnH__3})2~~%l z&lvG&0$u6z1<%V{1LO9~8B}94Gyp+jX`Wa@W{JAJoP$nrc^p9*7g(?xKKNFpRPME% zQf}Z|!7)%r+1aBpF&kXLW;7)7Xp2{3L{t1s66o!+!HC19N?K|ZT9l>m?~J|G-f8t8 z<59mhm+F)R7BIW-+Po)a(WhdkEMnq|)CG$SMr=k}e{9+khR z6PMnc#C`Q{$bg$(eq%Mgm*lC2{Y_NSE@F}wFgW5uN}Re_iiPsgcZv}xIwY^Yjx&#x z-#oW)siHC3k@WK`RW6;-;y|>x%L1S{4-GHAkm|BC>G<;i)yft~eL-_w%n7Bs$lb?I zvfL_x^c@{CKSrb!>c&=o3{4slf5&&<)ghSY9H!I|)AycQ%CD*OMYNU}tN6|jWvfFZ z3&sUcCO){Hmcb82TX#DY=ftR(`6o)-%Hpx_JovoD>mJpmxV7Q0zBstX8n2x~wN&!`LxvEo?IN5=?nUttcJ`~4uRQQyzhQ=x7^pJ>I&OnX0`UE&Ih76bi`v^x%iWz zI;C~-g_}Y|rby0X4bO8CQ^GRjo(q}B`#XvbVMd%af7ZkedyamF$%bMrzY{b99THu( zDdP1np*yN&Rn=5`Oz2rlYoXqjPKr0bW>j5=?7yk zH6_OqM&<+%VuoC}N;j~~?psGjVdtnuA~%TgX)Jh`wYnEJ>?gVzH9wv8W02DYFcmGhlpBenW>IVl)OJUOZpM4Z5f#kn(;54D% zhj6&2xe(&cII+|2s_tN;^i(m;G15HqK3ybduGiI=lxGLp2D={&bh}(|WBmZ)pS7`k z3d2Ky;#(iQK(rk=833Xf?G;n{OC#dr?QZWB?xAC)I^x#}$9-eXB;Z@We8NB)l9NX( zaauPT{M>So!QpB1+C!cA+&R*Fe|M^p6H-)J zK}>hR$mTc=%VnG7Im&V4Eg0lJXw{Z;jj{HqP@b}YTgWrmi8|z)G?foioa0({N`vab zvXj_NFH_6hF>BxH0eH2id*a_SB!E_L^%5-H3!?)7Brsq2Gu%6xo0>W~vj6_U`CHsi zkDrU0qlU>O%NYgE1!q@JJvqyuWkaDV-C6ebxgF?`PYP}Yp8xZ9gKLDN1{3dy7S9q~ zvrpG;rqFIAqqd#iOwC6@StdWVS-I-Mxcl29uKN71$RlcEb3}4bfCAMz)ysaD$zm@c z4ho)@{wQN$i9vrKQp92CURGzytN4ALcVut1nB9HfD z)|VaELu+A?1#;KCLV9$&?u#Gatv|nr9yCjA$KNlc#oCS&KK^m!C^h3rgn268HrPTM zPn}enC5j}1`&zbaLH$z)zPauwA`-OYX|e9b3*uTvNlYr&Q!+86>LA0j9SZ2Zf=VY1 zb4BIq+>z`(gVt>t9pSi9PDlJiiz;Di-0OJ3_aEJZ8&~FC8*bn&ClLB^b4G+Do!yYw zM+TEs{KFL$wIbcDLlrOe*UhENqwgRhCkj1xeDd9mA$^h9laD0b`}yGSVXyn)0rsvR z9ugAgApRl*l0HF)aFR$i#iDVFRhI1EOKk(=PJ4L7tZyzT#+UdT;^QxoN`zu(tgr&F zq>~8kmFN}SE^l41Y^Gc@ZdqnF)(dh|OHNj%;;YW?$<5qT_rhzm8OHuftKIp_#aEEZlTc{*HCV!X-tZTE6XGFrfbp;Z_VTRSrR9q2=0&K!J)Fq zBj^-8NW*hfluNYsP2%}&yC$LR%De*tk$@aoeQj<1s(xncRb*<;V`ZsM&JfSbDXaj4 z#y;xWC{lzzAtX_~_oBl*S#SKxJ|IYA_;;6AIncI!MljnxMMGF{wCEA@mq#t3uT5H# z@_&J9>GhrF?eOkA4rs%8TepS%kAVF9qUuD+qqU3RAeN4v!bOnc(C~>75aS|Bs_$oS z(FX`nc+4*Dfe;WEn*5~|bMn9n+gL1qaZTVL$$+e(U{+{mMN2F~H)YPzrydrqbtQhP zC5c?E-HTu#g1zb;;#!wcM46eYaElqpU39c*&atP6$~>dwm{p};LZh8stQIAaNIAYA zll4^vPfV9Ak+=Y78D~!k+u77frb$i)J*GCaipHLp{4OQuMIYz5gn&0p0{&SyabUl- z+Cu^WzEk|x*!VpvIGLN;n6m$V{yiY)LBETWDvwqrK{O?e~e|$q<8MGx!ibHX*Bw4^9Y9Cq+6ILMIfiQOa&D z(u<%sMH3~Jg}-md3b=ND={78>(!HM)I2Q2*S=puoYfhy4$n!RQI&vnRu=Hz2zs{e zrOW`2f)yzcu(G}AzwIr(cFGvn6?QL8W#3#}b za*c0Zi#_7}LOMtgB`ZZRdT>ceL%FxFyglt49_%kKtgxq9652 ze)-1jeA`kIa#RBpaMx<--uU88$Ms&}k;=qO1PdoejY0gSw7ge(kJ~Kv6H~ylgg1Wk z0XUuJX)IKp;SHqO2W{AVX*>HK34Hs)O*r$p<#eOZMNZRV4=;}emLbAXGdTFlrdc0f zBnsrt$eN}pewsVzJvX76^9A+46XcB|+;OkhLWExz z1)nJ1{;XXplOvf-Gk>An+DMc8i$*Y(R-2_`IcXNoMzH}({8ODgem>B)9!@1mRJa&} z^>}7#IOA*{S?P670agexPb@gQS9V25raIKr$je-hu*4`Or7!X}d>|OBhBwC-a~xf! zyb7M?_=_x@i<7Q=usB{pK_!bqe|ZobL1Iia0d9k=lP{7ry$eSzm36^c=%+v+di?M; z((9?J>H(H7HAK$E&^%MSbAGV9f4Jpm1v5rcvt`CbInw@&#L2hF_3oy;y&3w18ArBq ziIyY}LnnlCwaXNBd|IPR%v&PgUAKpsk@|dcY4hz1s6N_ruq6!|V#71E1)} zc-<|or>_}~k|Bv!iyPBFv3|^q8)z-A$u;7NRtYUX(?}{ZFrh7=E$J_w239C&=ufqp z>IMK4L_VciDkZ=LyrA^i_lgw_(71T=Fy7V4c;fEr+Eh1()ie6OJkeYw%-fVjEyEIR)aB%0Cjt$oDRu<9LACo*>HNSfXDOQZ9hg#P8hRnca)S5BC2&!0X` zsUk0l6cFl|W;6}Kz>UOue9~PmnsYg=%){f$Lx+c;C0n~nPNpM9k&O6xfldBL=UAvb zS|wM)Zrt>vPgf+Z7v*9mLbEA}&d;DgRbaZmJ zHg){HL82-Tnd87&Al{@v*(da}uB)M8`m}LIy#Oev)TW;SymUHPK^`B~Q| zE{XL(7FpxQTtZ<`=F4#A%C%Dm$XO%W(9C$OQMofu}=vXRNJ~CV? zH*^$PS_l5mCq3*D`l%PVZks z`DL-?_V%dyNDEij@5f$e+wd$|(+h>Rtn0~5Ek?HYE3||X9L+XB%uGt?v4)gF)R>8u zXhFn@*A`IQl!G3(QBl15m0>{r*+fksnY?*^jZ);Ie&f4T>pSXQUAr`2*{U}JA?FLB zkTV;Q>{kmz5%hs1mK*MZz{Magq)a!wwFuTk`uT$Dh7mUEj0gvB3+-E7d*p_vF*@NY z$>D-s9IQoy4Gi?7Y}$=3#z!~iY%bW7L^}_Lin_Z;R1JH;mJr!JTRNpAPph;Xk8@0Q zgDIA_4ESAT(Hc=BPY=hFojEh}>L@I&B2wO0!ZCsd>t`R*GgZf?H75w5REPzsWBx^T zb+c4mmu21{#~PyerYZ6o#XP^aM}`YfZVEZ8lLCK8AiI_o(Mt42C2{olqdB$2;;j!LQhSxTz|8ajGxHX{l-cHLtxu9OYM*is7Al%;eq*r(rj8!Mn zFyw_7(fIDe0smI(NyhTqh>Rn1907|>JZ@ zsO=aBK{MSE747>jL+9;sh`4Ot2z|EFq?FteWwoZ&XM3?-T9*}(kvvTMX*q0aoKzfQ ziEe`oshJj<*B9(4WYn<|)T=oy2TenZm5Y^$XCb2c(4}sI5DCPIFbw+lRUKH2x`gx2 z1D&eWitDjaQ`n)U{xjDe1x;328#b)=f{G?e7SK}jQ&{kTFXSnvo z$U2*+J_s$EiBi#OmbA5YAjs_5i6o;zzzrBE%`Xpk)LWG>&Cxnj+pA7Hl6}aI798*I zBjHo>TB$AxUHLtG-@oTQ70ldilmY8sUb6XI)w7yG<0xN^MOUs)=w+^(nf2V$31{j> zy<{IIM}%HH5fTKMMQ=!Vm~5cF&-85IlUW2E+nU{A*DLCTitIa9!E-LGHbH^h0?Z;JMstdj zyYxjNs~dKJOX&?#XHhuxh96H^=dIaL$wqi3->QR}pmP!V2CMBt0@MJJiEHP%9&a}wW%0movN0WbJDYoV zUTKl$>|?MLp6=+A>A{-!Vu7A&snHS?YA*E#UaCqd#W_@OTN4_BM^zi!Xn~nvj!g+F z+$j{(VAMiZD5+lZhkq{-cl2r+&qk~a!E`6Vdb43~g6l=hwcoW{Uc; z^xd7Cq@kp*UXhA!J>RV^`Q=4nUhkuNJl_KGmzWoZxP>}QF&8(MpZmSjV70lARSjPq$m~Q0kxRP^eTDn z3-Q-yS=)|24=3BOV)dVTJq5|Bork&H3s~a==VzBQw6puK$H83f&m%ov)QW(Up!>jo z(ePVSS_-pd(yOp4IqWd>hCQKy#u-d-JdJFKZmH7edP!{PlT%uyO;H^jmErR_r<%Q< zQUh=o#H{!+dq>~R!_`S&A9;lUSuZuRagZ8km=+D1h3T3UrKHQ)Znke!tYXwYjDAux4_pp>W{V|MV#vw z3&qb#Cv8hO(a0?pR;lhUdRBGm?c@S=J6cXQD4l95u+~beu9eT}T?&^pCVk`RW*A%c zI33}Jevf129_|shA4@RV1@@2S=$W?XXkkO~GvDWC`l2@uny)F27LA||bqz0X&ex`T zR(CfPkJcyFPF9Q#B+DP}Z_o0>$1FH!iZOX1>H~d__mbx_pYmOv?QA)(FIgUjgGMPh zfT2ExR~`N2CLKfb-?&U{T2-jxScX6!bmEJz^|MCgSQ_!KG0jg=n!iJ@|5d5=WAwUM zVT{0r1!366m7jvJv!j!(&3{Spzmxy~KE~?GcW{sd9Uwl4fAdO>_6Ao`XwUh%eaPBZ zlS_jHF*1P!D1P|xiff&q9DV zwwjm>P@@fyHeBLrX_h;9EELvr-VO+P!x-RU)s5JdH-oN8KGfOEkdbL(v%tSswqM_j zn+pLmC3Hr_pzv0fYh@piBfoSH54dBgm>B38w5E#A85@9oJ7Zdna zwNWHtnEQutG_EX)p_Q8TWdStAKroRMp&$uz4Jvmo3ed$^2`^V}{vrAmP3uy$?lW^2 zzrJLGl;A0lOr6smOCyZ^jNm=@J=5y@APCj|{GbbD7rGzf!jebj9TAApskfxaz8w0H zhTiPS^+z-LH>HPzXM?4F|Gq!!uQmPS>R)zC{c7Ov`!{|z@BtRff7;FQEAZDT_FvF( zSaSNyO#4^x-^XZwK>>gmwBN!1&rt1GJHO6W{<748@qa(!ACs0}t^C@H{maS>&cAkM ze>L!Hx%!uZ3*z4m{8haE3jH;s{|kx@iz~2Ybo@2H|JB0Z)4jj&0KjutZ}}g|->>k$ yOZU(4e5#+|e+v1p=)Z@#pV4)+KcS8PFX+9IM})b@Z+nH%0d26@NXhWqxBmfA`#^pG literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py new file mode 100644 index 00000000..fbf7b874 --- /dev/null +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py @@ -0,0 +1,1477 @@ +# -*- coding: utf-8 -*- +from cgi import print_arguments +from doctest import debug +from typing import Dict, Any, List, Optional, Tuple +import requests +from pylabrobot.resources.resource import Resource as ResourcePLR +from pathlib import Path +import pandas as pd +import time +from datetime import datetime, timedelta +import re +import threading +import json +from copy import deepcopy +from urllib3 import response +from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation, BioyondResourceSynchronizer +from unilabos.devices.workstation.bioyond_studio.config import ( + API_CONFIG, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, SOLID_LIQUID_MAPPINGS +) +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService +from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck +from unilabos.resources.graphio import resource_bioyond_to_plr +from unilabos.utils.log import logger +from unilabos.registry.registry import lab_registry +from unilabos.ros.nodes.base_device_node import ROS2DeviceNode + + +class device(BIOYOND_YB_Deck): + + @classmethod + def deserialize(cls, data, allow_marshal=False): # type: ignore[override] + patched = dict(data) + if patched.get("type") == "device": + patched["type"] = "Deck" + if patched.get("category") == "device": + patched["category"] = "deck" + return super().deserialize(patched, allow_marshal=allow_marshal) + +def _iso_local_now_ms() -> str: + # 文档要求:到毫秒 + Z,例如 2025-08-15T05:43:22.814Z + dt = datetime.now() + # print(dt) + return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond/1000):03d}Z" + + +class BioyondCellWorkstation(BioyondWorkstation): + """ + 集成 Bioyond LIMS 的工作站示例, + 覆盖:入库(2.17/2.18) → 新建实验(2.14) → 启动调度(2.7) → + 运行中推送:物料变更(2.24)、步骤完成(2.21)、订单完成(2.23) → + 查询实验(2.5/2.6) → 3-2-1 转运(2.32) → 样品/废料取出(2.28) + """ + + def __init__(self, config: dict = None, deck=None, protocol_type=None, **kwargs): + + # 使用统一配置,支持自定义覆盖, 从 config.py 加载完整配置 + self.bioyond_config = { + **API_CONFIG, + "material_type_mappings": MATERIAL_TYPE_MAPPINGS, + "warehouse_mapping": WAREHOUSE_MAPPING, + "debug_mode": False, + } + if config: + self.bioyond_config.update(config) + + # "material_type_mappings": MATERIAL_TYPE_MAPPINGS + # "warehouse_mapping": WAREHOUSE_MAPPING + if deck is None and config: + deck = config.get('deck') + # print(self.bioyond_config) + self.debug_mode = self.bioyond_config["debug_mode"] + self.http_service_started = self.debug_mode + self._device_id = "bioyond_cell_workstation" # 默认值,后续会从_ros_node获取 + super().__init__(bioyond_config=config, deck=deck) + self.transfer_target_device_id = self.bioyond_config.get("transfer_target_device_id", "BatteryStation") + self.transfer_target_parent = self.bioyond_config.get("transfer_target_parent", "YB_YH_Deck") + self.transfer_timeout = float(self.bioyond_config.get("transfer_timeout", 180.0)) + self.coin_cell_workflow_config = self.bioyond_config.get("coin_cell_workflow_config", {}) + self.pending_transfer_materials: List[Dict[str, Any]] = [] + self.pending_transfer_plr: List[ResourcePLR] = [] + self.update_push_ip() #直接修改奔耀端的报送ip地址 + logger.info("已更新奔耀端推送 IP 地址") + + # 启动 HTTP 服务线程 + t = threading.Thread(target=self._start_http_service, daemon=True, name="unilab_http") + t.start() + logger.info("HTTP 服务线程已启动") + # 等到任务报送成功 + self.order_finish_event = threading.Event() + self.last_order_status = None + self.last_order_code = None + logger.info(f"Bioyond工作站初始化完成 (debug_mode={self.debug_mode})") + + @property + def device_id(self): + """获取设备ID,优先从_ros_node获取,否则返回默认值""" + if hasattr(self, '_ros_node') and self._ros_node is not None: + return getattr(self._ros_node, 'device_id', self._device_id) + return self._device_id + + def _start_http_service(self): + """启动 HTTP 服务""" + host = self.bioyond_config.get("HTTP_host", "") + port = self.bioyond_config.get("HTTP_port", None) + try: + self.service = WorkstationHTTPService(self, host=host, port=port) + self.service.start() + self.http_service_started = True + logger.info(f"WorkstationHTTPService 成功启动: {host}:{port}") + while True: + time.sleep(1) #一直挂着,直到进程退出 + except Exception as e: + self.http_service_started = False + logger.error(f"启动 WorkstationHTTPService 失败: {e}", exc_info=True) + + + # http报送服务,返回数据部分 + def process_step_finish_report(self, report_request): + stepId = report_request.data.get("stepId") + logger.info(f"步骤完成: stepId: {stepId}, stepName:{report_request.data.get('stepName')}") + return report_request.data.get('executionStatus') + + def process_sample_finish_report(self, report_request): + logger.info(f"通量完成: {report_request.data.get('sampleId')}") + return {"status": "received"} + + def process_order_finish_report(self, report_request, used_materials=None): + order_code = report_request.data.get("orderCode") + status = report_request.data.get("status") + logger.info(f"report_request: {report_request}") + logger.info(f"任务完成: {order_code}, status={status}") + + # 保存完整报文 + self.last_order_report = report_request.data + # 如果是当前等待的订单,触发事件 + if self.last_order_code == order_code: + self.order_finish_event.set() + + return {"status": "received"} + + def wait_for_order_finish(self, order_code: str, timeout: int = 36000) -> Dict[str, Any]: + """ + 等待指定 orderCode 的 /report/order_finish 报送。 + Args: + order_code: 任务编号 + timeout: 超时时间(秒) + Returns: + 完整的报送数据 + 状态判断结果 + """ + if not order_code: + logger.error("wait_for_order_finish() 被调用,但 order_code 为空!") + return {"status": "error", "message": "empty order_code"} + + self.last_order_code = order_code + self.last_order_report = None + self.order_finish_event.clear() + + logger.info(f"等待任务完成报送: orderCode={order_code} (timeout={timeout}s)") + + if not self.order_finish_event.wait(timeout=timeout): + logger.error(f"等待任务超时: orderCode={order_code}") + return {"status": "timeout", "orderCode": order_code} + + # 报送数据匹配验证 + report = self.last_order_report or {} + report_code = report.get("orderCode") + status = str(report.get("status", "")) + + if report_code != order_code: + logger.warning(f"收到的报送 orderCode 不匹配: {report_code} ≠ {order_code}") + return {"status": "mismatch", "report": report} + + if status == "30": + logger.info(f"任务成功完成 (orderCode={order_code})") + return {"status": "success", "report": report} + elif status == "-11": + logger.error(f"任务异常停止 (orderCode={order_code})") + return {"status": "abnormal_stop", "report": report} + elif status == "-12": + logger.warning(f"任务人工停止 (orderCode={order_code})") + return {"status": "manual_stop", "report": report} + else: + logger.warning(f"任务未知状态 ({status}) (orderCode={order_code})") + return {"status": f"unknown_{status}", "report": report} + + + # -------------------- 基础HTTP封装 -------------------- + def _url(self, path: str) -> str: + return f"{self.bioyond_config['api_host'].rstrip('/')}/{path.lstrip('/')}" + + def _post_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]: + """LIMS API:大多数接口用 {apiKey/requestTime,data} 包装""" + payload = { + "apiKey": self.bioyond_config["api_key"], + "requestTime": _iso_local_now_ms() + } + if data is not None: + payload["data"] = data + + if self.debug_mode: + # 模拟返回,不发真实请求 + logger.info(f"[DEBUG] POST {path} with payload={payload}") + + return {"debug": True, "url": self._url(path), "payload": payload, "status": "ok"} + + try: + logger.info(json.dumps(payload, ensure_ascii=False)) + response = requests.post( + self._url(path), + json=payload, + timeout=self.bioyond_config.get("timeout", 30), + headers={"Content-Type": "application/json"} + ) # 拼接网址+post bioyond接口 + response.raise_for_status() + return response.json() + except Exception as e: + logger.info(f"{self.bioyond_config['api_host'].rstrip('/')}/{path.lstrip('/')}") + logger.error(f"POST {path} 失败: {e}") + return {"error": str(e)} + + def _put_lims(self, path: str, data: Optional[Any] = None) -> Dict[str, Any]: + """LIMS API:PUT {apiKey/requestTime,data} 包装""" + payload = { + "apiKey": self.bioyond_config["api_key"], + "requestTime": _iso_local_now_ms() + } + if data is not None: + payload["data"] = data + + if self.debug_mode: + logger.info(f"[DEBUG] PUT {path} with payload={payload}") + return {"debug_mode": True, "url": self._url(path), "payload": payload, "status": "ok"} + + try: + response = requests.put( + self._url(path), + json=payload, + timeout=self.bioyond_config.get("timeout", 30), + headers={"Content-Type": "application/json"} + ) + response.raise_for_status() + return response.json() + except Exception as e: + logger.info(f"{self.bioyond_config['api_host'].rstrip('/')}/{path.lstrip('/')}") + logger.error(f"PUT {path} 失败: {e}") + return {"error": str(e)} + + # -------------------- 3.36 更新推送 IP 地址 -------------------- + def update_push_ip(self, ip: Optional[str] = None, port: Optional[int] = None) -> Dict[str, Any]: + """ + 3.36 更新推送 IP 地址接口(PUT) + URL: /api/lims/order/ip-config + 请求体:{ apiKey, requestTime, data: { ip, port } } + """ + target_ip = ip or self.bioyond_config.get("HTTP_host", "") + target_port = int(port or self.bioyond_config.get("HTTP_port", 0)) + data = {"ip": target_ip, "port": target_port} + + # 固定接口路径,不做其他路径兼容 + path = "/api/lims/order/ip-config" + return self._put_lims(path, data) + + # -------------------- 单点接口封装 -------------------- + # 2.17 入库物料(单个) + def storage_inbound(self, material_id: str, location_id: str) -> Dict[str, Any]: + return self._post_lims("/api/lims/storage/inbound", { + "materialId": material_id, + "locationId": location_id + }) + + # 2.18 批量入库(多个) + def storage_batch_inbound(self, items: List[Dict[str, str]]) -> Dict[str, Any]: + """ + items = [{"materialId": "...", "locationId": "..."}, ...] + """ + return self._post_lims("/api/lims/storage/batch-inbound", items) + + + def auto_feeding4to3( + self, + # ★ 修改点:默认模板路径 + xlsx_path: Optional[str] = "/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx", + # ---------------- WH4 - 加样头面 (Z=1, 12个点位) ---------------- + WH4_x1_y1_z1_1_materialName: str = "", WH4_x1_y1_z1_1_quantity: float = 0.0, + WH4_x2_y1_z1_2_materialName: str = "", WH4_x2_y1_z1_2_quantity: float = 0.0, + WH4_x3_y1_z1_3_materialName: str = "", WH4_x3_y1_z1_3_quantity: float = 0.0, + WH4_x4_y1_z1_4_materialName: str = "", WH4_x4_y1_z1_4_quantity: float = 0.0, + WH4_x5_y1_z1_5_materialName: str = "", WH4_x5_y1_z1_5_quantity: float = 0.0, + WH4_x1_y2_z1_6_materialName: str = "", WH4_x1_y2_z1_6_quantity: float = 0.0, + WH4_x2_y2_z1_7_materialName: str = "", WH4_x2_y2_z1_7_quantity: float = 0.0, + WH4_x3_y2_z1_8_materialName: str = "", WH4_x3_y2_z1_8_quantity: float = 0.0, + WH4_x4_y2_z1_9_materialName: str = "", WH4_x4_y2_z1_9_quantity: float = 0.0, + WH4_x5_y2_z1_10_materialName: str = "", WH4_x5_y2_z1_10_quantity: float = 0.0, + WH4_x1_y3_z1_11_materialName: str = "", WH4_x1_y3_z1_11_quantity: float = 0.0, + WH4_x2_y3_z1_12_materialName: str = "", WH4_x2_y3_z1_12_quantity: float = 0.0, + + # ---------------- WH4 - 原液瓶面 (Z=2, 9个点位) ---------------- + WH4_x1_y1_z2_1_materialName: str = "", WH4_x1_y1_z2_1_quantity: float = 0.0, WH4_x1_y1_z2_1_materialType: str = "", WH4_x1_y1_z2_1_targetWH: str = "", + WH4_x2_y1_z2_2_materialName: str = "", WH4_x2_y1_z2_2_quantity: float = 0.0, WH4_x2_y1_z2_2_materialType: str = "", WH4_x2_y1_z2_2_targetWH: str = "", + WH4_x3_y1_z2_3_materialName: str = "", WH4_x3_y1_z2_3_quantity: float = 0.0, WH4_x3_y1_z2_3_materialType: str = "", WH4_x3_y1_z2_3_targetWH: str = "", + WH4_x1_y2_z2_4_materialName: str = "", WH4_x1_y2_z2_4_quantity: float = 0.0, WH4_x1_y2_z2_4_materialType: str = "", WH4_x1_y2_z2_4_targetWH: str = "", + WH4_x2_y2_z2_5_materialName: str = "", WH4_x2_y2_z2_5_quantity: float = 0.0, WH4_x2_y2_z2_5_materialType: str = "", WH4_x2_y2_z2_5_targetWH: str = "", + WH4_x3_y2_z2_6_materialName: str = "", WH4_x3_y2_z2_6_quantity: float = 0.0, WH4_x3_y2_z2_6_materialType: str = "", WH4_x3_y2_z2_6_targetWH: str = "", + WH4_x1_y3_z2_7_materialName: str = "", WH4_x1_y3_z2_7_quantity: float = 0.0, WH4_x1_y3_z2_7_materialType: str = "", WH4_x1_y3_z2_7_targetWH: str = "", + WH4_x2_y3_z2_8_materialName: str = "", WH4_x2_y3_z2_8_quantity: float = 0.0, WH4_x2_y3_z2_8_materialType: str = "", WH4_x2_y3_z2_8_targetWH: str = "", + WH4_x3_y3_z2_9_materialName: str = "", WH4_x3_y3_z2_9_quantity: float = 0.0, WH4_x3_y3_z2_9_materialType: str = "", WH4_x3_y3_z2_9_targetWH: str = "", + + # ---------------- WH3 - 人工堆栈 (Z=3, 15个点位) ---------------- + WH3_x1_y1_z3_1_materialType: str = "", WH3_x1_y1_z3_1_materialId: str = "", WH3_x1_y1_z3_1_quantity: float = 0, + WH3_x2_y1_z3_2_materialType: str = "", WH3_x2_y1_z3_2_materialId: str = "", WH3_x2_y1_z3_2_quantity: float = 0, + WH3_x3_y1_z3_3_materialType: str = "", WH3_x3_y1_z3_3_materialId: str = "", WH3_x3_y1_z3_3_quantity: float = 0, + WH3_x1_y2_z3_4_materialType: str = "", WH3_x1_y2_z3_4_materialId: str = "", WH3_x1_y2_z3_4_quantity: float = 0, + WH3_x2_y2_z3_5_materialType: str = "", WH3_x2_y2_z3_5_materialId: str = "", WH3_x2_y2_z3_5_quantity: float = 0, + WH3_x3_y2_z3_6_materialType: str = "", WH3_x3_y2_z3_6_materialId: str = "", WH3_x3_y2_z3_6_quantity: float = 0, + WH3_x1_y3_z3_7_materialType: str = "", WH3_x1_y3_z3_7_materialId: str = "", WH3_x1_y3_z3_7_quantity: float = 0, + WH3_x2_y3_z3_8_materialType: str = "", WH3_x2_y3_z3_8_materialId: str = "", WH3_x2_y3_z3_8_quantity: float = 0, + WH3_x3_y3_z3_9_materialType: str = "", WH3_x3_y3_z3_9_materialId: str = "", WH3_x3_y3_z3_9_quantity: float = 0, + WH3_x1_y4_z3_10_materialType: str = "", WH3_x1_y4_z3_10_materialId: str = "", WH3_x1_y4_z3_10_quantity: float = 0, + WH3_x2_y4_z3_11_materialType: str = "", WH3_x2_y4_z3_11_materialId: str = "", WH3_x2_y4_z3_11_quantity: float = 0, + WH3_x3_y4_z3_12_materialType: str = "", WH3_x3_y4_z3_12_materialId: str = "", WH3_x3_y4_z3_12_quantity: float = 0, + WH3_x1_y5_z3_13_materialType: str = "", WH3_x1_y5_z3_13_materialId: str = "", WH3_x1_y5_z3_13_quantity: float = 0, + WH3_x2_y5_z3_14_materialType: str = "", WH3_x2_y5_z3_14_materialId: str = "", WH3_x2_y5_z3_14_quantity: float = 0, + WH3_x3_y5_z3_15_materialType: str = "", WH3_x3_y5_z3_15_materialId: str = "", WH3_x3_y5_z3_15_quantity: float = 0, + ): + """ + 自动化上料(支持两种模式) + - Excel 路径存在 → 从 Excel 模板解析 + - Excel 路径不存在 → 使用手动参数 + """ + items: List[Dict[str, Any]] = [] + + # ---------- 模式 1: Excel 导入 ---------- + if xlsx_path: + path = Path(__file__).parent / Path(xlsx_path) + if path.exists(): # ★ 修改点:路径存在才加载 + try: + df = pd.read_excel(path, sheet_name=0, header=None, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败:{e}") + + # 四号手套箱加样头面 + for _, row in df.iloc[1:13, 2:7].iterrows(): + if pd.notna(row[5]): + items.append({ + "sourceWHName": "四号手套箱堆栈", + "posX": int(row[2]), "posY": int(row[3]), "posZ": int(row[4]), + "materialName": str(row[5]).strip(), + "quantity": float(row[6]) if pd.notna(row[6]) else 0.0, + "temperature": 0, + }) + # 四号手套箱原液瓶面 + for _, row in df.iloc[14:23, 2:9].iterrows(): + if pd.notna(row[5]): + items.append({ + "sourceWHName": "四号手套箱堆栈", + "posX": int(row[2]), "posY": int(row[3]), "posZ": int(row[4]), + "materialName": str(row[5]).strip(), + "quantity": float(row[6]) if pd.notna(row[6]) else 0.0, + "materialType": str(row[7]).strip() if pd.notna(row[7]) else "", + "targetWH": str(row[8]).strip() if pd.notna(row[8]) else "", + "temperature": 0, + }) + # 三号手套箱人工堆栈 + for _, row in df.iloc[25:40, 2:7].iterrows(): + if pd.notna(row[5]) or pd.notna(row[6]): + items.append({ + "sourceWHName": "三号手套箱人工堆栈", + "posX": int(row[2]), "posY": int(row[3]), "posZ": int(row[4]), + "materialType": str(row[5]).strip() if pd.notna(row[5]) else "", + "materialId": str(row[6]).strip() if pd.notna(row[6]) else "", + "quantity": 1, + "temperature": 0, + }) + else: + logger.warning(f"未找到 Excel 文件 {xlsx_path},自动切换到手动参数模式。") + # TODO: 温度下面手动模式没改,上面的改了 + # ---------- 模式 2: 手动填写 ---------- + if not items: + params = locals() + for name, value in params.items(): + if name.startswith("四号手套箱堆栈") and "materialName" in name and value: + idx = name.split("_") + items.append({ + "sourceWHName": "四号手套箱堆栈", + "posX": int(idx[1][1:]), "posY": int(idx[2][1:]), "posZ": int(idx[3][1:]), + "materialName": value, + "quantity": float(params.get(name.replace("materialName", "quantity"), 0.0)) + }) + elif name.startswith("四号手套箱堆栈") and "materialType" in name and (value or params.get(name.replace("materialType", "materialName"), "")): + idx = name.split("_") + items.append({ + "sourceWHName": "四号手套箱堆栈", + "posX": int(idx[1][1:]), "posY": int(idx[2][1:]), "posZ": int(idx[3][1:]), + "materialName": params.get(name.replace("materialType", "materialName"), ""), + "quantity": float(params.get(name.replace("materialType", "quantity"), 0.0)), + "materialType": value, + "targetWH": params.get(name.replace("materialType", "targetWH"), ""), + }) + elif name.startswith("三号手套箱人工堆栈") and "materialType" in name and (value or params.get(name.replace("materialType", "materialId"), "")): + idx = name.split("_") + items.append({ + "sourceWHName": "三号手套箱人工堆栈", + "posX": int(idx[1][1:]), "posY": int(idx[2][1:]), "posZ": int(idx[3][1:]), + "materialType": value, + "materialId": params.get(name.replace("materialType", "materialId"), ""), + "quantity": int(params.get(name.replace("materialType", "quantity"), 1)), + }) + + if not items: + logger.warning("没有有效的上料条目,已跳过提交。") + return {"code": 0, "message": "no valid items", "data": []} + logger.info(items) + response = self._post_lims("/api/lims/order/auto-feeding4to3", items) + + # 等待任务报送成功 + order_code = response.get("data", {}).get("orderCode") + if not order_code: + logger.error("上料任务未返回有效 orderCode!") + return {"api_response": response, "order_finish": None} + # 等待完成报送 + result = self.wait_for_order_finish(order_code) + return { + "api_response": response, + "order_finish": result, + "items": items, + } + + + def auto_batch_outbound_from_xlsx(self, xlsx_path: str) -> Dict[str, Any]: + """ + 3.31 自动化下料(Excel -> JSON -> POST /api/lims/storage/auto-batch-out-bound) + """ + path = Path(xlsx_path) + if not path.exists(): + raise FileNotFoundError(f"未找到 Excel 文件:{path}") + + try: + df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败:{e}") + + def pick(names: List[str]) -> Optional[str]: + for n in names: + if n in df.columns: + return n + return None + + c_loc = pick(["locationId", "库位ID", "库位Id", "库位id"]) + c_wh = pick(["warehouseId", "仓库ID", "仓库Id", "仓库id"]) + c_qty = pick(["数量", "quantity"]) + c_x = pick(["x", "X", "posX", "坐标X"]) + c_y = pick(["y", "Y", "posY", "坐标Y"]) + c_z = pick(["z", "Z", "posZ", "坐标Z"]) + + required = [c_loc, c_wh, c_qty, c_x, c_y, c_z] + if any(c is None for c in required): + raise KeyError("Excel 缺少必要列:locationId/warehouseId/数量/x/y/z(支持多别名,至少要能匹配到)。") + + def as_int(v, d=0): + try: + if pd.isna(v): return d + return int(v) + except Exception: + try: + return int(float(v)) + except Exception: + return d + + def as_float(v, d=0.0): + try: + if pd.isna(v): return d + return float(v) + except Exception: + return d + + def as_str(v, d=""): + if v is None or (isinstance(v, float) and pd.isna(v)): return d + s = str(v).strip() + return s if s else d + + items: List[Dict[str, Any]] = [] + for _, row in df.iterrows(): + items.append({ + "locationId": as_str(row[c_loc]), + "warehouseId": as_str(row[c_wh]), + "quantity": as_float(row[c_qty]), + "x": as_int(row[c_x]), + "y": as_int(row[c_y]), + "z": as_int(row[c_z]), + }) + + response = self._post_lims("/api/lims/storage/auto-batch-out-bound", items) + self.wait_for_response_orders(response, "auto_batch_outbound_from_xlsx") + return response + + # 2.14 新建实验 + def create_orders(self, xlsx_path: str, *, material_filter: Optional[str] = None) -> Dict[str, Any]: + """ + 从 Excel 解析并创建实验(2.14) + 约定: + - batchId = Excel 文件名(不含扩展名) + - 物料列:所有以 "(g)" 结尾(不再读取“总质量(g)”列) + - totalMass 自动计算为所有物料质量之和 + - createTime 缺失或为空时自动填充为当前日期(YYYY/M/D) + """ + default_path = Path("/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx") + path = Path(xlsx_path) if xlsx_path else default_path + print(f"[create_orders] 使用 Excel 路径: {path}") + if path != default_path: + print("[create_orders] 来源: 调用方传入自定义路径") + else: + print("[create_orders] 来源: 使用默认模板路径") + + if not path.exists(): + print(f"[create_orders] ⚠️ Excel 文件不存在: {path}") + raise FileNotFoundError(f"未找到 Excel 文件:{path}") + + try: + df = pd.read_excel(path, sheet_name=0, engine="openpyxl") + except Exception as e: + raise RuntimeError(f"读取 Excel 失败:{e}") + print(f"[create_orders] Excel 读取成功,行数: {len(df)}, 列: {list(df.columns)}") + + # 列名容错:返回可选列名,找不到则返回 None + def _pick(col_names: List[str]) -> Optional[str]: + for c in col_names: + if c in df.columns: + return c + return None + + col_order_name = _pick(["配方ID", "orderName", "订单编号"]) + col_create_time = _pick(["创建日期", "createTime"]) + col_bottle_type = _pick(["配液瓶类型", "bottleType"]) + col_mix_time = _pick(["混匀时间(s)", "mixTime"]) + col_load = _pick(["扣电组装分液体积", "loadSheddingInfo"]) + col_pouch = _pick(["软包组装分液体积", "pouchCellInfo"]) + col_cond = _pick(["电导测试分液体积", "conductivityInfo"]) + col_cond_cnt = _pick(["电导测试分液瓶数", "conductivityBottleCount"]) + print("[create_orders] 列匹配结果:", { + "order_name": col_order_name, + "create_time": col_create_time, + "bottle_type": col_bottle_type, + "mix_time": col_mix_time, + "load": col_load, + "pouch": col_pouch, + "conductivity": col_cond, + "conductivity_bottle_count": col_cond_cnt, + }) + + # 物料列:所有以 (g) 结尾 + material_cols = [c for c in df.columns if isinstance(c, str) and c.endswith("(g)")] + print(f"[create_orders] 识别到的物料列: {material_cols}") + if not material_cols: + raise KeyError("未发现任何以“(g)”结尾的物料列,请检查表头。") + + batch_id = path.stem + + def _to_ymd_slash(v) -> str: + # 统一为 "YYYY/M/D";为空或解析失败则用当前日期 + if v is None or (isinstance(v, float) and pd.isna(v)) or str(v).strip() == "": + ts = datetime.now() + else: + try: + ts = pd.to_datetime(v) + except Exception: + ts = datetime.now() + return f"{ts.year}/{ts.month}/{ts.day}" + + def _as_int(val, default=0) -> int: + try: + if pd.isna(val): + return default + return int(val) + except Exception: + return default + + def _as_float(val, default=0.0) -> float: + try: + if pd.isna(val): + return default + return float(val) + except Exception: + return default + + def _as_str(val, default="") -> str: + if val is None or (isinstance(val, float) and pd.isna(val)): + return default + s = str(val).strip() + return s if s else default + + orders: List[Dict[str, Any]] = [] + + for idx, row in df.iterrows(): + mats: List[Dict[str, Any]] = [] + total_mass = 0.0 + + for mcol in material_cols: + val = row.get(mcol, None) + if val is None or (isinstance(val, float) and pd.isna(val)): + continue + try: + mass = float(val) + except Exception: + continue + if mass > 0: + mats.append({"name": mcol.replace("(g)", ""), "mass": mass}) + total_mass += mass + else: + if mass < 0: + print(f"[create_orders] 第 {idx+1} 行物料 {mcol} 数值为负数: {mass}") + + order_data = { + "batchId": batch_id, + "orderName": _as_str(row[col_order_name], default=f"{batch_id}_order_{idx+1}") if col_order_name else f"{batch_id}_order_{idx+1}", + "createTime": _to_ymd_slash(row[col_create_time]) if col_create_time else _to_ymd_slash(None), + "bottleType": _as_str(row[col_bottle_type], default="配液小瓶") if col_bottle_type else "配液小瓶", + "mixTime": _as_int(row[col_mix_time]) if col_mix_time else 0, + "loadSheddingInfo": _as_float(row[col_load]) if col_load else 0.0, + "pouchCellInfo": _as_float(row[col_pouch]) if col_pouch else 0, + "conductivityInfo": _as_float(row[col_cond]) if col_cond else 0, + "conductivityBottleCount": _as_int(row[col_cond_cnt]) if col_cond_cnt else 0, + "materialInfos": mats, + "totalMass": round(total_mass, 4) # 自动汇总 + } + print(f"[create_orders] 第 {idx+1} 行解析结果: orderName={order_data['orderName']}, " + f"loadShedding={order_data['loadSheddingInfo']}, pouchCell={order_data['pouchCellInfo']}, " + f"conductivity={order_data['conductivityInfo']}, totalMass={order_data['totalMass']}, " + f"material_count={len(mats)}") + + if order_data["totalMass"] <= 0: + print(f"[create_orders] ⚠️ 第 {idx+1} 行总质量 <= 0,可能导致 LIMS 校验失败") + if not mats: + print(f"[create_orders] ⚠️ 第 {idx+1} 行未找到有效物料") + + orders.append(order_data) + print("================================================") + print("orders:", orders) + + print(f"[create_orders] 即将提交订单数量: {len(orders)}") + response = self._post_lims("/api/lims/order/orders", orders) + print(f"[create_orders] 接口返回: {response}") + # 等待任务报送成功 + data_list = response.get("data", []) + if data_list: + order_code = data_list[0].get("orderCode") + else: + order_code = None + + if not order_code: + logger.error("上料任务未返回有效 orderCode!") + return response + # 等待完成报送 + result = self.wait_for_order_finish(order_code) + report_data = result.get("report") if isinstance(result, dict) else None + materials_from_report = ( + report_data.get("usedMaterials") if isinstance(report_data, dict) else None + ) + if materials_from_report: + materials = materials_from_report + logger.info( + "[create_orders] 使用订单完成报送中的物料信息: " + f"{len(materials)} 条" + ) + else: + materials = self._fetch_bioyond_materials(filter_keyword=material_filter) + logger.info( + "[create_orders] 未收到订单报送物料信息,回退到实时查询" + ) + print("materials_from_report:", materials_from_report) + # TODO: 需要将 materials 字典转换为 ResourceSlot 对象后才能转运 + # self.transfer_resource_to_another( + # resource=[materials], + # mount_resource=["YB_YH_Deck"], + # sites=[None], + # mount_device_id="BatteryStation" + # ) + return { + "api_response": response, + "order_finish": result, + "materials": materials, + } + + # 2.7 启动调度 + def scheduler_start(self) -> Dict[str, Any]: + return self._post_lims("/api/lims/scheduler/start") + # 3.10 停止调度 + def scheduler_stop(self) -> Dict[str, Any]: + + """ + 停止调度 (3.10) + 请求体只包含 apiKey 和 requestTime + """ + return self._post_lims("/api/lims/scheduler/stop") + # 2.9 继续调度 + # 2.9 继续调度 + def scheduler_continue(self) -> Dict[str, Any]: + """ + 继续调度 (2.9) + 请求体只包含 apiKey 和 requestTime + """ + return self._post_lims("/api/lims/scheduler/continue") + def scheduler_reset(self) -> Dict[str, Any]: + """ + 复位调度 (2.11) + 请求体只包含 apiKey 和 requestTime + """ + return self._post_lims("/api/lims/scheduler/reset") + + + # 2.24 物料变更推送 + def report_material_change(self, material_obj: Dict[str, Any]) -> Dict[str, Any]: + """ + material_obj 按 2.24 的裸对象格式(包含 id/typeName/locations/detail 等) + """ + return self._post_report_raw("/report/material_change", material_obj) + + # 2.32 3-2-1 物料转运 + def transfer_3_to_2_to_1(self, + # source_wh_id: Optional[str] = None, + source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b', + source_x: int = 1, source_y: int = 1, source_z: int = 1) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "sourcePosX": source_x, "sourcePosY": source_y, "sourcePosZ": source_z + } + if source_wh_id: + payload["sourceWHID"] = source_wh_id + + response = self._post_lims("/api/lims/order/transfer-task3To2To1", payload) + # 等待任务报送成功 + order_code = response.get("data", {}).get("orderCode") + if not order_code: + logger.error("上料任务未返回有效 orderCode!") + return response + # 等待完成报送 + result = self.wait_for_order_finish(order_code) + return result + + # 3.35 1→2 物料转运 + def transfer_1_to_2(self) -> Dict[str, Any]: + """ + 1→2 物料转运 + URL: /api/lims/order/transfer-task1To2 + 只需要 apiKey 和 requestTime + """ + response = self._post_lims("/api/lims/order/transfer-task1To2") + # 等待任务报送成功 + order_code = response.get("data", {}).get("orderCode") + if not order_code: + logger.error("上料任务未返回有效 orderCode!") + return response + # 等待完成报送 + result = self.wait_for_order_finish(order_code) + + return result + + # 2.5 批量查询实验报告(post过滤关键字查询) + def order_list_v2(self, + timeType: str = "", + beginTime: str = "", + endTime: str = "", + status: str = "", # 60表示正在运行,80表示完成,90表示失败 + filter: str = "", + skipCount: int = 0, + pageCount: int = 1, # 显示多少页数据 + sorting: str = "") -> Dict[str, Any]: + """ + 批量查询实验报告的详细信息 (2.5) + URL: /api/lims/order/order-list + 参数默认值和接口文档保持一致 + """ + data: Dict[str, Any] = { + "timeType": timeType, + "beginTime": beginTime, + "endTime": endTime, + "status": status, + "filter": filter, + "skipCount": skipCount, + "pageCount": pageCount, + "sorting": sorting + } + return self._post_lims("/api/lims/order/order-list", data) + + # 一直post执行bioyond接口查询任务状态 + def wait_for_transfer_task(self, timeout: int = 3000, interval: int = 5, filter_text: Optional[str] = None) -> bool: + """ + 轮询查询物料转移任务是否成功完成 (status=80) + - timeout: 最大等待秒数 (默认600秒) + - interval: 轮询间隔秒数 (默认3秒) + 返回 True 表示找到并成功完成,False 表示超时未找到 + """ + now = datetime.now() + beginTime = now.strftime("%Y-%m-%dT%H:%M:%SZ") + endTime = (now + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ") + print(beginTime, endTime) + + deadline = time.time() + timeout + + while time.time() < deadline: + result = self.order_list_v2( + timeType="", + beginTime=beginTime, + endTime=endTime, + status="", + filter=filter_text, + skipCount=0, + pageCount=1, + sorting="" + ) + print(result) + + items = result.get("data", {}).get("items", []) + for item in items: + name = item.get("name", "") + status = item.get("status") + # 改成用 filter_text 判断 + if (not filter_text or filter_text in name) and status == 80: + logger.info(f"硬件转移动作完成: {name}, status={status}") + return True + + logger.info(f"等待中: {name}, status={status}") + time.sleep(interval) + + logger.warning("超时未找到成功的物料转移任务") + return False + + def create_materials(self, mappings: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将 SOLID_LIQUID_MAPPINGS 中的所有物料逐个 POST 到 /api/lims/storage/material + """ + results = [] + + for name, data in mappings.items(): + data = { + "typeId": data["typeId"], + "code": data.get("code", ""), + "barCode": data.get("barCode", ""), + "name": data["name"], + "unit": data.get("unit", "g"), + "parameters": data.get("parameters", ""), + "quantity": data.get("quantity", ""), + "warningQuantity": data.get("warningQuantity", ""), + "details": data.get("details", []) + } + + logger.info(f"正在创建第 {i}/{total} 个固体物料: {name}") + result = self._post_lims("/api/lims/storage/material", material_data) + + if result and result.get("code") == 1: + # data 字段可能是字符串(物料ID)或字典(包含id字段) + data = result.get("data") + if isinstance(data, str): + # data 直接是物料ID字符串 + material_id = data + elif isinstance(data, dict): + # data 是字典,包含id字段 + material_id = data.get("id") + else: + material_id = None + + if material_id: + created_materials.append({ + "name": name, + "materialId": material_id, + "typeId": type_id + }) + logger.info(f"✓ 成功创建物料: {name}, ID: {material_id}") + else: + logger.error(f"✗ 创建物料失败: {name}, 未返回ID") + logger.error(f" 响应数据: {result}") + else: + error_msg = result.get("error") or result.get("message", "未知错误") + logger.error(f"✗ 创建物料失败: {name}") + logger.error(f" 错误信息: {error_msg}") + logger.error(f" 完整响应: {result}") + + # 避免请求过快 + time.sleep(0.3) + + logger.info(f"物料创建完成,成功创建 {len(created_materials)}/{total} 个固体物料") + return created_materials + + def _sync_materials_safe(self) -> bool: + """仅使用 BioyondResourceSynchronizer 执行同步(与 station.py 保持一致)。""" + if hasattr(self, 'resource_synchronizer') and self.resource_synchronizer: + try: + return bool(self.resource_synchronizer.sync_from_external()) + except Exception as e: + logger.error(f"同步失败: {e}") + return False + logger.warning("资源同步器未初始化") + return False + + def _load_warehouse_locations(self, warehouse_name: str) -> tuple[List[str], List[str]]: + """从配置加载仓库位置信息 + + Args: + warehouse_name: 仓库名称 + + Returns: + (location_ids, position_names) 元组 + """ + warehouse_mapping = self.bioyond_config.get("warehouse_mapping", WAREHOUSE_MAPPING) + + if warehouse_name not in warehouse_mapping: + raise ValueError(f"配置中未找到仓库: {warehouse_name}。可用: {list(warehouse_mapping.keys())}") + + site_uuids = warehouse_mapping[warehouse_name].get("site_uuids", {}) + if not site_uuids: + raise ValueError(f"仓库 {warehouse_name} 没有配置位置") + + # 按顺序获取位置ID和名称 + location_ids = [] + position_names = [] + for key in sorted(site_uuids.keys()): + location_ids.append(site_uuids[key]) + position_names.append(key) + + return location_ids, position_names + + + def create_and_inbound_materials( + self, + material_names: Optional[List[str]] = None, + type_id: str = "3a190ca0-b2f6-9aeb-8067-547e72c11469", + warehouse_name: str = "粉末加样头堆栈" + ) -> Dict[str, Any]: + """ + 传参与默认列表方式创建物料并入库(不使用CSV)。 + + Args: + material_names: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2] + type_id: 物料类型ID + warehouse_name: 目标仓库名(用于取位置信息) + + Returns: + 执行结果字典 + """ + logger.info("=" * 60) + logger.info(f"开始执行:从参数创建物料并批量入库到 {warehouse_name}") + logger.info("=" * 60) + + try: + # 1) 准备物料名称(默认值) + default_materials = ["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"] + mat_names = [m.strip() for m in (material_names or default_materials) if str(m).strip()] + if not mat_names: + return {"success": False, "error": "物料名称列表为空"} + + # 2) 加载仓库位置信息 + all_location_ids, position_names = self._load_warehouse_locations(warehouse_name) + logger.info(f"✓ 加载 {len(all_location_ids)} 个位置 ({position_names[0]} ~ {position_names[-1]})") + + # 限制数量不超过可用位置 + if len(mat_names) > len(all_location_ids): + logger.warning(f"物料数量超出位置数量,仅处理前 {len(all_location_ids)} 个") + mat_names = mat_names[:len(all_location_ids)] + + # 3) 创建物料 + logger.info(f"\n【步骤1/3】创建 {len(mat_names)} 个固体物料...") + created_materials = self.create_solid_materials(mat_names, type_id) + if not created_materials: + return {"success": False, "error": "没有成功创建任何物料"} + + # 4) 批量入库 + logger.info(f"\n【步骤2/3】批量入库物料...") + location_ids = all_location_ids[:len(created_materials)] + selected_positions = position_names[:len(created_materials)] + + inbound_items = [ + {"materialId": mat["materialId"], "locationId": loc_id} + for mat, loc_id in zip(created_materials, location_ids) + ] + + for material, position in zip(created_materials, selected_positions): + logger.info(f" - {material['name']} → {position}") + + result = self.storage_batch_inbound(inbound_items) + if result.get("code") != 1: + logger.error(f"✗ 批量入库失败: {result}") + return {"success": False, "error": "批量入库失败", "created_materials": created_materials, "inbound_result": result} + + logger.info("✓ 批量入库成功") + + # 5) 同步 + logger.info(f"\n【步骤3/3】同步物料数据...") + if self._sync_materials_safe(): + logger.info("✓ 物料数据同步完成") + else: + logger.warning("⚠ 物料数据同步未完成(可忽略,不影响已创建与入库的数据)") + + logger.info("\n" + "=" * 60) + logger.info("流程完成") + logger.info("=" * 60 + "\n") + + return { + "success": True, + "created_materials": created_materials, + "inbound_result": result, + "total_created": len(created_materials), + "total_inbound": len(inbound_items), + "warehouse": warehouse_name, + "positions": selected_positions + } + + except Exception as e: + logger.error(f"✗ 执行失败: {e}") + return {"success": False, "error": str(e)} + + def create_material( + self, + material_name: str, + type_id: str, + warehouse_name: str, + location_name_or_id: Optional[str] = None + ) -> Dict[str, Any]: + """创建单个物料并可选入库。 + Args: + material_name: 物料名称(会优先匹配配置模板)。 + type_id: 物料类型 ID(若为空则尝试从配置推断)。 + warehouse_name: 需要入库的仓库名称;若为空则仅创建不入库。 + location_name_or_id: 具体库位名称(如 A01)或库位 UUID,由用户指定。 + Returns: + 包含创建结果、物料ID以及入库结果的字典。 + """ + material_name = (material_name or "").strip() + + resolved_type_id = (type_id or "").strip() + # 优先从 SOLID_LIQUID_MAPPINGS 中获取模板数据 + template = SOLID_LIQUID_MAPPINGS.get(material_name) + if not template: + raise ValueError(f"在配置中未找到物料 {material_name} 的模板,请检查 SOLID_LIQUID_MAPPINGS。") + material_data: Dict[str, Any] + material_data = deepcopy(template) + # 最终确保 typeId 为调用方传入的值 + if resolved_type_id: + material_data["typeId"] = resolved_type_id + material_data["name"] = material_name + # 生成唯一编码 + def _generate_code(prefix: str) -> str: + normalized = re.sub(r"\W+", "_", prefix) + normalized = normalized.strip("_") or "material" + return f"{normalized}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + if not material_data.get("code"): + material_data["code"] = _generate_code(material_name) + if not material_data.get("barCode"): + material_data["barCode"] = "" + # 处理数量字段类型 + def _to_number(value: Any, default: float = 0.0) -> float: + try: + if value is None: + return default + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str) and value.strip() == "": + return default + return float(value) + except (TypeError, ValueError): + return default + material_data["quantity"] = _to_number(material_data.get("quantity"), 1.0) + material_data["warningQuantity"] = _to_number(material_data.get("warningQuantity"), 0.0) + unit = material_data.get("unit") or "个" + material_data["unit"] = unit + if not material_data.get("parameters"): + material_data["parameters"] = json.dumps({"unit": unit}, ensure_ascii=False) + # 补充子物料信息 + details = material_data.get("details") or [] + if not isinstance(details, list): + logger.warning("details 字段不是列表,已忽略。") + details = [] + else: + for idx, detail in enumerate(details, start=1): + if not isinstance(detail, dict): + continue + if not detail.get("code"): + detail["code"] = f"{material_data['code']}_{idx:02d}" + if not detail.get("name"): + detail["name"] = f"{material_name}_detail_{idx:02d}" + if not detail.get("unit"): + detail["unit"] = unit + if not detail.get("parameters"): + detail["parameters"] = json.dumps({"unit": detail.get("unit", unit)}, ensure_ascii=False) + if "quantity" in detail: + detail["quantity"] = _to_number(detail.get("quantity"), 1.0) + material_data["details"] = details + create_result = self._post_lims("/api/lims/storage/material", material_data) + # 解析创建结果中的物料 ID + material_id: Optional[str] = None + if isinstance(create_result, dict): + data_field = create_result.get("data") + if isinstance(data_field, str): + material_id = data_field + elif isinstance(data_field, dict): + material_id = data_field.get("id") or data_field.get("materialId") + inbound_result: Optional[Dict[str, Any]] = None + location_id: Optional[str] = None + # 按用户指定位置入库 + if warehouse_name and material_id and location_name_or_id: + try: + location_ids, position_names = self._load_warehouse_locations(warehouse_name) + position_to_id = {name: loc_id for name, loc_id in zip(position_names, location_ids)} + target_location_id = position_to_id.get(location_name_or_id, location_name_or_id) + if target_location_id: + location_id = target_location_id + inbound_result = self.storage_inbound(material_id, target_location_id) + else: + inbound_result = {"error": f"未找到匹配的库位: {location_name_or_id}"} + except Exception as exc: + logger.error(f"获取仓库 {warehouse_name} 位置失败: {exc}") + inbound_result = {"error": str(exc)} + return { + "success": bool(isinstance(create_result, dict) and create_result.get("code") == 1 and material_id), + "material_name": material_name, + "material_id": material_id, + "warehouse": warehouse_name, + "location_id": location_id, + "location_name_or_id": location_name_or_id, + "create_result": create_result, + "inbound_result": inbound_result, + } + def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: ResourcePLR, parent_resource: ResourcePLR): + # ROS2DeviceNode.run_async_func(self._ros_node.resource_tree_transfer, True, **{ + # "old_parent": old_parent, + # "plr_resource": plr_resource, + # "parent_resource": parent_resource, + # }) + print("resource_tree_transfer", plr_resource, parent_resource) + if hasattr(plr_resource, "unilabos_extra") and plr_resource.unilabos_extra: + if "update_resource_site" in plr_resource.unilabos_extra: + site = plr_resource.unilabos_extra["update_resource_site"] + plr_model = plr_resource.model + board_type = None + for key, (moudle_name,moudle_uuid) in MATERIAL_TYPE_MAPPINGS.items(): + if plr_model == moudle_name: + board_type = key + break + if board_type is None: + pass + bottle1 = plr_resource.children[0] + + bottle_moudle = bottle1.model + bottle_type = None + for key, (moudle_name, moudle_uuid) in MATERIAL_TYPE_MAPPINGS.items(): + if bottle_moudle == moudle_name: + bottle_type = key + break + + # 从 parent_resource 获取仓库名称 + warehouse_name = parent_resource.name if parent_resource else "手动堆栈" + logger.info(f"拖拽上料: {plr_resource.name} -> {warehouse_name} / {site}") + + self.create_sample(plr_resource.name, board_type, bottle_type, site, warehouse_name) + return + self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}") + + def create_sample( + self, + name: str, + board_type: str, + bottle_type: str, + location_code: str, + warehouse_name: str = "手动堆栈" + ) -> Dict[str, Any]: + """创建配液板物料并自动入库。 + Args: + name: 物料名称 + board_type: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板" + bottle_type: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)" + location_code: 库位编号,例如 "A01" + warehouse_name: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等 + """ + carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1] + bottle_type_id = MATERIAL_TYPE_MAPPINGS[bottle_type][1] + + # 从指定仓库获取库位UUID + if warehouse_name not in WAREHOUSE_MAPPING: + logger.error(f"未找到仓库: {warehouse_name},回退到手动堆栈") + warehouse_name = "手动堆栈" + + if location_code not in WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]: + logger.error(f"仓库 {warehouse_name} 中未找到库位 {location_code}") + raise ValueError(f"库位 {location_code} 在仓库 {warehouse_name} 中不存在") + + location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code] + logger.info(f"创建样品入库: {name} -> {warehouse_name}/{location_code} (UUID: {location_id})") + + # 新建小瓶 + details = [] + for y in range(1, 5): + for x in range(1, 3): + details.append({ + "typeId": bottle_type_id, + "code": "", + "name": str(bottle_type) + str(x) + str(y), + "quantity": "1", + "x": x, + "y": y, + "z": 1, + "unit": "个", + "parameters": json.dumps({"unit": "个"}, ensure_ascii=False), + }) + + data = { + "typeId": carrier_type_id, + "code": "", + "barCode": "", + "name": name, + "unit": "块", + "parameters": json.dumps({"unit": "块"}, ensure_ascii=False), + "quantity": "1", + "details": details, + } + # print("xxx:",data) + create_result = self._post_lims("/api/lims/storage/material", data) + sample_uuid = create_result.get("data") + + final_result = self._post_lims("/api/lims/storage/inbound", { + "materialId": sample_uuid, + "locationId": location_id, + }) + return final_result + + def _fetch_bioyond_materials( + self, + *, + filter_keyword: Optional[str] = None, + type_mode: int = 2, + ) -> List[Dict[str, Any]]: + query: Dict[str, Any] = { + "typeMode": type_mode, + "includeDetail": True, + } + if filter_keyword: + query["filter"] = filter_keyword + + response = self._post_lims("/api/lims/storage/stock-material", query) + raw_materials = response.get("data") + if not isinstance(raw_materials, list): + raw_materials = [] + + try: + resource_bioyond_to_plr( + raw_materials, + type_mapping=self.bioyond_config.get("material_type_mappings", MATERIAL_TYPE_MAPPINGS), + deck=self.deck, + ) + except Exception as exc: + logger.warning(f"转换奔曜物料到 PLR 失败: {exc}", exc_info=True) + + return raw_materials + + def _convert_materials_to_plr(self, materials: List[Dict[str, Any]]) -> List[ResourcePLR]: + try: + return resource_bioyond_to_plr( + deepcopy(materials), + type_mapping=self.bioyond_config.get("material_type_mappings", MATERIAL_TYPE_MAPPINGS), + deck=self.deck, + ) + except Exception as exc: + logger.error(f"物料转换为 PLR 失败: {exc}", exc_info=True) + return [] + + def _wait_for_future(self, future, stage: str, timeout: Optional[float] = None): + if future is None: + return None + timeout = timeout or self.transfer_timeout + start = time.time() + while not future.done(): + if (time.time() - start) > timeout: + raise TimeoutError(f"{stage} 超时 {timeout}s") + time.sleep(0.05) + return future.result() + + def _register_plr_resources(self, resources: List[ResourcePLR]) -> None: + if not resources or not hasattr(self, "_ros_node") or self._ros_node is None: + return + future = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, resources=resources) + self._wait_for_future(future, "update_resource") + + def _get_target_resource(self, name: str) -> ResourcePLR: + if not hasattr(self, "_ros_node") or self._ros_node is None: + raise RuntimeError("ROS 节点未初始化,无法获取资源") + resource = self._ros_node.resource_tracker.figure_resource({"name": name}, try_mode=False) # type: ignore + if resource is None: + raise ValueError(f"未找到目标资源: {name}") + return resource + + def _allocate_sites(self, parent_resource: ResourcePLR, count: int) -> List[str]: + if not hasattr(parent_resource, "get_free_sites"): + raise ValueError(f"资源 {parent_resource} 不支持自动分配站位") + free_indices = list(parent_resource.get_free_sites()) + if len(free_indices) < count: + raise ValueError(f"{parent_resource.name} 可用站位不足 (need {count}, have {len(free_indices)})") + ordering = list(getattr(parent_resource, "_ordering", {}).keys()) + sites: List[str] = [] + for idx in free_indices[:count]: + if ordering and idx < len(ordering): + sites.append(ordering[idx]) + else: + sites.append(str(idx)) + return sites + + def _invoke_coin_cell_workflow(self, material_payload: List[Dict[str, Any]]) -> Any: + timeout = float(self.bioyond_config.get("coin_cell_workflow_timeout", 300.0)) + workflow_payload: Dict[str, Any] = {} + if isinstance(self.coin_cell_workflow_config, dict): + workflow_payload.update(deepcopy(self.coin_cell_workflow_config)) + workflow_payload["materials"] = deepcopy(material_payload) + return self._call_remote_device_method( + self.transfer_target_device_id, + "run_coin_cell_assembly_workflow", + timeout=timeout, + workflow_config=workflow_payload, + ) + + def _call_remote_device_method( + self, + device_id: str, + method: str, + *, + timeout: Optional[float] = None, + **kwargs, + ) -> Any: + if not hasattr(self, "_ros_node") or self._ros_node is None: + raise RuntimeError("ROS 节点未初始化,无法调用远程设备") + if not device_id: + raise ValueError("device_id 不能为空") + if not method: + raise ValueError("method 不能为空") + + timeout = timeout or self.transfer_timeout + payload = json.dumps( + { + "function_name": method, + "function_args": kwargs, + }, + ensure_ascii=False, + ) + future = ROS2DeviceNode.run_async_func( + self._ros_node.execute_single_action, + True, + device_id=device_id, + action_name="_execute_driver_command_async", + action_kwargs={"string": payload}, + ) + result = self._wait_for_future(future, f"{device_id}.{method}", timeout) + if hasattr(result, "return_info"): + try: + return json.loads(result.return_info) + except Exception: + return result.return_info + return result + + def run_feeding_stage(self) -> Dict[str, Any]: + self.create_sample( + board_type="配液瓶(小)板", + bottle_type="配液瓶(小)", + location_code="B01", + name="配液瓶", + warehouse_name="手动堆栈" + ) + self.create_sample( + board_type="5ml分液瓶板", + bottle_type="5ml分液瓶", + location_code="B02", + name="分液瓶", + warehouse_name="手动堆栈" + ) + self.scheduler_start() + feeding_task = self.auto_feeding4to3( + xlsx_path="/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx" + ) + feeding_materials = self._fetch_bioyond_materials() + return { + "feeding_materials": feeding_materials, + "feeding_items": feeding_task.get("items", []), + "feeding_task": feeding_task, + } + + def run_liquid_preparation_stage( + self, + feeding_materials: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, List[Dict[str, Any]]]: + result = self.create_orders( + xlsx_path="/Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/2025092701.xlsx" + ) + filter_keyword = self.bioyond_config.get("mixing_material_filter") or None + materials = result.get("materials") + if materials is None: + materials = self._fetch_bioyond_materials(filter_keyword=filter_keyword) + return { + "feeding_materials": feeding_materials or [], + "liquid_materials": materials, + } + + def run_transfer_stage( + self, + liquid_materials: Optional[List[Dict[str, Any]]] = None, + source_wh_id: Optional[str] = '3a19debc-84b4-0359-e2d4-b3beea49348b', + source_x: int = 1, + source_y: int = 1, + source_z: int = 1 + ) -> Dict[str, Any]: + """转运阶段:调用transfer_3_to_2_to_1执行3到2到1转运""" + logger.info("开始执行转运阶段 (run_transfer_stage)") + + # 暂时注释掉物料转换和跨工站转运逻辑 + # transfer_summary: Dict[str, Any] = {} + # try: + # source_materials = liquid_materials or self._fetch_bioyond_materials() + # transfer_plr = self._convert_materials_to_plr(source_materials) + # transfer_summary["plr_count"] = len(transfer_plr) + # ... + # except Exception as exc: + # transfer_summary["error"] = str(exc) + # logger.error(f"跨工站转运失败: {exc}", exc_info=True) + + # 只执行核心的3到2到1转运 + transfer_result = self.transfer_3_to_2_to_1( + source_wh_id=source_wh_id, + source_x=source_x, + source_y=source_y, + source_z=source_z + ) + + logger.info("转运阶段执行完成") + return { + "success": True, + "stage": "transfer", + "transfer_result": transfer_result + } +if __name__ == "__main__": + deck = BIOYOND_YB_Deck(setup=True) + w = BioyondCellWorkstation(deck=deck, address="172.16.28.102", port="502", debug_mode=False) + feeding = w.run_feeding_stage() + liquid = w.run_liquid_preparation_stage(feeding.get("feeding_materials")) + transfer = w.run_transfer_stage(liquid.get("liquid_materials")) + while True: + time.sleep(1) + # re=ws.scheduler_stop() + # re = ws.transfer_3_to_2_to_1() + + # print(re) + # logger.info("调度启动完成") + + # ws.scheduler_continue() + # 3.30 上料:读取模板 Excel 自动解析并 POST + # r1 = ws.auto_feeding4to3_from_xlsx(r"C:\ML\GitHub\Uni-Lab-OS\unilabos\devices\workstation\bioyond_cell\样品导入模板.xlsx") + # ws.wait_for_transfer_task(filter_text="物料转移任务") + # logger.info("4号箱向3号箱转运物料转移任务已完成") + + # ws.scheduler_start() + # print(r1["payload"]["data"]) # 调试模式下可直接看到要发的 JSON items + + # # 新建实验 + # response = ws.create_orders("C:/ML/GitHub/Uni-Lab-OS/unilabos/devices/workstation/bioyond_cell/2025092701.xlsx") + # logger.info(response) + # data_list = response.get("data", []) + # order_name = data_list[0].get("orderName", "") + + # ws.wait_for_transfer_task(filter_text=order_name) + # ws.wait_for_transfer_task(filter_text='DP20250927001') + # logger.info("3号站内实验完成") + # # ws.scheduler_start() + # # print(res) + # ws.transfer_3_to_2_to_1() + # ws.wait_for_transfer_task(filter_text="物料转移任务") + # logger.info("3号站向2号站向1号站转移任务完成") + # r321 = self.wait_for_transfer_task() + #1号站启动 + # ws.transfer_1_to_2() + # ws.wait_for_transfer_task(filter_text="物料转移任务") + # logger.info("1号站向2号站转移任务完成") + # logger.info("全流程结束") + + # 3.31 下料:同理 + # r2 = ws.auto_batch_outbound_from_xlsx(r"C:/path/样品导入模板 (8).xlsx") + # print(r2["payload"]["data"]) diff --git a/unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json similarity index 99% rename from unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json rename to unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json index 7e371327..3119d0bf 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_yihua_YB.json @@ -1,8 +1,23 @@ { "nodes": [ + { + "id": "bioyond_cell_workstation", + "name": "配液分液工站", + "children": [ + ], + "parent": null, + "type": "device", + "class": "bioyond_cell", + "config": { + "protocol_type": [], + "station_resource": {} + + }, + "data": {} + }, { "id": "BatteryStation", - "name": "扣电工作站", + "name": "扣电组装工作站", "children": [ "coin_cell_deck" ], @@ -98,7 +113,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -139,7 +154,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -234,7 +249,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -329,7 +344,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -424,7 +439,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -522,7 +537,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine_four", + "type": "MagazineHolder_4", "size_x": 80, "size_y": 80, "size_z": 10, @@ -563,7 +578,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -658,7 +673,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -753,7 +768,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -848,7 +863,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -948,7 +963,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -991,7 +1006,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1086,7 +1101,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1181,7 +1196,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1276,7 +1291,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1371,7 +1386,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1466,7 +1481,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1566,7 +1581,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -1609,7 +1624,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1704,7 +1719,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1799,7 +1814,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1894,7 +1909,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -1989,7 +2004,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2084,7 +2099,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2184,7 +2199,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2227,7 +2242,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2322,7 +2337,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2417,7 +2432,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2512,7 +2527,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2607,7 +2622,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2702,7 +2717,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2802,7 +2817,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -2845,7 +2860,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -2940,7 +2955,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3035,7 +3050,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3130,7 +3145,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3225,7 +3240,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3320,7 +3335,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3420,7 +3435,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -3463,7 +3478,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3558,7 +3573,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3653,7 +3668,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3748,7 +3763,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3843,7 +3858,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -3938,7 +3953,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4038,7 +4053,7 @@ "z": 0 }, "config": { - "type": "ClipMagazine", + "type": "MagazineHolder_6", "size_x": 80, "size_y": 80, "size_z": 10, @@ -4081,7 +4096,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4176,7 +4191,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4271,7 +4286,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4366,7 +4381,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4461,7 +4476,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, @@ -4556,7 +4571,7 @@ "z": 10 }, "config": { - "type": "ClipMagazineHole", + "type": "Magazine", "size_x": 14.0, "size_y": 14.0, "size_z": 10.0, diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7f3a0137419fe1732a32571a4059ecb3599d9fc9 GIT binary patch literal 10915 zcmeHtWkVcUw{;_pyCk?na0u?ft%JL}ySqCCw_w5D8w(aJKyV8ZG`I$L0=zwP@66)>-@PvlT%wus8rX00IC2AP1D99C}$o0RX5#000|+0IehDVDD;X z?`ojxu-s7e{J?`6f1xJkW7Pi#>$-Yck}hmH|S zV^ZFS|9Bm$c}qPZynb?sS5`p}BwzR}E8sFQ_L;kIXfe|mh}q=9NvIB^9+<3e#a;Vt z@Tj=M0+{3|i+7S|PAJTUZ(tB>o(0e>vX9`jiRy*frPZl}W+3e@&KZzmpj{c@MHglM zOmzFh&~dQX12#_ffMdb)5;j(ofd0e${@I)iY)F;o@(cH>%qWa^X~9VaFPz$bChME|0vVw*~jihX>q!yuI{SB7U*&w4WsjQq*U* z%M=bH4^~`&PT*A2XJBYtdJyWK=>KxT)=`&u{0Kh9Bu)9med;T?CYr_zExw4?4T=|x zA)#!c2=*?0pXB5O)76B2C%tP<;Mbj!hfu0uM4DLBjv|9VVRefrvu5-_UUHcu=)QMe zEu1r)2BLD97;rH2{x0x5I&Vf_aS*eiHmlKHAB@L{Bko%ObHSXZi^BI^)rhJ0qc}?eRuZ#5KQ} z#5=pz)zY9!( z&~3+E2N$w}I{)GQ$l(VH6Bv?L+K;(= zCpJ^c%97M!nJ_s!cSk(H#*QKh7wmxL3`yTz)($rGQcZi7p@N&&^@1kj8WY@DERHlM3;=)uu{dy4ZOW{N%s;4QtA)wY4#x=oBY*Sz^ ze1NUkrYnDtl|^MRbuj3YDm=ihsmNnvusQ532B*ryrH(Yo2GM)M6}6=*^hQ>F^~Hz} zT;Fv-p;3Z*AuegsjcnsnT2{zF1SoB0M7<5JyaK&wGIcmH5xd-+PK5(?5)keyk^AcY z3x*r+93y-s`vI?*X>5zL5H4Iei2DKCbA}4+ zXd7JPd%Ky4NES#%KqFBQRxaZfol=HUjDWDp^S!9(*8FNWaoap*szNVAK_OcjV-uL~TA`+1rr<0Pmq45r%LTe7? z^p-d`WjHwW_?<_G40kfti(c}d*Nziq(2SKuOz_VXDRU{$g z$&ml%19%Aq&t%Z8@jk3D1Gon)EHlIDGdb7+QXamP9UDXZnz>3#*!0Hl@#%7tP4*ZxZ>TZ}K>nXeQRlw$0Nt z%V@9F{7tmaBUD^TgMZawp@V-wHD-FPz@%8EmRwYY$eH%h*uuPBN5+ag^HP-D9?RBI zt^{Ao*g+TXhrV>CQXjs-bRRzXH`${K*O!7H#vE!k{BmBsGUE8Q7U1m8b;ptGchp?> z^j;C!8qK1uvSZe7bnp$tdj8Xtf`n4iFCfZlgP0Nl009c3us?k1?>hS*kAi|EkB}(; zx3?-~InZZTj85dI2v(0QcU;VGt}GNsYKQ0uLydGxH0135murOdZF-t>AQl*haIcfG zLH8R@v<(>Ss~(23Xe?N7Y}*rlpu^b37z~2(k7|-&DIhl1(ZONm4`j4V7wlG{q%Vvg zaCmlZX{qre3QH)&FI%U>IjqN7-Po*MCW(jTv{pwNw*v1&RgS8I8(Uh)GU?2>pIgP2 zuHkZny-24n@DXJMP`vJZhIuI5zae)n#*~O+w>IPqihpZf=DVj1%1O^$s)c; zkp|Euxoj6?w5y%0yNW&x({(MKGmc!Im~RaY&i;EIQ>lp%h5-)%kkSAEcn~i9IVihW znwhz}u>Ah=`nS5``GM)VRxA#2ovj5Y!8r`Sh^N zW{CB&u>zv;$HyO^3)`#JO;N`|Hb|M`uc-|~nToY=E`PW!wikbfx0kJ{3JN~%o@qb3 znzb}pnVts(k@egL&yjQRn&^McYmK*sful@y%Y^^wio8Pz-*MzW9bN2{{d%l8-$(SA z*=V6637Vuz(~8vy-6_wr``(XhmbM0NnBRL@(b6nBI6H6}@%#I<`5{y9p^mA&; zGLhCk;@tBqM*iN#AI^8OX~4(mfLfAI$?j1KBjnd|gN4c)h%I`U@M(#*lSeXuF?dmODj_|HeG+Jw{# zN}IKQlUeTUk6Y4+xCZ5{^#@{U`OJH)!(fr@VuYWuf=gqRUnGd@XfNG2lhWJaPoZ~T zjzqI�EhAwCCsFcPW`l_Dif7;Oe{5Q-j(^sAr4>?@K1xnVb@3(GlnJZ^ zMg_}&+ZlAEhBQ`cBt3|HXZ;k~sF#xLLr~$6;RvaH$X1Te&2{}b$-ZewA8&lG+zMZH zVX_jl+;;w>&QaH%n>}Wv5Qr9#NZ`sT;CFOCc2T3N@90bs1;FypG;l&)EKt>F*>J>6_Ol6K8FF%(@S1mm0|!uH?18l#d6z>X;dV**twdxEhOo4Jm$m>;)UeZLqN=p^t=ig??(j z<-6529mhIDz?U88w<#VR^cy&XR2ucgVRz69?u}4g&{x3pWmT;tj<%!ZAn-n$Scr^N zo<4EFlNXjAaYj{5Tv7jlDt;!MZ`5KCstMcsd?>oHU`#rXKIz@vxVKUoQq#t670Ze+ zC6Yf1OGtw?XJxA=F-5~zx)w(1rtc2ESR5QkYgfsqrdoGn)-Op7n-3TD6j4s5DJ&$8 zr%Rbvj!;^a3)Bop6i;pUK`S$(E4$B_A0d3bcr7^n?OZs89$ko?1-7HE{M;YyCm|_k z0$2_>Y=bQ3wF`By-=k7=F$+~VtcRSdwWnP(Y*#Y1?SiDNMjUBo@vgo?fNkzVPUKw< z3%aL?8i}WQxquqnd{B>>0fH*NZZcd^g#aB$V8qUH6epL}fa;4Du8gvQwh`y*DX%{A zF7MV*g}bmC(5x)e;8eE_BYY6tF!1g+CBD%xUN7|yR9HI_$rZgC90CjhN(BmaSu8VD z>Si|B$9v};8I4UEhQ$G5L@Q8I8|r^c2RFJ6)?Vi5%?^Xd4?|jn{9*ILz|E~z zOkX%-$JeN4HS{ZsQ86lyXM&t-4u6s|K0)%2KgT59_(SS35knj+ysH)Rgo zFX&WMoOY%=3$c$`J7Ko* zlbM^diV4^A3!UV(h>gBWroWs|Rp*GGsWsFcXSn|IuGEEBWFDq^hE0I%JU$`k;(bD% zLsY`KLHRjD&*Yw=F9*Hm+T6kFy=JAMtQD`(wvG?ohl`fEs6$^II2}ZW_0(JL19`l# zo;jg5<;qLRpQ^ub-%l) ztEHKp8O!hc-wpIg3*pYC;NG-ozGx zJupi7Zm~BQW&L$px|ckgC-)XbKa#yf2L_@qz*nWYPP3AUrRcpm)UYFx?FmW*2US_E z7Th8Bjvib~M$)~=m(MlkazscWvDXI#8?M z?`MG>>KLy%Bbr6$*}H;k$Re*_jAc@gq{Phm#LhBgQ3t>nTECe8;m*I}Jv0Gu#0xG_ zPotlZbSPf^a9S03w9R-TU#(^H@fPX17(YW}>4&>ZQ$jx*Cz;Q;_dT^=(+CM;&{hK( zKkKiz8H&Q&z8+Q!@GVyFE~SkO zXKTB}68li@RiCy2uX_o|3qoPYBW-M=U`D$_KVYxM@(MonGUylh(-TZmwV}3ix&(7Q)9>wMXI69cbN| zC-HSNjca8g*FxDJG||5}jt4JveZ*69;>SCAm^=PH>SX7UA~u^1TWsf1%DhT zPDT=E`s9{`oaD!`%E5wLOn5LzNOez0!tQM+&HhXfwzNtCAqTMQogZIFCGnE&ikRKF zueL*y-%CWqpbV*7A)`3_+Ren?@^~}S(kkmy9ZhN>a*siZs*N_~pi+}wo&!m7S<*3L zsC)Hne{8YN^ESVjSe2J_I4c)t9bvq+6x?_EuX?PGQ!~tEa36w}LooU+vlvKyW7_cx zPI}OJvJQ`ZQh1JqIx&`u>nW!>gfFuaPjAop*6oGjzF}ainB^7mr}7nllQGLuDqA`k zzBVOY3X~g77vPS=J@on*U=+3bxzILCSbfx9>4g5W9f|noF2iMquK$Ibmv(f%tb&i- zj8~}(;R(5!twt<8${Y|#uy)9$f$smIU5*sV51NM_b8;pLG#(8p-Ni;EGZw*C4cGlpidgrN4m zumUaOC0C+(!LZDxu5?qBnX#XxK5mV1M#f0&&zP}rv}W$YK-90u()Eqd9l?|l0s&@u>H+Y@ylg3|oLAi?i(Z6|x%Xqo^{ zq)E(#iq`fOJ!Yt_m=@cV2U3G~{gq}~m7ys`1x3wh^#Tl7LDOKq+e|M6CPla`%UU@F zDukaT;MgxwBt-Lu_WA2TKh1@ghev1Y657yoZhfkyYP7!@gXUEW`zvld70huk)X2Vb zv+4CCTIeO|!gCoy#-n~d_gA5{pvCC#7a_PLBB}<{YN!;_FQt7r z13wK4HTD!~eZ!63xnyz|*=|cHercsTVRZzKa!z+~KHK`V<2%62c(l+)AVs1QVi8|j zW*<-E66y5rZc^>>ML#@x_f(eS!5ic2!<5nW@hy>0OmAILJvo?}Ga3nNV)+F77HG^O zP%vZBUR?~wbaC~vHFNo+?AQ6*q-7QP zk{IDQ+TXUdnVha{69FLs8khvFXB7pGI+e<^)Ucnb))^EMZyDFR=R%zv+*3-j1IjGY zXfd99{yc7f@Z8<|_=3o;tmb{v>+5)dcV(lh<6CS4C(J4q@083UM-erW20TFwzS$kV zm}=%D`~A_%AplYJ*ZTM>@_H@9>Z%C`(y6MwGWm^UaLwNP>cu5gwMW!FE8Q28HDqr$ zUKr7XpP?|t4XQz}R0^oVneZ&kmkf=l!292n>E&J!mCZdRn3i2j@d-Dr0{9Ir_L zN;<*&sj8%=*73sY;|x(1@s?sdn$SlyN+lnszXZA}@~&x9VK!cUygR#^pKyqskKRMV zabQ6UpPSoT@j`-|ZIPU3v$`XqkRu6ZiZt|HDnR={y89Ymkl!`g3RuoIep8HYo7$5g zekzGN%NB-uFqgiBWzCqMvnb2y|9q-%4ZaGg^XRdIrkN7mCtCD))i&G0NsU<=2F1jo zQ*$(5hUNcq7|(MM)6aC&ChW|lY&3q3*H>4^caoMM z-C}#T7~6kR*t8(1sZ%xBSJ1Yw`NowW;D3|6xF%V|csIu-b#p{m(_NzxtOHyq4Ym#W ze7kdhe{;h!X-!PrsL5{szT=fw_voP*uV7_KeM1G^r4W)TXDLw2#ijMlyECK5@Euzw z8lT!0+rqann45iVSJr$fb2}Zu_Mt4=HZL|6N*gqi@2m)TOdPn}!(j;ck{!7`de?_+ zg%n=tf6wtfmK(Jg9Wn0?>3(w5c6wjY25JVKZ5YNlIk?!9+ObFI=B?}sAxf?uPTpj@ ze!m-c6dxG(u!r$Wt2rAO)H#@<6{&gn`e~JoR`CfQU`V zd6X8Bho(*r*D$ccPcR^EY=Jo^l8o_O&$#K0-NyCd+&5Cw^c%8nR^5c5#DG8G2b{gA z^m1CZN{ZFDCSus4ws}c$cwTDsA}(kBCL?lcFI+qVca&H>4wU^Jvg4YWLiC}1{MfmT zy&M{BvE*9Va@xE4K5>C!A04N!UWu^==9|>yS6;U_dOj(DD|*LIy& zs!%PewHHvYyOq(TS&1akYv81CD(R$Txv}SS<8TolF2={xW(3=oyP2~>NRmCk7D2W8 z+*_3yy41Udc~p#Kt_h!x#ztPxXb0` zs?}j#KLeJkW_8IuR$7g!DPzm~!XM71(`rnwDtLzRviD__w>Tu-rjHD#e+~Wtt>;Euu9=NW z#)#0*+NkbbYG>4HGuTr6$F%>$x5lxyH>{@;#={C?AMQ$hg%_xf|>9i2Y&&O~2DIa@>xY~KB(!ib;d zXL=2!t;bLL^bKn?E@~TV+I0O1aft8&fp0f_j@}Gl3BW{5mLTsCR&`3Xtwej3S$ zZ#$cHhP-1U1M9VZq6zOw$Cwm;`=z)4ogC%})UJ|(QHgQ9sg&$`-&^t~qh3EHJXCmn zgRyS%wYM|GXfCknVxN?lQMC?v%1&EKTIV?p1V~wAq0@|O8LOv|_S4S4?tL$0Vdc%X zsSEay5OJlP)adyxS`zG`^Ll!?R|tPZ^czBO0NUr*WqghKY~->^>v_@ zmbYRl)Pr0@@e5l!@EbLI=)1vjk9_~Vi)-$YCwZ0?*?m-g_MSJ-)xMB*=l`_d%-riO zCJ2$yAw-6BS^aJ7baQcau=_8U|A)x{KvCiZsE-xzw`K;hq0X^*E<5(cxauFm0<>lA zvNAK(lQh&arD8wV#wK|MJf}7XAI?uZe7~K$5p{%2mT0iS^9XliCsj75w-X5n+W*Ay z9N+@m!BI<~#q|c7Ir*io*l;4+`4|aAB+$V7*pvc8!%zAb-goy;Q|C;Zn5>k0D7xuQ zNEF`+AX$-%(1!bRsmDHD#=_)w^!GgBexvsXvo8&9Zn?%BA^TspOquk0lO;rkobu z)!IGsD(1JYhCf&a1EN%@2`jY$Gd;&?NbE(Gr&K)js6}WA42TE^&b5S{I%!e zFX&fD%K1wN#;@RiE+_v51pv5^eh2@57MFjG^XnSpUy-a({`VsOW5w~;D8JTN|B6xz zDY-&!<=2YquK|9YhW-^`jNtbGe@#k%h5nkS{{`KJRD2=7?ec5Z{%eGPrfGlS0RVb3 z0N_88wqN1@wA{bLb141>|I>hfMgMc0`#aj4`fq5H{~h%dK|lySe(P*Q2J}D%$I(~6 GefodM->yvn literal 0 HcmV?d00001 diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_cell/solid_materials.csv b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/solid_materials.csv new file mode 100644 index 00000000..8db9a5cb --- /dev/null +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_cell/solid_materials.csv @@ -0,0 +1,7 @@ +material_name +LiPF6 +LiDFOB +DTD +LiFSI +LiPO2F2 + diff --git a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py index afd515aa..ef6ff77f 100644 --- a/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py +++ b/unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py @@ -47,8 +47,8 @@ class BioyondV1RPC(BaseRequest): super().__init__() print("开始初始化 BioyondV1RPC") self.config = config - self.api_key = config["api_key"] - self.host = config["api_host"] + self.api_key = config.get("api_key", "") + self.host = config.get("api_host", "") or config.get("base_url", "") self._logger = SimpleLogger() self.material_cache = {} self._load_material_cache() @@ -61,7 +61,7 @@ class BioyondV1RPC(BaseRequest): :return: 当前时间的 ISO 8601 格式字符串 """ - current_time = datetime.now(timezone.utc).isoformat( + current_time = datetime.now().isoformat( timespec='milliseconds' ) # 替换时区部分为 'Z' @@ -192,23 +192,6 @@ class BioyondV1RPC(BaseRequest): return [] return str(response.get("data", {})) - def material_type_list(self) -> list: - """查询物料类型列表 - - 返回值: - list: 物料类型数组,失败返回空列表 - """ - response = self.post( - url=f'{self.host}/api/lims/storage/material-type-list', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": {}, - }) - if not response or response['code'] != 1: - return [] - return response.get("data", []) - def material_inbound(self, material_id: str, location_id: str) -> dict: """ 描述:指定库位入库一个物料 @@ -229,34 +212,8 @@ class BioyondV1RPC(BaseRequest): }) if not response or response['code'] != 1: - if response: - error_msg = response.get('message', '未知错误') - print(f"[ERROR] 物料入库失败: code={response.get('code')}, message={error_msg}") - else: - print(f"[ERROR] 物料入库失败: API 无响应") return {} - # 入库成功时,即使没有 data 字段,也返回成功标识 - return response.get("data") or {"success": True} - - def batch_inbound(self, inbound_items: List[Dict[str, Any]]) -> int: - """批量入库物料 - - 参数: - inbound_items: 入库条目列表,每项包含 materialId/locationId/quantity 等 - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/storage/batch-inbound', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": inbound_items, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) + return response.get("data", {}) def delete_material(self, material_id: str) -> dict: """ @@ -276,7 +233,7 @@ class BioyondV1RPC(BaseRequest): return response.get("data", {}) def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict: - """指定库位出库物料(通过库位名称)""" + """指定库位出库物料""" location_id = LOCATION_MAPPING.get(location_name, location_name) params = { @@ -293,98 +250,9 @@ class BioyondV1RPC(BaseRequest): "data": params }) - if not response or response['code'] != 1: - return None - return response - - def material_outbound_by_id(self, material_id: str, location_id: str, quantity: int) -> dict: - """指定库位出库物料(直接使用location_id) - - Args: - material_id: 物料ID - location_id: 库位ID(不是库位名称,是UUID) - quantity: 数量 - - Returns: - dict: API响应,失败返回None - """ - params = { - "materialId": material_id, - "locationId": location_id, - "quantity": quantity - } - - response = self.post( - url=f'{self.host}/api/lims/storage/outbound', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": params - }) - - if not response or response['code'] != 1: - return None - return response - - def batch_outbound(self, outbound_items: List[Dict[str, Any]]) -> int: - """批量出库物料 - - 参数: - outbound_items: 出库条目列表,每项包含 materialId/locationId/quantity 等 - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/storage/batch-outbound', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": outbound_items, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - - def material_info(self, material_id: str) -> dict: - """查询物料详情 - - 参数: - material_id: 物料ID - - 返回值: - dict: 物料信息字典,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/storage/material-info', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": material_id, - }) if not response or response['code'] != 1: return {} - return response.get("data", {}) - - def reset_location(self, location_id: str) -> int: - """复位库位 - - 参数: - location_id: 库位ID - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/storage/reset-location', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": location_id, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) + return response # ==================== 工作流查询相关接口 ==================== @@ -429,66 +297,6 @@ class BioyondV1RPC(BaseRequest): return {} return response.get("data", {}) - def split_workflow_list(self, params: Dict[str, Any]) -> dict: - """查询可拆分工作流列表 - - 参数: - params: 查询条件参数 - - 返回值: - dict: 返回数据字典,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/workflow/split-workflow-list', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": params, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - - def merge_workflow(self, data: Dict[str, Any]) -> dict: - """合并工作流(无参数版) - - 参数: - data: 合并请求体,包含待合并的子工作流信息 - - 返回值: - dict: 合并结果,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/workflow/merge-workflow', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": data, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - - def merge_workflow_with_parameters(self, data: Dict[str, Any]) -> dict: - """合并工作流(携带参数) - - 参数: - data: 合并请求体,包含 name、workflows 以及 stepParameters 等 - - 返回值: - dict: 合并结果,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/workflow/merge-workflow-with-parameters', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": data, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - def validate_workflow_parameters(self, workflows: List[Dict[str, Any]]) -> Dict[str, Any]: """验证工作流参数格式""" try: @@ -651,15 +459,18 @@ class BioyondV1RPC(BaseRequest): return {} return response.get("data", {}) - def order_report(self, order_id: str) -> dict: - """查询订单报告 - - 参数: - order_id: 订单ID - - 返回值: - dict: 报告数据,失败返回空字典 + def order_report(self, json_str: str) -> dict: """ + 描述:查询某个任务明细 + json_str 格式为JSON字符串: + '{"order_id": "order123"}' + """ + try: + data = json.loads(json_str) + order_id = data.get("order_id", "") + except json.JSONDecodeError: + return {} + response = self.post( url=f'{self.host}/api/lims/order/order-report', params={ @@ -667,18 +478,16 @@ class BioyondV1RPC(BaseRequest): "requestTime": self.get_current_time_iso8601(), "data": order_id, }) + if not response or response['code'] != 1: return {} return response.get("data", {}) def order_takeout(self, json_str: str) -> int: - """取出任务产物 - - 参数: - json_str: JSON字符串,包含 order_id 与 preintake_id - - 返回值: - int: 成功返回1,失败返回0 + """ + 描述:取出任务产物 + json_str 格式为JSON字符串: + '{"order_id": "order123", "preintake_id": "preintake123"}' """ try: data = json.loads(json_str) @@ -701,15 +510,14 @@ class BioyondV1RPC(BaseRequest): return 0 return response.get("code", 0) - def sample_waste_removal(self, order_id: str) -> dict: - """样品/废料取出 + """ + 样品/废料取出接口 参数: - order_id: 订单ID + - order_id: 订单ID - 返回值: - dict: 取出结果,失败返回空字典 + 返回: 取出结果 """ params = {"orderId": order_id} @@ -731,13 +539,10 @@ class BioyondV1RPC(BaseRequest): return response.get("data", {}) def cancel_order(self, json_str: str) -> bool: - """取消指定任务 - - 参数: - json_str: JSON字符串,包含 order_id - - 返回值: - bool: 成功返回 True,失败返回 False + """ + 描述:取消指定任务 + json_str 格式为JSON字符串: + '{"order_id": "order123"}' """ try: data = json.loads(json_str) @@ -757,126 +562,6 @@ class BioyondV1RPC(BaseRequest): return False return True - def cancel_experiment(self, order_id: str) -> int: - """取消指定实验 - - 参数: - order_id: 订单ID - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/order/cancel-experiment', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_id, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - - def batch_cancel_experiment(self, order_ids: List[str]) -> int: - """批量取消实验 - - 参数: - order_ids: 订单ID列表 - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/order/batch-cancel-experiment', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_ids, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - - def gantts_by_order_id(self, order_id: str) -> dict: - """查询订单甘特图数据 - - 参数: - order_id: 订单ID - - 返回值: - dict: 甘特数据,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/order/gantts-by-order-id', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_id, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - - def simulation_gantt_by_order_id(self, order_id: str) -> dict: - """查询订单模拟甘特图数据 - - 参数: - order_id: 订单ID - - 返回值: - dict: 模拟甘特数据,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/order/simulation-gantt-by-order-id', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_id, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - - def reset_order_status(self, order_id: str) -> int: - """复位订单状态 - - 参数: - order_id: 订单ID - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/order/reset-order-status', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_id, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - - def gantt_with_simulation_by_order_id(self, order_id: str) -> dict: - """查询订单甘特与模拟联合数据 - - 参数: - order_id: 订单ID - - 返回值: - dict: 联合数据,失败返回空字典 - """ - response = self.post( - url=f'{self.host}/api/lims/order/gantt-with-simulation-by-order-id', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": order_id, - }) - if not response or response['code'] != 1: - return {} - return response.get("data", {}) - # ==================== 设备管理相关接口 ==================== def device_list(self, json_str: str = "") -> list: @@ -908,13 +593,9 @@ class BioyondV1RPC(BaseRequest): return response.get("data", []) def device_operation(self, json_str: str) -> int: - """设备操作 - - 参数: - json_str: JSON字符串,包含 device_no/operationType/operationParams - - 返回值: - int: 成功返回1,失败返回0 + """ + 描述:操作设备 + json_str 格式为JSON字符串 """ try: data = json.loads(json_str) @@ -927,7 +608,7 @@ class BioyondV1RPC(BaseRequest): return 0 response = self.post( - url=f'{self.host}/api/lims/device/execute-operation', + url=f'{self.host}/api/lims/device/device-operation', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -938,30 +619,9 @@ class BioyondV1RPC(BaseRequest): return 0 return response.get("code", 0) - def reset_devices(self) -> int: - """复位设备集合 - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/device/reset-devices', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - # ==================== 调度器相关接口 ==================== def scheduler_status(self) -> dict: - """查询调度器状态 - - 返回值: - dict: 包含 schedulerStatus/hasTask/creationTime 等 - """ response = self.post( url=f'{self.host}/api/lims/scheduler/scheduler-status', params={ @@ -974,7 +634,7 @@ class BioyondV1RPC(BaseRequest): return response.get("data", {}) def scheduler_start(self) -> int: - """启动调度器""" + """描述:启动调度器""" response = self.post( url=f'{self.host}/api/lims/scheduler/start', params={ @@ -987,22 +647,9 @@ class BioyondV1RPC(BaseRequest): return response.get("code", 0) def scheduler_pause(self) -> int: - """暂停调度器""" + """描述:暂停调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/pause', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - }) - - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - - def scheduler_smart_pause(self) -> int: - """智能暂停调度器""" - response = self.post( - url=f'{self.host}/api/lims/scheduler/smart-pause', + url=f'{self.host}/api/lims/scheduler/scheduler-pause', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -1013,9 +660,8 @@ class BioyondV1RPC(BaseRequest): return response.get("code", 0) def scheduler_continue(self) -> int: - """继续调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/continue', + url=f'{self.host}/api/lims/scheduler/scheduler-continue', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -1026,9 +672,9 @@ class BioyondV1RPC(BaseRequest): return response.get("code", 0) def scheduler_stop(self) -> int: - """停止调度器""" + """描述:停止调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/stop', + url=f'{self.host}/api/lims/scheduler/scheduler-stop', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -1039,9 +685,9 @@ class BioyondV1RPC(BaseRequest): return response.get("code", 0) def scheduler_reset(self) -> int: - """复位调度器""" + """描述:重置调度器""" response = self.post( - url=f'{self.host}/api/lims/scheduler/reset', + url=f'{self.host}/api/lims/scheduler/scheduler-reset', params={ "apiKey": self.api_key, "requestTime": self.get_current_time_iso8601(), @@ -1051,36 +697,16 @@ class BioyondV1RPC(BaseRequest): return 0 return response.get("code", 0) - def scheduler_reply_error_handling(self, data: Dict[str, Any]) -> int: - """调度错误处理回复 - - 参数: - data: 错误处理参数 - - 返回值: - int: 成功返回1,失败返回0 - """ - response = self.post( - url=f'{self.host}/api/lims/scheduler/reply-error-handling', - params={ - "apiKey": self.api_key, - "requestTime": self.get_current_time_iso8601(), - "data": data, - }) - if not response or response['code'] != 1: - return 0 - return response.get("code", 0) - # ==================== 辅助方法 ==================== def _load_material_cache(self): """预加载材料列表到缓存中""" try: print("正在加载材料列表缓存...") - + # 加载所有类型的材料:耗材(0)、样品(1)、试剂(2) - material_types = [0, 1, 2] - + material_types = [1, 2] + for type_mode in material_types: print(f"正在加载类型 {type_mode} 的材料...") stock_query = f'{{"typeMode": {type_mode}, "includeDetail": true}}' @@ -1097,7 +723,7 @@ class BioyondV1RPC(BaseRequest): material_id = material.get("id") if material_name and material_id: self.material_cache[material_name] = material_id - + # 处理样品板等容器中的detail材料 detail_materials = material.get("detail", []) for detail_material in detail_materials: @@ -1133,24 +759,4 @@ class BioyondV1RPC(BaseRequest): def get_available_materials(self): """获取所有可用的材料名称列表""" - return list(self.material_cache.keys()) - - def get_scheduler_state(self) -> Optional[MachineState]: - """将调度状态字符串映射为枚举值 - - 返回值: - Optional[MachineState]: 映射后的枚举,失败返回 None - """ - data = self.scheduler_status() - if not isinstance(data, dict): - return None - status = data.get("schedulerStatus") - mapping = { - "Init": MachineState.INITIAL, - "Stop": MachineState.STOPPED, - "Running": MachineState.RUNNING, - "Pause": MachineState.PAUSED, - "ErrorPause": MachineState.ERROR_PAUSED, - "ErrorStop": MachineState.ERROR_STOPPED, - } - return mapping.get(status) + return list(self.material_cache.keys()) \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/config.py b/unilabos/devices/workstation/bioyond_studio/config.py index e06c4135..55b11241 100644 --- a/unilabos/devices/workstation/bioyond_studio/config.py +++ b/unilabos/devices/workstation/bioyond_studio/config.py @@ -2,141 +2,330 @@ """ 配置文件 - 包含所有配置信息和映射关系 """ +import os -# API配置 +# ==================== API 基础配置 ==================== +# BioyondCellWorkstation 默认配置(包含所有必需参数) API_CONFIG = { - "api_key": "", - "api_host": "" -} - -# 工作流映射配置 -WORKFLOW_MAPPINGS = { - "reactor_taken_out": "", - "reactor_taken_in": "", - "Solid_feeding_vials": "", - "Liquid_feeding_vials(non-titration)": "", - "Liquid_feeding_solvents": "", - "Liquid_feeding(titration)": "", - "liquid_feeding_beaker": "", - "Drip_back": "", -} - -# 工作流名称到DisplaySectionName的映射 -WORKFLOW_TO_SECTION_MAP = { - 'reactor_taken_in': '反应器放入', - 'liquid_feeding_beaker': '液体投料-烧杯', - 'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)', - 'Liquid_feeding_solvents': '液体投料-溶剂', - 'Solid_feeding_vials': '固体投料-小瓶', - 'Liquid_feeding(titration)': '液体投料-滴定', - 'reactor_taken_out': '反应器取出' + # API 连接配置 + # "api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.1.143:44389"),#实机 + "api_host": os.getenv("BIOYOND_API_HOST", "http://172.16.11.219:44388"),# 仿真机 + "api_key": os.getenv("BIOYOND_API_KEY", "8A819E5C"), + "timeout": int(os.getenv("BIOYOND_TIMEOUT", "30")), + + # 报送配置 + "report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"), + + # HTTP 服务配置 + "HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.6"), # HTTP服务监听地址,监听计算机飞连ip地址 + "HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")), + "debug_mode": False,# 调试模式 } # 库位映射配置 WAREHOUSE_MAPPING = { - "粉末堆栈": { + "粉末加样头堆栈": { "uuid": "", "site_uuids": { - # 样品板 - "A1": "3a14198e-6929-31f0-8a22-0f98f72260df", - "A2": "3a14198e-6929-4379-affa-9a2935c17f99", - "A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af", - "A4": "3a14198e-6929-5e99-2b79-80720f7cfb54", - "B1": "3a14198e-6929-f525-9a1b-1857552b28ee", - "B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d", - "B3": "3a14198e-6929-2d86-a468-602175a2b5aa", - "B4": "3a14198e-6929-1a98-ae57-e97660c489ad", - # 分装板 - "C1": "3a14198e-6929-46fe-841e-03dd753f1e4a", - "C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95", - "C3": "3a14198e-6929-72ac-32ce-9b50245682b8", - "C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118", - "D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2", - "D2": "3a14198e-6929-dde1-fc78-34a84b71afdf", - "D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963", - "D4": "3a14198e-6929-7ac8-915a-fea51cb2e884" + "A01": "3a19da56-1379-ff7c-1745-07e200b44ce2", + "B01": "3a19da56-1379-2424-d751-fe6e94cef938", + "C01": "3a19da56-1379-271c-03e3-6bdb590e395e", + "D01": "3a19da56-1379-277f-2b1b-0d11f7cf92c6", + "E01": "3a19da56-1379-2f1c-a15b-e01db90eb39a", + "F01": "3a19da56-1379-3fa1-846b-088158ac0b3d", + "G01": "3a19da56-1379-5aeb-d0cd-d3b4609d66e1", + "H01": "3a19da56-1379-6077-8258-bdc036870b78", + "I01": "3a19da56-1379-863b-a120-f606baf04617", + "J01": "3a19da56-1379-8a74-74e5-35a9b41d4fd5", + "K01": "3a19da56-1379-b270-b7af-f18773918abe", + "L01": "3a19da56-1379-ba54-6d78-fd770a671ffc", + "M01": "3a19da56-1379-c22d-c96f-0ceb5eb54a04", + "N01": "3a19da56-1379-d64e-c6c5-c72ea4829888", + "O01": "3a19da56-1379-d887-1a3c-6f9cce90f90e", + "P01": "3a19da56-1379-e77d-0e65-7463b238a3b9", + "Q01": "3a19da56-1379-edf6-1472-802ddb628774", + "R01": "3a19da56-1379-f281-0273-e0ef78f0fd97", + "S01": "3a19da56-1379-f924-7f68-df1fa51489f4", + "T01": "3a19da56-1379-ff7c-1745-07e200b44ce2" } }, - "溶液堆栈": { + "配液站内试剂仓库": { "uuid": "", "site_uuids": { - "A1": "3a14198e-d724-e036-afdc-2ae39a7f3383", - "A2": "3a14198e-d724-afa4-fc82-0ac8a9016791", - "A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6", - "A4": "3a14198e-d724-df6d-5e32-5483b3cab583", - "B1": "3a14198e-d724-d818-6d4f-5725191a24b5", - "B2": "3a14198e-d724-be8a-5e0b-012675e195c6", - "B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8", - "B4": "3a14198e-d724-1e28-c885-574c3df468d0", - "C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31", - "C2": "3a14198e-d724-ab4e-48cb-817c3c146707", - "C3": "3a14198e-d724-7f18-1853-39d0c62e1d33", - "C4": "3a14198e-d724-28a2-a760-baa896f46b66", - "D1": "3a14198e-d724-d378-d266-2508a224a19f", - "D2": "3a14198e-d724-f56e-468b-0110a8feb36a", - "D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c", - "D4": "3a14198e-d724-0ddd-9654-f9352a421de9" + "A01": "3a19da43-57b5-294f-d663-154a1cc32270", + "B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2", + "C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f", + "A02": "3a19da43-57b5-8441-db94-b4d3875a4b6c", + "B02": "3a19da43-57b5-3e41-c181-5119dddaf50c", + "C02": "3a19da43-57b5-269b-282d-fba61fe8ce96", + "A03": "3a19da43-57b5-7c1e-d02e-c40e8c33f8a1", + "B03": "3a19da43-57b5-659f-621f-1dcf3f640363", + "C03": "3a19da43-57b5-855a-6e71-f398e376dee1", } }, - "试剂堆栈": { + "试剂替换仓库": { "uuid": "", "site_uuids": { - "A1": "3a14198c-c2cf-8b40-af28-b467808f1c36", - "A2": "3a14198c-c2d0-f3e7-871a-e470d144296f", - "A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", - "A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f", - "B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", - "B2": "3a14198c-c2d0-1559-105d-0ea30682cab4", - "B3": "3a14198c-c2d0-725e-523d-34c037ac2440", - "B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" + "A01": "3a19da51-8f4e-30f3-ea08-4f8498e9b097", + "B01": "3a19da51-8f4e-1da7-beb0-80a4a01e67a8", + "C01": "3a19da51-8f4e-337d-2675-bfac46880b06", + "D01": "3a19da51-8f4e-e514-b92c-9c44dc5e489d", + "E01": "3a19da51-8f4e-22d1-dd5b-9774ddc80402", + "F01": "3a19da51-8f4e-273a-4871-dff41c29bfd9", + "G01": "3a19da51-8f4e-b32f-454f-74bc1a665653", + "H01": "3a19da51-8f4e-8c93-68c9-0b4382320f59", + "I01": "3a19da51-8f4e-360c-0149-291b47c6089b", + "J01": "3a19da51-8f4e-4152-9bca-8d64df8c1af0" + } + }, + "自动堆栈-左": { + "uuid": "", + "site_uuids": { + "A01": "3a19debc-84b5-4c1c-d3a1-26830cf273ff", + "A02": "3a19debc-84b5-033b-b31f-6b87f7c2bf52", + "B01": "3a19debc-84b5-3924-172f-719ab01b125c", + "B02": "3a19debc-84b5-aad8-70c6-b8c6bb2d8750" + } + }, + "自动堆栈-右": { + "uuid": "", + "site_uuids": { + "A01": "3a19debe-5200-7df2-1dd9-7d202f158864", + "A02": "3a19debe-5200-573b-6120-8b51f50e1e50", + "B01": "3a19debe-5200-7cd8-7666-851b0a97e309", + "B02": "3a19debe-5200-e6d3-96a3-baa6e3d5e484" + } + }, + "手动堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea", + "A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe", + "A03": "3a19deae-2c7a-5876-c454-6b7e224ca927", + "B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1", + "B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3", + "B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a", + "C01": "3a19deae-2c7a-32bc-768e-556647e292f3", + "C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4", + "C03": "3a19deae-2c7a-3056-6504-10dc73fbc276", + "D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440", + "D02": "3a19deae-2c7a-61be-601c-b6fb5610499a", + "D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560", + "E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363", + "E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910", + "E03": "3a19deae-2c7a-b163-2219-23df15200311", + "F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a", + "F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679", + "F03": "3a19deae-2c7a-f7c4-12bd-425799425698", + "G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955", + "G02": "3a19deae-2c7a-204e-95ed-1f1950f28343", + "G03": "3a19deae-2c7a-392b-62f1-4907c66343f8", + "H01": "3a19deae-2c7a-5602-e876-d27aca4e3201", + "H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702", + "H03": "3a19deae-2c7a-780b-8965-2e1345f7e834", + "I01": "3a19deae-2c7a-8849-e172-07de14ede928", + "I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0", + "I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4", + "J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6", + "J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205", + "J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9" + } + }, + "4号手套箱内部堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a1baa20-a7b1-c665-8b9c-d8099d07d2f6", + "A02": "3a1baa20-a7b1-93a7-c988-f9c8ad6c58c9", + "A03": "3a1baa20-a7b1-00ee-f751-da9b20b6c464", + "A04": "3a1baa20-a7b1-4712-c37b-0b5b658ef7b9", + "B01": "3a1baa20-a7b1-9847-fc9c-96d604cd1a8e", + "B02": "3a1baa20-a7b1-4ae9-e604-0601db06249c", + "B03": "3a1baa20-a7b1-8329-ea75-81ca559d9ce1", + "B04": "3a1baa20-a7b1-89c5-d96f-36e98a8f7268", + "C01": "3a1baa20-a7b1-32ec-39e6-8044733839d6", + "C02": "3a1baa20-a7b1-b573-e426-4c86040348b2", + "C03": "3a1baa20-a7b1-cca7-781e-0522b729bf5d", + "C04": "3a1baa20-a7b1-7c98-5fd9-5855355ae4b3" + } + }, + "大分液瓶堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a19da3d-4f3d-bcac-2932-7542041e10e0", + "A02": "3a19da3d-4f3d-4d75-38ac-fb58ad0687c3", + "A03": "3a19da3d-4f3d-b25e-f2b1-85342a5b7eae", + "B01": "3a19da3d-4f3d-fd3e-058a-2733a0925767", + "B02": "3a19da3d-4f3d-37bd-a944-c391ad56857f", + "B03": "3a19da3d-4f3d-e353-7862-c6d1d4bc667f", + "C01": "3a19da3d-4f3d-9519-5da7-76179c958e70", + "C02": "3a19da3d-4f3d-b586-d7ed-9ec244f6f937", + "C03": "3a19da3d-4f3d-5061-249b-35dfef732811" + } + }, + "小分液瓶堆栈": { + "uuid": "", + "site_uuids": { + "C03": "3a19da40-55bf-8943-d20d-a8b3ea0d16c0" + } + }, + "站内Tip头盒堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a19deab-d5cc-be1e-5c37-4e9e5a664388", + "A02": "3a19deab-d5cc-b394-8141-27cb3853e8ea", + "B01": "3a19deab-d5cc-4dca-596e-ca7414d5f501", + "B02": "3a19deab-d5cc-9bc0-442b-12d9d59aa62a", + "C01": "3a19deab-d5cc-2eaf-b6a4-f0d54e4f1246", + "C02": "3a19deab-d5cc-d9f4-25df-b8018c372bc7" + } + }, + "配液站内配液大板仓库(无需提前上料)": { + "uuid": "", + "site_uuids": { + "A01": "3a1a21dc-06af-3915-9cb9-80a9dc42f386" + } + }, + "配液站内配液小板仓库(无需以前入料)": { + "uuid": "", + "site_uuids": { + "A01": "3a1a21de-8e8b-7938-2d06-858b36c10e31" + } + }, + "移液站内大瓶板仓库(无需提前如料)": { + "uuid": "", + "site_uuids": { + "A01": "3a1a224c-c727-fa62-1f2b-0037a84b9fca" + } + }, + "移液站内小瓶板仓库(无需提前入料)": { + "uuid": "", + "site_uuids": { + "A01": "3a1a224d-ed49-710c-a9c3-3fc61d479cbb" + } + }, + "适配器位仓库": { + "uuid": "", + "site_uuids": { + "A01": "3a1abd46-18fe-1f56-6ced-a1f7fe08e36c" + } + }, + "1号2号手套箱交接堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a1baa49-7f77-35aa-60b1-e55a45d065fa" + } + }, + "2号手套箱内部堆栈": { + "uuid": "", + "site_uuids": { + "A01": "3a1baa4b-393e-9f86-3921-7a18b0a8e371", + "A02": "3a1baa4b-393e-9425-928b-ee0f6f679d44", + "A03": "3a1baa4b-393e-0baf-632b-59dfdc931a3a", + "B01": "3a1baa4b-393e-f8aa-c8a9-956f3132f05c", + "B02": "3a1baa4b-393e-ef05-42f6-53f4c6e89d70", + "B03": "3a1baa4b-393e-c07b-a924-a9f0dfda9711", + "C01": "3a1baa4b-393e-4c2b-821a-16a7fe025c48", + "C02": "3a1baa4b-393e-2eaf-61a1-9063c832d5a2", + "C03": "3a1baa4b-393e-034e-8e28-8626d934a85f" } } + } # 物料类型配置 MATERIAL_TYPE_MAPPINGS = { - "烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), - "试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""), - "样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"), - "分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), - "样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), - "90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), - "10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"), + "100ml液体": ("YB_100ml_yeti", "d37166b3-ecaa-481e-bd84-3032b795ba07"), + "液": ("YB_ye", "3a190ca1-2add-2b23-f8e1-bbd348b7f790"), + "高粘液": ("YB_gaonianye", "abe8df30-563d-43d2-85e0-cabec59ddc16"), + "加样头(大)": ("YB_jia_yang_tou_da_Carrier", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "加样头(大)板": ("YB_jia_yang_tou_da", "a8e714ae-2a4e-4eb9-9614-e4c140ec3f16"), + "5ml分液瓶板": ("YB_5ml_fenyepingban", "3a192fa4-007d-ec7b-456e-2a8be7a13f23"), + "5ml分液瓶": ("YB_5ml_fenyeping", "3a192c2a-ebb7-58a1-480d-8b3863bf74f4"), + "20ml分液瓶板": ("YB_20ml_fenyepingban", "3a192fa4-47db-3449-162a-eaf8aba57e27"), + "20ml分液瓶": ("YB_20ml_fenyeping", "3a192c2b-19e8-f0a3-035e-041ca8ca1035"), + "配液瓶(小)板": ("YB_peiyepingxiaoban", "3a190c8b-3284-af78-d29f-9a69463ad047"), + "配液瓶(小)": ("YB_pei_ye_xiao_Bottle", "3a190c8c-fe8f-bf48-0dc3-97afc7f508eb"), + "配液瓶(大)板": ("YB_peiyepingdaban", "53e50377-32dc-4781-b3c0-5ce45bc7dc27"), + "配液瓶(大)": ("YB_pei_ye_da_Bottle", "19c52ad1-51c5-494f-8854-576f4ca9c6ca"), + "适配器块": ("YB_shi_pei_qi_kuai", "efc3bb32-d504-4890-91c0-b64ed3ac80cf"), + "枪头盒": ("YB_qiang_tou_he", "3a192c2e-20f3-a44a-0334-c8301839d0b3"), + "枪头": ("YB_qiang_tou", "b6196971-1050-46da-9927-333e8dea062d"), } -# 步骤参数配置(各工作流的步骤UUID) -WORKFLOW_STEP_IDS = { - "reactor_taken_in": { - "config": "" +SOLID_LIQUID_MAPPINGS = { + # 固体 + "LiDFOB": { + "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469", + "code": "", + "barCode": "", + "name": "LiDFOB", + "unit": "g", + "parameters": "", + "quantity": "2", + "warningQuantity": "1", + "details": [] }, - "liquid_feeding_beaker": { - "liquid": "", - "observe": "" - }, - "liquid_feeding_vials_non_titration": { - "liquid": "", - "observe": "" - }, - "liquid_feeding_solvents": { - "liquid": "", - "observe": "" - }, - "solid_feeding_vials": { - "feeding": "", - "observe": "" - }, - "liquid_feeding_titration": { - "liquid": "", - "observe": "" - }, - "drip_back": { - "liquid": "", - "observe": "" - } + # "LiPF6": { + # "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469", + # "code": "", + # "barCode": "", + # "name": "LiPF6", + # "unit": "g", + # "parameters": "", + # "quantity": 2, + # "warningQuantity": 1, + # "details": [] + # }, + # "LiFSI": { + # "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469", + # "code": "", + # "barCode": "", + # "name": "LiFSI", + # "unit": "g", + # "parameters": "", + # "quantity": 2, + # "warningQuantity": 1, + # "details": [] + # }, + # "DTC": { + # "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469", + # "code": "", + # "barCode": "", + # "name": "DTC", + # "unit": "g", + # "parameters": "", + # "quantity": 2, + # "warningQuantity": 1, + # "details": [] + # }, + # "LiPO2F2": { + # "typeId": "3a190ca0-b2f6-9aeb-8067-547e72c11469", + # "code": "", + # "barCode": "", + # "name": "LiPO2F2", + # "unit": "g", + # "parameters": "", + # "quantity": 2, + # "warningQuantity": 1, + # "details": [] + # }, + # 液体 + # "SA": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "EC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "VC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "AND": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "HTCN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "DENE": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "TMSP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "TMSB": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "EP": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "DEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "EMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "SN": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "DMC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), + # "FEC": ("BIOYOND_PolymerStation_Solid_Stock", "3a190ca0-b2f6-9aeb-8067-547e72c11469"), } -LOCATION_MAPPING = {} +WORKFLOW_MAPPINGS = {} -ACTION_NAMES = {} - -HTTP_SERVICE_CONFIG = {} \ No newline at end of file +LOCATION_MAPPING = {} \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py index 6d512720..8617a13f 100644 --- a/unilabos/devices/workstation/bioyond_studio/dispensing_station.py +++ b/unilabos/devices/workstation/bioyond_studio/dispensing_station.py @@ -1,25 +1,8 @@ from datetime import datetime import json -import time -from typing import Optional, Dict, Any, List -from typing_extensions import TypedDict -import requests -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.station import BioyondWorkstation -from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode -import json -import sys -from pathlib import Path -import importlib - -class ComputeExperimentDesignReturn(TypedDict): - solutions: list - titration: dict - solvents: dict - feeding_order: list - return_info: str class BioyondDispensingStation(BioyondWorkstation): @@ -40,111 +23,6 @@ class BioyondDispensingStation(BioyondWorkstation): # self._logger = SimpleLogger() # self.is_running = False - # 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}} - self.order_completion_status = {} - - def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]: - """项目接口通用POST调用 - - 参数: - endpoint: 接口路径(例如 /api/lims/order/brief-step-paramerers) - data: 请求体中的 data 字段内容 - - 返回: - dict: 服务端响应,失败时返回 {code:0,message,...} - """ - request_data = { - "apiKey": API_CONFIG["api_key"], - "requestTime": self.hardware_interface.get_current_time_iso8601(), - "data": data - } - try: - response = requests.post( - f"{self.hardware_interface.host}{endpoint}", - json=request_data, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - result = response.json() - return result if isinstance(result, dict) else {"code": 0, "message": "非JSON响应"} - except json.JSONDecodeError: - return {"code": 0, "message": "非JSON响应"} - except requests.exceptions.Timeout: - return {"code": 0, "message": "请求超时"} - except requests.exceptions.RequestException as e: - return {"code": 0, "message": str(e)} - - def _delete_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]: - """项目接口通用DELETE调用 - - 参数: - endpoint: 接口路径(例如 /api/lims/order/workflows) - data: 请求体中的 data 字段内容 - - 返回: - dict: 服务端响应,失败时返回 {code:0,message,...} - """ - request_data = { - "apiKey": API_CONFIG["api_key"], - "requestTime": self.hardware_interface.get_current_time_iso8601(), - "data": data - } - try: - response = requests.delete( - f"{self.hardware_interface.host}{endpoint}", - json=request_data, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - result = response.json() - return result if isinstance(result, dict) else {"code": 0, "message": "非JSON响应"} - except json.JSONDecodeError: - return {"code": 0, "message": "非JSON响应"} - except requests.exceptions.Timeout: - return {"code": 0, "message": "请求超时"} - except requests.exceptions.RequestException as e: - return {"code": 0, "message": str(e)} - - def compute_experiment_design( - self, - ratio: dict, - wt_percent: str = "0.25", - m_tot: str = "70", - titration_percent: str = "0.03", - ) -> ComputeExperimentDesignReturn: - try: - if isinstance(ratio, str): - try: - ratio = json.loads(ratio) - except Exception: - 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: - wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent - mt = float(m_tot) if isinstance(m_tot, str) else m_tot - tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent - except Exception as e: - raise BioyondException(f"参数解析失败: {e}") - res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp) - out = { - "solutions": res.get("solutions", []), - "titration": res.get("titration", {}), - "solvents": res.get("solvents", {}), - "feeding_order": res.get("feeding_order", []), - "return_info": json.dumps(res, ensure_ascii=False) - } - return out - except BioyondException: - raise - except Exception as e: - raise BioyondException(str(e)) - # 90%10%小瓶投料任务创建方法 def create_90_10_vial_feeding_task(self, order_name: str = None, @@ -392,45 +270,7 @@ class BioyondDispensingStation(BioyondWorkstation): # 7. 调用create_order方法创建任务 result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建90%10%小瓶投料任务结果: {result}") - - # 8. 解析结果获取order_id - order_id = None - if isinstance(result, str): - # result 格式: "{'3a1d895c-4d39-d504-1398-18f5a40bac1e': [{'id': '...', ...}]}" - # 第一个键就是order_id (UUID) - try: - # 尝试解析字符串为字典 - import ast - result_dict = ast.literal_eval(result) - # 获取第一个键作为order_id - if result_dict and isinstance(result_dict, dict): - first_key = list(result_dict.keys())[0] - order_id = first_key - self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}") - else: - self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}") - except Exception as e: - self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}, result类型={type(result)}") - elif isinstance(result, dict): - # 如果已经是字典 - if result: - first_key = list(result.keys())[0] - order_id = first_key - self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}") - - if not order_id: - self.hardware_interface._logger.warning( - f"⚠ 未能提取order_id,result={result[:100] if isinstance(result, str) else result}" - ) - - # 返回成功结果和构建的JSON数据 - return json.dumps({ - "suc": True, - "order_code": order_code, - "order_id": order_id, - "result": result, - "order_params": order_data - }) + return json.dumps({"suc": True}) except BioyondException: # 重新抛出BioyondException @@ -558,37 +398,7 @@ class BioyondDispensingStation(BioyondWorkstation): result = self.hardware_interface.create_order(json_str) self.hardware_interface._logger.info(f"创建二胺溶液配置任务结果: {result}") - # 8. 解析结果获取order_id - order_id = None - if isinstance(result, str): - try: - import ast - result_dict = ast.literal_eval(result) - if result_dict and isinstance(result_dict, dict): - first_key = list(result_dict.keys())[0] - order_id = first_key - self.hardware_interface._logger.info(f"✓ 成功提取order_id: {order_id}") - else: - self.hardware_interface._logger.warning(f"result_dict格式异常: {result_dict}") - except Exception as e: - self.hardware_interface._logger.error(f"✗ 无法从结果中提取order_id: {e}") - elif isinstance(result, dict): - if result: - first_key = list(result.keys())[0] - order_id = first_key - self.hardware_interface._logger.info(f"✓ 成功提取order_id(dict): {order_id}") - - if not order_id: - self.hardware_interface._logger.warning(f"⚠ 未能提取order_id") - - # 返回成功结果和构建的JSON数据 - return json.dumps({ - "suc": True, - "order_code": order_code, - "order_id": order_id, - "result": result, - "order_params": order_data - }) + return json.dumps({"suc": True}) except BioyondException: # 重新抛出BioyondException @@ -689,24 +499,15 @@ class BioyondDispensingStation(BioyondWorkstation): hold_m_name=hold_m_name ) - # 解析返回结果以获取order_code和order_id - result_data = json.loads(result) if isinstance(result, str) else result - order_code = result_data.get("order_code") - order_id = result_data.get("order_id") - order_params = result_data.get("order_params", {}) - results.append({ "index": idx + 1, "name": name, "success": True, - "order_code": order_code, - "order_id": order_id, - "hold_m_name": hold_m_name, - "order_params": order_params + "hold_m_name": hold_m_name }) success_count += 1 self.hardware_interface._logger.info( - f"成功创建二胺溶液配置任务: {name}, order_code={order_code}, order_id={order_id}" + f"成功创建二胺溶液配置任务: {name}" ) except BioyondException as e: @@ -732,17 +533,11 @@ class BioyondDispensingStation(BioyondWorkstation): f"创建第 {idx + 1} 个任务时发生未知错误: {str(e)}" ) - # 提取所有成功任务的order_code和order_id - order_codes = [r["order_code"] for r in results if r["success"]] - order_ids = [r["order_id"] for r in results if r["success"]] - # 返回汇总结果 summary = { "total": len(solutions), "success": success_count, "failed": failed_count, - "order_codes": order_codes, - "order_ids": order_ids, "details": results } @@ -751,13 +546,8 @@ class BioyondDispensingStation(BioyondWorkstation): f"成功={success_count}, 失败={failed_count}" ) - # 构建返回结果 - summary["return_info"] = { - "order_codes": order_codes, - "order_ids": order_ids, - } - - return summary + # 返回JSON字符串格式 + return json.dumps(summary, ensure_ascii=False) except BioyondException: raise @@ -766,40 +556,6 @@ class BioyondDispensingStation(BioyondWorkstation): self.hardware_interface._logger.error(error_msg) raise BioyondException(error_msg) - def brief_step_parameters(self, data: Dict[str, Any]) -> Dict[str, Any]: - """获取简要步骤参数(站点项目接口) - - 参数: - data: 查询参数字典 - - 返回值: - dict: 接口返回数据 - """ - return self._post_project_api("/api/lims/order/brief-step-paramerers", data) - - def project_order_report(self, order_id: str) -> Dict[str, Any]: - """查询项目端订单报告(兼容旧路径) - - 参数: - order_id: 订单ID - - 返回值: - dict: 报告数据 - """ - return self._post_project_api("/api/lims/order/project-order-report", order_id) - - def workflow_sample_locations(self, workflow_id: str) -> Dict[str, Any]: - """查询工作流样品库位(站点项目接口) - - 参数: - workflow_id: 工作流ID - - 返回值: - dict: 位置信息数据 - """ - return self._post_project_api("/api/lims/storage/workflow-sample-locations", workflow_id) - - # 批量创建90%10%小瓶投料任务 def batch_create_90_10_vial_feeding_tasks(self, titration, @@ -857,15 +613,22 @@ class BioyondDispensingStation(BioyondWorkstation): if not all([name, main_portion is not None, titration_portion is not None, titration_solvent is not None]): raise BioyondException("titration 数据缺少必要参数") + # 将main_portion平均分成3份作为90%物料(3个小瓶) + portion_90 = main_portion / 3 + # 调用单个任务创建方法 result = self.create_90_10_vial_feeding_task( order_name=f"90%10%小瓶投料-{name}", speed=speed, temperature=temperature, delay_time=delay_time, - # 90%物料 - 主称固体直接使用main_portion + # 90%物料 - 主称固体平均分成3份 percent_90_1_assign_material_name=name, - percent_90_1_target_weigh=str(round(main_portion, 6)), + percent_90_1_target_weigh=str(round(portion_90, 6)), + percent_90_2_assign_material_name=name, + percent_90_2_target_weigh=str(round(portion_90, 6)), + percent_90_3_assign_material_name=name, + percent_90_3_target_weigh=str(round(portion_90, 6)), # 10%物料 - 滴定固体 + 滴定溶剂(只使用第1个10%小瓶) percent_10_1_assign_material_name=name, percent_10_1_target_weigh=str(round(titration_portion, 6)), @@ -874,54 +637,29 @@ class BioyondDispensingStation(BioyondWorkstation): hold_m_name=hold_m_name ) - # 解析返回结果以获取order_code和order_id - result_data = json.loads(result) if isinstance(result, str) else result - order_code = result_data.get("order_code") - order_id = result_data.get("order_id") - order_params = result_data.get("order_params", {}) - - # 构建详细信息(保持原有结构) - detail = { - "index": 1, - "name": name, + summary = { "success": True, - "order_code": order_code, - "order_id": order_id, "hold_m_name": hold_m_name, + "material_name": name, "90_vials": { - "count": 1, - "weight_per_vial": round(main_portion, 6), + "count": 3, + "weight_per_vial": round(portion_90, 6), "total_weight": round(main_portion, 6) }, "10_vials": { "count": 1, "solid_weight": round(titration_portion, 6), "liquid_volume": round(titration_solvent, 6) - }, - "order_params": order_params - } - - # 构建批量结果格式(与diamine_solution_tasks保持一致) - summary = { - "total": 1, - "success": 1, - "failed": 0, - "order_codes": [order_code], - "order_ids": [order_id], - "details": [detail] + } } self.hardware_interface._logger.info( - f"成功创建90%10%小瓶投料任务: {name}, order_code={order_code}, order_id={order_id}" + f"成功创建90%10%小瓶投料任务: {hold_m_name}, " + f"90%物料={portion_90:.6f}g×3, 10%物料={titration_portion:.6f}g+{titration_solvent:.6f}mL" ) - # 构建返回结果 - summary["return_info"] = { - "order_codes": [order_code], - "order_ids": [order_id], - } - - return summary + # 返回JSON字符串格式 + return json.dumps(summary, ensure_ascii=False) except BioyondException: raise @@ -930,571 +668,6 @@ class BioyondDispensingStation(BioyondWorkstation): self.hardware_interface._logger.error(error_msg) raise BioyondException(error_msg) - def _extract_actuals_from_report(self, report) -> Dict[str, Any]: - data = report.get('data') if isinstance(report, dict) else None - actual_target_weigh = None - actual_volume = None - if data: - extra = data.get('extraProperties') or {} - if isinstance(extra, dict): - for v in extra.values(): - obj = None - try: - obj = json.loads(v) if isinstance(v, str) else v - except Exception: - obj = None - if isinstance(obj, dict): - tw = obj.get('targetWeigh') - vol = obj.get('volume') - if tw is not None: - try: - actual_target_weigh = float(tw) - except Exception: - pass - if vol is not None: - try: - actual_volume = float(vol) - except Exception: - pass - return { - 'actualTargetWeigh': actual_target_weigh, - 'actualVolume': actual_volume - } - - # 等待多个任务完成并获取实验报告 - def wait_for_multiple_orders_and_get_reports(self, - batch_create_result: str = None, - timeout: int = 7200, - check_interval: int = 10) -> Dict[str, Any]: - """ - 同时等待多个任务完成并获取实验报告 - - 参数说明: - - batch_create_result: 批量创建任务的返回结果JSON字符串,包含order_codes和order_ids数组 - - timeout: 超时时间(秒),默认7200秒(2小时) - - check_interval: 检查间隔(秒),默认10秒 - - 返回: 包含所有任务状态和报告的字典 - { - "total": 2, - "completed": 2, - "timeout": 0, - "elapsed_time": 120.5, - "reports": [ - { - "order_code": "task_vial_1", - "order_id": "uuid1", - "status": "completed", - "completion_status": 30, - "report": {...} - }, - ... - ] - } - - 异常: - - BioyondException: 所有任务都超时或发生错误 - """ - try: - # 参数类型转换 - timeout = int(timeout) if timeout else 7200 - check_interval = int(check_interval) if check_interval else 10 - - # 验证batch_create_result参数 - if not batch_create_result or batch_create_result == "": - raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle") - - # 解析batch_create_result JSON对象 - try: - # 清理可能存在的截断标记 [...] - if isinstance(batch_create_result, str) and '[...]' in batch_create_result: - batch_create_result = batch_create_result.replace('[...]', '[]') - - result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result - - # 兼容外层包装格式 {error, suc, return_value} - if isinstance(result_obj, dict) and "return_value" in result_obj: - inner = result_obj.get("return_value") - if isinstance(inner, str): - result_obj = json.loads(inner) - elif isinstance(inner, dict): - result_obj = inner - - # 从summary对象中提取order_codes和order_ids - order_codes = result_obj.get("order_codes", []) - order_ids = result_obj.get("order_ids", []) - - except json.JSONDecodeError as e: - raise BioyondException(f"解析batch_create_result失败: {e}") - except Exception as e: - raise BioyondException(f"处理batch_create_result时出错: {e}") - - # 验证提取的数据 - if not order_codes: - raise BioyondException("batch_create_result中未找到order_codes字段或为空") - if not order_ids: - raise BioyondException("batch_create_result中未找到order_ids字段或为空") - - # 确保order_codes和order_ids是列表类型 - if not isinstance(order_codes, list): - order_codes = [order_codes] if order_codes else [] - if not isinstance(order_ids, list): - order_ids = [order_ids] if order_ids else [] - - codes_list = order_codes - ids_list = order_ids - - if len(codes_list) != len(ids_list): - raise BioyondException( - f"order_codes数量({len(codes_list)})与order_ids数量({len(ids_list)})不匹配" - ) - - if not codes_list or not ids_list: - raise BioyondException("order_codes和order_ids不能为空") - - # 初始化跟踪变量 - total = len(codes_list) - pending_orders = {code: {"order_id": ids_list[i], "completed": False} - for i, code in enumerate(codes_list)} - reports = [] - - start_time = time.time() - self.hardware_interface._logger.info( - f"开始等待 {total} 个任务完成: {', '.join(codes_list)}" - ) - - # 轮询检查任务状态 - while pending_orders: - elapsed_time = time.time() - start_time - - # 检查超时 - if elapsed_time > timeout: - # 收集超时任务 - timeout_orders = list(pending_orders.keys()) - self.hardware_interface._logger.error( - f"等待任务完成超时,剩余未完成任务: {', '.join(timeout_orders)}" - ) - - # 为超时任务添加记录 - for order_code in timeout_orders: - reports.append({ - "order_code": order_code, - "order_id": pending_orders[order_code]["order_id"], - "status": "timeout", - "completion_status": None, - "report": None, - "extracted": None, - "elapsed_time": elapsed_time - }) - - break - - # 检查每个待完成的任务 - completed_in_this_round = [] - for order_code in list(pending_orders.keys()): - order_id = pending_orders[order_code]["order_id"] - - # 检查任务是否完成 - if order_code in self.order_completion_status: - completion_info = self.order_completion_status[order_code] - self.hardware_interface._logger.info( - f"检测到任务 {order_code} 已完成,状态: {completion_info.get('status')}" - ) - - # 获取实验报告 - try: - report = self.project_order_report(order_id) - - if not report: - self.hardware_interface._logger.warning( - f"任务 {order_code} 已完成但无法获取报告" - ) - report = {"error": "无法获取报告"} - else: - self.hardware_interface._logger.info( - f"成功获取任务 {order_code} 的实验报告" - ) - - reports.append({ - "order_code": order_code, - "order_id": order_id, - "status": "completed", - "completion_status": completion_info.get('status'), - "report": report, - "extracted": self._extract_actuals_from_report(report), - "elapsed_time": elapsed_time - }) - - # 标记为已完成 - completed_in_this_round.append(order_code) - - # 清理完成状态记录 - del self.order_completion_status[order_code] - - except Exception as e: - self.hardware_interface._logger.error( - f"查询任务 {order_code} 报告失败: {str(e)}" - ) - reports.append({ - "order_code": order_code, - "order_id": order_id, - "status": "error", - "completion_status": completion_info.get('status'), - "report": None, - "extracted": None, - "error": str(e), - "elapsed_time": elapsed_time - }) - completed_in_this_round.append(order_code) - - # 从待完成列表中移除已完成的任务 - for order_code in completed_in_this_round: - del pending_orders[order_code] - - # 如果还有待完成的任务,等待后继续 - if pending_orders: - time.sleep(check_interval) - - # 每分钟记录一次等待状态 - new_elapsed_time = time.time() - start_time - if int(new_elapsed_time) % 60 == 0 and new_elapsed_time > 0: - self.hardware_interface._logger.info( - f"批量等待任务中... 已完成 {len(reports)}/{total}, " - f"待完成: {', '.join(pending_orders.keys())}, " - f"已等待 {int(new_elapsed_time/60)} 分钟" - ) - - # 统计结果 - completed_count = sum(1 for r in reports if r['status'] == 'completed') - timeout_count = sum(1 for r in reports if r['status'] == 'timeout') - error_count = sum(1 for r in reports if r['status'] == 'error') - - final_elapsed_time = time.time() - start_time - - summary = { - "total": total, - "completed": completed_count, - "timeout": timeout_count, - "error": error_count, - "elapsed_time": round(final_elapsed_time, 2), - "reports": reports - } - - self.hardware_interface._logger.info( - f"批量等待任务完成: 总数={total}, 成功={completed_count}, " - f"超时={timeout_count}, 错误={error_count}, 耗时={final_elapsed_time:.1f}秒" - ) - - # 返回字典格式,在顶层包含统计信息 - return { - "return_info": json.dumps(summary, ensure_ascii=False) - } - - except BioyondException: - raise - except Exception as e: - error_msg = f"批量等待任务完成时发生未预期的错误: {str(e)}" - self.hardware_interface._logger.error(error_msg) - raise BioyondException(error_msg) - - def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]: - """ - 重写父类方法,处理任务完成报送并记录到 order_completion_status - - Args: - report_request: WorkstationReportRequest 对象,包含任务完成信息 - used_materials: 物料使用记录列表 - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - # 调用父类方法 - result = super().process_order_finish_report(report_request, used_materials) - - # 记录任务完成状态 - data = report_request.data - order_code = data.get('orderCode') - - if order_code: - self.order_completion_status[order_code] = { - 'status': data.get('status'), - 'order_name': data.get('orderName'), - 'timestamp': datetime.now().isoformat(), - 'start_time': data.get('startTime'), - 'end_time': data.get('endTime') - } - - self.hardware_interface._logger.info( - f"已记录任务完成状态: {order_code}, status={data.get('status')}" - ) - - return result - - except Exception as e: - self.hardware_interface._logger.error(f"处理任务完成报送失败: {e}") - return {"processed": False, "error": str(e)} - - def transfer_materials_to_reaction_station( - self, - target_device_id: str, - transfer_groups: list - ) -> dict: - """ - 将配液站完成的物料转移到指定反应站的堆栈库位 - 支持多组转移任务,每组包含物料名称、目标堆栈和目标库位 - - Args: - target_device_id: 目标反应站设备ID(所有转移组使用同一个设备) - transfer_groups: 转移任务组列表,每组包含: - - materials: 物料名称(字符串,将通过RPC查询) - - target_stack: 目标堆栈名称(如"堆栈1左") - - target_sites: 目标库位(如"A01") - - Returns: - dict: 转移结果 - { - "success": bool, - "total_groups": int, - "successful_groups": int, - "failed_groups": int, - "target_device_id": str, - "details": [...] - } - """ - try: - # 验证参数 - if not target_device_id: - raise ValueError("目标设备ID不能为空") - - if not transfer_groups: - raise ValueError("转移任务组列表不能为空") - - if not isinstance(transfer_groups, list): - raise ValueError("transfer_groups必须是列表类型") - - # 标准化设备ID格式: 确保以 /devices/ 开头 - if not target_device_id.startswith("/devices/"): - if target_device_id.startswith("/"): - target_device_id = f"/devices{target_device_id}" - else: - target_device_id = f"/devices/{target_device_id}" - - self.hardware_interface._logger.info( - f"目标设备ID标准化为: {target_device_id}" - ) - - self.hardware_interface._logger.info( - f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}" - ) - - from .config import WAREHOUSE_MAPPING - results = [] - successful_count = 0 - failed_count = 0 - - for idx, group in enumerate(transfer_groups, 1): - try: - # 提取参数 - material_name = group.get("materials", "") - target_stack = group.get("target_stack", "") - target_sites = group.get("target_sites", "") - - # 验证必填参数 - if not material_name: - raise ValueError(f"第{idx}组: 物料名称不能为空") - if not target_stack: - raise ValueError(f"第{idx}组: 目标堆栈不能为空") - if not target_sites: - raise ValueError(f"第{idx}组: 目标库位不能为空") - - self.hardware_interface._logger.info( - f"处理第{idx}组转移: {material_name} -> " - f"{target_device_id}/{target_stack}/{target_sites}" - ) - - # 通过物料名称从deck获取ResourcePLR对象 - try: - material_resource = self.deck.get_resource(material_name) - if not material_resource: - raise ValueError(f"在deck中未找到物料: {material_name}") - - self.hardware_interface._logger.info( - f"从deck获取到物料 {material_name}: {material_resource}" - ) - except Exception as e: - raise ValueError( - f"获取物料 {material_name} 失败: {str(e)},请确认物料已正确加载到deck中" - ) - - # 验证目标堆栈是否存在 - if target_stack not in WAREHOUSE_MAPPING: - raise ValueError( - f"未知的堆栈名称: {target_stack}," - f"可选值: {list(WAREHOUSE_MAPPING.keys())}" - ) - - # 验证库位是否有效 - stack_sites = WAREHOUSE_MAPPING[target_stack].get("site_uuids", {}) - if target_sites not in stack_sites: - raise ValueError( - f"库位 {target_sites} 不存在于堆栈 {target_stack} 中," - f"可选库位: {list(stack_sites.keys())}" - ) - - # 获取目标库位的UUID - target_site_uuid = stack_sites[target_sites] - if not target_site_uuid: - raise ValueError( - f"库位 {target_sites} 的 UUID 未配置,请在 WAREHOUSE_MAPPING 中完善" - ) - - # 目标位点(包含UUID) - future = ROS2DeviceNode.run_async_func( - self._ros_node.get_resource_with_dir, - True, - **{ - "resource_id": f"/reaction_station_bioyond/Bioyond_Deck/{target_stack}", - "with_children": True, - }, - ) - # 等待异步完成后再获取结果 - if not future: - raise ValueError(f"获取目标堆栈资源future无效: {target_stack}") - while not future.done(): - time.sleep(0.1) - target_site_resource = future.result() - - # 调用父类的 transfer_resource_to_another 方法 - # 传入ResourcePLR对象和目标位点资源 - future = self.transfer_resource_to_another( - resource=[material_resource], - mount_resource=[target_site_resource], - sites=[target_sites], - mount_device_id=target_device_id - ) - - # 等待异步任务完成(轮询直到完成,再取结果) - if future: - try: - while not future.done(): - time.sleep(0.1) - future.result() - self.hardware_interface._logger.info( - f"异步转移任务已完成: {material_name}" - ) - except Exception as e: - raise ValueError(f"转移任务执行失败: {str(e)}") - - self.hardware_interface._logger.info( - f"第{idx}组转移成功: {material_name} -> " - f"{target_device_id}/{target_stack}/{target_sites}" - ) - - successful_count += 1 - results.append({ - "group_index": idx, - "success": True, - "material_name": material_name, - "target_stack": target_stack, - "target_site": target_sites, - "message": "转移成功" - }) - - except Exception as e: - error_msg = f"第{idx}组转移失败: {str(e)}" - self.hardware_interface._logger.error(error_msg) - failed_count += 1 - results.append({ - "group_index": idx, - "success": False, - "material_name": group.get("materials", ""), - "error": str(e) - }) - - # 返回汇总结果 - return { - "success": failed_count == 0, - "total_groups": len(transfer_groups), - "successful_groups": successful_count, - "failed_groups": failed_count, - "target_device_id": target_device_id, - "details": results, - "message": f"完成 {len(transfer_groups)} 组转移任务到 {target_device_id}: " - f"{successful_count} 成功, {failed_count} 失败" - } - - except Exception as e: - error_msg = f"批量转移物料失败: {str(e)}" - self.hardware_interface._logger.error(error_msg) - return { - "success": False, - "total_groups": len(transfer_groups) if transfer_groups else 0, - "successful_groups": 0, - "failed_groups": len(transfer_groups) if transfer_groups else 0, - "target_device_id": target_device_id if target_device_id else "", - "error": error_msg - } - - def query_resource_by_name(self, material_name: str): - """ - 通过物料名称查询资源对象(适用于Bioyond系统) - - Args: - material_name: 物料名称 - - Returns: - 物料ID或None - """ - try: - # Bioyond系统使用material_cache存储物料信息 - if not hasattr(self.hardware_interface, 'material_cache'): - self.hardware_interface._logger.error( - "hardware_interface没有material_cache属性" - ) - return None - - material_cache = self.hardware_interface.material_cache - - self.hardware_interface._logger.info( - f"查询物料 '{material_name}', 缓存中共有 {len(material_cache)} 个物料" - ) - - # 调试: 打印前几个物料信息 - if material_cache: - cache_items = list(material_cache.items())[:5] - for name, material_id in cache_items: - self.hardware_interface._logger.debug( - f"缓存物料: name={name}, id={material_id}" - ) - - # 直接从缓存中查找 - if material_name in material_cache: - material_id = material_cache[material_name] - self.hardware_interface._logger.info( - f"找到物料: {material_name} -> ID: {material_id}" - ) - return material_id - - self.hardware_interface._logger.warning( - f"未找到物料: {material_name} (缓存中无此物料)" - ) - - # 打印所有可用物料名称供参考 - available_materials = list(material_cache.keys()) - if available_materials: - self.hardware_interface._logger.info( - f"可用物料列表(前10个): {available_materials[:10]}" - ) - - return None - - except Exception as e: - self.hardware_interface._logger.error( - f"查询物料失败 {material_name}: {str(e)}" - ) - return None - if __name__ == "__main__": bioyond = BioyondDispensingStation(config={ @@ -1916,3 +1089,4 @@ if __name__ == "__main__": # id = "3a1bce3c-4f31-c8f3-5525-f3b273bc34dc" # bioyond.sample_waste_removal(id) + diff --git a/unilabos/devices/workstation/bioyond_studio/reaction_station.py b/unilabos/devices/workstation/bioyond_studio/reaction_station.py index ffb83fd3..d35427d2 100644 --- a/unilabos/devices/workstation/bioyond_studio/reaction_station.py +++ b/unilabos/devices/workstation/bioyond_studio/reaction_station.py @@ -1,12 +1,7 @@ import json -import time import requests from typing import List, Dict, Any -from pathlib import Path -from datetime import datetime from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation -from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import MachineState -from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String from unilabos.devices.workstation.bioyond_studio.config import ( WORKFLOW_STEP_IDS, WORKFLOW_TO_SECTION_MAP, @@ -15,37 +10,6 @@ from unilabos.devices.workstation.bioyond_studio.config import ( from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG -class BioyondReactor: - def __init__(self, config: dict = None, deck=None, protocol_type=None, **kwargs): - self.in_temperature = 0.0 - self.out_temperature = 0.0 - self.pt100_temperature = 0.0 - self.sensor_average_temperature = 0.0 - self.target_temperature = 0.0 - self.setting_temperature = 0.0 - self.viscosity = 0.0 - self.average_viscosity = 0.0 - self.speed = 0.0 - self.force = 0.0 - - def update_metrics(self, payload: Dict[str, Any]): - def _f(v): - try: - return float(v) - except Exception: - return 0.0 - self.target_temperature = _f(payload.get("targetTemperature")) - self.setting_temperature = _f(payload.get("settingTemperature")) - self.in_temperature = _f(payload.get("inTemperature")) - self.out_temperature = _f(payload.get("outTemperature")) - self.pt100_temperature = _f(payload.get("pt100Temperature")) - self.sensor_average_temperature = _f(payload.get("sensorAverageTemperature")) - self.speed = _f(payload.get("speed")) - self.force = _f(payload.get("force")) - self.viscosity = _f(payload.get("viscosity")) - self.average_viscosity = _f(payload.get("averageViscosity")) - - class BioyondReactionStation(BioyondWorkstation): """Bioyond反应站类 @@ -73,19 +37,6 @@ class BioyondReactionStation(BioyondWorkstation): print(f"BioyondReactionStation初始化完成 - workflow_mappings: {self.workflow_mappings}") print(f"workflow_mappings长度: {len(self.workflow_mappings)}") - self.in_temperature = 0.0 - self.out_temperature = 0.0 - self.pt100_temperature = 0.0 - self.sensor_average_temperature = 0.0 - self.target_temperature = 0.0 - self.setting_temperature = 0.0 - self.viscosity = 0.0 - self.average_viscosity = 0.0 - self.speed = 0.0 - self.force = 0.0 - - self._frame_to_reactor_id = {1: "reactor_1", 2: "reactor_2", 3: "reactor_3", 4: "reactor_4", 5: "reactor_5"} - # ==================== 工作流方法 ==================== def reactor_taken_out(self): @@ -281,7 +232,7 @@ class BioyondReactionStation(BioyondWorkstation): temperature: 温度设定(°C) """ # 处理 volume 参数:优先使用直接传入的 volume,否则从 solvents 中提取 - if not volume and solvents is not None: + if volume is None and solvents is not None: # 参数类型转换:如果是字符串则解析为字典 if isinstance(solvents, str): try: @@ -340,39 +291,22 @@ class BioyondReactionStation(BioyondWorkstation): def liquid_feeding_titration( self, + volume_formula: str, assign_material_name: str, - volume_formula: str = None, - x_value: str = None, - feeding_order_data: str = None, - extracted_actuals: str = None, - titration_type: str = "2", + titration_type: str = "1", time: str = "90", torque_variation: int = 2, temperature: float = 25.00 ): """液体进料(滴定) - 支持两种模式: - 1. 直接提供 volume_formula (传统方式) - 2. 自动计算公式: 提供 x_value, feeding_order_data, extracted_actuals (新方式) - Args: + volume_formula: 分液公式(μL) assign_material_name: 物料名称 - volume_formula: 分液公式(μL),如果提供则直接使用,否则自动计算 - x_value: 手工输入的x值,格式如 "1-2-3" - feeding_order_data: feeding_order JSON字符串或对象,用于获取m二酐值 - extracted_actuals: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume - titration_type: 是否滴定(1=否, 2=是),默认2 + titration_type: 是否滴定(1=否, 2=是) time: 观察时间(分钟) torque_variation: 是否观察(int类型, 1=否, 2=是) temperature: 温度(°C) - - 自动公式模板: 1000*(m二酐-x)*V二酐滴定/m二酐滴定 - 其中: - - m二酐滴定 = actualTargetWeigh (从extracted_actuals获取) - - V二酐滴定 = actualVolume (从extracted_actuals获取) - - x = x_value (手工输入) - - m二酐 = feeding_order中type为"main_anhydride"的amount值 """ self.append_to_workflow_sequence('{"web_workflow_name": "Liquid_feeding(titration)"}') material_id = self.hardware_interface._get_material_id_by_name(assign_material_name) @@ -382,84 +316,6 @@ class BioyondReactionStation(BioyondWorkstation): if isinstance(temperature, str): temperature = float(temperature) - # 如果没有直接提供volume_formula,则自动计算 - if not volume_formula and x_value and feeding_order_data and extracted_actuals: - # 1. 解析 feeding_order_data 获取 m二酐 - if isinstance(feeding_order_data, str): - try: - feeding_order_data = json.loads(feeding_order_data) - except json.JSONDecodeError as e: - raise ValueError(f"feeding_order_data JSON解析失败: {str(e)}") - - # 支持两种格式: - # 格式1: 直接是数组 [{...}, {...}] - # 格式2: 对象包裹 {"feeding_order": [{...}, {...}]} - if isinstance(feeding_order_data, list): - feeding_order_list = feeding_order_data - elif isinstance(feeding_order_data, dict): - feeding_order_list = feeding_order_data.get("feeding_order", []) - else: - raise ValueError("feeding_order_data 必须是数组或包含feeding_order的字典") - - # 从feeding_order中找到main_anhydride的amount - m_anhydride = None - for item in feeding_order_list: - if item.get("type") == "main_anhydride": - m_anhydride = item.get("amount") - break - - if m_anhydride is None: - raise ValueError("在feeding_order中未找到type为'main_anhydride'的条目") - - # 2. 解析 extracted_actuals 获取 actualTargetWeigh 和 actualVolume - if isinstance(extracted_actuals, str): - try: - extracted_actuals_obj = json.loads(extracted_actuals) - except json.JSONDecodeError as e: - raise ValueError(f"extracted_actuals JSON解析失败: {str(e)}") - else: - extracted_actuals_obj = extracted_actuals - - # 获取actuals数组 - actuals_list = extracted_actuals_obj.get("actuals", []) - if not actuals_list: - # actuals为空,无法自动生成公式,回退到手动模式 - print(f"警告: extracted_actuals中actuals数组为空,无法自动生成公式,请手动提供volume_formula") - volume_formula = None # 清空,触发后续的错误检查 - else: - # 根据assign_material_name匹配对应的actual数据 - # 假设order_code中包含物料名称 - matched_actual = None - for actual in actuals_list: - order_code = actual.get("order_code", "") - # 简单匹配:如果order_code包含物料名称 - if assign_material_name in order_code: - matched_actual = actual - break - - # 如果没有匹配到,使用第一个 - if not matched_actual and actuals_list: - matched_actual = actuals_list[0] - - if not matched_actual: - raise ValueError("无法从extracted_actuals中获取实际加料量数据") - - m_anhydride_titration = matched_actual.get("actualTargetWeigh") # m二酐滴定 - v_anhydride_titration = matched_actual.get("actualVolume") # V二酐滴定 - - if m_anhydride_titration is None or v_anhydride_titration is None: - raise ValueError(f"实际加料量数据不完整: actualTargetWeigh={m_anhydride_titration}, actualVolume={v_anhydride_titration}") - - # 3. 构建公式: 1000*(m二酐-x)*V二酐滴定/m二酐滴定 - # x_value 格式如 "{{1-2-3}}",保留完整格式(包括花括号)直接替换到公式中 - volume_formula = f"1000*({m_anhydride}-{x_value})*{v_anhydride_titration}/{m_anhydride_titration}" - - print(f"自动生成滴定公式: {volume_formula}") - print(f" m二酐={m_anhydride}, x={x_value}, V二酐滴定={v_anhydride_titration}, m二酐滴定={m_anhydride_titration}") - - elif not volume_formula: - raise ValueError("必须提供 volume_formula 或 (x_value + feeding_order_data + extracted_actuals)") - liquid_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["liquid"] observe_step_id = WORKFLOW_STEP_IDS["liquid_feeding_titration"]["observe"] @@ -487,288 +343,9 @@ class BioyondReactionStation(BioyondWorkstation): print(f"当前队列长度: {len(self.pending_task_params)}") return json.dumps({"suc": True}) - def _extract_actuals_from_report(self, report) -> Dict[str, Any]: - data = report.get('data') if isinstance(report, dict) else None - actual_target_weigh = None - actual_volume = None - if data: - extra = data.get('extraProperties') or {} - if isinstance(extra, dict): - for v in extra.values(): - obj = None - try: - obj = json.loads(v) if isinstance(v, str) else v - except Exception: - obj = None - if isinstance(obj, dict): - tw = obj.get('targetWeigh') - vol = obj.get('volume') - if tw is not None: - try: - actual_target_weigh = float(tw) - except Exception: - pass - if vol is not None: - try: - actual_volume = float(vol) - except Exception: - pass - return { - 'actualTargetWeigh': actual_target_weigh, - 'actualVolume': actual_volume - } - - def extract_actuals_from_batch_reports(self, batch_reports_result: str) -> dict: - print(f"[DEBUG] extract_actuals 收到原始数据: {batch_reports_result[:500]}...") # 打印前500字符 - try: - obj = json.loads(batch_reports_result) if isinstance(batch_reports_result, str) else batch_reports_result - if isinstance(obj, dict) and "return_info" in obj: - inner = obj["return_info"] - obj = json.loads(inner) if isinstance(inner, str) else inner - reports = obj.get("reports", []) if isinstance(obj, dict) else [] - print(f"[DEBUG] 解析后的 reports 数组长度: {len(reports)}") - except Exception as e: - print(f"[DEBUG] 解析异常: {e}") - reports = [] - - actuals = [] - for i, r in enumerate(reports): - print(f"[DEBUG] 处理 report[{i}]: order_code={r.get('order_code')}, has_extracted={r.get('extracted') is not None}, has_report={r.get('report') is not None}") - order_code = r.get("order_code") - order_id = r.get("order_id") - ex = r.get("extracted") - if isinstance(ex, dict) and (ex.get("actualTargetWeigh") is not None or ex.get("actualVolume") is not None): - print(f"[DEBUG] 从 extracted 字段提取: actualTargetWeigh={ex.get('actualTargetWeigh')}, actualVolume={ex.get('actualVolume')}") - actuals.append({ - "order_code": order_code, - "order_id": order_id, - "actualTargetWeigh": ex.get("actualTargetWeigh"), - "actualVolume": ex.get("actualVolume") - }) - continue - report = r.get("report") - vals = self._extract_actuals_from_report(report) if report else {"actualTargetWeigh": None, "actualVolume": None} - print(f"[DEBUG] 从 report 字段提取: {vals}") - actuals.append({ - "order_code": order_code, - "order_id": order_id, - **vals - }) - - print(f"[DEBUG] 最终提取的 actuals 数组长度: {len(actuals)}") - result = { - "return_info": json.dumps({"actuals": actuals}, ensure_ascii=False) - } - print(f"[DEBUG] 返回结果: {result}") - return result - - def process_temperature_cutoff_report(self, report_request) -> Dict[str, Any]: - try: - data = report_request.data - def _f(v): - try: - return float(v) - except Exception: - return 0.0 - self.target_temperature = _f(data.get("targetTemperature")) - self.setting_temperature = _f(data.get("settingTemperature")) - self.in_temperature = _f(data.get("inTemperature")) - self.out_temperature = _f(data.get("outTemperature")) - self.pt100_temperature = _f(data.get("pt100Temperature")) - self.sensor_average_temperature = _f(data.get("sensorAverageTemperature")) - self.speed = _f(data.get("speed")) - self.force = _f(data.get("force")) - self.viscosity = _f(data.get("viscosity")) - self.average_viscosity = _f(data.get("averageViscosity")) - - try: - if hasattr(self, "_ros_node") and self._ros_node is not None: - props = [ - "in_temperature","out_temperature","pt100_temperature","sensor_average_temperature", - "target_temperature","setting_temperature","viscosity","average_viscosity", - "speed","force" - ] - for name in props: - pub = self._ros_node._property_publishers.get(name) - if pub: - pub.publish_property() - frame = data.get("frameCode") - reactor_id = None - try: - reactor_id = self._frame_to_reactor_id.get(int(frame)) - except Exception: - reactor_id = None - if reactor_id and hasattr(self._ros_node, "sub_devices"): - child = self._ros_node.sub_devices.get(reactor_id) - if child and hasattr(child, "driver_instance"): - child.driver_instance.update_metrics(data) - pubs = getattr(child.ros_node_instance, "_property_publishers", {}) - for name in props: - p = pubs.get(name) - if p: - p.publish_property() - except Exception: - pass - event = { - "frameCode": data.get("frameCode"), - "generateTime": data.get("generateTime"), - "targetTemperature": data.get("targetTemperature"), - "settingTemperature": data.get("settingTemperature"), - "inTemperature": data.get("inTemperature"), - "outTemperature": data.get("outTemperature"), - "pt100Temperature": data.get("pt100Temperature"), - "sensorAverageTemperature": data.get("sensorAverageTemperature"), - "speed": data.get("speed"), - "force": data.get("force"), - "viscosity": data.get("viscosity"), - "averageViscosity": data.get("averageViscosity"), - "request_time": report_request.request_time, - "timestamp": datetime.now().isoformat(), - "reactor_id": self._frame_to_reactor_id.get(int(data.get("frameCode", 0))) if str(data.get("frameCode", "")).isdigit() else None, - } - - base_dir = Path(__file__).resolve().parents[3] / "unilabos_data" - base_dir.mkdir(parents=True, exist_ok=True) - out_file = base_dir / "temperature_cutoff_events.json" - try: - existing = json.loads(out_file.read_text(encoding="utf-8")) if out_file.exists() else [] - if not isinstance(existing, list): - existing = [] - except Exception: - existing = [] - existing.append(event) - out_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8") - - if hasattr(self, "_ros_node") and self._ros_node is not None: - ns = self._ros_node.namespace - topics = { - "targetTemperature": f"{ns}/metrics/temperature_cutoff/target_temperature", - "settingTemperature": f"{ns}/metrics/temperature_cutoff/setting_temperature", - "inTemperature": f"{ns}/metrics/temperature_cutoff/in_temperature", - "outTemperature": f"{ns}/metrics/temperature_cutoff/out_temperature", - "pt100Temperature": f"{ns}/metrics/temperature_cutoff/pt100_temperature", - "sensorAverageTemperature": f"{ns}/metrics/temperature_cutoff/sensor_average_temperature", - "speed": f"{ns}/metrics/temperature_cutoff/speed", - "force": f"{ns}/metrics/temperature_cutoff/force", - "viscosity": f"{ns}/metrics/temperature_cutoff/viscosity", - "averageViscosity": f"{ns}/metrics/temperature_cutoff/average_viscosity", - } - for k, t in topics.items(): - v = data.get(k) - if v is not None: - pub = self._ros_node.create_publisher(Float64, t, 10) - pub.publish(convert_to_ros_msg(Float64, float(v))) - - evt_pub = self._ros_node.create_publisher(String, f"{ns}/events/temperature_cutoff", 10) - evt_pub.publish(convert_to_ros_msg(String, json.dumps(event, ensure_ascii=False))) - - return {"processed": True, "frame": data.get("frameCode")} - except Exception as e: - return {"processed": False, "error": str(e)} - - def wait_for_multiple_orders_and_get_reports(self, batch_create_result: str = None, timeout: int = 7200, check_interval: int = 10) -> Dict[str, Any]: - try: - timeout = int(timeout) if timeout else 7200 - check_interval = int(check_interval) if check_interval else 10 - if not batch_create_result or batch_create_result == "": - raise ValueError("batch_create_result为空") - try: - if isinstance(batch_create_result, str) and '[...]' in batch_create_result: - batch_create_result = batch_create_result.replace('[...]', '[]') - result_obj = json.loads(batch_create_result) if isinstance(batch_create_result, str) else batch_create_result - if isinstance(result_obj, dict) and "return_value" in result_obj: - inner = result_obj.get("return_value") - if isinstance(inner, str): - result_obj = json.loads(inner) - elif isinstance(inner, dict): - result_obj = inner - order_codes = result_obj.get("order_codes", []) - order_ids = result_obj.get("order_ids", []) - except Exception as e: - raise ValueError(f"解析batch_create_result失败: {e}") - if not order_codes or not order_ids: - raise ValueError("缺少order_codes或order_ids") - if not isinstance(order_codes, list): - order_codes = [order_codes] - if not isinstance(order_ids, list): - order_ids = [order_ids] - if len(order_codes) != len(order_ids): - raise ValueError("order_codes与order_ids数量不匹配") - total = len(order_codes) - pending = {c: {"order_id": order_ids[i], "completed": False} for i, c in enumerate(order_codes)} - reports = [] - start_time = time.time() - while pending: - elapsed_time = time.time() - start_time - if elapsed_time > timeout: - for oc in list(pending.keys()): - reports.append({ - "order_code": oc, - "order_id": pending[oc]["order_id"], - "status": "timeout", - "completion_status": None, - "report": None, - "extracted": None, - "elapsed_time": elapsed_time - }) - break - completed_round = [] - for oc in list(pending.keys()): - oid = pending[oc]["order_id"] - if oc in self.order_completion_status: - info = self.order_completion_status[oc] - try: - rep = self.hardware_interface.order_report(oid) - if not rep: - rep = {"error": "无法获取报告"} - reports.append({ - "order_code": oc, - "order_id": oid, - "status": "completed", - "completion_status": info.get('status'), - "report": rep, - "extracted": self._extract_actuals_from_report(rep), - "elapsed_time": elapsed_time - }) - completed_round.append(oc) - del self.order_completion_status[oc] - except Exception as e: - reports.append({ - "order_code": oc, - "order_id": oid, - "status": "error", - "completion_status": info.get('status') if 'info' in locals() else None, - "report": None, - "extracted": None, - "error": str(e), - "elapsed_time": elapsed_time - }) - completed_round.append(oc) - for oc in completed_round: - del pending[oc] - if pending: - time.sleep(check_interval) - completed_count = sum(1 for r in reports if r['status'] == 'completed') - timeout_count = sum(1 for r in reports if r['status'] == 'timeout') - error_count = sum(1 for r in reports if r['status'] == 'error') - final_elapsed_time = time.time() - start_time - summary = { - "total": total, - "completed": completed_count, - "timeout": timeout_count, - "error": error_count, - "elapsed_time": round(final_elapsed_time, 2), - "reports": reports - } - return { - "return_info": json.dumps(summary, ensure_ascii=False) - } - except Exception as e: - raise - def liquid_feeding_beaker( self, - volume: str = "350", + volume: str = "35000", assign_material_name: str = "BAPP", time: str = "0", torque_variation: int = 1, @@ -778,7 +355,7 @@ class BioyondReactionStation(BioyondWorkstation): """液体进料烧杯 Args: - volume: 分液质量(g) + volume: 分液量(μL) assign_material_name: 物料名称(试剂瓶位) time: 观察时间(分钟) torque_variation: 是否观察(int类型, 1=否, 2=是) @@ -912,106 +489,6 @@ class BioyondReactionStation(BioyondWorkstation): """ return self.hardware_interface.create_order(json_str) - def hard_delete_merged_workflows(self, workflow_ids: List[str]) -> Dict[str, Any]: - """ - 调用新接口:硬删除合并后的工作流 - - Args: - workflow_ids: 要删除的工作流ID数组 - - Returns: - 删除结果 - """ - try: - if not isinstance(workflow_ids, list): - raise ValueError("workflow_ids必须是字符串数组") - return self._delete_project_api("/api/lims/order/workflows", workflow_ids) - except Exception as e: - print(f"❌ 硬删除异常: {str(e)}") - return {"code": 0, "message": str(e), "timestamp": int(time.time())} - - # ==================== 项目接口通用方法 ==================== - - def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]: - """项目接口通用POST调用 - - 参数: - endpoint: 接口路径(例如 /api/lims/order/skip-titration-steps) - data: 请求体中的 data 字段内容 - - 返回: - dict: 服务端响应,失败时返回 {code:0,message,...} - """ - request_data = { - "apiKey": API_CONFIG["api_key"], - "requestTime": self.hardware_interface.get_current_time_iso8601(), - "data": data - } - print(f"\n📤 项目POST请求: {self.hardware_interface.host}{endpoint}") - print(json.dumps(request_data, indent=4, ensure_ascii=False)) - try: - response = requests.post( - f"{self.hardware_interface.host}{endpoint}", - json=request_data, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - result = response.json() - if result.get("code") == 1: - print("✅ 请求成功") - else: - print(f"❌ 请求失败: {result.get('message','未知错误')}") - return result - except json.JSONDecodeError: - print("❌ 非JSON响应") - return {"code": 0, "message": "非JSON响应", "timestamp": int(time.time())} - except requests.exceptions.Timeout: - print("❌ 请求超时") - return {"code": 0, "message": "请求超时", "timestamp": int(time.time())} - except requests.exceptions.RequestException as e: - print(f"❌ 网络异常: {str(e)}") - return {"code": 0, "message": str(e), "timestamp": int(time.time())} - - def _delete_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]: - """项目接口通用DELETE调用 - - 参数: - endpoint: 接口路径(例如 /api/lims/order/workflows) - data: 请求体中的 data 字段内容 - - 返回: - dict: 服务端响应,失败时返回 {code:0,message,...} - """ - request_data = { - "apiKey": API_CONFIG["api_key"], - "requestTime": self.hardware_interface.get_current_time_iso8601(), - "data": data - } - print(f"\n📤 项目DELETE请求: {self.hardware_interface.host}{endpoint}") - print(json.dumps(request_data, indent=4, ensure_ascii=False)) - try: - response = requests.delete( - f"{self.hardware_interface.host}{endpoint}", - json=request_data, - headers={"Content-Type": "application/json"}, - timeout=30 - ) - result = response.json() - if result.get("code") == 1: - print("✅ 请求成功") - else: - print(f"❌ 请求失败: {result.get('message','未知错误')}") - return result - except json.JSONDecodeError: - print("❌ 非JSON响应") - return {"code": 0, "message": "非JSON响应", "timestamp": int(time.time())} - except requests.exceptions.Timeout: - print("❌ 请求超时") - return {"code": 0, "message": "请求超时", "timestamp": int(time.time())} - except requests.exceptions.RequestException as e: - print(f"❌ 网络异常: {str(e)}") - return {"code": 0, "message": str(e), "timestamp": int(time.time())} - # ==================== 工作流执行核心方法 ==================== def process_web_workflows(self, web_workflow_json: str) -> List[Dict[str, str]]: @@ -1042,6 +519,69 @@ class BioyondReactionStation(BioyondWorkstation): print(f"错误:处理工作流失败: {e}") return [] + def process_and_execute_workflow(self, workflow_name: str, task_name: str) -> dict: + """ + 一站式处理工作流程:解析网页工作流列表,合并工作流(带参数),然后发布任务 + + Args: + workflow_name: 合并后的工作流名称 + task_name: 任务名称 + + Returns: + 任务创建结果 + """ + web_workflow_list = self.get_workflow_sequence() + print(f"\n{'='*60}") + print(f"📋 处理网页工作流列表: {web_workflow_list}") + print(f"{'='*60}") + + web_workflow_json = json.dumps({"web_workflow_list": web_workflow_list}) + workflows_result = self.process_web_workflows(web_workflow_json) + + if not workflows_result: + return self._create_error_result("处理网页工作流列表失败", "process_web_workflows") + + print(f"workflows_result 类型: {type(workflows_result)}") + print(f"workflows_result 内容: {workflows_result}") + + workflows_with_params = self._build_workflows_with_parameters(workflows_result) + + merge_data = { + "name": workflow_name, + "workflows": workflows_with_params + } + + # print(f"\n🔄 合并工作流(带参数),名称: {workflow_name}") + merged_workflow = self.merge_workflow_with_parameters(json.dumps(merge_data)) + + if not merged_workflow: + return self._create_error_result("合并工作流失败", "merge_workflow_with_parameters") + + workflow_id = merged_workflow.get("subWorkflows", [{}])[0].get("id", "") + # print(f"\n📤 使用工作流创建任务: {workflow_name} (ID: {workflow_id})") + + order_params = [{ + "orderCode": f"task_{self.hardware_interface.get_current_time_iso8601()}", + "orderName": task_name, + "workFlowId": workflow_id, + "borderNumber": 1, + "paramValues": {} + }] + + result = self.create_order(json.dumps(order_params)) + + if not result: + return self._create_error_result("创建任务失败", "create_order") + + # 清空工作流序列和参数,防止下次执行时累积重复 + self.pending_task_params = [] + self.clear_workflows() # 清空工作流序列,避免重复累积 + + # print(f"\n✅ 任务创建成功: {result}") + # print(f"\n✅ 任务创建成功") + print(f"{'='*60}\n") + return json.dumps({"success": True, "result": result}) + def _build_workflows_with_parameters(self, workflows_result: list) -> list: """ 构建带参数的工作流列表 @@ -1240,91 +780,4 @@ class BioyondReactionStation(BioyondWorkstation): except Exception as e: print(f" ❌ 工作流ID验证失败: {e}") print(f" 💡 将重新合并工作流") - return False - - def process_and_execute_workflow(self, workflow_name: str, task_name: str) -> dict: - """ - 一站式处理工作流程:解析网页工作流列表,合并工作流(带参数),然后发布任务 - - Args: - workflow_name: 合并后的工作流名称 - task_name: 任务名称 - - Returns: - 任务创建结果 - """ - web_workflow_list = self.get_workflow_sequence() - print(f"\n{'='*60}") - print(f"📋 处理网页工作流列表: {web_workflow_list}") - print(f"{'='*60}") - - web_workflow_json = json.dumps({"web_workflow_list": web_workflow_list}) - workflows_result = self.process_web_workflows(web_workflow_json) - - if not workflows_result: - return self._create_error_result("处理网页工作流列表失败", "process_web_workflows") - - print(f"workflows_result 类型: {type(workflows_result)}") - print(f"workflows_result 内容: {workflows_result}") - - workflows_with_params = self._build_workflows_with_parameters(workflows_result) - - merge_data = { - "name": workflow_name, - "workflows": workflows_with_params - } - - # print(f"\n🔄 合并工作流(带参数),名称: {workflow_name}") - merged_workflow = self.merge_workflow_with_parameters(json.dumps(merge_data)) - - if not merged_workflow: - return self._create_error_result("合并工作流失败", "merge_workflow_with_parameters") - - workflow_id = merged_workflow.get("subWorkflows", [{}])[0].get("id", "") - # print(f"\n📤 使用工作流创建任务: {workflow_name} (ID: {workflow_id})") - - order_params = [{ - "orderCode": f"task_{self.hardware_interface.get_current_time_iso8601()}", - "orderName": task_name, - "workFlowId": workflow_id, - "borderNumber": 1, - "paramValues": {} - }] - - result = self.create_order(json.dumps(order_params)) - - if not result: - return self._create_error_result("创建任务失败", "create_order") - - # 清空工作流序列和参数,防止下次执行时累积重复 - self.pending_task_params = [] - self.clear_workflows() # 清空工作流序列,避免重复累积 - - # print(f"\n✅ 任务创建成功: {result}") - # print(f"\n✅ 任务创建成功") - print(f"{'='*60}\n") - - # 返回结果,包含合并后的工作流数据和订单参数 - return json.dumps({ - "success": True, - "result": result, - "merged_workflow": merged_workflow, - "order_params": order_params - }) - - # ==================== 反应器操作接口 ==================== - - def skip_titration_steps(self, preintake_id: str) -> Dict[str, Any]: - """跳过当前正在进行的滴定步骤 - - Args: - preintake_id: 通量ID - - Returns: - Dict[str, Any]: 服务器响应,包含状态码、消息和时间戳 - """ - try: - return self._post_project_api("/api/lims/order/skip-titration-steps", preintake_id) - except Exception as e: - print(f"❌ 跳过滴定异常: {str(e)}") - return {"code": 0, "message": str(e), "timestamp": int(time.time())} + return False \ No newline at end of file diff --git a/unilabos/devices/workstation/bioyond_studio/station.py b/unilabos/devices/workstation/bioyond_studio/station.py index e349b083..44f9cf19 100644 --- a/unilabos/devices/workstation/bioyond_studio/station.py +++ b/unilabos/devices/workstation/bioyond_studio/station.py @@ -9,7 +9,6 @@ import traceback from datetime import datetime from typing import Dict, Any, List, Optional, Union import json -from pathlib import Path from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC @@ -20,13 +19,11 @@ from unilabos.resources.graphio import resource_bioyond_to_plr, resource_plr_to_ from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode -from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String from pylabrobot.resources.resource import Resource as ResourcePLR from unilabos.devices.workstation.bioyond_studio.config import ( - API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG + API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING ) -from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService class BioyondResourceSynchronizer(ResourceSynchronizer): @@ -66,367 +63,51 @@ class BioyondResourceSynchronizer(ResourceSynchronizer): logger.error("Bioyond API客户端未初始化") return False - # 同时查询耗材类型(typeMode=0)、样品类型(typeMode=1)和试剂类型(typeMode=2) - all_bioyond_data = [] - - # 查询耗材类型物料(例如:枪头盒) - bioyond_data_type0 = self.bioyond_api_client.stock_material('{"typeMode": 0, "includeDetail": true}') - if bioyond_data_type0: - all_bioyond_data.extend(bioyond_data_type0) - logger.debug(f"从Bioyond查询到 {len(bioyond_data_type0)} 个耗材类型物料") - - # 查询样品类型物料(烧杯、试剂瓶、分装板等) - bioyond_data_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') - if bioyond_data_type1: - all_bioyond_data.extend(bioyond_data_type1) - logger.debug(f"从Bioyond查询到 {len(bioyond_data_type1)} 个样品类型物料") - - # 查询试剂类型物料(样品板、样品瓶等) - bioyond_data_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') - if bioyond_data_type2: - all_bioyond_data.extend(bioyond_data_type2) - logger.debug(f"从Bioyond查询到 {len(bioyond_data_type2)} 个试剂类型物料") - - if not all_bioyond_data: + 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( - all_bioyond_data, + bioyond_data, type_mapping=self.workstation.bioyond_config["material_type_mappings"], deck=self.workstation.deck ) + print("unilab_resources:",unilab_resources) 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: - # ✅ 跳过仓库类型的资源 - 仓库是容器,不是物料 - resource_category = getattr(resource, "category", None) - if resource_category == "warehouse": - logger.debug(f"[同步→Bioyond] 跳过仓库类型资源: {resource.name} (仓库是容器,不需要同步为物料)") - return True - - logger.info(f"[同步→Bioyond] 收到物料变更: {resource.name}") - - # 获取物料的 Bioyond ID - extra_info = getattr(resource, "unilabos_extra", {}) - material_bioyond_id = extra_info.get("material_bioyond_id") - - # 🔥 查询所有物料,用于获取物料当前位置等信息 - existing_materials = [] - try: - import json - logger.info(f"[同步→Bioyond] 查询 Bioyond 系统中的所有物料...") - all_materials = [] - - for type_mode in [0, 1, 2]: # 0=耗材, 1=样品, 2=试剂 - query_params = json.dumps({ - "typeMode": type_mode, - "filter": "", - "includeDetail": True - }) - materials = self.bioyond_api_client.stock_material(query_params) - if materials: - all_materials.extend(materials) - - existing_materials = all_materials - logger.info(f"[同步→Bioyond] 查询到 {len(all_materials)} 个物料") - except Exception as e: - logger.error(f"查询 Bioyond 物料失败: {e}") + if self.bioyond_api_client is None: + logger.error("Bioyond API客户端未初始化") return False - # ⭐ 如果没有 Bioyond ID,尝试从查询结果中按名称匹配 - if not material_bioyond_id: - logger.warning(f"[同步→Bioyond] 物料 {resource.name} 没有 Bioyond ID,尝试按名称查询...") - for mat in existing_materials: - if mat.get("name") == resource.name: - material_bioyond_id = mat.get("id") - mat_type = mat.get("typeName", "未知") - logger.info(f"✅ 找到物料 {resource.name} ({mat_type}) 的 Bioyond ID: {material_bioyond_id[:8]}...") - # 保存 ID 到资源对象 - extra_info["material_bioyond_id"] = material_bioyond_id - setattr(resource, "unilabos_extra", extra_info) - break - - if not material_bioyond_id: - logger.warning(f"⚠️ 在 Bioyond 系统中未找到名为 {resource.name} 的物料") - logger.info(f"[同步→Bioyond] 这是一个新物料,将创建并入库到 Bioyond 系统") - - # 检查是否有位置更新请求 - update_site = extra_info.get("update_resource_site") - - if not update_site: - logger.debug(f"[同步→Bioyond] 物料 {resource.name} 无位置更新请求,跳过同步") - return True - - # ===== 物料移动/创建流程 ===== - logger.info(f"[同步→Bioyond] 📍 物料 {resource.name} 目标库位: {update_site}") - - if material_bioyond_id: - logger.info(f"[同步→Bioyond] 🔄 物料已存在于 Bioyond (ID: {material_bioyond_id[:8]}...),执行移动操作") - else: - logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库") - - # 第1步:获取仓库配置 - from .config import WAREHOUSE_MAPPING - warehouse_mapping = WAREHOUSE_MAPPING - - # 确定目标仓库名称 - parent_name = None - target_location_uuid = None - current_warehouse = None - - # 🔥 优先级1: 从 Bioyond 查询结果中获取物料当前所在的仓库 - if material_bioyond_id: - for mat in existing_materials: - if mat.get("name") == resource.name or mat.get("id") == material_bioyond_id: - locations = mat.get("locations", []) - if locations and len(locations) > 0: - current_warehouse = locations[0].get("whName") - logger.info(f"[同步→Bioyond] 💡 物料当前位于 Bioyond 仓库: {current_warehouse}") - break - - # 优先在当前仓库中查找目标库位 - if current_warehouse and current_warehouse in warehouse_mapping: - site_uuids = warehouse_mapping[current_warehouse].get("site_uuids", {}) - if update_site in site_uuids: - parent_name = current_warehouse - target_location_uuid = site_uuids[update_site] - logger.info(f"[同步→Bioyond] ✅ 在当前仓库找到目标库位: {parent_name}/{update_site}") - logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") - else: - logger.warning(f"⚠️ [同步→Bioyond] 当前仓库 {current_warehouse} 中没有库位 {update_site},将搜索其他仓库") - - # 🔥 优先级2: 检查 PLR 父节点名称 - if not parent_name or not target_location_uuid: - if resource.parent is not None: - parent_name_candidate = resource.parent.name - logger.info(f"[同步→Bioyond] 从 PLR 父节点获取仓库名称: {parent_name_candidate}") - - if parent_name_candidate in warehouse_mapping: - site_uuids = warehouse_mapping[parent_name_candidate].get("site_uuids", {}) - if update_site in site_uuids: - parent_name = parent_name_candidate - target_location_uuid = site_uuids[update_site] - logger.info(f"[同步→Bioyond] ✅ 在父节点仓库找到目标库位: {parent_name}/{update_site}") - logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") - - # 🔥 优先级3: 遍历所有仓库查找(兜底方案) - if not parent_name or not target_location_uuid: - logger.info(f"[同步→Bioyond] 从所有仓库中查找库位 {update_site}...") - for warehouse_name, warehouse_info in warehouse_mapping.items(): - site_uuids = warehouse_info.get("site_uuids", {}) - if update_site in site_uuids: - parent_name = warehouse_name - target_location_uuid = site_uuids[update_site] - logger.warning(f"[同步→Bioyond] ⚠️ 在其他仓库找到目标库位: {parent_name}/{update_site}") - logger.info(f"[同步→Bioyond] 目标库位UUID: {target_location_uuid[:8]}...") - break - - if not parent_name or not target_location_uuid: - logger.error(f"❌ [同步→Bioyond] 库位 {update_site} 没有在 WAREHOUSE_MAPPING 中配置") - logger.debug(f"[同步→Bioyond] 可用仓库: {list(warehouse_mapping.keys())}") - return False - - # 第2步:转换为 Bioyond 格式 - logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...") - - # 导入物料默认参数配置 - from .config import MATERIAL_DEFAULT_PARAMETERS - bioyond_material = resource_plr_to_bioyond( [resource], type_mapping=self.workstation.bioyond_config["material_type_mappings"], - warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], - material_params=MATERIAL_DEFAULT_PARAMETERS + warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"] )[0] - logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...") + location_info = bioyond_material.pop("locations") - # 🔥 强制覆盖 locations 信息,使用正确的目标库位 UUID - # resource_plr_to_bioyond 可能会生成错误的仓库信息,这里直接覆盖 - bioyond_material["locations"] = [{ - "id": target_location_uuid, - "whid": "", - "whName": parent_name, - "x": ord(update_site[0]) - ord('A') + 1, # A→1, B→2, ... - "y": int(update_site[1:]), # 01→1, 02→2, ... - "z": 1, - "quantity": 0 - }] - logger.info(f"[同步→Bioyond] ✅ 已覆盖库位信息: {parent_name}/{update_site} (UUID: {target_location_uuid[:8]}...)") + material_id = self.bioyond_api_client.add_material(bioyond_material) - logger.debug(f"[同步→Bioyond] Bioyond 物料数据: {bioyond_material}") - - location_info = bioyond_material.get("locations") - logger.debug(f"[同步→Bioyond] 库位信息: {location_info}, 类型: {type(location_info)}") - - # 第3步:根据是否已有 Bioyond ID 决定创建还是使用现有物料 - if material_bioyond_id: - # 物料已存在,直接使用现有 ID - material_id = material_bioyond_id - logger.info(f"✅ [同步→Bioyond] 使用已有物料 ID: {material_id[:8]}...") - else: - # 物料不存在,调用 API 创建新物料 - logger.info(f"[同步→Bioyond] 📤 调用 Bioyond API 添加物料...") - material_id = self.bioyond_api_client.add_material(bioyond_material) - - if not material_id: - logger.error(f"❌ [同步→Bioyond] 添加物料失败,API 返回空") - return False - - logger.info(f"✅ [同步→Bioyond] 物料添加成功,Bioyond ID: {material_id[:8]}...") - - # 保存新创建的物料 ID 到资源对象 - extra_info["material_bioyond_id"] = material_id - setattr(resource, "unilabos_extra", extra_info) - - # 第4步:物料入库前先检查目标库位是否被占用 - if location_info: - logger.info(f"[同步→Bioyond] 📥 准备入库到库位 {update_site}...") - - # 处理不同的 location_info 数据结构 - if isinstance(location_info, list) and len(location_info) > 0: - location_id = location_info[0]["id"] - elif isinstance(location_info, dict): - location_id = location_info["id"] - else: - logger.warning(f"⚠️ [同步→Bioyond] 无效的库位信息格式: {location_info}") - location_id = None - - if location_id: - # 查询目标库位是否已有物料 - logger.info(f"[同步→Bioyond] 🔍 检查库位 {update_site} (UUID: {location_id[:8]}...) 是否被占用...") - - # 查询所有物料,检查是否有物料在目标库位 - try: - all_materials_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') - all_materials_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') - all_materials = (all_materials_type1 or []) + (all_materials_type2 or []) - - # 检查是否有物料已经在目标库位 - location_occupied = False - occupying_material = None - - # 同时检查当前物料是否在其他位置(需要先出库) - current_material_location = None - current_location_uuid = None - - for material in all_materials: - locations = material.get("locations", []) - - # 检查目标库位占用情况 - for loc in locations: - if loc.get("id") == location_id: - location_occupied = True - occupying_material = material - logger.warning(f"⚠️ [同步→Bioyond] 库位 {update_site} 已被占用!") - logger.warning(f" 占用物料: {material.get('name')} (ID: {material.get('id', '')[:8]}...)") - logger.warning(f" 占用位置: code={loc.get('code')}, x={loc.get('x')}, y={loc.get('y')}") - logger.warning(f" 🔍 详细信息: location_id={loc.get('id')[:8]}..., 目标UUID={location_id[:8]}...") - logger.warning(f" 🔍 完整location数据: {loc}") - break - - # 检查当前物料是否在其他位置 - if material.get("id") == material_id and locations: - current_material_location = locations[0] - current_location_uuid = current_material_location.get("id") - logger.info(f"📍 [同步→Bioyond] 物料当前位置: {current_material_location.get('whName')}/{current_material_location.get('code')} (UUID: {current_location_uuid[:8]}...)") - - if location_occupied: - break - - if location_occupied: - # 如果是同一个物料(ID相同),说明已经在目标位置了,跳过 - if occupying_material and occupying_material.get("id") == material_id: - logger.info(f"✅ [同步→Bioyond] 物料 {resource.name} 已经在库位 {update_site},跳过重复入库") - return True - else: - logger.error(f"❌ [同步→Bioyond] 库位 {update_site} 已被其他物料占用,拒绝入库") - return False - - logger.info(f"✅ [同步→Bioyond] 库位 {update_site} 可用,准备入库...") - - except Exception as e: - logger.warning(f"⚠️ [同步→Bioyond] 检查库位状态时发生异常: {e},继续尝试入库...") - - # 🔧 如果物料当前在其他位置,先出库再入库 - if current_location_uuid and current_location_uuid != location_id: - logger.info(f"[同步→Bioyond] 🚚 物料需要移动,先从当前位置出库...") - logger.info(f" 当前位置 UUID: {current_location_uuid[:8]}...") - logger.info(f" 目标位置 UUID: {location_id[:8]}...") - - try: - # 获取物料数量用于出库 - material_quantity = current_material_location.get("totalNumber", 1) - logger.info(f" 出库数量: {material_quantity}") - - # 调用出库 API - outbound_response = self.bioyond_api_client.material_outbound_by_id( - material_id, - current_location_uuid, - material_quantity - ) - logger.info(f"✅ [同步→Bioyond] 物料从 {current_material_location.get('code')} 出库成功") - except Exception as e: - logger.error(f"❌ [同步→Bioyond] 物料出库失败: {e}") - return False - - # 执行入库 - logger.info(f"[同步→Bioyond] 📥 调用 Bioyond API 物料入库...") - response = self.bioyond_api_client.material_inbound(material_id, location_id) - - # 注意:Bioyond API 成功时返回空字典 {},所以不能用 if not response 判断 - # 只要没有抛出异常,就认为成功(response 是 dict 类型,即使是 {} 也不是 None) - if response is not None: - logger.info(f"✅ [同步→Bioyond] 物料 {resource.name} 成功入库到 {update_site}") - - # 入库成功后,重新查询验证物料实际入库位置 - logger.info(f"[同步→Bioyond] 🔍 验证物料实际入库位置...") - try: - all_materials_type1 = self.bioyond_api_client.stock_material('{"typeMode": 1, "includeDetail": true}') - all_materials_type2 = self.bioyond_api_client.stock_material('{"typeMode": 2, "includeDetail": true}') - all_materials = (all_materials_type1 or []) + (all_materials_type2 or []) - - for material in all_materials: - if material.get("id") == material_id: - locations = material.get("locations", []) - if locations: - actual_loc = locations[0] - logger.info(f"📍 [同步→Bioyond] 物料实际位置: code={actual_loc.get('code')}, " - f"warehouse={actual_loc.get('whName')}, " - f"x={actual_loc.get('x')}, y={actual_loc.get('y')}") - - # 验证 UUID 是否匹配 - if actual_loc.get("id") != location_id: - logger.error(f"❌ [同步→Bioyond] UUID 不匹配!") - logger.error(f" 预期 UUID: {location_id}") - logger.error(f" 实际 UUID: {actual_loc.get('id')}") - logger.error(f" 这说明配置文件中的 UUID 映射有误,请检查 config.py 中的 WAREHOUSE_MAPPING") - break - except Exception as e: - logger.warning(f"⚠️ [同步→Bioyond] 验证入库位置时发生异常: {e}") - else: - logger.error(f"❌ [同步→Bioyond] 物料入库失败") - return False - else: - logger.warning(f"⚠️ [同步→Bioyond] 无法获取库位 ID,跳过入库操作") - else: - logger.warning(f"⚠️ [同步→Bioyond] 物料没有库位信息,跳过入库操作") - return True - - except Exception as e: - logger.error(f"❌ [同步→Bioyond] 同步物料 {resource.name} 时发生异常: {e}") - import traceback - traceback.print_exc() - return False + response = self.bioyond_api_client.material_inbound(material_id, location_info[0]["id"]) + if not response: + return { + "status": "error", + "message": "Failed to inbound material" + } + except: + pass def handle_external_change(self, change_info: Dict[str, Any]) -> bool: """处理Bioyond系统的变更通知""" @@ -439,144 +120,6 @@ class BioyondResourceSynchronizer(ResourceSynchronizer): logger.error(f"处理Bioyond变更通知失败: {e}") return False - def _create_material_only(self, resource: Any) -> Optional[str]: - """只创建物料到 Bioyond 系统(不入库) - - Transfer 阶段使用:只调用 add_material API 创建物料记录 - - Args: - resource: 要创建的资源对象 - - Returns: - str: 创建成功返回 Bioyond 物料 ID,失败返回 None - """ - try: - # 跳过仓库类型的资源 - resource_category = getattr(resource, "category", None) - if resource_category == "warehouse": - logger.debug(f"[创建物料] 跳过仓库类型资源: {resource.name}") - return None - - logger.info(f"[创建物料] 开始创建物料: {resource.name}") - - # 检查是否已经有 Bioyond ID - extra_info = getattr(resource, "unilabos_extra", {}) - material_bioyond_id = extra_info.get("material_bioyond_id") - - if material_bioyond_id: - logger.info(f"[创建物料] 物料 {resource.name} 已存在 (ID: {material_bioyond_id[:8]}...),跳过创建") - return material_bioyond_id - - # 转换为 Bioyond 格式 - from .config import MATERIAL_DEFAULT_PARAMETERS - - bioyond_material = resource_plr_to_bioyond( - [resource], - type_mapping=self.workstation.bioyond_config["material_type_mappings"], - warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"], - material_params=MATERIAL_DEFAULT_PARAMETERS - )[0] - - # ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位 - # locations 字段在后续的入库操作中才会指定 - bioyond_material.pop("locations", None) - - logger.info(f"[创建物料] 调用 Bioyond API 创建物料(不指定库位)...") - material_id = self.bioyond_api_client.add_material(bioyond_material) - - if not material_id: - logger.error(f"[创建物料] 创建物料失败,API 返回空") - return None - - logger.info(f"✅ [创建物料] 物料创建成功,ID: {material_id[:8]}...") - - # 保存 Bioyond ID 到资源对象 - extra_info["material_bioyond_id"] = material_id - setattr(resource, "unilabos_extra", extra_info) - - return material_id - - except Exception as e: - logger.error(f"❌ [创建物料] 创建物料 {resource.name} 时发生异常: {e}") - import traceback - traceback.print_exc() - return None - - def _inbound_material_only(self, resource: Any, material_id: str) -> bool: - """只执行物料入库操作(物料已存在于 Bioyond 系统) - - Add 阶段使用:调用 material_inbound API 将物料入库到指定库位 - - Args: - resource: 要入库的资源对象 - material_id: Bioyond 物料 ID - - Returns: - bool: 入库成功返回 True,失败返回 False - """ - try: - logger.info(f"[物料入库] 开始入库物料: {resource.name} (ID: {material_id[:8]}...)") - - # 获取目标库位信息 - extra_info = getattr(resource, "unilabos_extra", {}) - update_site = extra_info.get("update_resource_site") - - if not update_site: - logger.warning(f"[物料入库] 物料 {resource.name} 没有指定目标库位,跳过入库") - return True - - logger.info(f"[物料入库] 目标库位: {update_site}") - - # 获取仓库配置和目标库位 UUID - from .config import WAREHOUSE_MAPPING - warehouse_mapping = WAREHOUSE_MAPPING - - parent_name = None - target_location_uuid = None - - # 查找目标库位的 UUID - if resource.parent is not None: - parent_name_candidate = resource.parent.name - if parent_name_candidate in warehouse_mapping: - site_uuids = warehouse_mapping[parent_name_candidate].get("site_uuids", {}) - if update_site in site_uuids: - parent_name = parent_name_candidate - target_location_uuid = site_uuids[update_site] - logger.info(f"[物料入库] 从父节点找到库位: {parent_name}/{update_site}") - - # 兜底:遍历所有仓库查找 - if not target_location_uuid: - for warehouse_name, warehouse_info in warehouse_mapping.items(): - site_uuids = warehouse_info.get("site_uuids", {}) - if update_site in site_uuids: - parent_name = warehouse_name - target_location_uuid = site_uuids[update_site] - logger.info(f"[物料入库] 从所有仓库找到库位: {parent_name}/{update_site}") - break - - if not target_location_uuid: - logger.error(f"❌ [物料入库] 库位 {update_site} 未在配置中找到") - return False - - logger.info(f"[物料入库] 库位 UUID: {target_location_uuid[:8]}...") - - # 调用入库 API - logger.info(f"[物料入库] 调用 Bioyond API 执行入库...") - response = self.bioyond_api_client.material_inbound(material_id, target_location_uuid) - - if response: # 空字典 {} 表示失败,非空字典表示成功 - logger.info(f"✅ [物料入库] 物料 {resource.name} 成功入库到 {update_site}") - return True - else: - logger.error(f"❌ [物料入库] 物料入库失败,API返回空响应或失败") - return False - - except Exception as e: - logger.error(f"❌ [物料入库] 入库物料 {resource.name} 时发生异常: {e}") - import traceback - traceback.print_exc() - return False - class BioyondWorkstation(WorkstationBase): """Bioyond工作站 @@ -623,85 +166,46 @@ class BioyondWorkstation(WorkstationBase): self.workflow_sequence = [] self.pending_task_params = [] - if "workflow_mappings" in bioyond_config: - self._set_workflow_mappings(bioyond_config["workflow_mappings"]) - - # 准备 HTTP 报送接收服务配置(延迟到 post_init 启动) - # 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值 - self._http_service_config = { - "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"]) - } - self.http_service = None # 将在 post_init 中启动 - + if self.bioyond_config and "workflow_mappings" in self.bioyond_config: + self._set_workflow_mappings(self.bioyond_config["workflow_mappings"]) logger.info(f"Bioyond工作站初始化完成") - def __del__(self): - """析构函数:清理资源,停止 HTTP 服务""" - try: - if hasattr(self, 'http_service') and self.http_service is not None: - logger.info("正在停止 HTTP 报送服务...") - self.http_service.stop() - except Exception as e: - logger.error(f"停止 HTTP 服务时发生错误: {e}") - def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node - - # 启动 HTTP 报送接收服务(现在 device_id 已可用) - if hasattr(self, '_http_service_config'): - try: - self.http_service = WorkstationHTTPService( - workstation_instance=self, - host=self._http_service_config["host"], - port=self._http_service_config["port"] - ) - self.http_service.start() - logger.info(f"Bioyond工作站HTTP报送服务已启动: {self.http_service.service_url}") - except Exception as e: - logger.error(f"启动HTTP报送服务失败: {e}") - import traceback - traceback.print_exc() - self.http_service = None - - # ⭐ 上传 deck(包括所有 warehouses 及其中的物料) - # 注意:如果有从 Bioyond 同步的物料,它们已经被放置到 warehouse 中了 - # 所以只需要上传 deck,物料会作为 warehouse 的 children 一起上传 - logger.info("正在上传 deck(包括 warehouses 和物料)到云端...") + print("~~~",self._ros_node) + print("deck",self.deck) ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ "resources": [self.deck] }) - - # 清理临时变量(物料已经在 deck 的 warehouse children 中,不需要单独上传) - if hasattr(self, "_synced_resources"): - logger.info(f"✅ {len(self._synced_resources)} 个从Bioyond同步的物料已包含在 deck 中") - self._synced_resources = [] - + def resource_tree_transfer(self, old_parent: ResourcePLR, plr_resource: ResourcePLR, parent_resource: ResourcePLR): + # ROS2DeviceNode.run_async_func(self._ros_node.resource_tree_transfer, True, **{ + # "old_parent": old_parent, + # "plr_resource": plr_resource, + # "parent_resource": parent_resource, + # }) + print("resource_tree_transfer", plr_resource, parent_resource) + if hasattr(plr_resource, "unilabos_data") and plr_resource.unilabos_data: + if "update_resource_site" in plr_resource.unilabos_data: + site = plr_resource.unilabos_data["update_resource_site"] + return + self.lab_logger().warning(f"无库位的上料,不处理,{plr_resource} 挂载到 {parent_resource}") def transfer_resource_to_another(self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], sites: List[str], mount_device_id: DeviceSlot): - future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ + ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True, **{ "plr_resources": resource, "target_device_id": mount_device_id, "target_resources": mount_resource, "sites": sites, }) - return future def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None: """创建Bioyond通信模块""" - # 创建默认配置 - default_config = { + self.bioyond_config = config or { **API_CONFIG, "workflow_mappings": WORKFLOW_MAPPINGS, "material_type_mappings": MATERIAL_TYPE_MAPPINGS, "warehouse_mapping": WAREHOUSE_MAPPING } - # 如果传入了 config,合并配置(config 中的值会覆盖默认值) - if config: - self.bioyond_config = {**default_config, **config} - else: - self.bioyond_config = default_config - self.hardware_interface = BioyondV1RPC(self.bioyond_config) def resource_tree_add(self, resources: List[ResourcePLR]) -> None: @@ -710,265 +214,7 @@ class BioyondWorkstation(WorkstationBase): Args: resources (List[ResourcePLR]): 要添加的资源列表 """ - logger.info(f"[resource_tree_add] 开始同步 {len(resources)} 个资源到 Bioyond 系统") - for resource in resources: - try: - # 🔍 检查资源是否已有 Bioyond ID - extra_info = getattr(resource, "unilabos_extra", {}) - material_bioyond_id = extra_info.get("material_bioyond_id") - - if material_bioyond_id: - # ⭐ 已有 Bioyond ID,说明 transfer 已经创建了物料 - # 现在只需要执行入库操作 - logger.info(f"✅ [resource_tree_add] 物料 {resource.name} 已有 Bioyond ID ({material_bioyond_id[:8]}...),执行入库操作") - self.resource_synchronizer._inbound_material_only(resource, material_bioyond_id) - else: - # ⚠️ 没有 Bioyond ID,说明是直接添加的物料(兜底逻辑) - # 需要先创建再入库 - logger.info(f"⚠️ [resource_tree_add] 物料 {resource.name} 无 Bioyond ID,执行创建+入库操作") - self.resource_synchronizer.sync_to_external(resource) - - except Exception as e: - logger.error(f"[resource_tree_add] 同步资源失败 {resource}: {e}") - import traceback - traceback.print_exc() - - def resource_tree_remove(self, resources: List[ResourcePLR]) -> None: - """处理资源删除时的同步(出库操作) - - 当 UniLab 前端删除物料时,需要将删除操作同步到 Bioyond 系统(出库) - - Args: - resources: 要删除的资源列表 - """ - logger.info(f"[resource_tree_remove] 收到 {len(resources)} 个资源的移除请求(出库操作)") - - # ⭐ 关键优化:先找出所有的顶层容器(BottleCarrier),只对它们进行出库 - # 因为在 Bioyond 中,容器(如分装板 1105-12)是一个完整的物料 - # 里面的小瓶子是它的 detail 字段,不需要单独出库 - - top_level_resources = [] - child_resource_names = set() - - # 第一步:识别所有子资源的名称 - for resource in resources: - resource_category = getattr(resource, "category", None) - if resource_category == "bottle_carrier": - children = list(resource.children) if hasattr(resource, 'children') else [] - for child in children: - child_resource_names.add(child.name) - - # 第二步:筛选出顶层资源(不是任何容器的子资源) - for resource in resources: - resource_category = getattr(resource, "category", None) - - # 跳过仓库类型的资源 - if resource_category == "warehouse": - logger.debug(f"[resource_tree_remove] 跳过仓库类型资源: {resource.name}") - continue - - # 如果是容器,它就是顶层资源 - if resource_category == "bottle_carrier": - top_level_resources.append(resource) - logger.info(f"[resource_tree_remove] 识别到顶层容器资源: {resource.name}") - # 如果不是任何容器的子资源,它也是顶层资源 - elif resource.name not in child_resource_names: - top_level_resources.append(resource) - logger.info(f"[resource_tree_remove] 识别到顶层独立资源: {resource.name}") - else: - logger.debug(f"[resource_tree_remove] 跳过子资源(将随容器一起出库): {resource.name}") - - logger.info(f"[resource_tree_remove] 实际需要处理的顶层资源: {len(top_level_resources)} 个") - - # 第三步:对每个顶层资源执行出库操作 - for resource in top_level_resources: - try: - self._outbound_single_resource(resource) - except Exception as e: - logger.error(f"❌ [resource_tree_remove] 处理资源 {resource.name} 出库失败: {e}") - import traceback - traceback.print_exc() - - logger.info(f"[resource_tree_remove] 资源移除(出库)操作完成") - - def _outbound_single_resource(self, resource: ResourcePLR) -> bool: - """对单个资源执行 Bioyond 出库操作 - - Args: - resource: 要出库的资源 - - Returns: - bool: 出库是否成功 - """ - try: - logger.info(f"[resource_tree_remove] 🎯 开始处理资源出库: {resource.name}") - - # 获取资源的 Bioyond 信息 - extra_info = getattr(resource, "unilabos_extra", {}) - material_bioyond_id = extra_info.get("material_bioyond_id") - material_bioyond_name = extra_info.get("material_bioyond_name") # ⭐ 原始 Bioyond 名称 - - # ⭐ 优先使用保存的 Bioyond ID,避免重复查询 - if material_bioyond_id: - logger.info(f"✅ [resource_tree_remove] 从资源中获取到 Bioyond ID: {material_bioyond_id[:8]}...") - if material_bioyond_name and material_bioyond_name != resource.name: - logger.info(f" 原始 Bioyond 名称: {material_bioyond_name} (当前名称: {resource.name})") - else: - # 如果没有 Bioyond ID,尝试按名称查询 - logger.info(f"[resource_tree_remove] 资源 {resource.name} 没有保存 Bioyond ID,尝试查询...") - - # ⭐ 优先使用保存的原始 Bioyond 名称,如果没有则使用当前名称 - query_name = material_bioyond_name if material_bioyond_name else resource.name - logger.info(f"[resource_tree_remove] 查询 Bioyond 系统中的物料: {query_name}") - - # 查询所有类型的物料:0=耗材, 1=样品, 2=试剂 - all_materials = [] - for type_mode in [0, 1, 2]: - query_params = json.dumps({ - "typeMode": type_mode, - "filter": query_name, # ⭐ 使用原始 Bioyond 名称查询 - "includeDetail": True - }) - materials = self.hardware_interface.stock_material(query_params) - if materials: - all_materials.extend(materials) - - # 精确匹配物料名称 - matched_material = None - for mat in all_materials: - if mat.get("name") == query_name: - matched_material = mat - material_bioyond_id = mat.get("id") - logger.info(f"✅ [resource_tree_remove] 找到物料 {query_name} 的 Bioyond ID: {material_bioyond_id[:8]}...") - break - - if not matched_material: - logger.warning(f"⚠️ [resource_tree_remove] Bioyond 系统中未找到物料: {query_name}") - logger.info(f"[resource_tree_remove] 该物料可能尚未入库或已被删除,跳过出库操作") - return True - - # 获取物料当前所在的库位信息 - logger.info(f"[resource_tree_remove] 📍 查询物料的库位信息...") - - # 重新查询物料详情以获取最新的库位信息 - all_materials_type1 = self.hardware_interface.stock_material('{"typeMode": 1, "includeDetail": true}') - all_materials_type2 = self.hardware_interface.stock_material('{"typeMode": 2, "includeDetail": true}') - all_materials_type0 = self.hardware_interface.stock_material('{"typeMode": 0, "includeDetail": true}') - all_materials = (all_materials_type0 or []) + (all_materials_type1 or []) + (all_materials_type2 or []) - - location_id = None - current_quantity = 0 - - for material in all_materials: - if material.get("id") == material_bioyond_id: - locations = material.get("locations", []) - if locations: - # 取第一个库位 - location = locations[0] - location_id = location.get("id") - current_quantity = location.get("quantity", 1) - logger.info(f"📍 [resource_tree_remove] 物料位于库位:") - logger.info(f" - 库位代码: {location.get('code')}") - logger.info(f" - 仓库名称: {location.get('whName')}") - logger.info(f" - 数量: {current_quantity}") - logger.info(f" - 库位ID: {location_id[:8]}...") - break - else: - logger.warning(f"⚠️ [resource_tree_remove] 物料没有库位信息,可能尚未入库") - return True - - if not location_id: - logger.warning(f"⚠️ [resource_tree_remove] 无法获取物料的库位信息,跳过出库") - return False - - # 调用 Bioyond 出库 API - logger.info(f"[resource_tree_remove] 📤 调用 Bioyond API 出库物料...") - logger.info(f" UniLab 名称: {resource.name}") - if material_bioyond_name and material_bioyond_name != resource.name: - logger.info(f" Bioyond 名称: {material_bioyond_name}") - logger.info(f" 物料ID: {material_bioyond_id[:8]}...") - logger.info(f" 库位ID: {location_id[:8]}...") - logger.info(f" 出库数量: {current_quantity}") - - response = self.hardware_interface.material_outbound_by_id( - material_id=material_bioyond_id, - location_id=location_id, - quantity=current_quantity - ) - - if response is not None: - logger.info(f"✅ [resource_tree_remove] 物料成功从 Bioyond 系统出库") - return True - else: - logger.error(f"❌ [resource_tree_remove] 物料出库失败,API 返回空") - return False - - except Exception as e: - logger.error(f"❌ [resource_tree_remove] 物料 {resource.name} 出库时发生异常: {e}") - import traceback - traceback.print_exc() - return False - - def resource_tree_transfer(self, old_parent: Optional[ResourcePLR], resource: ResourcePLR, new_parent: ResourcePLR) -> None: - """处理资源在设备间迁移时的同步 - - 当资源从一个设备迁移到 BioyondWorkstation 时,只创建物料(不入库) - 入库操作由后续的 resource_tree_add 完成 - - Args: - old_parent: 资源的原父节点(可能为 None) - resource: 要迁移的资源 - new_parent: 资源的新父节点 - """ - logger.info(f"[resource_tree_transfer] 资源迁移: {resource.name}") - logger.info(f" 旧父节点: {old_parent.name if old_parent else 'None'}") - logger.info(f" 新父节点: {new_parent.name}") - - try: - # ⭐ Transfer 阶段:只创建物料到 Bioyond 系统,不执行入库 - logger.info(f"[resource_tree_transfer] 开始创建物料 {resource.name} 到 Bioyond 系统(不入库)") - result = self.resource_synchronizer._create_material_only(resource) - - if result: - logger.info(f"✅ [resource_tree_transfer] 物料 {resource.name} 创建成功,Bioyond ID: {result[:8]}...") - else: - logger.warning(f"⚠️ [resource_tree_transfer] 物料 {resource.name} 创建失败") - - except Exception as e: - logger.error(f"❌ [resource_tree_transfer] 资源 {resource.name} 创建异常: {e}") - import traceback - traceback.print_exc() - - def resource_tree_update(self, resources: List[ResourcePLR]) -> None: - """处理资源更新时的同步(位置移动、属性修改等) - - 当 UniLab 前端更新物料信息时(如修改位置),需要将更新操作同步到 Bioyond 系统 - - Args: - resources: 要更新的资源列表 - """ - logger.info(f"[resource_tree_update] 开始同步 {len(resources)} 个资源更新到 Bioyond 系统") - - for resource in resources: - try: - logger.info(f"[resource_tree_update] 同步资源更新: {resource.name}") - - # 调用同步器的 sync_to_external 方法 - # 该方法会检查 unilabos_extra 中的 update_resource_site 字段 - # 如果存在,会执行位置移动操作 - result = self.resource_synchronizer.sync_to_external(resource) - - if result: - logger.info(f"✅ [resource_tree_update] 资源 {resource.name} 成功同步到 Bioyond 系统") - else: - logger.warning(f"⚠️ [resource_tree_update] 资源 {resource.name} 同步到 Bioyond 系统失败") - - except Exception as e: - logger.error(f"❌ [resource_tree_update] 同步资源 {resource.name} 时发生异常: {e}") - import traceback - traceback.print_exc() - - logger.info(f"[resource_tree_update] 资源更新同步完成") + self.resource_synchronizer.sync_to_external(resources) @property def bioyond_status(self) -> Dict[str, Any]: @@ -978,29 +224,43 @@ class BioyondWorkstation(WorkstationBase): Returns: Dict[str, Any]: Bioyond 系统的状态信息 - - 连接成功时返回 {"connected": True} - - 连接失败时返回 {"connected": False, "error": "错误信息"} """ try: - # 检查硬件接口是否存在 - if not self.hardware_interface: - return {"connected": False, "error": "hardware_interface not initialized"} + # 基础状态信息 + status = { + } - # 尝试获取调度器状态来验证连接 - scheduler_status = self.hardware_interface.scheduler_status() + # 如果有反应站接口,获取调度器状态 + if self.hardware_interface: + try: + scheduler_status = self.hardware_interface.scheduler_status() + status["scheduler"] = scheduler_status + except Exception as e: + logger.warning(f"获取调度器状态失败: {e}") + status["scheduler"] = {"error": str(e)} - # 如果能成功获取状态,说明连接正常 - if scheduler_status: - return {"connected": True} - else: - return {"connected": False, "error": "scheduler_status returned None"} + # 添加物料缓存信息 + if self.hardware_interface: + try: + available_materials = self.hardware_interface.get_available_materials() + status["material_cache_count"] = len(available_materials) + except Exception as e: + logger.warning(f"获取物料缓存失败: {e}") + status["material_cache_count"] = 0 + + return status except Exception as e: - logger.warning(f"获取Bioyond状态失败: {e}") - return {"connected": False, "error": str(e)} + logger.error(f"获取Bioyond状态失败: {e}") + return { + "status": "error", + "message": str(e), + "station_type": getattr(self, 'station_type', 'unknown'), + "station_name": getattr(self, 'station_name', 'unknown') + } # ==================== 工作流合并与参数设置 API ==================== - + def append_to_workflow_sequence(self, web_workflow_name: str) -> bool: # 检查是否为JSON格式的字符串 actual_workflow_name = web_workflow_name @@ -1011,7 +271,7 @@ class BioyondWorkstation(WorkstationBase): print(f"解析JSON格式工作流名称: {web_workflow_name} -> {actual_workflow_name}") except json.JSONDecodeError: print(f"JSON解析失败,使用原始字符串: {web_workflow_name}") - + workflow_id = self._get_workflow(actual_workflow_name) if workflow_id: self.workflow_sequence.append(workflow_id) @@ -1076,7 +336,7 @@ class BioyondWorkstation(WorkstationBase): # ============ 工作站状态管理 ============ def get_station_info(self) -> Dict[str, Any]: """获取工作站基础信息 - + Returns: Dict[str, Any]: 工作站基础信息,包括设备ID、状态等 """ @@ -1194,181 +454,6 @@ class BioyondWorkstation(WorkstationBase): "action": "reset_workstation" } - # ==================== HTTP 报送处理方法 ==================== - - def process_step_finish_report(self, report_request) -> Dict[str, Any]: - """处理步骤完成报送 - - Args: - report_request: WorkstationReportRequest 对象,包含步骤完成信息 - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - data = report_request.data - logger.info(f"[步骤完成报送] 订单: {data.get('orderCode')}, 步骤: {data.get('stepName')}") - logger.info(f" 样品ID: {data.get('sampleId')}") - logger.info(f" 开始时间: {data.get('startTime')}") - logger.info(f" 结束时间: {data.get('endTime')}") - - # TODO: 根据实际业务需求处理步骤完成逻辑 - # 例如:更新数据库、触发后续流程等 - - return { - "processed": True, - "step_id": data.get('stepId'), - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"处理步骤完成报送失败: {e}") - return {"processed": False, "error": str(e)} - - def process_sample_finish_report(self, report_request) -> Dict[str, Any]: - """处理通量完成报送 - - Args: - report_request: WorkstationReportRequest 对象,包含通量完成信息 - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - data = report_request.data - status_names = { - "0": "待生产", "2": "进样", "10": "开始", - "20": "完成", "-2": "异常停止", "-3": "人工停止" - } - status_desc = status_names.get(str(data.get('status')), f"状态{data.get('status')}") - - logger.info(f"[通量完成报送] 订单: {data.get('orderCode')}, 样品: {data.get('sampleId')}") - logger.info(f" 状态: {status_desc}") - logger.info(f" 开始时间: {data.get('startTime')}") - logger.info(f" 结束时间: {data.get('endTime')}") - - # TODO: 根据实际业务需求处理通量完成逻辑 - - return { - "processed": True, - "sample_id": data.get('sampleId'), - "status": data.get('status'), - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"处理通量完成报送失败: {e}") - return {"processed": False, "error": str(e)} - - def process_order_finish_report(self, report_request, used_materials: List) -> Dict[str, Any]: - """处理任务完成报送 - - Args: - report_request: WorkstationReportRequest 对象,包含任务完成信息 - used_materials: 物料使用记录列表 - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - data = report_request.data - status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} - status_desc = status_names.get(str(data.get('status')), f"状态{data.get('status')}") - - logger.info(f"[任务完成报送] 订单: {data.get('orderCode')} - {data.get('orderName')}") - logger.info(f" 状态: {status_desc}") - logger.info(f" 开始时间: {data.get('startTime')}") - logger.info(f" 结束时间: {data.get('endTime')}") - logger.info(f" 使用物料数量: {len(used_materials)}") - - # 记录物料使用情况 - for material in used_materials: - logger.debug(f" 物料: {material.materialId}, 用量: {material.usedQuantity}") - - # TODO: 根据实际业务需求处理任务完成逻辑 - # 例如:更新物料库存、生成报表等 - - return { - "processed": True, - "order_code": data.get('orderCode'), - "status": data.get('status'), - "materials_count": len(used_materials), - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"处理任务完成报送失败: {e}") - return {"processed": False, "error": str(e)} - - def process_material_change_report(self, report_data: Dict[str, Any]) -> Dict[str, Any]: - """处理物料变更报送 - - Args: - report_data: 物料变更数据 - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - logger.info(f"[物料变更报送] 工作站: {report_data.get('workstation_id')}") - logger.info(f" 资源ID: {report_data.get('resource_id')}") - logger.info(f" 变更类型: {report_data.get('change_type')}") - logger.info(f" 时间戳: {report_data.get('timestamp')}") - - # TODO: 根据实际业务需求处理物料变更逻辑 - # 例如:同步到资源树、更新Bioyond系统等 - - return { - "processed": True, - "resource_id": report_data.get('resource_id'), - "change_type": report_data.get('change_type'), - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"处理物料变更报送失败: {e}") - return {"processed": False, "error": str(e)} - - - def handle_external_error(self, error_data: Dict[str, Any]) -> Dict[str, Any]: - """处理错误处理报送 - - Args: - error_data: 错误数据(可能是奔曜格式或标准格式) - - Returns: - Dict[str, Any]: 处理结果 - """ - try: - # 检查是否为奔曜格式 - if 'task' in error_data and 'code' in error_data: - # 奔曜格式 - logger.error(f"[错误处理报送-奔曜] 任务: {error_data.get('task')}") - logger.error(f" 错误代码: {error_data.get('code')}") - logger.error(f" 错误信息: {error_data.get('message', '无')}") - error_type = "bioyond_error" - else: - # 标准格式 - logger.error(f"[错误处理报送] 工作站: {error_data.get('workstation_id')}") - logger.error(f" 错误类型: {error_data.get('error_type')}") - logger.error(f" 错误信息: {error_data.get('error_message')}") - error_type = error_data.get('error_type', 'unknown') - - # TODO: 根据实际业务需求处理错误 - # 例如:记录日志、发送告警、触发恢复流程等 - - return { - "handled": True, - "error_type": error_type, - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - logger.error(f"处理错误报送失败: {e}") - return {"handled": False, "error": str(e)} - - # ==================== 文件加载与其他功能 ==================== - def load_bioyond_data_from_file(self, file_path: str) -> bool: """从文件加载Bioyond数据(用于测试)""" try: @@ -1379,8 +464,8 @@ class BioyondWorkstation(WorkstationBase): # 转换为UniLab格式 unilab_resources = resource_bioyond_to_plr( - bioyond_data, - type_mapping=self.bioyond_config["material_type_mappings"], + bioyond_data, + type_mapping=self.bioyond_config["material_type_mappings"], deck=self.deck ) @@ -1397,7 +482,7 @@ def create_bioyond_workstation_example(): """创建Bioyond工作站示例""" # 配置参数 - device_id = "bioyond_workstation_001" + device_id = "bioyond_cell_workstation_001" # 子资源配置 children = { @@ -1416,8 +501,8 @@ def create_bioyond_workstation_example(): # Bioyond配置 bioyond_config = { - "base_url": "http://bioyond.example.com/api", - "api_key": "your_api_key_here", + "base_url": "http://172.16.11.219:44388", + "api_key": "8A819E5C", "sync_interval": 60, # 60秒同步一次 "timeout": 30 } @@ -1441,4 +526,18 @@ def create_bioyond_workstation_example(): if __name__ == "__main__": - pass + by = create_bioyond_workstation_example() + by.get_workstation_status() + by.get_device_status() + by.get_bioyond_status() + by.get_station_info() + by.get_workstation_status() + by.get_bioyond_status() + by.get_station_info() + by.get_workstation_status() + by.get_bioyond_status() + by.get_station_info() + by.get_workstation_status() + by.get_bioyond_status() + by.get_station_info() + pass \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py new file mode 100644 index 00000000..bc8dc4f0 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py @@ -0,0 +1,639 @@ +""" +纽扣电池组装工作站物料类定义 +Button Battery Assembly Station Resource Classes +""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Any, Dict, List, Optional, TypedDict, Union, cast + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.container import Container +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.itemized_resource import ItemizedResource +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_stack import ResourceStack +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.utils import create_ordered_items_2d + +from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery +from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier +from unilabos.resources.battery.electrode_sheet import ElectrodeSheet + + + +# TODO: 这个应该只能放一个极片 +class MaterialHoleState(TypedDict): + diameter: int + depth: int + max_sheets: int + info: Optional[str] # 附加信息 + +class MaterialHole(Resource): + """料板洞位类""" + children: List[ElectrodeSheet] = [] + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str = "material_hole", + **kwargs + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + ) + self._unilabos_state: MaterialHoleState = MaterialHoleState( + diameter=20, + depth=10, + max_sheets=1, + info=None + ) + + def get_all_sheet_info(self): + info_list = [] + for sheet in self.children: + info_list.append(sheet._unilabos_state["info"]) + return info_list + + #这个函数函数好像没用,一般不会集中赋值质量 + def set_all_sheet_mass(self): + for sheet in self.children: + sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g + + def load_state(self, state: Dict[str, Any]) -> None: + """格式不变""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + """格式不变""" + data = super().serialize_state() + data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + #移动极片前先取出对象 + def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]: + for sheet in self.children: + if sheet.name == name: + return sheet + return None + + def has_electrode_sheet(self) -> bool: + """检查洞位是否有极片""" + return len(self.children) > 0 + + def assign_child_resource( + self, + resource: ElectrodeSheet, + location: Optional[Coordinate], + reassign: bool = True, + ): + """放置极片""" + # TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题 + #if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]: + # raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}") + #if len(self.children) >= self._unilabos_state["max_sheets"]: + # raise ValueError(f"洞位已满,无法放置更多极片") + super().assign_child_resource(resource, location, reassign) + + # 根据children的编号取物料对象。 + def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet: + return self.children[index] + + +class MaterialPlateState(TypedDict): + hole_spacing_x: float + hole_spacing_y: float + hole_diameter: float + info: Optional[str] # 附加信息 + +class MaterialPlate(ItemizedResource[MaterialHole]): + """料板类 - 4x4个洞位,每个洞位放1个极片""" + + children: List[MaterialHole] + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + ordered_items: Optional[Dict[str, MaterialHole]] = None, + ordering: Optional[OrderedDict[str, str]] = None, + category: str = "material_plate", + model: Optional[str] = None, + fill: bool = False + ): + """初始化料板 + + Args: + name: 料板名称 + size_x: 长度 (mm) + size_y: 宽度 (mm) + size_z: 高度 (mm) + hole_diameter: 洞直径 (mm) + hole_depth: 洞深度 (mm) + hole_spacing_x: X方向洞位间距 (mm) + hole_spacing_y: Y方向洞位间距 (mm) + number: 编号 + category: 类别 + model: 型号 + """ + self._unilabos_state: MaterialPlateState = MaterialPlateState( + hole_spacing_x=24.0, + hole_spacing_y=24.0, + hole_diameter=20.0, + info="", + ) + # 创建4x4的洞位 + # TODO: 这里要改,对应不同形状 + holes = create_ordered_items_2d( + klass=MaterialHole, + num_items_x=4, + num_items_y=4, + dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 + dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 + dz=size_z, + item_dx=self._unilabos_state["hole_spacing_x"], + item_dy=self._unilabos_state["hole_spacing_y"], + size_x = 16, + size_y = 16, + size_z = 16, + ) + if fill: + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=holes, + category=category, + model=model, + ) + else: + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + ) + + def update_locations(self): + # TODO:调多次相加 + holes = create_ordered_items_2d( + klass=MaterialHole, + num_items_x=4, + num_items_y=4, + dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 + dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 + dz=self._size_z, + item_dx=self._unilabos_state["hole_spacing_x"], + item_dy=self._unilabos_state["hole_spacing_y"], + size_x = 1, + size_y = 1, + size_z = 1, + ) + for item, original_item in zip(holes.items(), self.children): + original_item.location = item[1].location + + +class PlateSlot(ResourceStack): + """板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + max_plates: int = 8, + category: str = "plate_slot", + model: Optional[str] = None + ): + """初始化板槽位 + + Args: + name: 槽位名称 + max_plates: 最大板数量 + category: 类别 + """ + super().__init__( + name=name, + direction="z", # Z方向堆叠 + resources=[], + ) + self.max_plates = max_plates + self.category = category + + def can_add_plate(self) -> bool: + """检查是否可以添加板""" + return len(self.children) < self.max_plates + + def add_plate(self, plate: MaterialPlate) -> None: + """添加料板""" + if not self.can_add_plate(): + raise ValueError(f"槽位 {self.name} 已满,无法添加更多板") + self.assign_child_resource(plate) + + def get_top_plate(self) -> MaterialPlate: + """获取最上方的板""" + if len(self.children) == 0: + raise ValueError(f"槽位 {self.name} 为空") + return cast(MaterialPlate, self.get_top_item()) + + def take_top_plate(self) -> MaterialPlate: + """取出最上方的板""" + top_plate = self.get_top_plate() + self.unassign_child_resource(top_plate) + return top_plate + + def can_access_for_picking(self) -> bool: + """检查是否可以进行取料操作(只有最上方的板能进行取料操作)""" + return len(self.children) > 0 + + def serialize(self) -> dict: + return { + **super().serialize(), + "max_plates": self.max_plates, + } + + +#是一种类型注解,不用self +class BatteryState(TypedDict): + """电池状态字典""" + diameter: float + height: float + assembly_pressure: float + electrolyte_volume: float + electrolyte_name: str + +class Battery(Resource): + """电池类 - 可容纳极片""" + children: List[ElectrodeSheet] = [] + + def __init__( + self, + name: str, + size_x=1, + size_y=1, + size_z=1, + category: str = "battery", + ): + """初始化电池 + + Args: + name: 电池名称 + diameter: 直径 (mm) + height: 高度 (mm) + max_volume: 最大容量 (μL) + barcode: 二维码编号 + category: 类别 + model: 型号 + """ + super().__init__( + name=name, + size_x=1, + size_y=1, + size_z=1, + category=category, + ) + self._unilabos_state: BatteryState = BatteryState( + diameter = 1.0, + height = 1.0, + assembly_pressure = 1.0, + electrolyte_volume = 1.0, + electrolyte_name = "DP001" + ) + + def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool: + to_add_name = bottle._unilabos_state["electrolyte_name"] + if bottle.aspirate_electrolyte(10): + if self.add_electrolyte(to_add_name, 10): + pass + else: + bottle._unilabos_state["electrolyte_volume"] += 10 + + def set_electrolyte(self, name: str, volume: float) -> None: + """设置电解液信息""" + self._unilabos_state["electrolyte_name"] = name + self._unilabos_state["electrolyte_volume"] = volume + #这个应该没用,不会有加了后再加的事情 + def add_electrolyte(self, name: str, volume: float) -> bool: + """添加电解液信息""" + if name != self._unilabos_state["electrolyte_name"]: + return False + self._unilabos_state["electrolyte_volume"] += volume + + def load_state(self, state: Dict[str, Any]) -> None: + """格式不变""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + """格式不变""" + data = super().serialize_state() + data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + +# 电解液作为属性放进去 + +class BatteryPressSlotState(TypedDict): + """电池状态字典""" + diameter: float =20.0 + depth: float = 4.0 + +class BatteryPressSlot(Resource): + """电池压制槽类 - 设备,可容纳一个电池""" + children: List[Battery] = [] + + def __init__( + self, + name: str = "BatteryPressSlot", + category: str = "battery_press_slot", + ): + """初始化电池压制槽 + + Args: + name: 压制槽名称 + diameter: 直径 (mm) + depth: 深度 (mm) + category: 类别 + model: 型号 + """ + super().__init__( + name=name, + size_x=10, + size_y=12, + size_z=13, + category=category, + ) + self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState() + + def has_battery(self) -> bool: + """检查是否有电池""" + return len(self.children) > 0 + + def load_state(self, state: Dict[str, Any]) -> None: + """格式不变""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + """格式不变""" + data = super().serialize_state() + data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + + def assign_child_resource( + self, + resource: Battery, + location: Optional[Coordinate], + reassign: bool = True, + ): + """放置极片""" + # TODO: 让高京看下槽位只有一个电池时是否这么写。 + if self.has_battery(): + raise ValueError(f"槽位已含有一个电池,无法再放置其他电池") + super().assign_child_resource(resource, location, reassign) + + # 根据children的编号取物料对象。 + def get_battery_info(self, index: int) -> Battery: + return self.children[0] + + +def TipBox64( + name: str, + size_x: float = 127.8, + size_y: float = 85.5, + size_z: float = 60.0, + category: str = "tip_rack", + model: Optional[str] = None, +): + """64孔枪头盒类""" + from pylabrobot.resources.tip import Tip + + # 创建12x8=96个枪头位 + def make_tip(): + return Tip( + has_filter=False, + total_tip_length=20.0, + maximal_volume=1000, # 1mL + fitting_depth=8.0, + ) + + tip_spots = create_ordered_items_2d( + klass=TipSpot, + num_items_x=12, + num_items_y=8, + dx=8.0, + dy=8.0, + dz=0.0, + item_dx=9.0, + item_dy=9.0, + size_x=10, + size_y=10, + size_z=0.0, + make_tip=make_tip, + ) + idx_available = list(range(0, 32)) + list(range(64, 96)) + tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available} + tip_rack = TipRack( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + # ordered_items=tip_spots_available, + ordered_items=tip_spots, + category=category, + model=model, + with_tips=False, + ) + tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头 + return tip_rack + + +class WasteTipBoxstate(TypedDict): + """"废枪头盒状态字典""" + max_tips: int = 100 + tip_count: int = 0 + +#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断 +class WasteTipBox(Trash): + """废枪头盒类 - 100个枪头容量""" + + def __init__( + self, + name: str, + size_x: float = 127.8, + size_y: float = 85.5, + size_z: float = 60.0, + material_z_thickness=0, + max_volume=float("inf"), + category="trash", + model=None, + compute_volume_from_height=None, + compute_height_from_volume=None, + ): + """初始化废枪头盒 + + Args: + name: 废枪头盒名称 + size_x: 长度 (mm) + size_y: 宽度 (mm) + size_z: 高度 (mm) + max_tips: 最大枪头容量 + category: 类别 + model: 型号 + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate() + + def add_tip(self) -> None: + """添加废枪头""" + if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]: + raise ValueError(f"废枪头盒 {self.name} 已满") + self._unilabos_state["tip_count"] += 1 + + def get_tip_count(self) -> int: + """获取枪头数量""" + return self._unilabos_state["tip_count"] + + def empty(self) -> None: + """清空废枪头盒""" + self._unilabos_state["tip_count"] = 0 + + + def load_state(self, state: Dict[str, Any]) -> None: + """格式不变""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + """格式不变""" + data = super().serialize_state() + data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + return data + + +class CoincellDeck(Deck): + """纽扣电池组装工作站台面类""" + + def __init__( + self, + name: str = "coin_cell_deck", + size_x: float = 1450.0, # 1m + size_y: float = 1450.0, # 1m + size_z: float = 100.0, # 0.9m + origin: Coordinate = Coordinate(-2200, 0, 0), + category: str = "coin_cell_deck", + setup: bool = False, # 是否自动执行 setup + ): + """初始化纽扣电池组装工作站台面 + + Args: + name: 台面名称 + size_x: 长度 (mm) - 1m + size_y: 宽度 (mm) - 1m + size_z: 高度 (mm) - 0.9m + origin: 原点坐标 + category: 类别 + setup: 是否自动执行 setup 配置标准布局 + """ + super().__init__( + name=name, + size_x=1450.0, + size_y=1450.0, + size_z=100.0, + origin=origin, + ) + if setup: + self.setup() + + def setup(self) -> None: + """设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置""" + # ====================================== 子弹夹 ============================================ + + # 正极片(4个洞位,2x2布局) + zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹") + self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0)) + + # 正极壳、平垫片(6个洞位,2x2+2布局) + zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹") + self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0)) + + # 负极壳、弹垫片(6个洞位,2x2+2布局) + fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹") + self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0)) + + # 成品弹夹(6个洞位,3x2布局) + chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹") + self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0)) + + # ====================================== 物料板 ============================================ + # 创建物料板(料盘carrier)- 4x4布局 + # 负极料盘 + fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0)) + # for i in range(16): + # fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) + # fujiliaopan.children[i].assign_child_resource(fujipian, location=None) + + # 隔膜料盘 + gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True) + self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0)) + # for i in range(16): + # gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) + # gemoliaopan.children[i].assign_child_resource(gemopian, location=None) + + # ====================================== 瓶架、移液枪 ============================================ + # 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒 + # 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板 + + # bottle_rack_3x4 = BottleRack( + # name="bottle_rack_3x4", + # size_x=210.0, + # size_y=140.0, + # size_z=100.0, + # num_items_x=2, + # num_items_y=4, + # position_spacing=35.0, + # orientation="vertical", + # ) + # self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0)) + + # 电解液缓存位 - 6x2布局 + bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2") + self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0)) + # 电解液回收位6x2 + bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2") + self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0)) + + tip_box = TipBox64(name="tip_box_64") + self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0)) + + waste_tip_box = WasteTipBox(name="waste_tip_box") + self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0)) + + +if __name__ == "__main__": + deck = create_coin_cell_deck() + print(deck) \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/button_battery_station.py b/unilabos/devices/workstation/coin_cell_assembly/button_battery_station.py deleted file mode 100644 index f663a217..00000000 --- a/unilabos/devices/workstation/coin_cell_assembly/button_battery_station.py +++ /dev/null @@ -1,1289 +0,0 @@ -""" -纽扣电池组装工作站物料类定义 -Button Battery Assembly Station Resource Classes -""" - -from __future__ import annotations - -from collections import OrderedDict -from typing import Any, Dict, List, Optional, TypedDict, Union, cast - -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.container import Container -from pylabrobot.resources.deck import Deck -from pylabrobot.resources.itemized_resource import ItemizedResource -from pylabrobot.resources.resource import Resource -from pylabrobot.resources.resource_stack import ResourceStack -from pylabrobot.resources.tip_rack import TipRack, TipSpot -from pylabrobot.resources.trash import Trash -from pylabrobot.resources.utils import create_ordered_items_2d - - -class ElectrodeSheetState(TypedDict): - diameter: float # 直径 (mm) - thickness: float # 厚度 (mm) - mass: float # 质量 (g) - material_type: str # 材料类型(正极、负极、隔膜、弹片、垫片、铝箔等) - info: Optional[str] # 附加信息 - -class ElectrodeSheet(Resource): - """极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料""" - - def __init__( - self, - name: str = "极片", - size_x=10, - size_y=10, - size_z=10, - category: str = "electrode_sheet", - model: Optional[str] = None, - ): - """初始化极片 - - Args: - name: 极片名称 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState( - diameter=14, - thickness=0.1, - mass=0.5, - material_type="copper", - info=None - ) - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - #序列化 - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - -# TODO: 这个应该只能放一个极片 -class MaterialHoleState(TypedDict): - diameter: int - depth: int - max_sheets: int - info: Optional[str] # 附加信息 - -class MaterialHole(Resource): - """料板洞位类""" - children: List[ElectrodeSheet] = [] - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - category: str = "material_hole", - **kwargs - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - ) - self._unilabos_state: MaterialHoleState = MaterialHoleState( - diameter=20, - depth=10, - max_sheets=1, - info=None - ) - - def get_all_sheet_info(self): - info_list = [] - for sheet in self.children: - info_list.append(sheet._unilabos_state["info"]) - return info_list - - #这个函数函数好像没用,一般不会集中赋值质量 - def set_all_sheet_mass(self): - for sheet in self.children: - sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - #移动极片前先取出对象 - def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]: - for sheet in self.children: - if sheet.name == name: - return sheet - return None - - def has_electrode_sheet(self) -> bool: - """检查洞位是否有极片""" - return len(self.children) > 0 - - def assign_child_resource( - self, - resource: ElectrodeSheet, - location: Optional[Coordinate], - reassign: bool = True, - ): - """放置极片""" - # TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题 - if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]: - raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}") - if len(self.children) >= self._unilabos_state["max_sheets"]: - raise ValueError(f"洞位已满,无法放置更多极片") - super().assign_child_resource(resource, location, reassign) - - # 根据children的编号取物料对象。 - def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet: - return self.children[index] - - - -class MaterialPlateState(TypedDict): - hole_spacing_x: float - hole_spacing_y: float - hole_diameter: float - info: Optional[str] # 附加信息 - - - -class MaterialPlate(ItemizedResource[MaterialHole]): - """料板类 - 4x4个洞位,每个洞位放1个极片""" - - children: List[MaterialHole] - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - ordered_items: Optional[Dict[str, MaterialHole]] = None, - ordering: Optional[OrderedDict[str, str]] = None, - category: str = "material_plate", - model: Optional[str] = None, - fill: bool = False - ): - """初始化料板 - - Args: - name: 料板名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing_x: X方向洞位间距 (mm) - hole_spacing_y: Y方向洞位间距 (mm) - number: 编号 - category: 类别 - model: 型号 - """ - self._unilabos_state: MaterialPlateState = MaterialPlateState( - hole_spacing_x=24.0, - hole_spacing_y=24.0, - hole_diameter=20.0, - info="", - ) - # 创建4x4的洞位 - # TODO: 这里要改,对应不同形状 - holes = create_ordered_items_2d( - klass=MaterialHole, - num_items_x=4, - num_items_y=4, - dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 - dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 - dz=size_z, - item_dx=self._unilabos_state["hole_spacing_x"], - item_dy=self._unilabos_state["hole_spacing_y"], - size_x = 16, - size_y = 16, - size_z = 16, - ) - if fill: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - else: - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=ordered_items, - ordering=ordering, - category=category, - model=model, - ) - - def update_locations(self): - # TODO:调多次相加 - holes = create_ordered_items_2d( - klass=MaterialHole, - num_items_x=4, - num_items_y=4, - dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中 - dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中 - dz=self._size_z, - item_dx=self._unilabos_state["hole_spacing_x"], - item_dy=self._unilabos_state["hole_spacing_y"], - size_x = 1, - size_y = 1, - size_z = 1, - ) - for item, original_item in zip(holes.items(), self.children): - original_item.location = item[1].location - - -class PlateSlot(ResourceStack): - """板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - max_plates: int = 8, - category: str = "plate_slot", - model: Optional[str] = None - ): - """初始化板槽位 - - Args: - name: 槽位名称 - max_plates: 最大板数量 - category: 类别 - """ - super().__init__( - name=name, - direction="z", # Z方向堆叠 - resources=[], - ) - self.max_plates = max_plates - self.category = category - - def can_add_plate(self) -> bool: - """检查是否可以添加板""" - return len(self.children) < self.max_plates - - def add_plate(self, plate: MaterialPlate) -> None: - """添加料板""" - if not self.can_add_plate(): - raise ValueError(f"槽位 {self.name} 已满,无法添加更多板") - self.assign_child_resource(plate) - - def get_top_plate(self) -> MaterialPlate: - """获取最上方的板""" - if len(self.children) == 0: - raise ValueError(f"槽位 {self.name} 为空") - return cast(MaterialPlate, self.get_top_item()) - - def take_top_plate(self) -> MaterialPlate: - """取出最上方的板""" - top_plate = self.get_top_plate() - self.unassign_child_resource(top_plate) - return top_plate - - def can_access_for_picking(self) -> bool: - """检查是否可以进行取料操作(只有最上方的板能进行取料操作)""" - return len(self.children) > 0 - - def serialize(self) -> dict: - return { - **super().serialize(), - "max_plates": self.max_plates, - } - - -class ClipMagazineHole(Container): - """子弹夹洞位类""" - children: List[ElectrodeSheet] = [] - def __init__( - self, - name: str, - diameter: float, - depth: float, - category: str = "clip_magazine_hole", - ): - """初始化子弹夹洞位 - - Args: - name: 洞位名称 - diameter: 洞直径 (mm) - depth: 洞深度 (mm) - category: 类别 - """ - super().__init__( - name=name, - size_x=diameter, - size_y=diameter, - size_z=depth, - category=category, - ) - self.diameter = diameter - self.depth = depth - - def can_add_sheet(self, sheet: ElectrodeSheet) -> bool: - """检查是否可以添加极片 - - 根据洞的深度和极片的厚度来判断是否可以添加极片 - """ - # 检查极片直径是否适合洞的直径 - if sheet._unilabos_state["diameter"] > self.diameter: - return False - - # 计算当前已添加极片的总厚度 - current_thickness = sum(s._unilabos_state["thickness"] for s in self.children) - - # 检查添加新极片后总厚度是否超过洞的深度 - if current_thickness + sheet._unilabos_state["thickness"] > self.depth: - return False - - return True - - - def assign_child_resource( - self, - resource: ElectrodeSheet, - location: Optional[Coordinate] = None, - reassign: bool = True, - ): - """放置极片到洞位中 - - Args: - resource: 要放置的极片 - location: 极片在洞位中的位置(对于洞位,通常为None) - reassign: 是否允许重新分配 - """ - # 检查是否可以添加极片 - if not self.can_add_sheet(resource): - raise ValueError(f"无法向洞位 {self.name} 添加极片:直径或厚度不匹配") - - # 调用父类方法实际执行分配 - super().assign_child_resource(resource, location, reassign) - - def unassign_child_resource(self, resource: ElectrodeSheet): - """从洞位中移除极片 - - Args: - resource: 要移除的极片 - """ - if resource not in self.children: - raise ValueError(f"极片 {resource.name} 不在洞位 {self.name} 中") - - # 调用父类方法实际执行移除 - super().unassign_child_resource(resource) - - - - def serialize_state(self) -> Dict[str, Any]: - return { - "sheet_count": len(self.children), - "sheets": [sheet.serialize() for sheet in self.children], - } -class ClipMagazine_four(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有4个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine_four", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建4个洞位,排成2x2布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=2, - num_items_y=2, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } -# TODO: 这个要改 -class ClipMagazine(ItemizedResource[ClipMagazineHole]): - """子弹夹类 - 有6个洞位,每个洞位放多个极片""" - children: List[ClipMagazineHole] - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 25.0, - max_sheets_per_hole: int = 100, - category: str = "clip_magazine", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - hole_spacing: 洞位间距 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - # 创建6个洞位,排成2x3布局 - holes = create_ordered_items_2d( - klass=ClipMagazineHole, - num_items_x=3, - num_items_y=2, - dx=(size_x - 2 * hole_spacing) / 2, # 居中 - dy=(size_y - hole_spacing) / 2, # 居中 - dz=size_z - 0, - item_dx=hole_spacing, - item_dy=hole_spacing, - diameter=hole_diameter, - depth=hole_depth, - ) - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=holes, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - } -#是一种类型注解,不用self -class BatteryState(TypedDict): - """电池状态字典""" - diameter: float - height: float - - electrolyte_name: str - electrolyte_volume: float - -class Battery(Resource): - """电池类 - 可容纳极片""" - children: List[ElectrodeSheet] = [] - - def __init__( - self, - name: str, - category: str = "battery", - ): - """初始化电池 - - Args: - name: 电池名称 - diameter: 直径 (mm) - height: 高度 (mm) - max_volume: 最大容量 (μL) - barcode: 二维码编号 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=1, - size_y=1, - size_z=1, - category=category, - ) - self._unilabos_state: BatteryState = BatteryState() - - def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool: - to_add_name = bottle._unilabos_state["electrolyte_name"] - if bottle.aspirate_electrolyte(10): - if self.add_electrolyte(to_add_name, 10): - pass - else: - bottle._unilabos_state["electrolyte_volume"] += 10 - - def set_electrolyte(self, name: str, volume: float) -> None: - """设置电解液信息""" - self._unilabos_state["electrolyte_name"] = name - self._unilabos_state["electrolyte_volume"] = volume - #这个应该没用,不会有加了后再加的事情 - def add_electrolyte(self, name: str, volume: float) -> bool: - """添加电解液信息""" - if name != self._unilabos_state["electrolyte_name"]: - return False - self._unilabos_state["electrolyte_volume"] += volume - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - -# 电解液作为属性放进去 - -class BatteryPressSlotState(TypedDict): - """电池状态字典""" - diameter: float =20.0 - depth: float = 4.0 - -class BatteryPressSlot(Resource): - """电池压制槽类 - 设备,可容纳一个电池""" - children: List[Battery] = [] - - def __init__( - self, - name: str = "BatteryPressSlot", - category: str = "battery_press_slot", - ): - """初始化电池压制槽 - - Args: - name: 压制槽名称 - diameter: 直径 (mm) - depth: 深度 (mm) - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=10, - size_y=12, - size_z=13, - category=category, - ) - self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState() - - def has_battery(self) -> bool: - """检查是否有电池""" - return len(self.children) > 0 - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - - def assign_child_resource( - self, - resource: Battery, - location: Optional[Coordinate], - reassign: bool = True, - ): - """放置极片""" - if self.has_battery(): - raise ValueError(f"槽位已含有一个电池,无法再放置其他电池") - super().assign_child_resource(resource, location, reassign) - - # 根据children的编号取物料对象。 - def get_battery_info(self, index: int) -> Battery: - return self.children[0] - -class TipBox64State(TypedDict): - """电池状态字典""" - tip_diameter: float = 5.0 - tip_length: float = 50.0 - with_tips: bool = True - -class TipBox64(TipRack): - """64孔枪头盒类""" - - children: List[TipSpot] = [] - def __init__( - self, - name: str, - size_x: float = 127.8, - size_y: float = 85.5, - size_z: float = 60.0, - category: str = "tip_box_64", - model: Optional[str] = None, - ): - """初始化64孔枪头盒 - - Args: - name: 枪头盒名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - tip_diameter: 枪头直径 (mm) - tip_length: 枪头长度 (mm) - category: 类别 - model: 型号 - with_tips: 是否带枪头 - """ - from pylabrobot.resources.tip import Tip - - # 创建8x8=64个枪头位 - def make_tip(): - return Tip( - has_filter=False, - total_tip_length=20.0, - maximal_volume=1000, # 1mL - fitting_depth=8.0, - ) - - tip_spots = create_ordered_items_2d( - klass=TipSpot, - num_items_x=8, - num_items_y=8, - dx=8.0, - dy=8.0, - dz=0.0, - item_dx=9.0, - item_dy=9.0, - size_x=10, - size_y=10, - size_z=0.0, - make_tip=make_tip, - ) - self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate() - # 记录网格参数用于前端渲染 - self._grid_params = { - "num_items_x": 8, - "num_items_y": 8, - "dx": 8.0, - "dy": 8.0, - "item_dx": 9.0, - "item_dy": 9.0, - } - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=tip_spots, - category=category, - model=model, - with_tips=True, - ) - - def serialize(self) -> dict: - return { - **super().serialize(), - **self._grid_params, - } - - - -class WasteTipBoxstate(TypedDict): - """"废枪头盒状态字典""" - max_tips: int = 100 - tip_count: int = 0 - -#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断 -class WasteTipBox(Trash): - """废枪头盒类 - 100个枪头容量""" - - def __init__( - self, - name: str, - size_x: float = 127.8, - size_y: float = 85.5, - size_z: float = 60.0, - category: str = "waste_tip_box", - model: Optional[str] = None, - ): - """初始化废枪头盒 - - Args: - name: 废枪头盒名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - max_tips: 最大枪头容量 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate() - - def add_tip(self) -> None: - """添加废枪头""" - if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]: - raise ValueError(f"废枪头盒 {self.name} 已满") - self._unilabos_state["tip_count"] += 1 - - def get_tip_count(self) -> int: - """获取枪头数量""" - return self._unilabos_state["tip_count"] - - def empty(self) -> None: - """清空废枪头盒""" - self._unilabos_state["tip_count"] = 0 - - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - - -class BottleRackState(TypedDict): - """ bottle_diameter: 瓶子直径 (mm) - bottle_height: 瓶子高度 (mm) - position_spacing: 位置间距 (mm)""" - bottle_diameter: float - bottle_height: float - name_to_index: dict - - -class BottleRackState(TypedDict): - """ bottle_diameter: 瓶子直径 (mm) - bottle_height: 瓶子高度 (mm) - position_spacing: 位置间距 (mm)""" - bottle_diameter: float - bottle_height: float - position_spacing: float - name_to_index: dict - - -class BottleRack(Resource): - """瓶架类 - 12个待配位置+12个已配位置""" - children: List[Resource] = [] - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - category: str = "bottle_rack", - model: Optional[str] = None, - num_items_x: int = 3, - num_items_y: int = 4, - position_spacing: float = 35.0, - orientation: str = "horizontal", - padding_x: float = 20.0, - padding_y: float = 20.0, - ): - """初始化瓶架 - - Args: - name: 瓶架名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - # 初始化状态 - self._unilabos_state: BottleRackState = BottleRackState( - bottle_diameter=30.0, - bottle_height=100.0, - position_spacing=position_spacing, - name_to_index={}, - ) - # 基于网格生成瓶位坐标映射(居中摆放) - # 使用内边距,避免点跑到容器外(前端渲染不按mm等比缩放时更稳妥) - origin_x = padding_x - origin_y = padding_y - self.index_to_pos = {} - for j in range(num_items_y): - for i in range(num_items_x): - idx = j * num_items_x + i - if orientation == "vertical": - # 纵向:沿 y 方向优先排列 - self.index_to_pos[idx] = Coordinate( - x=origin_x + j * position_spacing, - y=origin_y + i * position_spacing, - z=0, - ) - else: - # 横向(默认):沿 x 方向优先排列 - self.index_to_pos[idx] = Coordinate( - x=origin_x + i * position_spacing, - y=origin_y + j * position_spacing, - z=0, - ) - self.name_to_index = {} - self.name_to_pos = {} - self.num_items_x = num_items_x - self.num_items_y = num_items_y - self.orientation = orientation - self.padding_x = padding_x - self.padding_y = padding_y - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update( - self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - - # TODO: 这里有些问题要重新写一下 - def assign_child_resource_old(self, resource: Resource, location=Coordinate.zero(), reassign=True): - capacity = self.num_items_x * self.num_items_y - assert len(self.children) < capacity, "瓶架已满,无法添加更多瓶子" - index = len(self.children) - location = self.index_to_pos.get(index, Coordinate.zero()) - self.name_to_pos[resource.name] = location - self.name_to_index[resource.name] = index - return super().assign_child_resource(resource, location, reassign) - - def assign_child_resource(self, resource: Resource, index: int): - capacity = self.num_items_x * self.num_items_y - assert 0 <= index < capacity, "无效的瓶子索引" - self.name_to_index[resource.name] = index - location = self.index_to_pos[index] - return super().assign_child_resource(resource, location) - - def unassign_child_resource(self, resource: Bottle): - super().unassign_child_resource(resource) - self.index_to_pos.pop(self.name_to_index.pop(resource.name, None), None) - - def serialize(self) -> dict: - return { - **super().serialize(), - "num_items_x": self.num_items_x, - "num_items_y": self.num_items_y, - "position_spacing": self._unilabos_state.get("position_spacing", 35.0), - "orientation": self.orientation, - "padding_x": self.padding_x, - "padding_y": self.padding_y, - } - - -class BottleState(TypedDict): - diameter: float - height: float - electrolyte_name: str - electrolyte_volume: float - max_volume: float - -class Bottle(Resource): - """瓶子类 - 容纳电解液""" - - def __init__( - self, - name: str, - category: str = "bottle", - ): - """初始化瓶子 - - Args: - name: 瓶子名称 - diameter: 直径 (mm) - height: 高度 (mm) - max_volume: 最大体积 (μL) - barcode: 二维码 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=1, - size_y=1, - size_z=1, - category=category, - ) - self._unilabos_state: BottleState = BottleState() - - def aspirate_electrolyte(self, volume: float) -> bool: - current_volume = self._unilabos_state["electrolyte_volume"] - assert current_volume > volume, f"Cannot aspirate {volume}μL, only {current_volume}μL available." - self._unilabos_state["electrolyte_volume"] -= volume - return True - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data - -class CoincellDeck(Deck): - """纽扣电池组装工作站台面类""" - - def __init__( - self, - name: str = "coin_cell_deck", - size_x: float = 1620.0, # 3.66m - size_y: float = 1270.0, # 1.23m - size_z: float = 500.0, - origin: Coordinate = Coordinate(0, 0, 0), - category: str = "coin_cell_deck", - ): - """初始化纽扣电池组装工作站台面 - - Args: - name: 台面名称 - size_x: 长度 (mm) - 3.66m - size_y: 宽度 (mm) - 1.23m - size_z: 高度 (mm) - origin: 原点坐标 - category: 类别 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - origin=origin, - category=category, - ) - -#if __name__ == "__main__": -# # 转移极片的测试代码 -# deck = CoincellDeck("coin_cell_deck") -# ban_cao_wei = PlateSlot("ban_cao_wei", max_plates=8) -# deck.assign_child_resource(ban_cao_wei, Coordinate(x=0, y=0, z=0)) -# -# plate_1 = MaterialPlate("plate_1", 1,1,1, fill=True) -# for i, hole in enumerate(plate_1.children): -# sheet = ElectrodeSheet(f"hole_{i}_sheet_1") -# sheet._unilabos_state = { -# "diameter": 14, -# "info": "NMC", -# "mass": 5.0, -# "material_type": "positive_electrode", -# "thickness": 0.1 -# } -# hole._unilabos_state = { -# "depth": 1.0, -# "diameter": 14, -# "info": "", -# "max_sheets": 1 -# } -# hole.assign_child_resource(sheet, Coordinate.zero()) -# plate_1._unilabos_state = { -# "hole_spacing_x": 20.0, -# "hole_spacing_y": 20.0, -# "hole_diameter": 5, -# "info": "这是第一块料板" -# } -# plate_1.update_locations() -# ban_cao_wei.assign_child_resource(plate_1, Coordinate.zero()) -# # zi_dan_jia = ClipMagazine("zi_dan_jia", 1, 1, 1) -# # deck.assign_child_resource(ban_cao_wei, Coordinate(x=200, y=200, z=0)) -# -# from unilabos.resources.graphio import * -# A = tree_to_list([resource_plr_to_ulab(deck)]) -# with open("test.json", "w") as f: -# json.dump(A, f) -# -# -#def get_plate_with_14mm_hole(name=""): -# plate = MaterialPlate(name=name) -# for i in range(4): -# for j in range(4): -# hole = MaterialHole(f"{i+1}x{j+1}") -# hole._unilabos_state["diameter"] = 14 -# hole._unilabos_state["max_sheets"] = 1 -# plate.assign_child_resource(hole) -# return plate - -def create_a_liaopan(): - liaopan = MaterialPlate(name="liaopan", size_x=120.8, size_y=120.5, size_z=10.0, fill=True) - for i in range(16): - jipian = ElectrodeSheet(name=f"jipian_{i}", size_x= 12, size_y=12, size_z=0.1) - liaopan1.children[i].assign_child_resource(jipian, location=None) - return liaopan - -def create_a_coin_cell_deck(): - deck = Deck(size_x=1200, - size_y=800, - size_z=900) - - #liaopan = TipBox64(name="liaopan") - - #创建一个4*4的物料板 - liaopan1 = MaterialPlate(name="liaopan1", size_x=120.8, size_y=120.5, size_z=10.0, fill=True) - #把物料板放到桌子上 - deck.assign_child_resource(liaopan1, Coordinate(x=0, y=0, z=0)) - #创建一个极片 - for i in range(16): - jipian = ElectrodeSheet(name=f"jipian_{i}", size_x= 12, size_y=12, size_z=0.1) - liaopan1.children[i].assign_child_resource(jipian, location=None) - #创建一个4*4的物料板 - liaopan2 = MaterialPlate(name="liaopan2", size_x=120.8, size_y=120.5, size_z=10.0, fill=True) - #把物料板放到桌子上 - deck.assign_child_resource(liaopan2, Coordinate(x=500, y=0, z=0)) - - #创建一个4*4的物料板 - liaopan3 = MaterialPlate(name="liaopan3", size_x=120.8, size_y=120.5, size_z=10.0, fill=True) - #把物料板放到桌子上 - deck.assign_child_resource(liaopan3, Coordinate(x=1000, y=0, z=0)) - - print(deck) - - return deck - - -import json - -if __name__ == "__main__": - electrode1 = BatteryPressSlot() - #print(electrode1.get_size_x()) - #print(electrode1.get_size_y()) - #print(electrode1.get_size_z()) - #jipian = ElectrodeSheet() - #jipian._unilabos_state["diameter"] = 18 - #print(jipian.serialize()) - #print(jipian.serialize_state()) - - deck = CoincellDeck() - """======================================子弹夹============================================""" - zip_dan_jia = ClipMagazine_four("zi_dan_jia", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia, Coordinate(x=1400, y=50, z=0)) - zip_dan_jia2 = ClipMagazine_four("zi_dan_jia2", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia2, Coordinate(x=1600, y=200, z=0)) - zip_dan_jia3 = ClipMagazine("zi_dan_jia3", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia3, Coordinate(x=1500, y=200, z=0)) - zip_dan_jia4 = ClipMagazine("zi_dan_jia4", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia4, Coordinate(x=1500, y=300, z=0)) - zip_dan_jia5 = ClipMagazine("zi_dan_jia5", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia5, Coordinate(x=1600, y=300, z=0)) - zip_dan_jia6 = ClipMagazine("zi_dan_jia6", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia6, Coordinate(x=1530, y=500, z=0)) - zip_dan_jia7 = ClipMagazine("zi_dan_jia7", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia7, Coordinate(x=1180, y=400, z=0)) - zip_dan_jia8 = ClipMagazine("zi_dan_jia8", 80, 80, 10) - deck.assign_child_resource(zip_dan_jia8, Coordinate(x=1280, y=400, z=0)) - for i in range(4): - jipian = ElectrodeSheet(name=f"zi_dan_jia_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia2.children[i].assign_child_resource(jipian, location=None) - for i in range(4): - jipian2 = ElectrodeSheet(name=f"zi_dan_jia2_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia.children[i].assign_child_resource(jipian2, location=None) - for i in range(6): - jipian3 = ElectrodeSheet(name=f"zi_dan_jia3_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia3.children[i].assign_child_resource(jipian3, location=None) - for i in range(6): - jipian4 = ElectrodeSheet(name=f"zi_dan_jia4_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia4.children[i].assign_child_resource(jipian4, location=None) - for i in range(6): - jipian5 = ElectrodeSheet(name=f"zi_dan_jia5_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia5.children[i].assign_child_resource(jipian5, location=None) - for i in range(6): - jipian6 = ElectrodeSheet(name=f"zi_dan_jia6_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia6.children[i].assign_child_resource(jipian6, location=None) - for i in range(6): - jipian7 = ElectrodeSheet(name=f"zi_dan_jia7_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia7.children[i].assign_child_resource(jipian7, location=None) - for i in range(6): - jipian8 = ElectrodeSheet(name=f"zi_dan_jia8_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - zip_dan_jia8.children[i].assign_child_resource(jipian8, location=None) - """======================================子弹夹============================================""" - #liaopan = TipBox64(name="liaopan") - """======================================物料板============================================""" - #创建一个4*4的物料板 - liaopan1 = MaterialPlate(name="liaopan1", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan1, Coordinate(x=1010, y=50, z=0)) - for i in range(16): - jipian_1 = ElectrodeSheet(name=f"{liaopan1.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - liaopan1.children[i].assign_child_resource(jipian_1, location=None) - - liaopan2 = MaterialPlate(name="liaopan2", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan2, Coordinate(x=1130, y=50, z=0)) - - liaopan3 = MaterialPlate(name="liaopan3", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan3, Coordinate(x=1250, y=50, z=0)) - - liaopan4 = MaterialPlate(name="liaopan4", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan4, Coordinate(x=1010, y=150, z=0)) - for i in range(16): - jipian_4 = ElectrodeSheet(name=f"{liaopan4.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1) - liaopan4.children[i].assign_child_resource(jipian_4, location=None) - liaopan5 = MaterialPlate(name="liaopan5", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan5, Coordinate(x=1130, y=150, z=0)) - liaopan6 = MaterialPlate(name="liaopan6", size_x=120, size_y=100, size_z=10.0, fill=True) - deck.assign_child_resource(liaopan6, Coordinate(x=1250, y=150, z=0)) - #liaopan.children[3].assign_child_resource(jipian, location=None) - """======================================物料板============================================""" - """======================================瓶架,移液枪============================================""" - # 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒 - bottle_rack_3x4 = BottleRack( - name="bottle_rack_3x4", - size_x=210.0, - size_y=140.0, - size_z=100.0, - num_items_x=3, - num_items_y=4, - position_spacing=35.0, - orientation="vertical", - ) - deck.assign_child_resource(bottle_rack_3x4, Coordinate(x=100, y=200, z=0)) - - bottle_rack_6x2 = BottleRack( - name="bottle_rack_6x2", - size_x=120.0, - size_y=250.0, - size_z=100.0, - num_items_x=6, - num_items_y=2, - position_spacing=35.0, - orientation="vertical", - ) - deck.assign_child_resource(bottle_rack_6x2, Coordinate(x=300, y=300, z=0)) - - bottle_rack_6x2_2 = BottleRack( - name="bottle_rack_6x2_2", - size_x=120.0, - size_y=250.0, - size_z=100.0, - num_items_x=6, - num_items_y=2, - position_spacing=35.0, - orientation="vertical", - ) - deck.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=430, y=300, z=0)) - - - # 将 ElectrodeSheet 放满 3x4 与 6x2 的所有孔位 - for idx in range(bottle_rack_3x4.num_items_x * bottle_rack_3x4.num_items_y): - sheet = ElectrodeSheet(name=f"sheet_3x4_{idx}", size_x=12, size_y=12, size_z=0.1) - bottle_rack_3x4.assign_child_resource(sheet, index=idx) - - for idx in range(bottle_rack_6x2.num_items_x * bottle_rack_6x2.num_items_y): - sheet = ElectrodeSheet(name=f"sheet_6x2_{idx}", size_x=12, size_y=12, size_z=0.1) - bottle_rack_6x2.assign_child_resource(sheet, index=idx) - - tip_box = TipBox64(name="tip_box_64") - deck.assign_child_resource(tip_box, Coordinate(x=300, y=100, z=0)) - - waste_tip_box = WasteTipBox(name="waste_tip_box") - deck.assign_child_resource(waste_tip_box, Coordinate(x=300, y=200, z=0)) - """======================================瓶架,移液枪============================================""" - print(deck) - - - from unilabos.resources.graphio import convert_resources_from_type - from unilabos.config.config import BasicConfig - BasicConfig.ak = "56bbed5b-6e30-438c-b06d-f69eaa63bb45" - BasicConfig.sk = "238222fe-0bf7-4350-a426-e5ced8011dcf" - from unilabos.app.web.client import http_client - - resources = convert_resources_from_type([deck], [Resource]) - - # 检查序列化后的资源 - - json.dump({"nodes": resources, "links": []}, open("button_battery_decks_unilab.json", "w"), indent=2) - - - #print(resources) - http_client.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" - - http_client.resource_add(resources) \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py index 4758bdda..c69cad7d 100644 --- a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py @@ -1,44 +1,133 @@ import csv +import inspect import json import os import threading import time +import types from datetime import datetime from typing import Any, Dict, Optional -from pylabrobot.resources import Resource as PLRResource +from functools import wraps +from pylabrobot.resources import Deck, Resource as PLRResource from unilabos_msgs.msg import Resource from unilabos.device_comms.modbus_plc.client import ModbusTcpClient -from unilabos.devices.workstation.coin_cell_assembly.button_battery_station import MaterialHole, MaterialPlate from unilabos.devices.workstation.workstation_base import WorkstationBase from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusNode, PLCWorkflow, ModbusWorkflow, WorkflowAction, BaseClient from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNodeBase, DataType, WorderOrder -from unilabos.devices.workstation.coin_cell_assembly.button_battery_station import * +from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import * from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode +from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck +from unilabos.resources.graphio import convert_resources_to_type +from unilabos.utils.log import logger + + +def _ensure_modbus_slave_kw_alias(modbus_client): + if modbus_client is None: + return + + method_names = [ + "read_coils", + "write_coils", + "write_coil", + "read_discrete_inputs", + "read_holding_registers", + "write_register", + "write_registers", + ] + + def _wrap(func): + signature = inspect.signature(func) + has_var_kwargs = any(param.kind == param.VAR_KEYWORD for param in signature.parameters.values()) + accepts_unit = has_var_kwargs or "unit" in signature.parameters + accepts_slave = has_var_kwargs or "slave" in signature.parameters + + @wraps(func) + def _wrapped(self, *args, **kwargs): + if "slave" in kwargs and not accepts_slave: + slave_value = kwargs.pop("slave") + if accepts_unit and "unit" not in kwargs: + kwargs["unit"] = slave_value + if "unit" in kwargs and not accepts_unit: + unit_value = kwargs.pop("unit") + if accepts_slave and "slave" not in kwargs: + kwargs["slave"] = unit_value + return func(self, *args, **kwargs) + + _wrapped._has_slave_alias = True + return _wrapped + + for name in method_names: + if not hasattr(modbus_client, name): + continue + bound_method = getattr(modbus_client, name) + func = getattr(bound_method, "__func__", None) + if func is None: + continue + if getattr(func, "_has_slave_alias", False): + continue + wrapped = _wrap(func) + setattr(modbus_client, name, types.MethodType(wrapped, modbus_client)) + + +def _coerce_deck_input(deck: Any) -> Optional[Deck]: + if deck is None: + return None + + if isinstance(deck, Deck): + return deck + + if isinstance(deck, PLRResource): + return deck if isinstance(deck, Deck) else None + + candidates = None + if isinstance(deck, dict): + if "nodes" in deck and isinstance(deck["nodes"], list): + candidates = deck["nodes"] + else: + candidates = [deck] + elif isinstance(deck, list): + candidates = deck + + if candidates is None: + return None + + try: + converted = convert_resources_to_type(resources_list=candidates, resource_type=Deck) + if isinstance(converted, Deck): + return converted + if isinstance(converted, list): + for item in converted: + if isinstance(item, Deck): + return item + except Exception as exc: + logger.warning(f"deck 转换 Deck 失败: {exc}") + return None + #构建物料系统 class CoinCellAssemblyWorkstation(WorkstationBase): - def __init__( - self, - deck: CoincellDeck, - address: str = "192.168.1.20", + def __init__(self, + config: dict = None, + deck=None, + address: str = "172.16.28.102", port: str = "502", - debug_mode: bool = True, + debug_mode: bool = False, *args, - **kwargs, - ): - super().__init__( - #桌子 - deck=deck, - *args, - **kwargs, - ) + **kwargs): + + if deck is None and config: + deck = config.get('deck') + if deck is None: + logger.info("没有传入依华deck,检查启动json文件") + super().__init__(deck=deck, *args, **kwargs,) self.debug_mode = debug_mode - self.deck = deck + """ 连接初始化 """ modbus_client = TCPClient(addr=address, port=port) - print("modbus_client", modbus_client) + logger.debug(f"创建 Modbus 客户端: {modbus_client}") + _ensure_modbus_slave_kw_alias(modbus_client.client) if not debug_mode: modbus_client.client.connect() count = 100 @@ -49,27 +138,20 @@ class CoinCellAssemblyWorkstation(WorkstationBase): time.sleep(2) if not modbus_client.client.is_socket_open(): raise ValueError('modbus tcp connection failed') + self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_1105.csv')) + self.client = modbus_client.register_node_list(self.nodes) else: print("测试模式,跳过连接") - + self.nodes, self.client = None, None """ 工站的配置 """ - self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv')) - self.client = modbus_client.register_node_list(self.nodes) + self.success = False self.allow_data_read = False #允许读取函数运行标志位 self.csv_export_thread = None self.csv_export_running = False self.csv_export_file = None - #创建一个物料台面,包含两个极片板 - #self.deck = create_a_coin_cell_deck() - - #self._ros_node.update_resource(self.deck) - - #ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - # "resources": [self.deck] - #}) + self.coin_num_N = 0 #已组装电池数量 - def post_init(self, ros_node: ROS2WorkstationNode): self._ros_node = ros_node #self.deck = create_a_coin_cell_deck() @@ -77,6 +159,27 @@ class CoinCellAssemblyWorkstation(WorkstationBase): "resources": [self.deck] }) + def sync_transfer_resources(self) -> Dict[str, Any]: + """ + 供跨工站转运完成后调用,强制将当前台面资源同步到云端/前端。 + """ + if not hasattr(self, "_ros_node") or self._ros_node is None: + return {"status": "failed", "error": "ros_node_not_ready"} + if self.deck is None: + return {"status": "failed", "error": "deck_not_initialized"} + try: + future = ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, + True, + resources=[self.deck], + ) + if future: + future.result() + return {"status": "success"} + except Exception as exc: + logger.error(f"同步转运资源失败: {exc}", exc_info=True) + return {"status": "failed", "error": str(exc)} + # 批量操作在这里写 async def change_hole_sheet_to_2(self, hole: MaterialHole): hole._unilabos_state["max_sheets"] = 2 @@ -491,11 +594,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase): try: # 尝试不同的字节序读取 code_little, read_err = self.client.use_node('REG_DATA_COIN_CELL_CODE').read(10, word_order=WorderOrder.LITTLE) - print(code_little) + # logger.debug(f"读取电池二维码原始数据: {code_little}") clean_code = code_little[-8:][::-1] return clean_code except Exception as e: - print(f"读取电池二维码失败: {e}") + logger.error(f"读取电池二维码失败: {e}") return "N/A" @@ -504,11 +607,11 @@ class CoinCellAssemblyWorkstation(WorkstationBase): try: # 尝试不同的字节序读取 code_little, read_err = self.client.use_node('REG_DATA_ELECTROLYTE_CODE').read(10, word_order=WorderOrder.LITTLE) - print(code_little) + # logger.debug(f"读取电解液二维码原始数据: {code_little}") clean_code = code_little[-8:][::-1] return clean_code except Exception as e: - print(f"读取电解液二维码失败: {e}") + logger.error(f"读取电解液二维码失败: {e}") return "N/A" # ===================== 环境监控区 ====================== @@ -606,7 +709,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase): print("waiting for start_cmd") time.sleep(1) - def func_pack_send_bottle_num(self, bottle_num: int): + def func_pack_send_bottle_num(self, bottle_num): + bottle_num = int(bottle_num) #发送电解液平台数 print("启动") while (self._unilab_rece_electrolyte_bottle_num()) == False: @@ -654,16 +758,25 @@ class CoinCellAssemblyWorkstation(WorkstationBase): # self.success = True # return self.success - def func_pack_send_msg_cmd(self, elec_use_num) -> bool: + def func_pack_send_msg_cmd(self, elec_use_num, elec_vol, assembly_type, assembly_pressure) -> bool: """UNILAB写参数""" while (self.request_rec_msg_status) == False: print("wait for request_rec_msg_status to True") time.sleep(1) self.success = False #self._unilab_send_msg_electrolyte_num(elec_num) - time.sleep(1) + #设置平行样数目 self._unilab_send_msg_electrolyte_use_num(elec_use_num) time.sleep(1) + #发送电解液加注量 + self._unilab_send_msg_electrolyte_vol(elec_vol) + time.sleep(1) + #发送电解液组装类型 + self._unilab_send_msg_assembly_type(assembly_type) + time.sleep(1) + #发送电池压制力 + self._unilab_send_msg_assembly_pressure(assembly_pressure) + time.sleep(1) self._unilab_send_msg_succ_cmd(True) time.sleep(1) while (self.request_rec_msg_status) == True: @@ -688,15 +801,32 @@ class CoinCellAssemblyWorkstation(WorkstationBase): data_coin_num = self.data_coin_num data_electrolyte_code = self.data_electrolyte_code data_coin_cell_code = self.data_coin_cell_code - print("data_open_circuit_voltage", data_open_circuit_voltage) - print("data_pole_weight", data_pole_weight) - print("data_assembly_time", data_assembly_time) - print("data_assembly_pressure", data_assembly_pressure) - print("data_electrolyte_volume", data_electrolyte_volume) - print("data_coin_num", data_coin_num) - print("data_electrolyte_code", data_electrolyte_code) - print("data_coin_cell_code", data_coin_cell_code) + logger.debug(f"data_open_circuit_voltage: {data_open_circuit_voltage}") + logger.debug(f"data_pole_weight: {data_pole_weight}") + logger.debug(f"data_assembly_time: {data_assembly_time}") + logger.debug(f"data_assembly_pressure: {data_assembly_pressure}") + logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}") + logger.debug(f"data_coin_num: {data_coin_num}") + logger.debug(f"data_electrolyte_code: {data_electrolyte_code}") + logger.debug(f"data_coin_cell_code: {data_coin_cell_code}") #接收完信息后,读取完毕标志位置True + liaopan3 = self.deck.get_resource("成品弹夹") + #把物料解绑后放到另一盘上 + battery = ElectrodeSheet(name=f"battery_{self.coin_num_N}", size_x=14, size_y=14, size_z=2) + battery._unilabos_state = { + "electrolyte_name": data_coin_cell_code, + "data_electrolyte_code": data_electrolyte_code, + "open_circuit_voltage": data_open_circuit_voltage, + "assembly_pressure": data_assembly_pressure, + "electrolyte_volume": data_electrolyte_volume + } + liaopan3.children[self.coin_num_N].assign_child_resource(battery, location=None) + #print(jipian2.parent) + ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + "resources": [self.deck] + }) + + self._unilab_rec_msg_succ_cmd(True) time.sleep(1) #等待允许读取标志位置False @@ -754,11 +884,25 @@ class CoinCellAssemblyWorkstation(WorkstationBase): self.success = True return self.success + def qiming_coin_cell_code(self, fujipian_panshu:int, fujipian_juzhendianwei:int=0, gemopanshu:int=0, gemo_juzhendianwei:int=0, lvbodian:bool=True, battery_pressure_mode:bool=True, battery_pressure:int=4000, battery_clean_ignore:bool=False) -> bool: + self.success = False + self.client.use_node('REG_MSG_NE_PLATE_NUM').write(fujipian_panshu) + self.client.use_node('REG_MSG_NE_PLATE_MATRIX').write(fujipian_juzhendianwei) + self.client.use_node('REG_MSG_SEPARATOR_PLATE_NUM').write(gemopanshu) + self.client.use_node('REG_MSG_SEPARATOR_PLATE_MATRIX').write(gemo_juzhendianwei) + self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian) + self.client.use_node('REG_MSG_PRESS_MODE').write(not battery_pressure_mode) + # self.client.use_node('REG_MSG_ASSEMBLY_PRESSURE').write(battery_pressure) + self.client.use_node('REG_MSG_BATTERY_CLEAN_IGNORE').write(battery_clean_ignore) + self.success = True + + return self.success - - def func_allpack_cmd(self, elec_num, elec_use_num, file_path: str="D:\\coin_cell_data") -> bool: + def func_allpack_cmd(self, elec_num, elec_use_num, elec_vol:int=50, assembly_type:int=7, assembly_pressure:int=4200, file_path: str="/Users/sml/work") -> bool: + elec_num, elec_use_num, elec_vol, assembly_type, assembly_pressure = int(elec_num), int(elec_use_num), int(elec_vol), int(assembly_type), int(assembly_pressure) summary_csv_file = os.path.join(file_path, "duandian.csv") # 如果断点文件存在,先读取之前的进度 + if os.path.exists(summary_csv_file): read_status_flag = True with open(summary_csv_file, 'r', newline='', encoding='utf-8') as csvfile: @@ -784,54 +928,38 @@ class CoinCellAssemblyWorkstation(WorkstationBase): elec_num_N = 0 elec_use_num_N = 0 coin_num_N = 0 - - print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}") - + for i in range(20): + print(f"剩余电解液瓶数: {elec_num}, 已组装电池数: {elec_use_num}") + print(f"剩余电解液瓶数: {type(elec_num)}, 已组装电池数: {type(elec_use_num)}") + print(f"剩余电解液瓶数: {type(int(elec_num))}, 已组装电池数: {type(int(elec_use_num))}") #如果是第一次运行,则进行初始化、切换自动、启动, 如果是断点重启则跳过。 if read_status_flag == False: + pass #初始化 - self.func_pack_device_init() + #self.func_pack_device_init() #切换自动 - self.func_pack_device_auto() + #self.func_pack_device_auto() #启动,小车收回 - self.func_pack_device_start() + #self.func_pack_device_start() #发送电解液瓶数量,启动搬运,多搬运没事 - self.func_pack_send_bottle_num(elec_num) + #self.func_pack_send_bottle_num(elec_num) last_i = elec_num_N last_j = elec_use_num_N for i in range(last_i, elec_num): print(f"开始第{last_i+i+1}瓶电解液的组装") #第一个循环从上次断点继续,后续循环从0开始 j_start = last_j if i == last_i else 0 - self.func_pack_send_msg_cmd(elec_use_num-j_start) + self.func_pack_send_msg_cmd(elec_use_num-j_start, elec_vol, assembly_type, assembly_pressure) for j in range(j_start, elec_use_num): print(f"开始第{last_i+i+1}瓶电解液的第{j+j_start+1}个电池组装") #读取电池组装数据并存入csv self.func_pack_get_msg_cmd(file_path) time.sleep(1) - - #这里定义物料系统 # TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑 - liaopan1 = self.deck.get_resource("liaopan1") - liaopan4 = self.deck.get_resource("liaopan4") - jipian1 = liaopan1.children[coin_num_N].children[0] - jipian4 = liaopan4.children[coin_num_N].children[0] - #print(jipian1) - #从料盘上去物料解绑后放到另一盘上 - jipian1.parent.unassign_child_resource(jipian1) - jipian4.parent.unassign_child_resource(jipian4) - - #print(jipian2.parent) - battery = Battery(name = f"battery_{coin_num_N}") - battery.assign_child_resource(jipian1, location=None) - battery.assign_child_resource(jipian4, location=None) - - zidanjia6 = self.deck.get_resource("zi_dan_jia6") - zidanjia6.children[0].assign_child_resource(battery, location=None) - + # 生成断点文件 # 生成包含elec_num_N、coin_num_N、timestamp的CSV文件 @@ -842,6 +970,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase): writer.writerow([elec_num, elec_use_num, elec_num_N, elec_use_num_N, coin_num_N, timestamp]) csvfile.flush() coin_num_N += 1 + self.coin_num_N = coin_num_N elec_use_num_N += 1 elec_num_N += 1 elec_use_num_N = 0 @@ -876,38 +1005,54 @@ class CoinCellAssemblyWorkstation(WorkstationBase): #self.success = True #return self.success + def run_packaging_workflow(self, workflow_config: Dict[str, Any]) -> "CoinCellAssemblyWorkstation": + config = workflow_config or {} + + qiming_params = config.get("qiming") or {} + if qiming_params: + self.qiming_coin_cell_code(**qiming_params) + + if config.get("init", True): + self.func_pack_device_init() + if config.get("auto", True): + self.func_pack_device_auto() + if config.get("start", True): + self.func_pack_device_start() + + packaging_config = config.get("packaging") or {} + bottle_num = packaging_config.get("bottle_num") + if bottle_num is not None: + self.func_pack_send_bottle_num(bottle_num) + + allpack_params = packaging_config.get("command") or {} + if allpack_params: + self.func_allpack_cmd(**allpack_params) + + return self + def fun_wuliao_test(self) -> bool: #找到data_init中构建的2个物料盘 - #liaopan1 = self.deck.get_resource("liaopan1") - #liaopan4 = self.deck.get_resource("liaopan4") - #for coin_num_N in range(16): - # liaopan1 = self.deck.get_resource("liaopan1") - # liaopan4 = self.deck.get_resource("liaopan4") - # jipian1 = liaopan1.children[coin_num_N].children[0] - # jipian4 = liaopan4.children[coin_num_N].children[0] - # #print(jipian1) - # #从料盘上去物料解绑后放到另一盘上 - # jipian1.parent.unassign_child_resource(jipian1) - # jipian4.parent.unassign_child_resource(jipian4) - # - # #print(jipian2.parent) - # battery = Battery(name = f"battery_{coin_num_N}") - # battery.assign_child_resource(jipian1, location=None) - # battery.assign_child_resource(jipian4, location=None) - # - # zidanjia6 = self.deck.get_resource("zi_dan_jia6") - # zidanjia6.children[0].assign_child_resource(battery, location=None) - # ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ - # "resources": [self.deck] - # }) - # time.sleep(2) - for i in range(20): - print(f"输出{i}") - time.sleep(2) - + liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8") + for i in range(16): + battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2) + battery._unilabos_state = { + "diameter": 20.0, + "height": 20.0, + "assembly_pressure": i, + "electrolyte_volume": 20.0, + "electrolyte_name": f"DP{i}" + } + liaopan3.children[i].assign_child_resource(battery, location=None) + ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{ + "resources": [self.deck] + }) + # for i in range(40): + # print(f"fun_wuliao_test 运行结束{i}") + # time.sleep(1) + # time.sleep(40) # 数据读取与输出 - def func_read_data_and_output(self, file_path: str="D:\\coin_cell_data"): + def func_read_data_and_output(self, file_path: str="/Users/sml/work"): # 检查CSV导出是否正在运行,已运行则跳出,防止同时启动两个while循环 if self.csv_export_running: return False, "读取已在运行中" @@ -1012,7 +1157,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase): # else: # print("子弹夹洞位0没有极片") # - # #把电解液从瓶中取到电池夹子中 + # # TODO:#把电解液从瓶中取到电池夹子中 # battery_site = deck.get_resource("battery_press_1") # clip_magazine_battery = deck.get_resource("clip_magazine_battery") # if battery_site.has_battery(): @@ -1098,45 +1243,95 @@ class CoinCellAssemblyWorkstation(WorkstationBase): """移液枪头库存 (数量, INT16)""" inventory, read_err = self.client.register_node_list(self.nodes).use_node('REG_DATA_TIPS_INVENTORY').read(1) return inventory - + ''' + def run_coin_cell_assembly_workflow( + self, + workflow_config: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + config: Dict[str, Any] + if workflow_config is None: + config = {} + elif isinstance(workflow_config, list): + config = {"materials": workflow_config} + else: + config = workflow_config + qiming_defaults = { + "fujipian_panshu": 1, + "fujipian_juzhendianwei": 0, + "gemopanshu": 1, + "gemo_juzhendianwei": 0, + "lvbodian": True, + "battery_pressure_mode": True, + "battery_pressure": 4200, + "battery_clean_ignore": False, + } + qiming_params = {**qiming_defaults, **(config.get("qiming") or {})} + qiming_success = self.qiming_coin_cell_code(**qiming_params) + + step_results: Dict[str, Any] = {} + try: + self.func_pack_device_init() + step_results["init"] = True + except Exception as exc: + step_results["init"] = f"error: {exc}" + + try: + self.func_pack_device_auto() + step_results["auto"] = True + except Exception as exc: + step_results["auto"] = f"error: {exc}" + + try: + self.func_pack_device_start() + step_results["start"] = True + except Exception as exc: + step_results["start"] = f"error: {exc}" + + packaging_cfg = config.get("packaging") or {} + bottle_num = packaging_cfg.get("bottle_num", 1) + try: + self.func_pack_send_bottle_num(bottle_num) + step_results["send_bottle_num"] = True + except Exception as exc: + step_results["send_bottle_num"] = f"error: {exc}" + + command_defaults = { + "elec_num": 1, + "elec_use_num": 1, + "elec_vol": 50, + "assembly_type": 7, + "assembly_pressure": 4200, + "file_path": "/Users/sml/work", + } + command_params = {**command_defaults, **(packaging_cfg.get("command") or {})} + packaging_result = self.func_allpack_cmd(**command_params) + + finished_result = self.func_pack_send_finished_cmd() + stop_result = self.func_pack_device_stop() + + return { + "qiming": { + "params": qiming_params, + "success": qiming_success, + }, + "workflow_steps": step_results, + "packaging": { + "bottle_num": bottle_num, + "command": command_params, + "result": packaging_result, + }, + "finish": { + "send_finished": finished_result, + "stop": stop_result, + }, + } + if __name__ == "__main__": - from pylabrobot.resources import Resource - Coin_Cell = CoinCellAssemblyWorkstation(Resource("1", 1, 1, 1), debug_mode=True) - #Coin_Cell.func_pack_device_init() - #Coin_Cell.func_pack_device_auto() - #Coin_Cell.func_pack_device_start() - #Coin_Cell.func_pack_send_bottle_num(2) - #Coin_Cell.func_pack_send_msg_cmd(2) - #Coin_Cell.func_pack_get_msg_cmd() - #Coin_Cell.func_pack_get_msg_cmd() - #Coin_Cell.func_pack_send_finished_cmd() -# - #Coin_Cell.func_allpack_cmd(3, 2) - #print(Coin_Cell.data_stack_vision_code) - #print("success") - #创建一个物料台面 - - #deck = create_a_coin_cell_deck() - - ##在台面上找到料盘和极片 - #liaopan1 = deck.get_resource("liaopan1") - #liaopan2 = deck.get_resource("liaopan2") - #jipian1 = liaopan1.children[1].children[0] -# - ##print(jipian1) - ##把物料解绑后放到另一盘上 - #jipian1.parent.unassign_child_resource(jipian1) - #liaopan2.children[1].assign_child_resource(jipian1, location=None) - ##print(jipian2.parent) - from unilabos.resources.graphio import resource_ulab_to_plr, convert_resources_to_type - - with open("./button_battery_decks_unilab.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(ulab_resources) - + deck = CoincellDeck(setup=True, name="coin_cell_deck") + w = CoinCellAssemblyWorkstation(deck=deck, address="172.16.28.102", port="502", debug_mode=False) + w.run_coin_cell_assembly_workflow() + + \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_1105.csv b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_1105.csv new file mode 100644 index 00000000..3f7b357f --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_1105.csv @@ -0,0 +1,64 @@ +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,,,coil,9010, +COIL_SYS_STOP_CMD,BOOL,,,,coil,9020, +COIL_SYS_RESET_CMD,BOOL,,,,coil,9030, +COIL_SYS_HAND_CMD,BOOL,,,,coil,9040, +COIL_SYS_AUTO_CMD,BOOL,,,,coil,9050, +COIL_SYS_INIT_CMD,BOOL,,,,coil,9060, +COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,9700, +COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,9710,unilab_rec_msg_succ_cmd +COIL_SYS_START_STATUS,BOOL,,,,coil,9210, +COIL_SYS_STOP_STATUS,BOOL,,,,coil,9220, +COIL_SYS_RESET_STATUS,BOOL,,,,coil,9230, +COIL_SYS_HAND_STATUS,BOOL,,,,coil,9240, +COIL_SYS_AUTO_STATUS,BOOL,,,,coil,9250, +COIL_SYS_INIT_STATUS,BOOL,,,,coil,9260, +COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,9500, +COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,9510,request_send_msg_status +REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,17000, +REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,17002,unilab_send_msg_electrolyte_num +REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,17004,unilab_send_msg_electrolyte_vol +REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,17006,unilab_send_msg_assembly_type +REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,17008,unilab_send_msg_assembly_pressure +REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,16000,data_assembly_coin_cell_num +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,16002,data_open_circuit_voltage +REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,16004, +REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,16006, +REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,16008, +REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,16010,data_pole_weight +REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,16012,data_assembly_time +REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,16014,data_assembly_pressure +REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,16016,data_electrolyte_volume +REG_DATA_COIN_NUM,INT16,,,,hold_register,16018,data_coin_num +REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,16020,data_electrolyte_code() +REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,16030,data_coin_cell_code() +REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,18004,data_stack_vision_code() +REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,16050,data_glove_box_pressure +REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,16052,data_glove_box_water_content +REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,16054,data_glove_box_o2_content +UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9720, +UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,9520, +REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,17496, +REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,16000, +UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,9730, +UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,9530, +REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,16018,ASSEMBLY_TYPE7or8 +COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,9340, +REG_MSG_NE_PLATE_MATRIX,INT16,,负极片矩阵点位,,hold_register,17440, +REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,隔膜矩阵点位,,hold_register,17450, +REG_MSG_TIP_BOX_MATRIX,INT16,,移液枪头矩阵点位,,hold_register,17480, +REG_MSG_NE_PLATE_NUM,INT16,,负极片盘数,,hold_register,17443, +REG_MSG_SEPARATOR_PLATE_NUM,INT16,,隔膜盘数,,hold_register,17453, +REG_MSG_PRESS_MODE,BOOL,,压制模式(false:压力检测模式,True:距离模式),,coil,9360,电池压制模式 +,,,,,,, +,BOOL,,视觉对位(false:使用,true:忽略),,coil,9300,视觉对位 +,BOOL,,复检(false:使用,true:忽略),,coil,9310,视觉复检 +,BOOL,,手套箱_左仓(false:使用,true:忽略),,coil,9320,手套箱左仓 +,BOOL,,手套箱_右仓(false:使用,true:忽略),,coil,9420,手套箱右仓 +,BOOL,,真空检知(false:使用,true:忽略),,coil,9350,真空检知 +,BOOL,,电解液添加模式(false:单次滴液,true:二次滴液),,coil,9370,滴液模式 +,BOOL,,正极片称重(false:使用,true:忽略),,coil,9380,正极片称重 +,BOOL,,正负极片组装方式(false:正装,true:倒装),,coil,9390,正负极反装 +,BOOL,,压制清洁(false:使用,true:忽略),,coil,9400,压制清洁 +,BOOL,,物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘),,coil,9410,负极片摆盘方式 +REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,忽略电池清洁(false:使用,true:忽略),,coil,9460, diff --git a/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_a.csv b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_a.csv new file mode 100644 index 00000000..98f90383 --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_a.csv @@ -0,0 +1,64 @@ +Name,DataType,InitValue,Comment,Attribute,DeviceType,Address, +COIL_SYS_START_CMD,BOOL,,,,coil,8010, +COIL_SYS_STOP_CMD,BOOL,,,,coil,8020, +COIL_SYS_RESET_CMD,BOOL,,,,coil,8030, +COIL_SYS_HAND_CMD,BOOL,,,,coil,8040, +COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050, +COIL_SYS_INIT_CMD,BOOL,,,,coil,8060, +COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700, +COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd +COIL_SYS_START_STATUS,BOOL,,,,coil,8210, +COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220, +COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230, +COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240, +COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250, +COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260, +COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500, +COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status +REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000, +REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num +REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol +REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type +REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure +REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num +REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage +REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004, +REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006, +REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008, +REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight +REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time +REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure +REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume +REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num +REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code() +REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code() +REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code() +REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure +REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content +REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content +UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720, +UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520, +REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496, +REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000, +UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730, +UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530, +REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8 +COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340, +REG_MSG_NE_PLATE_MATRIX,INT16,,负极片矩阵点位,,hold_register,440, +REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,隔膜矩阵点位,,hold_register,450, +REG_MSG_TIP_BOX_MATRIX,INT16,,移液枪头矩阵点位,,hold_register,480, +REG_MSG_NE_PLATE_NUM,INT16,,负极片盘数,,hold_register,443, +REG_MSG_SEPARATOR_PLATE_NUM,INT16,,隔膜盘数,,hold_register,453, +REG_MSG_PRESS_MODE,BOOL,,压制模式(false:压力检测模式,True:距离模式),,coil,8360,电池压制模式 +,,,,,,, +,BOOL,,视觉对位(false:使用,true:忽略),,coil,8300,视觉对位 +,BOOL,,复检(false:使用,true:忽略),,coil,8310,视觉复检 +,BOOL,,手套箱_左仓(false:使用,true:忽略),,coil,8320,手套箱左仓 +,BOOL,,手套箱_右仓(false:使用,true:忽略),,coil,8420,手套箱右仓 +,BOOL,,真空检知(false:使用,true:忽略),,coil,8350,真空检知 +,BOOL,,电解液添加模式(false:单次滴液,true:二次滴液),,coil,8370,滴液模式 +,BOOL,,正极片称重(false:使用,true:忽略),,coil,8380,正极片称重 +,BOOL,,正负极片组装方式(false:正装,true:倒装),,coil,8390,正负极反装 +,BOOL,,压制清洁(false:使用,true:忽略),,coil,8400,压制清洁 +,BOOL,,物料盘摆盘方式(false:水平摆盘,true:堆叠摆盘),,coil,8410,负极片摆盘方式 +REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,忽略电池清洁(false:使用,true:忽略),,coil,8460, \ No newline at end of file diff --git a/unilabos/devices/workstation/coin_cell_assembly/new_cellconfig3c.json b/unilabos/devices/workstation/coin_cell_assembly/new_cellconfig3c.json new file mode 100644 index 00000000..14e6a22a --- /dev/null +++ b/unilabos/devices/workstation/coin_cell_assembly/new_cellconfig3c.json @@ -0,0 +1,39 @@ +{ + "nodes": [ + { + "id": "bioyond_cell_workstation", + "name": "配液分液工站", + "children": [ + ], + "parent": null, + "type": "device", + "class": "bioyond_cell", + "config": { + "protocol_type": [], + "station_resource": {} + }, + "data": {} + }, + + { + "id": "BatteryStation", + "name": "扣电工作站", + "children": [ + "coin_cell_deck" + ], + "parent": null, + "type": "device", + "class": "coincellassemblyworkstation_device", + "position": { + "x": -600, + "y": -400, + "z": 0 + }, + "config": { + "debug_mode": false, + "protocol_type": [] + } + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/workstation/workstation_base.py b/unilabos/devices/workstation/workstation_base.py index 75fd7ea8..97db1505 100644 --- a/unilabos/devices/workstation/workstation_base.py +++ b/unilabos/devices/workstation/workstation_base.py @@ -147,7 +147,7 @@ class WorkstationBase(ABC): def __init__( self, - deck: Optional[Deck], + deck: Deck, *args, **kwargs, # 必须有kwargs ): @@ -349,5 +349,5 @@ class WorkstationBase(ABC): class ProtocolNode(WorkstationBase): - def __init__(self, protocol_type: List[str], deck: Optional[PLRResource], *args, **kwargs): + def __init__(self, deck: Optional[PLRResource], *args, **kwargs): super().__init__(deck, *args, **kwargs) diff --git a/unilabos/devices/workstation/workstation_http_service.py b/unilabos/devices/workstation/workstation_http_service.py index 11d87693..4565edea 100644 --- a/unilabos/devices/workstation/workstation_http_service.py +++ b/unilabos/devices/workstation/workstation_http_service.py @@ -4,7 +4,7 @@ Workstation HTTP Service Module 统一的工作站报送接收服务,基于LIMS协议规范: 1. 步骤完成报送 - POST /report/step_finish -2. 通量完成报送 - POST /report/sample_finish +2. 通量完成报送 - POST /report/sample_finish 3. 任务完成报送 - POST /report/order_finish 4. 批量更新报送 - POST /report/batch_update 5. 物料变更报送 - POST /report/material_change @@ -22,7 +22,6 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse from dataclasses import dataclass, asdict from datetime import datetime -from pathlib import Path from unilabos.utils.log import logger @@ -55,18 +54,18 @@ class HttpResponse: class WorkstationHTTPHandler(BaseHTTPRequestHandler): """工作站HTTP请求处理器""" - + def __init__(self, workstation_instance, *args, **kwargs): self.workstation = workstation_instance super().__init__(*args, **kwargs) - + def do_POST(self): """处理POST请求 - 统一的工作站报送接口""" try: # 解析请求路径 parsed_path = urlparse(self.path) endpoint = parsed_path.path - + # 读取请求体 content_length = int(self.headers.get('Content-Length', 0)) if content_length > 0: @@ -74,17 +73,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): request_data = json.loads(post_data.decode('utf-8')) else: request_data = {} - + logger.info(f"收到工作站报送: {endpoint} - {request_data.get('token', 'unknown')}") - - try: - payload_for_log = {"method": "POST", **request_data} - self._save_raw_request(endpoint, payload_for_log) - if hasattr(self.workstation, '_reports_received_count'): - self.workstation._reports_received_count += 1 - except Exception: - pass - + # 统一的报送端点路由(基于LIMS协议规范) if endpoint == '/report/step_finish': response = self._handle_step_finish_report(request_data) @@ -99,8 +90,6 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): response = self._handle_material_change_report(request_data) elif endpoint == '/report/error_handling': response = self._handle_error_handling_report(request_data) - elif endpoint == '/report/temperature-cutoff': - response = self._handle_temperature_cutoff_report(request_data) # 保留LIMS协议端点以兼容现有系统 elif endpoint == '/LIMS/step_finish': response = self._handle_step_finish_report(request_data) @@ -113,19 +102,18 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"不支持的报送端点: {endpoint}", data={"supported_endpoints": [ - "/report/step_finish", - "/report/sample_finish", + "/report/step_finish", + "/report/sample_finish", "/report/order_finish", "/report/batch_update", "/report/material_change", - "/report/error_handling", - "/report/temperature-cutoff" + "/report/error_handling" ]} ) - + # 发送响应 self._send_response(response) - + except Exception as e: logger.error(f"处理工作站报送失败: {e}\\n{traceback.format_exc()}") error_response = HttpResponse( @@ -133,18 +121,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): message=f"请求处理失败: {str(e)}" ) self._send_response(error_response) - + def do_GET(self): """处理GET请求 - 健康检查和状态查询""" try: parsed_path = urlparse(self.path) endpoint = parsed_path.path - - try: - self._save_raw_request(endpoint, {"method": "GET"}) - except Exception: - pass - + if endpoint == '/status': response = self._handle_status_check() elif endpoint == '/health': @@ -155,9 +138,9 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): message=f"不支持的查询端点: {endpoint}", data={"supported_endpoints": ["/status", "/health"]} ) - + self._send_response(response) - + except Exception as e: logger.error(f"GET请求处理失败: {e}") error_response = HttpResponse( @@ -165,7 +148,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): message=f"GET请求处理失败: {str(e)}" ) self._send_response(error_response) - + def do_OPTIONS(self): """处理OPTIONS请求 - CORS预检请求""" try: @@ -176,12 +159,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): 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: """处理步骤完成报送(统一LIMS协议规范)""" try: @@ -192,7 +175,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'stepName', 'stepId', 'sampleId', 'startTime', 'endTime'] @@ -201,31 +184,31 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_step_finish_report(report_request) - + return HttpResponse( success=True, message=f"步骤完成报送已处理: {data['stepName']} ({data['orderCode']})", acknowledgment_id=f"STEP_{int(time.time() * 1000)}_{data['stepId']}", data=result ) - + except Exception as e: logger.error(f"处理步骤完成报送失败: {e}") return HttpResponse( success=False, message=f"步骤完成报送处理失败: {str(e)}" ) - + def _handle_sample_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理通量完成报送(统一LIMS协议规范)""" try: @@ -236,7 +219,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'sampleId', 'startTime', 'endTime', 'status'] @@ -245,37 +228,37 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_sample_finish_report(report_request) - + status_names = { - "0": "待生产", "2": "进样", "10": "开始", + "0": "待生产", "2": "进样", "10": "开始", "20": "完成", "-2": "异常停止", "-3": "人工停止" } status_desc = status_names.get(str(data['status']), f"状态{data['status']}") - + return HttpResponse( success=True, message=f"通量完成报送已处理: {data['sampleId']} ({data['orderCode']}) - {status_desc}", acknowledgment_id=f"SAMPLE_{int(time.time() * 1000)}_{data['sampleId']}", data=result ) - + except Exception as e: logger.error(f"处理通量完成报送失败: {e}") return HttpResponse( success=False, message=f"通量完成报送处理失败: {str(e)}" ) - + def _handle_order_finish_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理任务完成报送(统一LIMS协议规范)""" try: @@ -286,7 +269,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 验证data字段内容 data = request_data['data'] data_required_fields = ['orderCode', 'orderName', 'startTime', 'endTime', 'status'] @@ -295,7 +278,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"data字段缺少必要内容: {', '.join(data_missing_fields)}" ) - + # 处理物料使用记录 used_materials = [] if 'usedMaterials' in data: @@ -307,85 +290,41 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): usedQuantity=material_data.get('usedQuantity', 0.0) ) used_materials.append(material) - + # 创建统一请求对象 report_request = WorkstationReportRequest( token=request_data['token'], request_time=request_data['request_time'], data=data ) - + # 调用工作站处理方法 result = self.workstation.process_order_finish_report(report_request, used_materials) - + status_names = {"30": "完成", "-11": "异常停止", "-12": "人工停止"} status_desc = status_names.get(str(data['status']), f"状态{data['status']}") - + return HttpResponse( success=True, message=f"任务完成报送已处理: {data['orderName']} ({data['orderCode']}) - {status_desc}", acknowledgment_id=f"ORDER_{int(time.time() * 1000)}_{data['orderCode']}", data=result ) - + except Exception as e: logger.error(f"处理任务完成报送失败: {e}") return HttpResponse( success=False, message=f"任务完成报送处理失败: {str(e)}" ) - - def _handle_temperature_cutoff_report(self, request_data: Dict[str, Any]) -> HttpResponse: - try: - required_fields = ['token', 'request_time', 'data'] - if missing := [f for f in required_fields if f not in request_data]: - return HttpResponse(success=False, message=f"缺少必要字段: {', '.join(missing)}") - - data = request_data['data'] - metrics = [ - 'frameCode', - 'generateTime', - 'targetTemperature', - 'settingTemperature', - 'inTemperature', - 'outTemperature', - 'pt100Temperature', - 'sensorAverageTemperature', - 'speed', - 'force', - 'viscosity', - 'averageViscosity' - ] - if miss := [f for f in metrics if f not in data]: - return HttpResponse(success=False, message=f"data字段缺少必要内容: {', '.join(miss)}") - - report_request = WorkstationReportRequest( - token=request_data['token'], - request_time=request_data['request_time'], - data=data - ) - - result = {} - if hasattr(self.workstation, 'process_temperature_cutoff_report'): - result = self.workstation.process_temperature_cutoff_report(report_request) - - return HttpResponse( - success=True, - message=f"温度/粘度报送已处理: 帧{data['frameCode']}", - acknowledgment_id=f"TEMP_CUTOFF_{int(time.time()*1000)}_{data['frameCode']}", - data=result - ) - except Exception as e: - logger.error(f"处理温度/粘度报送失败: {e}\n{traceback.format_exc()}") - return HttpResponse(success=False, message=f"温度/粘度报送处理失败: {str(e)}") - + def _handle_batch_update_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理批量报送""" try: step_updates = request_data.get('step_updates', []) sample_updates = request_data.get('sample_updates', []) order_updates = request_data.get('order_updates', []) - + results = { 'step_results': [], 'sample_results': [], @@ -393,7 +332,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): 'total_processed': 0, 'total_failed': 0 } - + # 处理批量步骤更新 for step_data in step_updates: try: @@ -408,7 +347,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): except Exception as e: results['step_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + # 处理批量通量更新 for sample_data in sample_updates: try: @@ -423,7 +362,7 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): except Exception as e: results['sample_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + # 处理批量任务更新 for order_data in order_updates: try: @@ -438,21 +377,21 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): except Exception as e: results['order_results'].append(HttpResponse(success=False, message=str(e))) results['total_failed'] += 1 - + return HttpResponse( success=results['total_failed'] == 0, message=f"批量报送处理完成: {results['total_processed']} 成功, {results['total_failed']} 失败", acknowledgment_id=f"BATCH_{int(time.time() * 1000)}", data=results ) - + except Exception as e: logger.error(f"处理批量报送失败: {e}") return HttpResponse( success=False, message=f"批量报送处理失败: {str(e)}" ) - + def _handle_material_change_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理物料变更报送""" try: @@ -478,24 +417,24 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 调用工作站的处理方法 result = self.workstation.process_material_change_report(request_data) - + return HttpResponse( success=True, message=f"物料变更报送已处理: {request_data['resource_id']} ({request_data['change_type']})", acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{request_data['resource_id']}", data=result ) - + except Exception as e: logger.error(f"处理物料变更报送失败: {e}") return HttpResponse( success=False, message=f"物料变更报送处理失败: {str(e)}" ) - + def _handle_error_handling_report(self, request_data: Dict[str, Any]) -> HttpResponse: """处理错误处理报送""" try: @@ -507,13 +446,13 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): 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')}", @@ -528,50 +467,42 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"缺少必要字段: {', '.join(missing_fields)}" ) - + # 调用工作站的处理方法 result = self.workstation.handle_external_error(request_data) - + return HttpResponse( success=True, message=f"错误处理报送已处理: {request_data['error_type']} - {request_data['error_message']}", acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{request_data.get('action_id', 'unknown')}", data=result ) - + except Exception as e: logger.error(f"处理错误处理报送失败: {e}") return HttpResponse( success=False, message=f"错误处理报送处理失败: {str(e)}" ) - + def _handle_status_check(self) -> HttpResponse: """处理状态查询""" try: - # 安全地获取 device_id - device_id = "unknown" - if hasattr(self.workstation, 'device_id'): - device_id = self.workstation.device_id - elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'): - device_id = self.workstation._ros_node.device_id - return HttpResponse( success=True, message="工作站报送服务正常运行", data={ - "workstation_id": device_id, + "workstation_id": self.workstation.device_id, "service_type": "unified_reporting_service", "uptime": time.time() - getattr(self.workstation, '_start_time', time.time()), "reports_received": getattr(self.workstation, '_reports_received_count', 0), "supported_endpoints": [ "POST /report/step_finish", - "POST /report/sample_finish", + "POST /report/sample_finish", "POST /report/order_finish", "POST /report/batch_update", "POST /report/material_change", "POST /report/error_handling", - "POST /report/temperature-cutoff", "GET /status", "GET /health" ] @@ -583,52 +514,36 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler): success=False, message=f"状态查询失败: {str(e)}" ) - + def _send_response(self, response: HttpResponse): """发送响应""" try: # 设置响应状态码 status_code = 200 if response.success else 400 self.send_response(status_code) - + # 设置响应头 self.send_header('Content-Type', 'application/json; charset=utf-8') 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') self.end_headers() - + # 发送响应体 response_json = json.dumps(asdict(response), ensure_ascii=False, indent=2) self.wfile.write(response_json.encode('utf-8')) - + except Exception as e: logger.error(f"发送响应失败: {e}") - + def log_message(self, format, *args): """重写日志方法""" logger.debug(f"HTTP请求: {format % args}") - def _save_raw_request(self, endpoint: str, request_data: Dict[str, Any]) -> None: - try: - base_dir = Path(__file__).resolve().parents[3] / "unilabos_data" / "http_reports" - base_dir.mkdir(parents=True, exist_ok=True) - log_path = getattr(self.workstation, "_http_log_path", None) - log_file = Path(log_path) if log_path else (base_dir / f"http_{int(time.time()*1000)}.log") - payload = { - "endpoint": endpoint, - "received_at": datetime.now().isoformat(), - "body": request_data - } - with open(log_file, "a", encoding="utf-8") as f: - f.write(json.dumps(payload, ensure_ascii=False) + "\n") - except Exception: - pass - class WorkstationHTTPService: """工作站HTTP服务""" - + def __init__(self, workstation_instance, host: str = "127.0.0.1", port: int = 8080): self.workstation = workstation_instance self.host = host @@ -636,42 +551,31 @@ class WorkstationHTTPService: self.server = None self.server_thread = None self.running = False - + # 初始化统计信息 self.workstation._start_time = time.time() self.workstation._reports_received_count = 0 - + def start(self): """启动HTTP服务""" try: # 创建处理器工厂函数 def handler_factory(*args, **kwargs): return WorkstationHTTPHandler(self.workstation, *args, **kwargs) - + # 创建HTTP服务器 self.server = HTTPServer((self.host, self.port), handler_factory) - base_dir = Path(__file__).resolve().parents[3] / "unilabos_data" / "http_reports" - base_dir.mkdir(parents=True, exist_ok=True) - session_log = base_dir / f"http_{int(time.time()*1000)}.log" - setattr(self.workstation, "_http_log_path", str(session_log)) - - # 安全地获取 device_id 用于线程命名 - device_id = "unknown" - if hasattr(self.workstation, 'device_id'): - device_id = self.workstation.device_id - elif hasattr(self.workstation, '_ros_node') and hasattr(self.workstation._ros_node, 'device_id'): - device_id = self.workstation._ros_node.device_id - + # 在单独线程中运行服务器 self.server_thread = threading.Thread( target=self._run_server, daemon=True, - name=f"WorkstationHTTP-{device_id}" + name=f"WorkstationHTTP-{self.workstation.device_id}" ) - + self.running = True self.server_thread.start() - + logger.info(f"工作站HTTP报送服务已启动: http://{self.host}:{self.port}") logger.info("统一的报送端点 (基于LIMS协议规范):") logger.info(" - POST /report/step_finish # 步骤完成报送") @@ -681,7 +585,6 @@ class WorkstationHTTPService: logger.info("扩展报送端点:") logger.info(" - POST /report/material_change # 物料变更报送") logger.info(" - POST /report/error_handling # 错误处理报送") - logger.info(" - POST /report/temperature-cutoff # 温度/粘度报送") logger.info("兼容端点:") logger.info(" - POST /LIMS/step_finish # 兼容LIMS步骤完成") logger.info(" - POST /LIMS/preintake_finish # 兼容LIMS通量完成") @@ -689,33 +592,33 @@ class WorkstationHTTPService: logger.info("服务端点:") logger.info(" - GET /status # 服务状态查询") logger.info(" - GET /health # 健康检查") - + except Exception as e: logger.error(f"启动HTTP服务失败: {e}") raise - + def stop(self): """停止HTTP服务""" try: if self.running and self.server: logger.info("正在停止工作站HTTP报送服务...") self.running = False - + # 停止serve_forever循环 self.server.shutdown() - + # 等待服务器线程结束 if self.server_thread and self.server_thread.is_alive(): self.server_thread.join(timeout=5.0) - + # 关闭服务器套接字 self.server.server_close() - + logger.info("工作站HTTP报送服务已停止") - + except Exception as e: logger.error(f"停止HTTP服务失败: {e}") - + def _run_server(self): """运行HTTP服务器""" try: @@ -726,12 +629,12 @@ class WorkstationHTTPService: logger.error(f"HTTP服务运行错误: {e}") finally: logger.info("HTTP服务器线程已退出") - + @property def is_running(self) -> bool: """检查服务是否正在运行""" return self.running and self.server_thread and self.server_thread.is_alive() - + @property def service_url(self) -> str: """获取服务URL""" @@ -745,7 +648,7 @@ class MaterialChangeReport: pass -@dataclass +@dataclass class TaskExecutionReport: """已废弃:任务执行报送,请使用统一的WorkstationReportRequest""" pass @@ -765,43 +668,40 @@ __all__ = [ if __name__ == "__main__": # 简单测试HTTP服务 - class BioyondWorkstation: + 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} - - def process_temperature_cutoff_report(self, report_request): - return {"processed": True, "metrics": report_request.data} - - workstation = BioyondWorkstation() + + 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() @@ -809,3 +709,4 @@ if __name__ == "__main__": except Exception as e: print(f"服务器运行错误: {e}") http_service.stop() + diff --git a/unilabos/registry/devices/bioyond_cell.yaml b/unilabos/registry/devices/bioyond_cell.yaml new file mode 100644 index 00000000..a48edfc1 --- /dev/null +++ b/unilabos/registry/devices/bioyond_cell.yaml @@ -0,0 +1,1285 @@ +bioyond_cell: + category: + - bioyond_cell + class: + action_value_mappings: + auto-auto_batch_outbound_from_xlsx: + feedback: {} + goal: {} + goal_default: + xlsx_path: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + xlsx_path: + type: string + required: + - xlsx_path + type: object + result: {} + required: + - goal + title: auto_batch_outbound_from_xlsx参数 + type: object + type: UniLabJsonCommand + auto-auto_feeding4to3: + feedback: {} + goal: {} + goal_default: + WH3_x1_y1_z3_1_materialId: '' + WH3_x1_y1_z3_1_materialType: '' + WH3_x1_y1_z3_1_quantity: 0 + WH3_x1_y2_z3_4_materialId: '' + WH3_x1_y2_z3_4_materialType: '' + WH3_x1_y2_z3_4_quantity: 0 + WH3_x1_y3_z3_7_materialId: '' + WH3_x1_y3_z3_7_materialType: '' + WH3_x1_y3_z3_7_quantity: 0 + WH3_x1_y4_z3_10_materialId: '' + WH3_x1_y4_z3_10_materialType: '' + WH3_x1_y4_z3_10_quantity: 0 + WH3_x1_y5_z3_13_materialId: '' + WH3_x1_y5_z3_13_materialType: '' + WH3_x1_y5_z3_13_quantity: 0 + WH3_x2_y1_z3_2_materialId: '' + WH3_x2_y1_z3_2_materialType: '' + WH3_x2_y1_z3_2_quantity: 0 + WH3_x2_y2_z3_5_materialId: '' + WH3_x2_y2_z3_5_materialType: '' + WH3_x2_y2_z3_5_quantity: 0 + WH3_x2_y3_z3_8_materialId: '' + WH3_x2_y3_z3_8_materialType: '' + WH3_x2_y3_z3_8_quantity: 0 + WH3_x2_y4_z3_11_materialId: '' + WH3_x2_y4_z3_11_materialType: '' + WH3_x2_y4_z3_11_quantity: 0 + WH3_x2_y5_z3_14_materialId: '' + WH3_x2_y5_z3_14_materialType: '' + WH3_x2_y5_z3_14_quantity: 0 + WH3_x3_y1_z3_3_materialId: '' + WH3_x3_y1_z3_3_materialType: '' + WH3_x3_y1_z3_3_quantity: 0 + WH3_x3_y2_z3_6_materialId: '' + WH3_x3_y2_z3_6_materialType: '' + WH3_x3_y2_z3_6_quantity: 0 + WH3_x3_y3_z3_9_materialId: '' + WH3_x3_y3_z3_9_materialType: '' + WH3_x3_y3_z3_9_quantity: 0 + WH3_x3_y4_z3_12_materialId: '' + WH3_x3_y4_z3_12_materialType: '' + WH3_x3_y4_z3_12_quantity: 0 + WH3_x3_y5_z3_15_materialId: '' + WH3_x3_y5_z3_15_materialType: '' + WH3_x3_y5_z3_15_quantity: 0 + WH4_x1_y1_z1_1_materialName: '' + WH4_x1_y1_z1_1_quantity: 0.0 + WH4_x1_y1_z2_1_materialName: '' + WH4_x1_y1_z2_1_materialType: '' + WH4_x1_y1_z2_1_quantity: 0.0 + WH4_x1_y1_z2_1_targetWH: '' + WH4_x1_y2_z1_6_materialName: '' + WH4_x1_y2_z1_6_quantity: 0.0 + WH4_x1_y2_z2_4_materialName: '' + WH4_x1_y2_z2_4_materialType: '' + WH4_x1_y2_z2_4_quantity: 0.0 + WH4_x1_y2_z2_4_targetWH: '' + WH4_x1_y3_z1_11_materialName: '' + WH4_x1_y3_z1_11_quantity: 0.0 + WH4_x1_y3_z2_7_materialName: '' + WH4_x1_y3_z2_7_materialType: '' + WH4_x1_y3_z2_7_quantity: 0.0 + WH4_x1_y3_z2_7_targetWH: '' + WH4_x2_y1_z1_2_materialName: '' + WH4_x2_y1_z1_2_quantity: 0.0 + WH4_x2_y1_z2_2_materialName: '' + WH4_x2_y1_z2_2_materialType: '' + WH4_x2_y1_z2_2_quantity: 0.0 + WH4_x2_y1_z2_2_targetWH: '' + WH4_x2_y2_z1_7_materialName: '' + WH4_x2_y2_z1_7_quantity: 0.0 + WH4_x2_y2_z2_5_materialName: '' + WH4_x2_y2_z2_5_materialType: '' + WH4_x2_y2_z2_5_quantity: 0.0 + WH4_x2_y2_z2_5_targetWH: '' + WH4_x2_y3_z1_12_materialName: '' + WH4_x2_y3_z1_12_quantity: 0.0 + WH4_x2_y3_z2_8_materialName: '' + WH4_x2_y3_z2_8_materialType: '' + WH4_x2_y3_z2_8_quantity: 0.0 + WH4_x2_y3_z2_8_targetWH: '' + WH4_x3_y1_z1_3_materialName: '' + WH4_x3_y1_z1_3_quantity: 0.0 + WH4_x3_y1_z2_3_materialName: '' + WH4_x3_y1_z2_3_materialType: '' + WH4_x3_y1_z2_3_quantity: 0.0 + WH4_x3_y1_z2_3_targetWH: '' + WH4_x3_y2_z1_8_materialName: '' + WH4_x3_y2_z1_8_quantity: 0.0 + WH4_x3_y2_z2_6_materialName: '' + WH4_x3_y2_z2_6_materialType: '' + WH4_x3_y2_z2_6_quantity: 0.0 + WH4_x3_y2_z2_6_targetWH: '' + WH4_x3_y3_z2_9_materialName: '' + WH4_x3_y3_z2_9_materialType: '' + WH4_x3_y3_z2_9_quantity: 0.0 + WH4_x3_y3_z2_9_targetWH: '' + WH4_x4_y1_z1_4_materialName: '' + WH4_x4_y1_z1_4_quantity: 0.0 + WH4_x4_y2_z1_9_materialName: '' + WH4_x4_y2_z1_9_quantity: 0.0 + WH4_x5_y1_z1_5_materialName: '' + WH4_x5_y1_z1_5_quantity: 0.0 + WH4_x5_y2_z1_10_materialName: '' + WH4_x5_y2_z1_10_quantity: 0.0 + xlsx_path: /Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + WH3_x1_y1_z3_1_materialId: + default: '' + type: string + WH3_x1_y1_z3_1_materialType: + default: '' + type: string + WH3_x1_y1_z3_1_quantity: + default: 0 + type: number + WH3_x1_y2_z3_4_materialId: + default: '' + type: string + WH3_x1_y2_z3_4_materialType: + default: '' + type: string + WH3_x1_y2_z3_4_quantity: + default: 0 + type: number + WH3_x1_y3_z3_7_materialId: + default: '' + type: string + WH3_x1_y3_z3_7_materialType: + default: '' + type: string + WH3_x1_y3_z3_7_quantity: + default: 0 + type: number + WH3_x1_y4_z3_10_materialId: + default: '' + type: string + WH3_x1_y4_z3_10_materialType: + default: '' + type: string + WH3_x1_y4_z3_10_quantity: + default: 0 + type: number + WH3_x1_y5_z3_13_materialId: + default: '' + type: string + WH3_x1_y5_z3_13_materialType: + default: '' + type: string + WH3_x1_y5_z3_13_quantity: + default: 0 + type: number + WH3_x2_y1_z3_2_materialId: + default: '' + type: string + WH3_x2_y1_z3_2_materialType: + default: '' + type: string + WH3_x2_y1_z3_2_quantity: + default: 0 + type: number + WH3_x2_y2_z3_5_materialId: + default: '' + type: string + WH3_x2_y2_z3_5_materialType: + default: '' + type: string + WH3_x2_y2_z3_5_quantity: + default: 0 + type: number + WH3_x2_y3_z3_8_materialId: + default: '' + type: string + WH3_x2_y3_z3_8_materialType: + default: '' + type: string + WH3_x2_y3_z3_8_quantity: + default: 0 + type: number + WH3_x2_y4_z3_11_materialId: + default: '' + type: string + WH3_x2_y4_z3_11_materialType: + default: '' + type: string + WH3_x2_y4_z3_11_quantity: + default: 0 + type: number + WH3_x2_y5_z3_14_materialId: + default: '' + type: string + WH3_x2_y5_z3_14_materialType: + default: '' + type: string + WH3_x2_y5_z3_14_quantity: + default: 0 + type: number + WH3_x3_y1_z3_3_materialId: + default: '' + type: string + WH3_x3_y1_z3_3_materialType: + default: '' + type: string + WH3_x3_y1_z3_3_quantity: + default: 0 + type: number + WH3_x3_y2_z3_6_materialId: + default: '' + type: string + WH3_x3_y2_z3_6_materialType: + default: '' + type: string + WH3_x3_y2_z3_6_quantity: + default: 0 + type: number + WH3_x3_y3_z3_9_materialId: + default: '' + type: string + WH3_x3_y3_z3_9_materialType: + default: '' + type: string + WH3_x3_y3_z3_9_quantity: + default: 0 + type: number + WH3_x3_y4_z3_12_materialId: + default: '' + type: string + WH3_x3_y4_z3_12_materialType: + default: '' + type: string + WH3_x3_y4_z3_12_quantity: + default: 0 + type: number + WH3_x3_y5_z3_15_materialId: + default: '' + type: string + WH3_x3_y5_z3_15_materialType: + default: '' + type: string + WH3_x3_y5_z3_15_quantity: + default: 0 + type: number + WH4_x1_y1_z1_1_materialName: + default: '' + type: string + WH4_x1_y1_z1_1_quantity: + default: 0.0 + type: number + WH4_x1_y1_z2_1_materialName: + default: '' + type: string + WH4_x1_y1_z2_1_materialType: + default: '' + type: string + WH4_x1_y1_z2_1_quantity: + default: 0.0 + type: number + WH4_x1_y1_z2_1_targetWH: + default: '' + type: string + WH4_x1_y2_z1_6_materialName: + default: '' + type: string + WH4_x1_y2_z1_6_quantity: + default: 0.0 + type: number + WH4_x1_y2_z2_4_materialName: + default: '' + type: string + WH4_x1_y2_z2_4_materialType: + default: '' + type: string + WH4_x1_y2_z2_4_quantity: + default: 0.0 + type: number + WH4_x1_y2_z2_4_targetWH: + default: '' + type: string + WH4_x1_y3_z1_11_materialName: + default: '' + type: string + WH4_x1_y3_z1_11_quantity: + default: 0.0 + type: number + WH4_x1_y3_z2_7_materialName: + default: '' + type: string + WH4_x1_y3_z2_7_materialType: + default: '' + type: string + WH4_x1_y3_z2_7_quantity: + default: 0.0 + type: number + WH4_x1_y3_z2_7_targetWH: + default: '' + type: string + WH4_x2_y1_z1_2_materialName: + default: '' + type: string + WH4_x2_y1_z1_2_quantity: + default: 0.0 + type: number + WH4_x2_y1_z2_2_materialName: + default: '' + type: string + WH4_x2_y1_z2_2_materialType: + default: '' + type: string + WH4_x2_y1_z2_2_quantity: + default: 0.0 + type: number + WH4_x2_y1_z2_2_targetWH: + default: '' + type: string + WH4_x2_y2_z1_7_materialName: + default: '' + type: string + WH4_x2_y2_z1_7_quantity: + default: 0.0 + type: number + WH4_x2_y2_z2_5_materialName: + default: '' + type: string + WH4_x2_y2_z2_5_materialType: + default: '' + type: string + WH4_x2_y2_z2_5_quantity: + default: 0.0 + type: number + WH4_x2_y2_z2_5_targetWH: + default: '' + type: string + WH4_x2_y3_z1_12_materialName: + default: '' + type: string + WH4_x2_y3_z1_12_quantity: + default: 0.0 + type: number + WH4_x2_y3_z2_8_materialName: + default: '' + type: string + WH4_x2_y3_z2_8_materialType: + default: '' + type: string + WH4_x2_y3_z2_8_quantity: + default: 0.0 + type: number + WH4_x2_y3_z2_8_targetWH: + default: '' + type: string + WH4_x3_y1_z1_3_materialName: + default: '' + type: string + WH4_x3_y1_z1_3_quantity: + default: 0.0 + type: number + WH4_x3_y1_z2_3_materialName: + default: '' + type: string + WH4_x3_y1_z2_3_materialType: + default: '' + type: string + WH4_x3_y1_z2_3_quantity: + default: 0.0 + type: number + WH4_x3_y1_z2_3_targetWH: + default: '' + type: string + WH4_x3_y2_z1_8_materialName: + default: '' + type: string + WH4_x3_y2_z1_8_quantity: + default: 0.0 + type: number + WH4_x3_y2_z2_6_materialName: + default: '' + type: string + WH4_x3_y2_z2_6_materialType: + default: '' + type: string + WH4_x3_y2_z2_6_quantity: + default: 0.0 + type: number + WH4_x3_y2_z2_6_targetWH: + default: '' + type: string + WH4_x3_y3_z2_9_materialName: + default: '' + type: string + WH4_x3_y3_z2_9_materialType: + default: '' + type: string + WH4_x3_y3_z2_9_quantity: + default: 0.0 + type: number + WH4_x3_y3_z2_9_targetWH: + default: '' + type: string + WH4_x4_y1_z1_4_materialName: + default: '' + type: string + WH4_x4_y1_z1_4_quantity: + default: 0.0 + type: number + WH4_x4_y2_z1_9_materialName: + default: '' + type: string + WH4_x4_y2_z1_9_quantity: + default: 0.0 + type: number + WH4_x5_y1_z1_5_materialName: + default: '' + type: string + WH4_x5_y1_z1_5_quantity: + default: 0.0 + type: number + WH4_x5_y2_z1_10_materialName: + default: '' + type: string + WH4_x5_y2_z1_10_quantity: + default: 0.0 + type: number + xlsx_path: + default: /Users/sml/work/Unilab/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/material_template.xlsx + type: string + required: [] + type: object + result: {} + required: + - goal + title: auto_feeding4to3参数 + type: object + type: UniLabJsonCommand + auto-create_and_inbound_materials: + feedback: {} + goal: {} + goal_default: + material_names: null + type_id: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + warehouse_name: 粉末加样头堆栈 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + material_names: + type: string + type_id: + default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 + type: string + warehouse_name: + default: 粉末加样头堆栈 + type: string + required: [] + type: object + result: {} + required: + - goal + title: create_and_inbound_materials参数 + type: object + type: UniLabJsonCommand + auto-create_material: + feedback: {} + goal: {} + goal_default: + location_name_or_id: null + material_name: null + type_id: null + warehouse_name: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + location_name_or_id: + type: string + material_name: + type: string + type_id: + type: string + warehouse_name: + type: string + required: + - material_name + - type_id + - warehouse_name + type: object + result: {} + required: + - goal + title: create_material参数 + type: object + type: UniLabJsonCommand + auto-create_materials: + feedback: {} + goal: {} + goal_default: + mappings: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + mappings: + type: object + required: + - mappings + type: object + result: {} + required: + - goal + title: create_materials参数 + type: object + type: UniLabJsonCommand + auto-create_orders: + feedback: {} + goal: {} + goal_default: + xlsx_path: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + xlsx_path: + type: string + required: + - xlsx_path + type: object + result: {} + required: + - goal + title: create_orders参数 + type: object + type: UniLabJsonCommand + auto-create_sample: + feedback: {} + goal: {} + goal_default: + board_type: null + bottle_type: null + location_code: null + name: null + warehouse_name: 手动堆栈 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + board_type: + type: string + bottle_type: + type: string + location_code: + type: string + name: + type: string + warehouse_name: + default: 手动堆栈 + type: string + required: + - name + - board_type + - bottle_type + - location_code + type: object + result: {} + required: + - goal + title: create_sample参数 + type: object + type: UniLabJsonCommand + auto-order_list_v2: + feedback: {} + goal: {} + goal_default: + beginTime: '' + endTime: '' + filter: '' + pageCount: 1 + skipCount: 0 + sorting: '' + status: '' + timeType: '' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + beginTime: + default: '' + type: string + endTime: + default: '' + type: string + filter: + default: '' + type: string + pageCount: + default: 1 + type: integer + skipCount: + default: 0 + type: integer + sorting: + default: '' + type: string + status: + default: '' + type: string + timeType: + default: '' + type: string + required: [] + type: object + result: {} + required: + - goal + title: order_list_v2参数 + 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 + type: object + result: {} + required: + - goal + title: process_order_finish_report参数 + type: object + type: UniLabJsonCommand + auto-process_sample_finish_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_sample_finish_report参数 + type: object + type: UniLabJsonCommand + auto-process_step_finish_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_step_finish_report参数 + type: object + type: UniLabJsonCommand + auto-report_material_change: + feedback: {} + goal: {} + goal_default: + material_obj: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + material_obj: + type: object + required: + - material_obj + type: object + result: {} + required: + - goal + title: report_material_change参数 + type: object + type: UniLabJsonCommand + auto-resource_tree_transfer: + feedback: {} + goal: {} + goal_default: + old_parent: null + parent_resource: null + plr_resource: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + old_parent: + type: object + parent_resource: + type: object + plr_resource: + type: object + required: + - old_parent + - plr_resource + - parent_resource + type: object + result: {} + required: + - goal + title: resource_tree_transfer参数 + type: object + type: UniLabJsonCommand + auto-run_feeding_stage: + feedback: {} + goal: {} + goal_default: {} + handles: + input: [] + output: + - data_key: feeding_materials + data_source: executor + data_type: resource + handler_key: feeding_materials + label: Feeding Materials + placeholder_keys: {} + result: + properties: + feeding_materials: + items: + type: object + type: array + required: + - feeding_materials + type: object + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: run_feeding_stage参数 + type: object + type: UniLabJsonCommand + auto-run_liquid_preparation_stage: + feedback: {} + goal: {} + goal_default: {} + handles: + input: + - data_key: feeding_materials + data_source: handle + data_type: resource + handler_key: feeding_materials + label: Feeding Materials + output: + - data_key: liquid_materials + data_source: executor + data_type: resource + handler_key: liquid_materials + label: Liquid Materials + placeholder_keys: {} + result: + properties: + feeding_materials: + items: + type: object + type: array + liquid_materials: + items: + type: object + type: array + required: + - liquid_materials + type: object + schema: + description: '' + properties: + feedback: {} + goal: + properties: + feeding_materials: + items: + type: object + type: array + required: [] + type: object + result: {} + required: + - goal + title: run_liquid_preparation_stage参数 + type: object + type: UniLabJsonCommand + auto-run_transfer_stage: + feedback: {} + goal: {} + goal_default: {} + handles: + input: + - data_key: liquid_materials + data_source: handle + data_type: resource + handler_key: liquid_materials + label: Liquid Materials + output: + - data_key: transfer_materials + data_source: executor + data_type: resource + handler_key: transfer_materials + label: Transfer Materials + placeholder_keys: {} + result: + properties: + liquid_materials: + items: + type: object + type: array + transfer_materials: + items: + type: object + type: array + transfer_summary: + type: object + required: + - transfer_materials + type: object + schema: + description: '' + properties: + feedback: {} + goal: + properties: + liquid_materials: + items: + type: object + type: array + required: [] + type: object + result: + properties: + liquid_materials: + items: + type: object + type: array + transfer_materials: + items: + type: object + type: array + transfer_summary: + type: object + type: object + required: + - goal + title: run_transfer_stage参数 + type: object + type: UniLabJsonCommand + auto-scheduler_continue: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_continue参数 + type: object + type: UniLabJsonCommand + auto-scheduler_reset: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_reset参数 + type: object + type: UniLabJsonCommand + auto-scheduler_start: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_start参数 + type: object + type: UniLabJsonCommand + auto-scheduler_stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: scheduler_stop参数 + type: object + type: UniLabJsonCommand + auto-storage_batch_inbound: + feedback: {} + goal: {} + goal_default: + items: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + items: + items: + type: object + type: array + required: + - items + type: object + result: {} + required: + - goal + title: storage_batch_inbound参数 + type: object + type: UniLabJsonCommand + auto-storage_inbound: + feedback: {} + goal: {} + goal_default: + location_id: null + material_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + location_id: + type: string + material_id: + type: string + required: + - material_id + - location_id + type: object + result: {} + required: + - goal + title: storage_inbound参数 + type: object + type: UniLabJsonCommand + auto-transfer_1_to_2: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: transfer_1_to_2参数 + type: object + type: UniLabJsonCommand + auto-transfer_3_to_2_to_1: + feedback: {} + goal: {} + goal_default: + source_wh_id: 3a19debc-84b4-0359-e2d4-b3beea49348b + source_x: 1 + source_y: 1 + source_z: 1 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + source_wh_id: + default: 3a19debc-84b4-0359-e2d4-b3beea49348b + type: string + source_x: + default: 1 + type: integer + source_y: + default: 1 + type: integer + source_z: + default: 1 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: transfer_3_to_2_to_1参数 + type: object + type: UniLabJsonCommand + auto-update_push_ip: + feedback: {} + goal: {} + goal_default: + ip: null + port: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ip: + type: string + port: + type: string + required: [] + type: object + result: {} + required: + - goal + title: update_push_ip参数 + type: object + type: UniLabJsonCommand + auto-wait_for_order_finish: + feedback: {} + goal: {} + goal_default: + order_code: null + timeout: 36000 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + order_code: + type: string + timeout: + default: 36000 + type: integer + required: + - order_code + type: object + result: {} + required: + - goal + title: wait_for_order_finish参数 + type: object + type: UniLabJsonCommand + auto-wait_for_transfer_task: + feedback: {} + goal: {} + goal_default: + filter_text: null + interval: 5 + timeout: 3000 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + filter_text: + type: string + interval: + default: 5 + type: integer + timeout: + default: 3000 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: wait_for_transfer_task参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.workstation.bioyond_studio.bioyond_cell.bioyond_cell_workstation:BioyondCellWorkstation + status_types: + device_id: String + type: python + config_info: [] + description: 配液工站 + handles: [] + icon: benyao2.webp + init_param_schema: + config: + properties: + config: + type: object + deck: + type: string + protocol_type: + type: string + required: [] + type: object + data: + properties: + device_id: + type: string + required: + - device_id + type: object + registry_type: device + version: 1.0.0 diff --git a/unilabos/registry/devices/coin_cell_workstation.yaml b/unilabos/registry/devices/coin_cell_workstation.yaml new file mode 100644 index 00000000..c59cc2e6 --- /dev/null +++ b/unilabos/registry/devices/coin_cell_workstation.yaml @@ -0,0 +1,751 @@ +coincellassemblyworkstation_device: + category: + - coin_cell_workstation + class: + action_value_mappings: + auto-change_hole_sheet_to_2: + feedback: {} + goal: {} + goal_default: + hole: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + hole: + type: object + required: + - hole + type: object + result: {} + required: + - goal + title: change_hole_sheet_to_2参数 + type: object + type: UniLabJsonCommandAsync + auto-fill_plate: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: fill_plate参数 + type: object + type: UniLabJsonCommandAsync + auto-fun_wuliao_test: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: fun_wuliao_test参数 + type: object + type: UniLabJsonCommand + auto-func_allpack_cmd: + feedback: {} + goal: {} + goal_default: + assembly_pressure: 4200 + assembly_type: 7 + elec_num: null + elec_use_num: null + elec_vol: 50 + file_path: /Users/sml/work + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + assembly_pressure: + default: 4200 + type: integer + assembly_type: + default: 7 + type: integer + elec_num: + type: string + elec_use_num: + type: string + elec_vol: + default: 50 + type: integer + file_path: + default: /Users/sml/work + type: string + required: + - elec_num + - elec_use_num + type: object + result: {} + required: + - goal + title: func_allpack_cmd参数 + type: object + type: UniLabJsonCommand + auto-func_get_csv_export_status: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_get_csv_export_status参数 + type: object + type: UniLabJsonCommand + auto-func_pack_device_auto: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_pack_device_auto参数 + type: object + type: UniLabJsonCommand + auto-func_pack_device_init: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_pack_device_init参数 + type: object + type: UniLabJsonCommand + auto-func_pack_device_start: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_pack_device_start参数 + type: object + type: UniLabJsonCommand + auto-func_pack_device_stop: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_pack_device_stop参数 + type: object + type: UniLabJsonCommand + auto-func_pack_get_msg_cmd: + feedback: {} + goal: {} + goal_default: + file_path: D:\coin_cell_data + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + file_path: + default: D:\coin_cell_data + type: string + required: [] + type: object + result: {} + required: + - goal + title: func_pack_get_msg_cmd参数 + type: object + type: UniLabJsonCommand + auto-func_pack_send_bottle_num: + feedback: {} + goal: {} + goal_default: + bottle_num: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + bottle_num: + type: string + required: + - bottle_num + type: object + result: {} + required: + - goal + title: func_pack_send_bottle_num参数 + type: object + type: UniLabJsonCommand + auto-func_pack_send_finished_cmd: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_pack_send_finished_cmd参数 + type: object + type: UniLabJsonCommand + auto-func_pack_send_msg_cmd: + feedback: {} + goal: {} + goal_default: + assembly_pressure: null + assembly_type: null + elec_use_num: null + elec_vol: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + assembly_pressure: + type: string + assembly_type: + type: string + elec_use_num: + type: string + elec_vol: + type: string + required: + - elec_use_num + - elec_vol + - assembly_type + - assembly_pressure + type: object + result: {} + required: + - goal + title: func_pack_send_msg_cmd参数 + type: object + type: UniLabJsonCommand + auto-func_read_data_and_output: + feedback: {} + goal: {} + goal_default: + file_path: /Users/sml/work + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + file_path: + default: /Users/sml/work + type: string + required: [] + type: object + result: {} + required: + - goal + title: func_read_data_and_output参数 + type: object + type: UniLabJsonCommand + auto-func_stop_read_data: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: func_stop_read_data参数 + type: object + type: UniLabJsonCommand + auto-modify_deck_name: + feedback: {} + goal: {} + goal_default: + resource_name: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + resource_name: + type: string + required: + - resource_name + type: object + result: {} + required: + - goal + title: modify_deck_name参数 + type: object + type: UniLabJsonCommand + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: object + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-qiming_coin_cell_code: + feedback: {} + goal: {} + goal_default: + battery_clean_ignore: false + battery_pressure: 4000 + battery_pressure_mode: true + fujipian_juzhendianwei: 0 + fujipian_panshu: null + gemo_juzhendianwei: 0 + gemopanshu: 0 + lvbodian: true + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + battery_clean_ignore: + default: false + type: boolean + battery_pressure: + default: 4000 + type: integer + battery_pressure_mode: + default: true + type: boolean + fujipian_juzhendianwei: + default: 0 + type: integer + fujipian_panshu: + type: integer + gemo_juzhendianwei: + default: 0 + type: integer + gemopanshu: + default: 0 + type: integer + lvbodian: + default: true + type: boolean + required: + - fujipian_panshu + type: object + result: {} + required: + - goal + title: qiming_coin_cell_code参数 + type: object + type: UniLabJsonCommand + auto-run_coin_cell_assembly_workflow: + feedback: {} + goal: + properties: + workflow_config: + type: object + required: [] + type: object + goal_default: + workflow_config: {} + handles: + input: + - data_key: workflow_config + data_source: handle + data_type: resource + handler_key: WorkflowConfig + label: Workflow Config + output: + - data_key: qiming + data_source: executor + data_type: resource + handler_key: QimingResult + label: Qiming Result + - data_key: workflow_steps + data_source: executor + data_type: resource + handler_key: WorkflowSteps + label: Workflow Steps + - data_key: packaging + data_source: executor + data_type: resource + handler_key: PackagingResult + label: Packaging Result + - data_key: finish + data_source: executor + data_type: resource + handler_key: FinishResult + label: Finish Result + placeholder_keys: {} + result: + properties: + finish: + properties: + send_finished: + type: object + stop: + type: object + required: + - send_finished + - stop + type: object + packaging: + properties: + bottle_num: + type: integer + command: + type: object + result: + type: object + required: + - bottle_num + - command + - result + type: object + qiming: + properties: + params: + type: object + success: + type: boolean + required: + - params + - success + type: object + workflow_steps: + type: object + required: + - qiming + - workflow_steps + - packaging + - finish + type: object + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_config: + type: object + required: [] + type: object + result: + properties: + finish: + properties: + send_finished: + type: object + stop: + type: object + required: + - send_finished + - stop + type: object + packaging: + properties: + bottle_num: + type: integer + command: + type: object + result: + type: object + required: + - bottle_num + - command + - result + type: object + qiming: + properties: + params: + type: object + success: + type: boolean + required: + - params + - success + type: object + workflow_steps: + type: object + required: + - qiming + - workflow_steps + - packaging + - finish + type: object + required: + - goal + title: run_coin_cell_assembly_workflow参数 + type: object + type: UniLabJsonCommand + auto-run_packaging_workflow: + feedback: {} + goal: {} + goal_default: + workflow_config: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_config: + type: object + required: + - workflow_config + type: object + result: {} + required: + - goal + title: run_packaging_workflow参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation + status_types: + data_assembly_coin_cell_num: int + data_assembly_pressure: int + data_assembly_time: float + data_axis_x_pos: float + data_axis_y_pos: float + data_axis_z_pos: float + data_coin_cell_code: str + data_coin_num: int + data_electrolyte_code: str + data_electrolyte_volume: int + data_glove_box_o2_content: float + data_glove_box_pressure: float + data_glove_box_water_content: float + data_open_circuit_voltage: float + data_pole_weight: float + request_rec_msg_status: bool + request_send_msg_status: bool + sys_mode: str + sys_status: str + type: python + config_info: [] + description: 扣电工站 + handles: [] + icon: koudian.webp + init_param_schema: + config: + properties: + address: + default: 172.16.28.102 + type: string + config: + type: object + debug_mode: + default: false + type: boolean + deck: + type: string + port: + default: '502' + type: string + required: [] + type: object + data: + properties: + data_assembly_coin_cell_num: + type: integer + data_assembly_pressure: + type: integer + data_assembly_time: + type: number + data_axis_x_pos: + type: number + data_axis_y_pos: + type: number + data_axis_z_pos: + type: number + data_coin_cell_code: + type: string + data_coin_num: + type: integer + data_electrolyte_code: + type: string + data_electrolyte_volume: + type: integer + data_glove_box_o2_content: + type: number + data_glove_box_pressure: + type: number + data_glove_box_water_content: + type: number + data_open_circuit_voltage: + type: number + data_pole_weight: + type: number + request_rec_msg_status: + type: boolean + request_send_msg_status: + type: boolean + sys_mode: + type: string + sys_status: + type: string + required: + - sys_status + - sys_mode + - request_rec_msg_status + - request_send_msg_status + - data_assembly_coin_cell_num + - data_assembly_time + - data_open_circuit_voltage + - data_axis_x_pos + - data_axis_y_pos + - data_axis_z_pos + - data_pole_weight + - data_assembly_pressure + - data_electrolyte_volume + - data_coin_num + - data_coin_cell_code + - data_electrolyte_code + - data_glove_box_pressure + - data_glove_box_o2_content + - data_glove_box_water_content + type: object + registry_type: device + version: 1.0.0 diff --git a/unilabos/resources/battery/__init__.py b/unilabos/resources/battery/__init__.py new file mode 100644 index 00000000..b722d42b --- /dev/null +++ b/unilabos/resources/battery/__init__.py @@ -0,0 +1,4 @@ +"""Battery-related resource classes for coin cell assembly""" + + + diff --git a/unilabos/resources/battery/bottle_carriers.py b/unilabos/resources/battery/bottle_carriers.py new file mode 100644 index 00000000..dbc2ac43 --- /dev/null +++ b/unilabos/resources/battery/bottle_carriers.py @@ -0,0 +1,45 @@ +""" +瓶架类定义 - 用于纽扣电池组装工作站 +Bottle Carrier Resource Classes +""" + +from __future__ import annotations +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.utils import create_ordered_items_2d +from unilabos.resources.itemized_carrier import ItemizedCarrier + + +def YIHUA_Electrolyte_12VialCarrier(name: str) -> ItemizedCarrier: + """依华电解液12瓶架 - 3x4布局 + + Args: + name: 瓶架名称 + + Returns: + ItemizedCarrier: 包含12个瓶位的瓶架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, + num_items_y=3, + dx=10.0, + dy=10.0, + dz=5.0, + item_dx=70.0, + item_dy=26.67, + size_x=60.0, + size_y=20.0, + size_z=70.0, + ) + + return ItemizedCarrier( + name=name, + size_x=300.0, + size_y=100.0, + size_z=80.0, + num_items_x=4, + num_items_y=3, + sites=sites, + category="bottle_carrier", + ) + diff --git a/unilabos/resources/battery/electrode_sheet.py b/unilabos/resources/battery/electrode_sheet.py new file mode 100644 index 00000000..6fc9b0e3 --- /dev/null +++ b/unilabos/resources/battery/electrode_sheet.py @@ -0,0 +1,67 @@ +""" +电极片类定义 +Electrode Sheet Resource Classes +""" + +from __future__ import annotations +from typing import Any, Dict, Optional +from pylabrobot.resources.resource import Resource + + +class ElectrodeSheet(Resource): + """电极片类 - 用于纽扣电池组装""" + + def __init__( + self, + name: str, + size_x: float = 12.0, + size_y: float = 12.0, + size_z: float = 0.1, + category: str = "electrode_sheet", + electrode_type: str = "anode", # "anode" 负极, "cathode" 正极, "separator" 隔膜 + **kwargs + ): + """初始化电极片 + + Args: + name: 电极片名称 + size_x: X方向尺寸 (mm) + size_y: Y方向尺寸 (mm) + size_z: Z方向尺寸/厚度 (mm) + category: 类别 + electrode_type: 电极类型 + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + **kwargs + ) + self._electrode_type = electrode_type + self._unilabos_state: Dict[str, Any] = { + "electrode_type": electrode_type, + "material": "", + "thickness": size_z, + } + + @property + def electrode_type(self) -> str: + """获取电极类型""" + return self._electrode_type + + def load_state(self, state: Dict[str, Any]) -> None: + """加载状态""" + super().load_state(state) + if isinstance(state, dict): + self._unilabos_state.update(state) + + def serialize_state(self) -> Dict[str, Any]: + """序列化状态""" + data = super().serialize_state() + data.update(self._unilabos_state) + return data + + + diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py new file mode 100644 index 00000000..8fbe694b --- /dev/null +++ b/unilabos/resources/battery/magazine.py @@ -0,0 +1,152 @@ +""" +弹夹架类定义 - 用于纽扣电池组装工作站 +Magazine Holder Resource Classes +""" + +from __future__ import annotations +from typing import List, Optional +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.utils import create_ordered_items_2d +from unilabos.resources.itemized_carrier import ItemizedCarrier + + +def MagazineHolder_4_Cathode(name: str) -> ItemizedCarrier: + """正极&铝箔弹夹 - 4个洞位 (2x2布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含4个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=2, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=50.0, + item_dy=30.0, + size_x=40.0, + size_y=25.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=120.0, + size_y=80.0, + size_z=50.0, + num_items_x=2, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + + +def MagazineHolder_6_Cathode(name: str) -> ItemizedCarrier: + """正极壳&平垫片弹夹 - 6个洞位 (2x3布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=40.0, + item_dy=30.0, + size_x=35.0, + size_y=25.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=150.0, + size_y=80.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + + +def MagazineHolder_6_Anode(name: str) -> ItemizedCarrier: + """负极壳&弹垫片弹夹 - 6个洞位 (2x3布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=40.0, + item_dy=30.0, + size_x=35.0, + size_y=25.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=150.0, + size_y=80.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + + +def MagazineHolder_6_Battery(name: str) -> ItemizedCarrier: + """成品弹夹 - 6个洞位 (3x2布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=33.0, + item_dy=40.0, + size_x=30.0, + size_y=35.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=120.0, + size_y=100.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) +