From 48849a4f0aaf8626dfb1ab343937fd6416f53351 Mon Sep 17 00:00:00 2001 From: DBras Date: Fri, 14 Jun 2024 13:29:16 +0200 Subject: [PATCH] handout files --- battery_local_control.py | 96 +++++++++++++++++++ d7_controller_splitting.pdf | Bin 0 -> 9972 bytes d7_controller_transfer.pdf | Bin 0 -> 9501 bytes mobileload_local_control.py | 98 +++++++++++++++++++ parameters.py | 21 ++++ readme.md | 37 +++++++ simlab_controller_d5_batt.py | 178 ++++++++++++++++++++++++++++++++++ simlab_controller_d5_load.py | 181 +++++++++++++++++++++++++++++++++++ supervisory_controller.py | 93 ++++++++++++++++++ util.py | 37 +++++++ 10 files changed, 741 insertions(+) create mode 100644 battery_local_control.py create mode 100644 d7_controller_splitting.pdf create mode 100644 d7_controller_transfer.pdf create mode 100644 mobileload_local_control.py create mode 100644 parameters.py create mode 100644 readme.md create mode 100644 simlab_controller_d5_batt.py create mode 100644 simlab_controller_d5_load.py create mode 100644 supervisory_controller.py diff --git a/battery_local_control.py b/battery_local_control.py new file mode 100644 index 0000000..23be00a --- /dev/null +++ b/battery_local_control.py @@ -0,0 +1,96 @@ +from util import pos, clamp, soc_scaler +import parameters +from time import time, sleep +import zmq +import logging +import syslab +logging.basicConfig(level=logging.INFO) + +# Controller Parameters +Kp = ... # P factor for our controller. +Ki = ... # I factor for our controller. + +### Variables +# Target that we are trying to reach at the grid connection. +pcc_target = 0.0 + +# Controller variables +x_battery = 1 # Default splitting factor + +# Info on battery state +battery_setpoint = 0.0 # Default setpoint +battery_soc = 0.5 # Default SOC (= state of charge) + +### Communication +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket we can use to publish our soc on +soc_out_socket = context.socket(zmq.PUB) +soc_out_socket.bind(f"tcp://*:{parameters.BATTERY_SOC_PORT}") + +# Set up a socket to subscribe to our splitting factor +splitting_in_socket = context.socket(zmq.SUB) +splitting_in_socket.connect(f"tcp://{parameters.SUPERVISOR_IP}:{parameters.SUPERVISOR_PORT}") + +# Ensure we only see message on the battery's splitting factor +splitting_in_socket.subscribe(parameters.TOPIC_BATTERY_SPLITTING) + +### Unit connections +# TODO step 1.2: Set up connection to control the battery and reconstruct the pcc (remember that vswitchboard is still not working) + +### Import your controller class +# TODO step 1.2: Import the controller class from "simlab_controller_d5_batt.py" or copy/paste it here and pick reasonable controller parameters +# Note: The controller is identical to Day 5 with the exception of incorporating the splitting factor + +pid = PIDController(Kp=Kp, Ki=Ki, Kd=0.0, + u_min=parameters.MIN_BATTERY_P, + u_max=parameters.MAX_BATTERY_P, + Iterm=0.0) + + +# Put everything in a "try" block so clean-up is easy +try: + while True: + try: + # Try to connect to the supervisor. + # If we have a connection to the supervisor, get our requested splitting factor. + # If none have come in, continue with previous splitting factor. + # We put this in a while-loop to ensure we empty the queue each time, + # so we always have the latest value. + while True: + # Receive the latest splitting factor + incoming_str = splitting_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "batt_split;0.781", + # so we split it up and take the last bit forward. + x_battery = float(incoming_str.split(" ")[-1]) + logging.info(f"New splitting factor: {x_battery}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current grid exchange. + pcc_p = 10.0 # TODO step 1.2: Reconstruct the pcc from unit measurements + # Check our own state of charge + battery_soc = 0.68 # TODO step 1.2: Read the true battery SOC + + # Calculate new requests using PID controller + battery_setpoint = pid.update(pcc_p.value, x_batt=x_battery) + + # Ensure we don't exceed our bounds for the battery + battery_setpoint = clamp(parameters.MIN_BATTERY_P, battery_setpoint, parameters.MAX_BATTERY_P) + + # Send the new setpoint to the battery + # TODO step 1.2: Send the new setpoint to the battery + logging.info(f"Sent setpoint: {battery_setpoint}") + + # Publish our current state of charge for the supervisory controller to see + soc_out_socket.send_string(f"{parameters.TOPIC_BATTERY_SOC} {battery_soc:.06f}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our sockets. + # TODO step 1.2: Set the setpoint of the battery to zero after use + splitting_in_socket.close() + soc_out_socket.close() diff --git a/d7_controller_splitting.pdf b/d7_controller_splitting.pdf new file mode 100644 index 0000000000000000000000000000000000000000..81611787a14d3ec7c43735015a26339c9bb73215 GIT binary patch literal 9972 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

0VDx`iHl~env#m8IxKY7*yijhg?3{FI1TvAeMF z+^x&ls^aY7tT<>98sYD-hBl#llB8B0kIV{eBsb~=bn@-LT6P{oyy@RLb2Oqh%R{v* zH)3N%7frUqHft6&35s@6HSPDTB0~H$dAHGdH2rl=be=BNt;}YKT20I(DV;g*o|*rQ z;ar6n${P2?ndb0S?LD5W{!-sxzAK1ETv@#39yjta)<~FRvQn@)r3seqrritg@mhJb zGSM~Ojgjt>c{0}5{Gvxe2CG5jI`NWMHQS&2ChGb_7-6}x$8 zEZ|C9cTZs6P8f7nzF^L z&orq(v~H%T&FbUoi;F;69jW8+unyzx%kT-wrVX(^c3QeRpoz-zDBXeN(?}W(Gb)UR zr!Xye^TVxCjrL6(@8+MLlfhNGx%}TBH!ZaGVy8bdc!7a0X5A3FebTE~Q@9SJV%<_7 z$*VyJaO_4z_tJHD#O;aAKCs0K)uS#`e&UF`GxfOHbg~p@ys=#aIy@z6+FlbbDw^@s zdh;g-#~zn(0;#3sC*yk8lfgu`fQ0NmE<%FEo$;?{edv}$`6#SvD(IgwswcnA)I zjJ!FcJI46xbsbre>a_lFJUP<6+wnpmAZSm+1Y#TIiBi`(HL!!}zdYHPMjpfT+#8R3 ziBj(Hb<0Na$>`xA`#KpJqs*51P|Sx1c@YxY_fI2O6>Z+PM2hYF$ zo+mgiu#Wzz_!HEskjnJ~QRWJl?3*Ut>)`Lr?9u&s$(!jv4hDFFEcbIKir+hpmQxOO zS6cIDmo}T*6$Pp1FP=KQU#6Lzsa7!%G+e!D9lkEEKEHWrCwW{O6%|KY^mC2Hhu4Uz zL3TXF5xlO;INkRELPA_UMWNrk`e?3eFS<`!ue zDQH0)g8{#rj(sjfb8H6Q>bknPB6C;qAZoyzKi9_KFm;OzGs0_(C;>ZE*i|&X6nawz z)Zu%V@woKSsb%v_iHE(5gUcSzQ-PEElltuvypm$=bjU+@xjJjrvX&-_3cGxpZ zSb}QIe&0ODKB@c9o~~C%N9XQp4S8K_*4WC>Ueq#c=x*HC*|=i>2_V5;baP&wwc12K zVws&6F#mZ%D5y8RJ9EYO!LdCHw^5BSCe+bFpGEm#%Vl>) z3Rex%1VukaOt@>kbYS4%;82d0V{ikeDKN-Zvtmla3ckHbDlAoe-*srQH9l6OMdiS3 z#&Nx;|M>={Ku)hJeApd+(L-7~Z*+4OKfG63Mx$N3`qoda8a^_aJso=NWZ=+YHtW&k z`(yk?pfHb{O{3wCt}R7>m*mRsV1u2o%lTGFO*e7jLt3;$TaU6eh%A%T-J%zoHcwfj zct58zXfsSO%`B|q?&&g6zbwqGXC=TCY+nvUPtJ0pcUVuRwiiB}QRLd4KK!Cs)`oww zw3vQEH2j*rD0KY=`Ims}i{nF2S|i!m(oDJJX%*5j!8va~(i@7We2(Vq2i|+TD{3)0 zP^0PH@==w^&ApLhDlXDxdNj&G+^<)?f@gPGy@o#On~-^y?+AaHI5D1X3p_q%{}6-? zt?&%Bk6B!r2IhI|1ROGpLQ1$&dK;)c4qE29oZ1)_ZGrVM4vI1Bt)>~2!+R^JzL zH%VnB)H)Y=3v8-H&JKW#*wenvNzlErQRJ(CZ5_fO$oe5!L*7EtBd4=o~MDYZ5t zHPGk}O_~UqJJZuPF$LlcafO98Hr{{=n%y3m5q0}ly1k??ne((+i?sw5l_X}nq?ni< zp>@#T29kSlsokhwciS#lz5}r+% z*m<7iU@q2^n_YcFx+(9DbTucQNJ^Ad>c~~O>Kq-Px^wNf!4l(6v99n1AmlSZw6@6jMC1K>hW*%=bSMEE=+2c^)U~7$@RX zt%s7D%mI|U%XoFz-!CvaEjV7G2u@zi_K(|XmlN>A7rq`UKFt=;t?nS@YuVfhc&!v` z%!sk+9uuGQ@rl#*6nJ;)as($+KQzbOw@VD=N2n#H(FaC5mIL1+eAPO_a3`Cs``Pv{ ze)ilU+IsqSU}BH;i4*hI;68UY1uVaU2lBd*{7zn3nw`*KQYF|uT)izzJsUJpVXdP4 zBG)BG!be=dd*~w^s5F9kJUwIR7h5A`qLLkEdjUSPuV>gEQ}JX0KU$--BpI;g-@Q`Q z^EXc7eW}Pn;X68~$x!`3B~3JHm87QR+sX_oMkr-T8v8mAH5!3q&pYTBLrJaztGDO2 zs9Mg{2kI%BnLf!^J_FHR1>Y2X1iR(hK=3&HACJa`z=Oj);*Z6?lhs&rL1mzkXlYhA@ z@$kdm4&7J4iI-lvrHx6s`S4IPev{}+(74Kx9&`Z@r^|iCZ>lu`#yM>>l|exAiviq)12L-d3*kDWXL>_aS}+uW3J=xoJ6%&hsSFDOcJ5u>_k(_% zaG#RTj>*=_V=oq_I3$zm6!;+D^r;!&#A3~ztm{3~;ASozS}b8-Pkc~ctQA_pw$q4V z8iQ!!`kHe?CUq{v_0?c9x74>?W*btaUhk2VdDpy>7}WuaFCS;IIEg& zatU$FG1!C(*$I9c^~@@;qMtg5#yf4HOq8@Oagjj&Qu1Bs6II`1h1l{w+X-mOQz>>I|d`d>op+jY|eL-V|_Vb~` zih?96I<{U3d#|vpH;+qrYj&A<`()278Ts*i_;bwVacc;kGbuZc+;t`E>CF>K3rhtq ze?2!9i48k<_YXW;$2vnb?j%)HTIeFGcvvQM#(0?Pz!(c`Rv?MV+h<%AL!oIiT76{r zta%kHBjoGZdY^t}d;%?ph(L5gUPR^#4y#VoO4MPmsen7xNA&sMS^Sa=>C&Hl7_oTR z9K0={daaNZa##D6LVAZn%r!UP1nQ+fmR@UkofHn8o~pg%mP=`Btk2yel4QDd<*G`s zhM31A)2=s#OYG1dkI|>r@s(L)anVb%KpK%8Hc!mO4%ztXUCL*4l_Wh2W&wE#pBd{s zezJd%wNm{-*O2Vn7crK~cfz$xyqpm>EoG>VVbi)T-xXv>A*CZmtYLe~6oa*;n-#wJ z_5K#v5JGzhNFMCcAmcCtLj=^m1sd%e*m@ z)+F~T);OxK%J9FiExk2w19iFQU}&OPVa5BLUT}>+lf@Z}y%boF6RUgA5%4IQFvOki zBkps3D!fA9i(vuL-FZ91>oGB-ZNx7|-Ya&BK!^B3vyoC$?wV*y{LK%{?+L{(qgdZz z4W^gcrsD(*hUn)#g{?W&|^UK*Dj?;R~W46w2 zbdqzrADI_LQVA}7Eq-%CZI7o$u6JW2DfH=*9^mE2T-!rb;$;`UnZ%s;+k!(*84l0f zHM-M#eNSlnRv9fm?&sFLVw4TRo7IkYXbQxxoZaW|ye1hyA3NN=@}wfR>*H@!Wv5?H z*w!_EF-gKsBQx9P&)9JSiZ#DSK-pfL^YsGlt$6sMCfUz>&WxwaU-sXIV2m6dNbo?> z9t2A??Qga^W%$hoPDNdPj|ldJ;+w&iL-xGOq%2f@-Gk3gzq-fASZ8p16Vrm`cW*N3 z=j220eu*CxH>|!{o{yYkm>bSL=<6t<8}l%b_<(m!9qd%O>;&1@A@${0P71!kZZ|R9 zU#F-e4;B@ET6-2B{1CGL7>5VaL6g25pm^pAxHRQ6Oy2)#f3`Bia3GCi*g;yR{Crq+ zfGC@{!zy+EZSbOeT^i2K0D8;&KsExAgZ2RSpn;wxUILNsNl|08WX=UPPL806({|aG zSA;b3XYQ1~EXz6sA~E|PgRvm{3+GY?S;2?l`;O3zcxUerMwOlpuDl%ec0mGv+GPif zHw+mG&Jyh|7&rv4x!nstm;iYeKc_kOQlm_zwhf}Ey*>m` zChc3|P{ui{Nn{4|GElOFX4gNt`S^Ou1pDWSch^V2&T;#;+|l+>xoo=~frsMMT1X~= z@%%DDnPmqq>7@Z^Y=x~FQf|~A= zrv%H!Ei)5*)9>h-lix27<{wz1ElH3|;mFZ#XUE`X93;ChJ+%KCrBKmRtepoVL=#7S z3R@GWPE90X7)sp^3E?o)MUHCK8%s?0%IXSiFVqihKn%$+&n`6@ zZ1Nov$tIv@j3zd2b-if}A(}#OJn(gkDz!2wf1oFgX+&}C zP+p$Grdblb%b)66h6T>AjEU5m>UteeGtaj5Up+7khBe4wU1BHGmIp04mTx;G-gxX- zY3Hvv8fcTvE-_nTbBTQUR@oi3mo*0uMepY4TF4FJ3&vXX%M?&>pY*y8#Bc9coZy)Y zRCVRLx}1q_jWBF-GMb^8aLDezCd;11He5XpWS{P91D}$Z(QvjZ=|Sf_7lHS`FB&rz zt?ukTrAdtqlj0a4N*MuvB}&1Eb+ZEV?7LYRb})Qa?N;nMiI*|?^WUmzjtARyzATqe z@=ZLC4yI9;jd|57w8=I`x+?w}UD-Fv7q#=wAPs9kr;urgZajLXFkio5C)_0D^fA1& z-Oa8YVy2u0@lHxePD#$ESC@_B4PRh5VEnQ|Z<>v-~z}(7O(R z{0of!ySo9m8}ciyq#Y4uC^n8(04^OHM{!3to8OFE%Q`j|aBDXNV$BUguHwJl|6B5J zZ9W9Z{fmO~@c^KFNDuJxA~_IVB$EI8hCp}$e2A_3O+)#S0QmpDp&&2-%FP3S@bKUE zP!I&b4f(}`a&seq^)V}bM=axEJFLiOE|1p$A7))CYHcPOa2 zI$5}zBkF@#%uQ`vod6(C5HBY;fW;aPcXkutLKG_?xK^&F&ek^OZk$f8R;<55UlLJ0 zgvcR)kc_m93=qh}4+KJ`5lsRJ6$Ob|@0}(w0h)4$@kQ+gR5jqIbEe+;F$PkT} z=g)@VBgb-!Bjkv8A{+Go^JlmHlMkq?oU z+=$WuJ~kkj83<(kt55!2#PPeXLednGs!mq75qaz3KdUPKU5nxA3bVun0zf>Nh?)O+ z0iX~FHw0h__>;#EM!Fk$0UUqxKtK@U$LQ}oAU_bX(f`2%A|mTQd3=0`==!@JA1~w| zdLS?a5y5}g1gmq(d5{bAX{Fhan}D29fTNPDvF|K~TCCknWNW0RaW^ z&fxKP&pqe9pZ9&AhdujQYxmk~?LYW#b{$1!ejx!-T=vHO+Dlv!01)7Az7+|f6#4C7Qc0u z*Y6cxrpdv>I?T`_B`?*gq9``3UOjq@^z}Z_>{Za4}$bj z!7)j;&DMr`fs_nIgo-%RiVeH-R6?3kK5p<`$aKbu@uc+HtSvkBrJg?9`Pv$5hRvnU5_dy#iO?G#KM+j?i4@`5YZ zAv^+v+eqN@gt~-gtxIrK`0>b>PjqCikzPgXj(TF*ck}?6=+!IRe0#^YPr&;5YHB6- zD@0W3bdoj%6EI&mR{v}c?W*r}Ml?_q;tr|8f&27WoLVt#W-QouU7)AB8oG2%stKmE zTeeAxTZ`U&QK{&fkD7IDamrLJ(0W=koJfI32~WnqxdiGP7EE-=^R?iNT-bfpBWT``*t51t4iM4Cn8c>Cg$!0gKE$ls%5}Ymn@u{P;%Jt+6 zh2cb(x8WpFw^)?xyMcm=$vK5|2j3I_A4B_gi^|w#%niP6SCHjq?}VY?UksV_v^^6b zF)JMA$mOM~q6mwRQj*mt((4B2>F$yKv?uCTzo=Ia*%qJ;@Y8>~87}MVG1hwN0Y++*Ia_!WqDN^R>Zk&$~HqpXiJP;5?g3Uz_LH$>G1W?;-S# zxMWQ`FVt8s3EeoL?R=2diqJ2$`nGdH+R}|nGxzOWjzxfkv&6V@wvarXYDwf;Gj+I% z-?{JD!V>nd;(3hXd5i1l*;?f9F9s1om7^r#{{+TOC@A?MRP+(ZFaStN zp%lnR5DG*J06_&gfDk~?7K;2u1_!d>_phL`yBp$fwe9(V{6K)%pWs4PLjL_V80bHP z0R->+d%ysKI#34~fE^%oGq?^E4ny`O^rx+$Cd|$WD(~(GFhi<<01+{90SHhOA}$I5 zfyD)c#Kpm)VgQSqwhG8uARBuDP?7#StX1Id-X4FO+3yi=w11YSkDLb_>gMHv8rjzW zk6I0YfP2IKND9bWiZCB1TbQ1TJWBc3!u4QY?%r@)m=^$LM7_VQQyeM($6MVUyq1(*P7Wu0v(#a2HLtyRlLOo@NF#lnTwf zd7y>9^{(msC?@LFQnE68DOZfjdbhqWKSg zqIu>&ke(pjGSOA@$@)Ng&Kl7?(-DMygSp}Rsu@F$;Uhv&Souc%P>Y_qJx4 zS%0$1{sxO)?w;@c9*hF!GVTF>vhh+bz1qsxaNCF6qidlD@sn}!@$rV^DG~Z{^#0qk zeP1T=$H}tN-2BH=;3@Z#C)p=fu1&|fOKREDMhc3z3*xZbX+*F%u^y@~y`f$wx=XI# z+tzlqVf!>epn7rUVymc8l8)}>ZiZ9?R#m#p^*7GEyQEE5@RNr9ytIyV*N^YckmJesO###+Jwp&%*RW-R^y{_?N$^g?VRdM#k3`m#?|yJQ)y?_V~Yg}j8ICiWxKAZ zX9!q4aPt_RuvXr)U= zN0&{;)xW*w2{LE+ zegYb;5?>#n&&{yr!l`g z4STa#V~Mswt=is_o&;ou2L2irENUb)^26%skV}|>SD)OfH5qm3={O-!>J02|A{L~v zSEAAI{Gi@dqIs{hjF$&p&hufMr;KlJpZ(Qf-wWxQ#<`C#UW^g7?v;b@Q91sJgW^X5g7Ry=HxswhLi1BjBl)Kr{dhaI$Xd-L59fGdy^6mMIXU}D}j6QIwaJ?;xrqnzS7FyDs^o8OsjnBZU_aHFz-(txuxSa?;y0&@9BdoZzH-E`?<5e za*ZYGetw@usnOhPxjrOU@2WB!$sj5AdCzgVHR#J?e@gTG$!?elR(jj`=+}|4+a?4Z z75Y>Hxf1>>9h+BV*Cysyygx4)gZ8gyUT%5d-Y@BN7+!Rp6C|M8kf@EyST}1J(qtG= zC#_+lBdAm(YXv14lz}aNSjgloi{vaLa+WzJ=~i}Yr=m8Z7D{Hw&QjEA7@~{t&A0>l zjyHeoUI9DLNVwx?8OdHrvwIyS%5{AEG2=Tfay{4c<42GR`_judUJgdSB_G_Mb27|~ zhs|wsWeoFfm5Tepo{q|j%V+8y$hy(cQ7OAmSJa5BZs>@YPfv=BJ^($$SNY^|ifOM4 zn4bIu4E!~gE-*In0ui#QW|v~olBm0!c=UeB%$H)M#I_>zGBayFButO1a?=W@G&u^~ zuQSLl9To{6xFbdS)j9IAqbs1!m2y~Yuq(jmVpeRbG|q6miRfOA7W;?86GhCFh@lV9 z3gx#4Hh6h!1`zt}HeZ6;&(UlE2Q_tUayFrn&Q6RqMCxPcvyM;^y6VygzBs-w z>4(RTBm6tGr^8Gi8}g*$sGHF6PB}-=QgV_R=sb!hWsOt4Pnk4tmsU|!28kvi2p+9d zgv=|;YS^K11F%vHvP&XPACUl+aXXpy>AS|#n4Qy~?W>CSBzYo2tz&G3y1uAF;PE!x z=@QBBJNTIvzq^{Rn!{7LD3Pf|%? z4aGyc_SNJ(&@|(2p3mi{&bGy`C;Vn!hhWN`=~n(!3F9G%hlEiN{P--Bw_%Xm-o3QE zcO}mFwJPYA`i{`QfB-Cv!*rJ74D2X~ZiVrhmQFd}^87NCy=6Ow~A_ zLBFahTsixRmylm{IM&%QGLmK1%s3~I8m!;k?C3+chr5Zv)#>xu;;=_hM8e$JR`3_C zk-|_9*Xq~#(LrXYycaL;)`!EG!{zfiocGP7VdQsd?+7gQp-F@U^9oZhPIHxfdvEZ1 z)68FPMIcofP|7uR>(x_b`I7#7G50>P8iciybBog2Tf;h5rhBZz-f)b`u@q*!lffyt z=;0_%br>lufD*F?S?#{If3nl&Y@cO99bXfomY|-Xt*uH_!dZ4cqSdfk?Z^88*Eq)T zaQ^lD#eDd@tecJ7;e2XxYV7NQi-GU~S(uH|p*OcTw=4Ho@wbaxh++k~KskB20zd;> z8(SfpPO>qh)iWJNF6}+hH8Yw%E{W5g)#0vjx{j^oME!DY<$(4((f`ZR@)jR&Y zC7qS-rx;PkU+8nLnOr*xipEUvWWbpLMw=-uPwW|&d``W!XLcUn_AM_#%=hIDd@__F zRk&BpwpzY(4@Pm)u4qym#<)GP<>XNv^YklTtQ9jEM#2LZm(2K_&Y8+Q9SeBz)A^}A zt_-(Q``)y!iSC~qwfyn06qjs%yUUlh#vu?*HT}y6-6ffnc-_~l)ZRUTpFieP69y{$ ztT25P{ynE}1))MYC_xAwX_GiyubyfDj?*5wec!p4^S&FgPxb6)M{FXEyFT!v<)EO; zap?U{sa>Dz-Y-wF&RrXXAh>HjS;n&Fh(_<0YV0aC^X$q8;ji(>mCUuqSJ=)HRun_M zGga##S{B}9c?KQ23hr<^t*A1V=R5+Vr7a7ad-dQ3%l?*MhAyWIaZiVb?d_&1x7MWF zjuZOQgGjQHmU2v8NujYNp~CV{5O4G39Ll^}&lFsfz1~V#3lilP+>3W52qh#*d)2{-9L#!#=b}N>B6kap;w=Mmv z;Q0ahK6Sx$tV?~q8`(;)R5PDT+exkS0M*mFdu)s-{O{F#;-xnj;VB;YR&8251s0>p zF`xUkA1~d*X!P!Tt^epAXcN#53Og&#dObTMGW4?JhZ}Uam{8Ix%+#4^8 z+&d{w_qmc?yA#t8X^z#1+A&(q1|d=$faw{yKd-G}^nfsKL3#qM-Dl#bXgc;=-W~>h z#+L$R0+BQEvw2}rAE?hJJ=ha7NzjZqr1LKaV#w;&W$tbtFbGS0zeLcYn&=SC5o5M@|~y2dp0V84`@`{H_$bk-UE;U#55-Z5d%5EOb0uBh?|v)vsl$Qii#;>K*<8+}MONyQ z$}x989Z(#mlshTSuASn1_53d6TJzfcq2WA_TWbE~q?6>f`s&~+;dhK0ttS(*c9~Jz znGL{p+M05C5ME`_w?}09=+pz4gFf-e2lPa1)5hMRZjh7y``Lo)ok7ugfn6i_MXf%1 zQ%X{egEqYoVXaYa8M}Fh5`q#fxlYko2K8i5Jw})*kMln*FdcPX9buhPTyJ5Cq?N^D zUELId?kpy&3k}6^?Ly zJsCflGoMPKHAB`qVQd`wG(9JYDTwC#x$Iz@_|o)Lhv4zuuY*@Cl1mNs`|r+Yt-X5_ zE-+5_+C=)(3#mQ5Nj=l#YmHg+2tRx(XX~;x;C`S_O1kPLA}u3#%DPykxO6YXK#Alvo3^|&KvakuR@$Uz zwQan8I{l0fJDFp9`4%VjGP<~;oJf~!HFbGeNvVDLHMo(lQOeuG*}~b(S{{g#Cx$zvD|PI6y?k>g=*5Q}wU#m?gW=i4h1NCq_WbKFol2bc0#?Ef35}as35(nk zT=s{%&cp_P?=`Wv4AFfG1MAxmcFl@Aq<@t}_W`2DW-TgEi4 z)ms_{^~%s}a^CpK#PnRQK$QnXt^3-3nR1Xb_s` zsUP5FXNmRqxzaKmH#*?{Z<@5Ci7N>$X>ZHTGU);J2+LT}b}_xQ;i4}S+k4haYmYwX5VXxu{{hnkr9=)&+4?~|4I@jI;usHp?;Mg2dSfIf%AdhwEJ9T=DFW(r}F~Rhi$A-TMiYUn`Kq8 z+@5?0K%acbvaZVVHEgR+H%N4Z(>qDcdNo^xTH{G9>99*aNW*WT`JBW#z;q9*qTf|U$ z2F@WEUU!5`l5g6pd%?;9%388H%m9{ma@5hI4E!0?^$WO9j@`t=8k^JNWiCHFATtzf zI}(#tQtOQ!;kia=4v)+0cfHL&e)Fo@$IyX7^4Im@6eDHuw0|SKJ2v*9kErWMB;Uc8lkg)5 zEz#P;fQN}hET32`lurE`i2+tGu#@CgG6{T@nTk`|Aw3OG&STH<5bW7>7*$_V*d`pO zSeNq0R9w+|5%EmrooSy{KW-n{05B7uG*;(21w3i8*B--1j3cQn+7S^v9XYmU{<32e zO#OZ&>Re3Ggu9=0j$Na412AG^=E512(sXZEjlrKN_|D0Sf+;g*x5a8+$=f+}9oZ_+ zJxw2`%4nN1>P7Ya(Fi3Qv;|EWGQ4#Y@R?bYGI&#Nh4FJmw8%<>D!f>W)e+-O%YZYtZ#QKh}<#+dD6{g2Ly# zzS-2B$Gavj(&4EALgZH$Oww0=vcF>9jH7?cv+X^NlW4NWZ_><)%s68ZL!QP$D-LHR z-}u5q*6?<2zud%p2ER)|f1UbpXxi|+X@>*ueQX3@6_l{ymooZdvx+snX>TQ$CP-Z||9 z(D2wLtqqa6iq)40fr+QU0?i#Oaosp2zn+ZlRnQ& z_X-RN^GWUK8t@UNsfSm1wZxblx<}!%Z3tHg)Am<%u1QdSAIJ7Ab{#@5`8b91HNAkz z+REzP&-T801N20dA4zYfEo8q0Qr4&{wi83WoWpe7yn{Lm7qCKzK77N!Fyp_PcvJ^5 zUpr!Az8HAa{#o7(yDe}>?2J9?dE=21|Es3X&HbZ8dKo&xkdeo{NMoX92x>wz9;}eI z`8nYR{!pX6e8nK67M1`B>{_wGyviZEHpZg@2W~yS%NIrz|Naotno!r?jz5y$5|(F$ zxq0yDJeaC;aqXyu`hq`d$-^(BQ<8pUvLh?q&nHqrU8*$?tsIhD0i203_Txp*hP3vt z5D@l4T1Qqy346ix8=tyXu%kLTL~8LHlnDvy7@-98meL|&?*i^v@>3E0l;|QHu7A6n z$bKzx2Mf}whld5;+tm5E0wcV{T`|JkTOhM}hzI@XMl?qjy1?j%6A@g=;N$6+)Tu=% zDOI1>i8qITCXeYNyqpoC6IJ(lqQj8T?3r&pKm9Y`_g^-ProYJhxuxVtQ(@%HA5~+1 z^q4l@wWEW4mMFxOocP=k`3-aK6V@c%5km6HW`X1kk8rojq261?+TxAz&lBS3&(J2) zFHVT7H|p)b$u>oH?mRvsuloMkRj2WugMo~3sRFy$eaen|uRaeD-_JY}Cge@ealwe% z;E?Js%vGSldcIpenBI^NH*UgDr<8YkmY){6j6p*C0j^oMJuL&Wl=l zpi}wM`m-t(-Nxf2)?Am8iH%SaE$R{GC!O_$GGf&Xk%XQv3uUO2D_=6Z^?inVNa4ff zdx#L}A6Bp=O3JtS7I2&-@aYR)Ja(flkpG3hH6`BlgF=4kKG7~+=zXG7HJk>SX1ROV z$qEwcK60v2Y=m-z?<^9d12_TzI@mb-#0flIRPwoGHlv{p^ft515?Dn?9I*;@^3jUw zO7Q6SS|qy5o6Bp<8_OT4r06w1PYj`*gFGlgKYZAZw$>s<{!51hO#*9}fCmf1N*~-O zlZY$V6~z+ph^d|etV|$IhIz~T_KGZ>BedIe_V8DS@^v%DMY-902zp2Lh|#~95{hd4 zVM-wI4Rk_@kbmGbJz@5T0lKbWYz+Sy64U_$-A|!ehDwPp&M?eVjUo>Vzl7M!7UT^*9^k}a~thh zk?&*e4w_*Rl_zfYnw?J)5U;B}*IGCw^H&~wjy!30WRAb?@U<#`R&h<+L8P&4Wci|L zgVLPm927nUMYps&X?wXHXx<$3;wQIE^DXoBAf==s7bs5HWAFuDeHowGB6tdPbhb3J zVh@o#kyf1S>t*8~d{wVJ6F1&LYhj<8-X*!pa2)FQ*qAiEqELco3a#UjG1(*zUKKZ7 zQ>j&6!<&4PL}iSgthV2**odSqaZ2=CO;WiO?nyyOVWoEC2Dwt8$Op{#z2+xH#raDoYucL zY?!R2st}!@Q-66>d-W6D5Z;XT?;X9_r@yvTSWNT}xf4P`xj#tjH%LQqKSh|AE!@ci z;SNV}u^SYq1$9MoJ$-d`Ln9qtMVK?x&|4o#*#1+l40UyK@dt4IE&`y+xPKSAKpl`M z4fLOy^1sO+zYq`v1n`T9fdE3{5Mh82NEq}N^ixAXU7T#?+#FnB0N_nKIWJojLWF?8 zHzI0&D48E5EPA6*fO@FHoE#jHlo8p@jY=N@b2S8jf44^os(%F33X!x91_Gdf-roTq z3UB_~8o#^qwn5yW&HG4%h_cj;RvzjFy8)&DC(Ag&y$}kHQ1~AMXh8pxg+RaI=>Ie~ z;ATU9$CHv9^2UObn*%`5$jMF4&CBVJ;$~zcCp(0r7m}a~3!zr=_g5GK1fbIY?<)pI ztse<_g;8FCdO;8rb_D`NMa2PN08tUhO)e%R z3J@0ktrHU#27ttW&l{CP#6^&GgaKm6<)hjlnK1H86zLzr5L62kh(*o#Ub zC1qtGPy_-5iYXzJJP;@@jLcC<2>A^}=AuAkFaUwVNEw8zgDkp{K}cpShD_ojeJI`0|E(cqfuV^ literal 0 HcmV?d00001 diff --git a/mobileload_local_control.py b/mobileload_local_control.py new file mode 100644 index 0000000..b06801b --- /dev/null +++ b/mobileload_local_control.py @@ -0,0 +1,98 @@ +from util import pos, clamp +import parameters +from time import time, sleep +import zmq +import syslab +import logging + +# Ensure that we see "info" statements below +logging.basicConfig(level=logging.INFO) + +# # Parameters +Kp = ... # P factor for our controller. +Ki = ... # I factor for our controller. + +# # Variables +# Target that we are trying to reach at the grid connection. +pcc_target = 0.0 + +# Controller variables +x_load = 1 # Default splitting factor + +# Info on battery state +mobileload_setpoint = 0.0 # Default setpoint +battery_soc = 0.5 # Default SOC (= state of charge) + +### Communication +# Handle the sockets we need +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to our splitting factor +splitting_in_socket = context.socket(zmq.SUB) +splitting_in_socket.connect(f"tcp://{parameters.SUPERVISOR_IP}:{parameters.SUPERVISOR_PORT}") + +# Ensure we only see message on the load's splitting factor +splitting_in_socket.subscribe(parameters.TOPIC_LOAD_SPLITTING) + +### Unit connections +# TODO step 1.2: Set up connection to control the battery and reconstruct the pcc (remember that vswitchboard is still not working) + +### Import your controller class +# TODO step 1.2: Import the controller class from "simlab_controller_d5_load.py" or copy/paste it here and pick reasonable controller parameters +# Note: The controller is identical to Day 5 with the exception of incorporating the splitting factor + +pid = PIDController(Kp=Kp, Ki=Ki, Kd=0.0, + u_min=parameters.MIN_LOAD_P, + u_max=parameters.MAX_LOAD_P, + Iterm=0.0) + + +# # Unit connections +# TODO step 2.2: Set up connection to control the battery/mobile load and reconstruct the pcc (remember that vswitchboard is still not working) + + +# Ensure the mobile load is on before we start (The sleeps wait for the load to respond.) +while not mobload1.isLoadOn().value: + print("Starting load") + mobload1.startLoad() + sleep(2) + + +try: + while True: + try: + # Try to connect the the supervisor. + # If we have a connection to the supervisor, get our requested splitting factor. + # If none have come in, continue with previous splitting factor. + # We put this in a while-loop to ensure we empty the queue each time. + while True: + # Receive the latest splitting factor + incoming_str = splitting_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "load_split;0.781", + # so we split it up and take the last part. + x_load = float(incoming_str.split(" ")[-1]) + logging.info(f"New splitting factor: {x_load}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current grid exchange. + pcc_p = 10.0 # TODO step 1.2: Reconstruct the pcc from unit measurements + + # Calculate new requests using PID controller + mobileload_setpoint = pid.update(pcc_p.value, x_load=x_load) + + # Ensure we don't exceed our bounds for the load + mobileload_setpoint = clamp(parameters.MIN_LOAD_P, mobileload_setpoint, parameters.MAX_LOAD_P) + + # Send the new setpoint to the load + # TODO step 1.2: Send the new setpoint to the load + logging.info(f"Sent setpoint: {mobileload_setpoint}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our socket. + # TODO step 1.2: Set the setpoint of the mobile load to zero and shut it down after use + splitting_in_socket.close() diff --git a/parameters.py b/parameters.py new file mode 100644 index 0000000..d041bfe --- /dev/null +++ b/parameters.py @@ -0,0 +1,21 @@ + +# Parameters + +# Location of supervisor +SUPERVISOR_IP = 'localhost' +SUPERVISOR_PORT = 6001 + +# Location of battery +BATTERY_SOC_IP = 'localhost' +BATTERY_SOC_PORT = 6002 + +# Unit parameters +MAX_BATTERY_P = 15 +MIN_BATTERY_P = -15 +MAX_LOAD_P = 15.0 +MIN_LOAD_P = 0 + +# Topics for pub/sub +TOPIC_BATTERY_SPLITTING = "batt_split" +TOPIC_BATTERY_SOC = "batt_soc" +TOPIC_LOAD_SPLITTING = "load_split" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..96de8e0 --- /dev/null +++ b/readme.md @@ -0,0 +1,37 @@ +# Power-sharing controller + +This is an example of a power sharing controller for the Microgrid case. + +The case consists of the following units: +- A battery +- A mobile load, representing controllable consumption that gives us money, e.g. an electrolyzer which produces hydrogen. +- A wind turbine +- A solar panel +- A dump load, which is controlled externally from the controller, representing load that must be served, for instance the local neighbourhood. + +The controller uses the battery and mobile loads to attempt to keep the active power at 0 on the grid connection (PCC). +Further, we try to keep the battery's state of charge somewhat away from being full or empty, to give the most flexibility if we are called on to deliver services. +The service delivery is not implemented here, and so we just try to keep the battery in a certain range of state of charge. + +If we need more power in the system, i.e. we are importing from the grid, the battery will be asked to provide the missing amount, up to its maximum. + +If we have too much power, both the battery and mobile load will act together to consume the excess. +How much will be consumed by each unit is determined by a *splitting factor*: +- If the battery's state of charge is less than an `soc_lower`, the battery will consume all excess power. +- If the battery's state of charge is above an `soc_upper`, the mobile load will consume all excess power. +- If the battery's state of charge is *in between* these two values, the excess will be split between the two in a manner which linearly depends on the battery's state of charge; the higher the state of charge, the more power is given to the mobile load. + +See the included "d7_controller_splitting.pdf" for a graph of the splitting factor. +Essentially, we are trying to keep the battery charged to above `soc_lower` plus some buffer, and then use the rest of the power for production. + +In addition to changing the splitting factor, the boundaries `soc_lower` and `soc_upper` change depending on the currently available renewable energy: +- If there is a lot of renewable energy available, the range from `soc_lower` to `soc_upper` gets wider. We are less worried about getting the battery charged, so we use more power for the dump load. +- If there is only a small amount of renewable energy available, the range from `soc_lower` to `soc_upper` gets narrower. We need to ensure the battery gets charged up so we are ready to handle a high load or a request for energy from elsewhere. + +## Included scripts + +- `battery_local_control.py`: Connects to the battery and acts on the splitting factor delivered by the supervisor. Makes the battery state of charge available to the supervisor. +- `mobileload_local_control.py`: Connects to the battery and acts on the splitting factor delivered by the supervisor. +- `supervisory_controller.py`: Is in charge of calculating the splitting factor depending on the available renewable production. + +Each script can be started independently, and will use defaults or re-use last known values if the other scripts are down. diff --git a/simlab_controller_d5_batt.py b/simlab_controller_d5_batt.py new file mode 100644 index 0000000..f62726f --- /dev/null +++ b/simlab_controller_d5_batt.py @@ -0,0 +1,178 @@ +import syslab +from time import sleep, time +from dataclasses import dataclass +from util import clamp, cast_to_cm +import sys +import zmq +import logging + +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to the tester +reference_in_socket = context.socket(zmq.SUB) +reference_in_socket.connect(f"tcp://localhost:5000") + +# Ensure we only see message on the setpoint reference +reference_in_socket.subscribe("setpoint_reference") + +# Used to log the incoming reference setpoints +logging.basicConfig(level=logging.INFO) + +@dataclass +class PIDController: + Kp: float = 0.0 # P factor + Ki: float = 0.0 # I factor + Kd: float = 0.0 # D factor + r: float = 0.0 # Reference which we want the measured y to be + Pterm: float = 0.0 # P part + Iterm: float = 0.0 # Integral part + Dterm: float = 0.0 # Differential part + previous_error: float = 0.0 # used to calculate differential part + previous_Iterm: float = 0.0 # used to calculate integral part + current_time: float = None # used to calculate differential and integral part + previous_time: float = None # used to calculate differential and integral part + u_max: float = None # used to calculate controller input saturation + u_min: float = None # used to calculate controller input saturation + u_p: float = 0.0 + u: float = 0.0 + + def __post_init__(self): + self.previous_time = self.previous_time if self.previous_time is not None else time() + self.current_time = self.current_time if self.current_time is not None else time() + + def set_reference(self, new_reference): + self.r = new_reference + + def update(self, new_y_meas, x_batt, current_time=None): + + # Ensure we have the time elapsed since our last value; we'll use that for the integral and differential parts + self.current_time = current_time if current_time is not None else time() + delta_time = self.current_time - self.previous_time + + # Filter the incoming value + y_2 = self._filter_input(new_y_meas) + + # Calculate the error to the setpoint + error = self._calculate_error(self.r, y_2) + + # Apply splitting factor # TODO step 1.2: Familiarize with the usage of the splitting factor + error = error - (1-x_batt)*max(error, 0) + + # Calculate the PID terms + Pterm = self._calc_Pterm(error) + Iterm = self._calc_Iterm(error, delta_time) + Dterm = self._calc_Dterm(error, delta_time) + + # Calculate our raw response + self.u_p = Pterm + Iterm + Dterm + + # Filter our response + self.u = self._filter_output(self.u_p) + u_new = clamp(self.u_min, self.u, self.u_max) + + # Remember values for next update + if self.u == u_new: + self.previous_Iterm = Iterm + else: # in saturation - don't sum up Iterm + self.u = u_new + + self.previous_time = self.current_time + self.previous_error = error + + # Return the filtered response + return self.u + + def _filter_input(self, new_y_hat): + # optional code to filter the measurement signal + return new_y_hat + + def _filter_output(self, u_p): + # optional code to filter the input / actuation signal + return u_p + + def _calculate_error(self, y_hat_2): + # TODO step 1.2: calculate and return the error between reference and measurement + return 0.0 + + def _calc_Pterm(self, error): + # TODO step 1.2: calculate the proportional term based on the error and self.Kp + return 0.0 + + def _calc_Iterm(self, error, delta_time): + # TODO step 1.2: calculate the integral term based on error, last error and deltaT, and self.Ki + return 0.0 + + def _calc_Dterm(self, error, delta_time): + # calculate the proportional term based on error, last error and deltaT + return self.Kd * 0.0 + +if __name__ == "__main__": + # "main" method if this class is run as a script + BATT_MIN_P, BATT_MAX_P, = -15, 15 + #LOAD_MIN_P, LOAD_MAX_P = 0, 20 + pid = PIDController(Kp=0.4, Ki=0.8, Kd=0.0, # 0.5 , 0.2 / 0.4 , 0.1, Kp 0.7 + u_min=BATT_MIN_P, + u_max=BATT_MAX_P) + + # Establish connection to the switchboard to get the pcc power measurement (that's the usual practice, however, vswitchboard currently not working) + # sb = syslab.SwitchBoard('vswitchboard') + + # Instead, establish connection to all units to replace switchboard measurements + gaia = syslab.WindTurbine("vgaia1") + dumpload = syslab.Dumpload("vload1") + mobload1 = syslab.Dumpload("vmobload1") + pv319 = syslab.Photovoltaics("vpv319") + batt = syslab.Battery('vbatt1') + + # Initiate reference signal + r_old = 0.0 + r = 0.0 + + try: + while True: + + # We loop over the broadcast queue to empty it and only keep the latest value + try: + while True: + # Receive reference setpoint + incoming_str = reference_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "setpoint_reference;0.547", + # so we split it up and take the last bit forward. + r = float(incoming_str.split(" ")[-1]) + #logging.info(f"New reference setpoint: {r}") + + # An error will indicate that the queue is empty so we move along. + except zmq.Again as e: + pass + + # Overwrite setpoint reference r if different from previous value + pid.set_reference(r) if r != r_old else None + + # Store reference for comparison in next loop + r_old = r + + # To ensure accurate + start_time = time() + # Get measurements of the current power exchange at the grid connection (=Point of Common Coupling (PCC)) + #pcc_p = sb.getActivePower(0) + pcc_p = cast_to_cm(-(gaia.getActivePower().value - dumpload.getActivePower().value - mobload1.getActivePower().value + batt.getActivePower().value + pv319.getACActivePower().value)) + + # Calculate new requests using PID controller + p_request_total = pid.update(pcc_p.value) + + # Ensure requests do not exceed limits of battery (extra safety, should be ensured by controller) + batt_p = clamp(BATT_MIN_P, p_request_total, BATT_MAX_P) + + # # Send new setpoints + batt.setActivePower(batt_p) + + #print(f"Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + print(f"Setpoint:{r:.02f} Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + + # Run the loop again once at most a second has passed. + sleep(1) + finally: + # When closing, set all setpoints to 0. + batt.setActivePower(0.0) + reference_in_socket.close() diff --git a/simlab_controller_d5_load.py b/simlab_controller_d5_load.py new file mode 100644 index 0000000..860d529 --- /dev/null +++ b/simlab_controller_d5_load.py @@ -0,0 +1,181 @@ +import syslab +from time import sleep, time +from dataclasses import dataclass +from util import clamp, cast_to_cm +import sys +import zmq +import logging + +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket to subscribe to the tester +reference_in_socket = context.socket(zmq.SUB) +reference_in_socket.connect(f"tcp://localhost:5000") + +# Ensure we only see message on the setpoint reference +reference_in_socket.subscribe("setpoint_reference") + +# Used to log the incoming reference setpoints +logging.basicConfig(level=logging.INFO) + +@dataclass +class PIDController: + Kp: float = 0.0 # P factor + Ki: float = 0.0 # I factor + Kd: float = 0.0 # D factor + r: float = 0.0 # Reference which we want the measured y to be + Pterm: float = 0.0 # P part + Iterm: float = 0.0 # Integral part + Dterm: float = 0.0 # Differential part + previous_error: float = 0.0 # used to calculate differential part + previous_Iterm: float = 0.0 # used to calculate integral part + current_time: float = None # used to calculate differential and integral part + previous_time: float = None # used to calculate differential and integral part + u_max: float = None # used to calculate controller input saturation + u_min: float = None # used to calculate controller input saturation + u_p: float = 0.0 + u: float = 0.0 + + def __post_init__(self): + self.previous_time = self.previous_time if self.previous_time is not None else time() + self.current_time = self.current_time if self.current_time is not None else time() + + def set_reference(self, new_reference): + self.r = new_reference + + def update(self, new_y_meas, x_load, current_time=None): + + # Ensure we have the time elapsed since our last value; we'll use that for the integral and differential parts + self.current_time = current_time if current_time is not None else time() + delta_time = self.current_time - self.previous_time + + # Filter the incoming value + y_2 = self._filter_input(new_y_meas) + + # Calculate the error to the setpoint + error = self._calculate_error(self.r, y_2) + + # Apply splitting factor # TODO step 1.2: Familiarize with the usage of the splitting factor + error = x_load*max(error, 0) + + # Calculate the PID terms + Pterm = self._calc_Pterm(error) + Iterm = self._calc_Iterm(error, delta_time) + Dterm = self._calc_Dterm(error, delta_time) + + # Calculate our raw response + self.u_p = Pterm + Iterm + Dterm + + # Filter our response + self.u = self._filter_output(self.u_p) + u_new = clamp(self.u_min, self.u, self.u_max) + + # Remember values for next update + if self.u == u_new: + self.previous_Iterm = Iterm + else: # in saturation - don't sum up Iterm + self.u = u_new + + self.previous_time = self.current_time + self.previous_error = error + + # Return the filtered response + return self.u + + def _filter_input(self, new_y_hat): + # optional code to filter the measurement signal + return new_y_hat + + def _filter_output(self, u_p): + # optional code to filter the input / actuation signal + return u_p + + def _calculate_error(self, r, y_hat_2): + # calculate the error between reference and measurement + return r - y_hat_2 + + def _calculate_error(self, y_hat_2): + # TODO step 1.2: calculate and return the error between reference and measurement + return 0.0 + + def _calc_Pterm(self, error): + # TODO step 1.2: calculate the proportional term based on the error and self.Kp + return 0.0 + + def _calc_Iterm(self, error, delta_time): + # TODO step 1.2: calculate the integral term based on error, last error and deltaT, and self.Ki + return 0.0 + def _calc_Dterm(self, error, delta_time): + # calculate the proportional term based on error, last error and deltaT + return self.Kd * 0.0 + +if __name__ == "__main__": + # "main" method if this class is run as a script + BATT_MIN_P, BATT_MAX_P, = -15, 15 + #LOAD_MIN_P, LOAD_MAX_P = 0, 20 + pid = PIDController(Kp=0.4, Ki=0.8, Kd=0.0, # 0.5 , 0.2 / 0.4 , 0.1, Kp 0.7 + u_min=BATT_MIN_P, + u_max=BATT_MAX_P) + + # Establish connection to the switchboard to get the pcc power measurement (that's the usual practice, however, vswitchboard currently not working) + # sb = syslab.SwitchBoard('vswitchboard') + + # Instead, establish connection to all units to replace switchboard measurements + gaia = syslab.WindTurbine("vgaia1") + dumpload = syslab.Dumpload("vload1") + mobload1 = syslab.Dumpload("vmobload1") + pv319 = syslab.Photovoltaics("vpv319") + batt = syslab.Battery('vbatt1') + + # Initiate reference signal + r_old = 0.0 + r = 0.0 + + try: + while True: + + # We loop over the broadcast queue to empty it and only keep the latest value + try: + while True: + # Receive reference setpoint + incoming_str = reference_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "setpoint_reference;0.547", + # so we split it up and take the last bit forward. + r = float(incoming_str.split(" ")[-1]) + #logging.info(f"New reference setpoint: {r}") + + # An error will indicate that the queue is empty so we move along. + except zmq.Again as e: + pass + + # Overwrite setpoint reference r if different from previous value + pid.set_reference(r) if r != r_old else None + + # Store reference for comparison in next loop + r_old = r + + # To ensure accurate + start_time = time() + # Get measurements of the current power exchange at the grid connection (=Point of Common Coupling (PCC)) + #pcc_p = sb.getActivePower(0) + pcc_p = cast_to_cm(-(gaia.getActivePower().value - dumpload.getActivePower().value - mobload1.getActivePower().value + batt.getActivePower().value + pv319.getACActivePower().value)) + + # Calculate new requests using PID controller + p_request_total = pid.update(pcc_p.value) + + # Ensure requests do not exceed limits of battery (extra safety, should be ensured by controller) + batt_p = clamp(BATT_MIN_P, p_request_total, BATT_MAX_P) + + # # Send new setpoints + batt.setActivePower(batt_p) + + #print(f"Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + print(f"Setpoint:{r:.02f} Measured: P:{pcc_p.value:.02f}, want to set: P {batt_p:.02f}") + + # Run the loop again once at most a second has passed. + sleep(1) + finally: + # When closing, set all setpoints to 0. + batt.setActivePower(0.0) + reference_in_socket.close() diff --git a/supervisory_controller.py b/supervisory_controller.py new file mode 100644 index 0000000..e495356 --- /dev/null +++ b/supervisory_controller.py @@ -0,0 +1,93 @@ +from util import pos, clamp +import parameters +from time import time, sleep +import zmq +import syslab +from syslab.comm.LogUtils import setup_event_logger +logging = setup_event_logger() + + +### Parameters +# If soc is below this limit, divert all power to the battery. +base_soc_lower_limit = 0.2 +# If soc is above this limit, divert all power to the mobile load. +base_soc_upper_limit = 0.8 +# This much renewable production shifts our soc_lower_limit down by 0.1 and soc_upper_limit up by 0.1. +# I.e. if there is a lot of renewable production, the range over which we split between battery and +# load widens. +base_renewable_shift = 10.0 + + +# # Variables +# Controller variables +x_battery = 0.5 # Default splitting factor +x_load = 0.5 # Default splitting factor +# Info on battery state +battery_soc = 0.5 # Default SOC (= state of charge) + +soc_lower_limit = 0.2 +soc_upper_limit = 0.8 + +# # Communication +# Handle the sockets we need +# Make a context that we use to set up sockets +context = zmq.Context() + +# Set up a socket we can use to broadcast splitting factors on +splitting_out_socket = context.socket(zmq.PUB) +splitting_out_socket.bind(f"tcp://*:{parameters.SUPERVISOR_PORT}") + +# Set up a socket to subscribe to the battery's soc +soc_in_socket = context.socket(zmq.SUB) +soc_in_socket.connect(f"tcp://{parameters.BATTERY_SOC_IP}:{parameters.BATTERY_SOC_PORT}") + +# Ensure we only see message on the battery's soc +soc_in_socket.subscribe(parameters.TOPIC_BATTERY_SOC) + +### Unit connections +# TODO step 1.3: Set up connection to the units + +try: + while True: + try: + # Try to connect the the battery. + # If we have a connection to the battery, get its current soc. + # If none have come in, continue with previous soc. + # We put this in a while-loop to ensure we empty the queue each time, + # so we always have the latest value. + while True: + # Receive the latest splitting factor + incoming_str = soc_in_socket.recv_string(flags=zmq.NOBLOCK) + # The incoming string will look like "batt_split;0.781", + # so we split it up and take the last bit forward. + battery_soc = float(incoming_str.split(" ")[-1]) + logging.info(f"New battery soc: {battery_soc}") + except zmq.Again as e: + # No (more) new messages, move along + pass + + # Poll the grid connection to get the current production of renewables. + wind_p = 4.0 # TODO Task 1.3: Change into syslab connection. Remember that the measurements need to be collected directly from the units since vswitchboard is not working + solar_p = 7.0 + logging.info(f"Current renewable production: {wind_p + solar_p} kW.") + + soc_lower_limit = base_soc_lower_limit - (wind_p + solar_p) / base_renewable_shift / 10.0 + soc_upper_limit = base_soc_upper_limit + (wind_p + solar_p) / base_renewable_shift / 10.0 + + # Calculate a new splitting factor for the battery. + x_battery = clamp(0, (soc_upper_limit - battery_soc)/(soc_upper_limit - soc_lower_limit), 1) + + # If anything is not taken by the battery, put it in the load. + x_load = 1 - x_battery + + # Publish the new splitting factors. + splitting_out_socket.send_string(f"{parameters.TOPIC_BATTERY_SPLITTING} {x_battery:.06f}") + splitting_out_socket.send_string(f"{parameters.TOPIC_LOAD_SPLITTING} {x_load:.06f}") + logging.info(f"Battery split: {x_battery:.03f}; Load split: {x_load:.03f}") + + # Loop once more in a second + sleep(1) +finally: + # Clean up by closing our sockets. + soc_in_socket.close() + splitting_out_socket.close() diff --git a/util.py b/util.py index 321e0e9..51cde42 100644 --- a/util.py +++ b/util.py @@ -1,5 +1,42 @@ +import numpy as np +from syslab.core.datatypes import CompositeMeasurement +from typing import Union +from time import time + + +def pos(x): + """ + :param x: input + :return: x if x > 0 else 0 + """ + return max(x, 0) + + def clamp(a, x, b): """ Restrict x to lie in the range [a, b] """ return max(a, min(x, b)) + +def cast_to_cm(m: Union[CompositeMeasurement, float]): + if type(m) == float: + request = CompositeMeasurement(m, timestampMicros=time()*1e6, timePrecision=1000) + elif type(m) == CompositeMeasurement: + request = m + else: + raise TypeError(f"Unknown request type: {type(m)}") + return request + +def soc_scaler(soc, min_soc=0.0, max_soc=1.0): + """ + Scale an soc to emulate having a smaller battery. + If the actual battery has a capacity of 14 kWh, the rescaled battery will have a capacity + of 14*(max_soc - min_soc) kWh. + Does not check for negative soc. + :param soc: current actual soc + :param min_soc: actual soc that should correspond to an soc of 0.0 + :param max_soc: actual soc that should correspond to an soc of 1.0 + :return: rescaled soc + """ + + return (soc - min_soc)/(max_soc - min_soc)