From 496bc6bba2002d5110b7feaead97afc5a2b94992 Mon Sep 17 00:00:00 2001 From: Michael Soukup Date: Mon, 4 Aug 2014 22:11:23 +0200 Subject: [PATCH] controller is almost up and running. Need some fixes and improvements though. --- config.py | 10 ++- config.pyc | Bin 1421 -> 1557 bytes demo.py | 36 +++++++++++ unittesting.py | 113 +++++++++++++++++++++++++++------- ur5controller.py | 153 ++++++++++++++++++++++++++++++++++++++-------- ur5controller.pyc | Bin 9164 -> 12526 bytes 6 files changed, 261 insertions(+), 51 deletions(-) diff --git a/config.py b/config.py index 6f24a9d..dd03964 100644 --- a/config.py +++ b/config.py @@ -8,7 +8,7 @@ FONTSIZE = 24 BG_COL = (255,255,255) #white FONT_COL = (0,0,0) #black MENU_FPS = 15 # enough to poll keys efficiently - +CTR_FPS = 60 # IP of the robot. UR5_IP = "129.241.187.119" @@ -17,17 +17,23 @@ UR5_IP = "129.241.187.119" # polling the UR5 through a real-time monitor that runs at 125Hz. If the controller # runs slow, try disabling this first. USE_FORCE_MONITOR = True +FORCE_CONSTRAINT = 60 +FORCE_T = 0.1 # Control parameters. VELOCITY = 0.1 ACCELERATION = 0.5 DECELERATION = 0.5 +CHCMD_DECEL = 0.8 +CHCMD_T = 0.01 # cooldown between commands to smooth out the change in motion + +# Loop parameters DEFAULT_LOOP_SPEED = 0.1 LOOP_SPEED_MAX = 0.7 LOOP_SPEED_MIN = 0.05 # Block parameters -BLOCK_DIM = 0.0995 #TODO measure +BLOCK_DIM = 0.0975 #TODO measure # Table cylinder parameters TABLE_QUADRANT = 0 # In range [0..4). Quadrant 0 is along the x-axis of the robot. diff --git a/config.pyc b/config.pyc index ad1357ee2b8bb2d23db7ee3b40b39da7758e6027..51ea7d19cc6fe62a08457eef880993d233a07ac4 100644 GIT binary patch delta 652 zcmY*XJ5L)y5dQYgJ{zCk-1!U!N`UL2BzOsAfm5IqI1NgJXFxN$KBEyEoB?IE&4F!D zUfTkAm<1KVIZ#Qb%iug{7F+;1;3CKcTc8TK1oFUTP!&82ngctad4$0G8frowH9qcK<@n}iErh#xMkMLlcV@$_Y)$?$Q?hdcgBTZX?N00SzPqb}< z=RnKaZ|Mpf4y_=pOlTZ63ExcYs3CdvUU+jBSvTc!?UrBxZX~}(Tiwc_)rfzJW#irR z_*AqzqaR8xyU`ctc;n*p*KYJy%b#vEPW|de*R5AaXIH;t)A%Mj@uBfLrEc2~`thaF zY!s!xzSsAA2mapfAn3o`+6_Lbf-xAxdGlG#Rt=l}&f39R&+l#P{5TFyR?UB+DgP8? a4a>B|t(sO+`*)Zb?AX;COZ^WtqIR(W delta 522 zcmY*WJ5R$f5dNIBNt>kaq-pw~lvi0$SGF=SBXzqCaiJ1oK`abNojWi^3?Tjh3kwnw zh>738irD%GIwB-&Hx+PapZ%Th?Q8GKotX22bNBE$IzmdeJon^2zZ3w=C2$H55DSn2 zZh}m329yD}Kv{4bWPxpv4bFjb+A2q;XE1`WhD#L~TnAfyO!1ooA}52>m1w3Lq8e?u&yaiCl} zn50m~{(WfBiVo``q^>S*Rm&c@4qDUqb#39mrwzoFG=Z-w;bo1!D)K*Cwj#|I*RHEw zWGA{wpCbfgIgL#<_tADIc@h0&Ci>B_rrB_Od!y)nINpDnf4^TmC%eW}Y$b2TVKOj# j$(cELd^NvB`#}DoXIQ2sQqEY}B}zWBJFZ<6ie1S+)OcCX diff --git a/demo.py b/demo.py index edd075d..842c655 100755 --- a/demo.py +++ b/demo.py @@ -149,6 +149,38 @@ class UR5Demo(object): self.clock.tick(config.MENU_FPS) + def keyboard_control(self): + running = True + while running: + pressed = pygame.key.get_pressed() + for e in pygame.event.get(): + if e.type == pygame.QUIT: + self.sig_quit = True + if pressed[pygame.K_ESCAPE] or self.sig_quit: + running = False + + vec = (0,0,0) + for m in (self.kb_map[key] for key in self.kb_map if pressed[key]): + vec = map(sum, zip(vec, m)) + + self._disptxt([ + "Keyboard control", + "Use arrow keys, [w], and [s]", + " ", + "move vec: %s" % str(vec) + ]) + + dt = 1.0/config.CTR_FPS + try: + self.controller.update(vec, dt) + except Exception, e: + self._disptxt(["Error: %s" % e]) + time.sleep(2) + running = False + + self.clock.tick(config.CTR_FPS) + + def main_menu(self): menu = [ "Go to [h]ome position", @@ -189,6 +221,10 @@ class UR5Demo(object): self.loop_init(_instr, 2) self._disptxt(menu) + elif pressed[pygame.K_c]: + self.keyboard_control() + self._disptxt(menu) + elif pressed[pygame.K_q] or self.sig_quit: running = False diff --git a/unittesting.py b/unittesting.py index d7b4b17..04a0fb3 100755 --- a/unittesting.py +++ b/unittesting.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import sys, config, math, time +import sys, config, math, time, traceback from ur5controller import DemoController @@ -103,35 +103,100 @@ def test_pick_place(controller): def small_pyramid(controller): + # Initial block placement. + # On each side + # (r0, edge, z0) + # (r1, edge, z0) + # (r0, edge, z1) + + dtheta_lvl0 = config.BLOCK_DIM / config.R_LVL0 + math.pi/64 #medium gap + speed = 0.2 + acc = config.ACCELERATION + moves = [] + + # Pick left (r0, edge, z1), place in middle + moves += controller.pick_block(0, config.THETA_EDGE_LEFT, 1, speed) + tmp_pose = controller.blocklvl2pose(0.2, -dtheta_lvl0, 1.1) + moves.append(tmp_pose + [acc, speed, 0.05]) + moves += controller.place_block(0, 0, 0, speed) + + # Pick left (r1, edge, z0), place left + moves += controller.pick_block(1, config.THETA_EDGE_LEFT, 0, speed) + tmp_pose = controller.blocklvl2pose(1.1, config.THETA_EDGE_LEFT+dtheta_lvl0, 0.5) + moves.append(tmp_pose + [acc, speed, 0.05]) + moves += controller.place_block(0, -dtheta_lvl0, 0, speed) + + # Pick right (r0, edge, z1), place right + tmp_pose = controller.blocklvl2pose(1.3, config.THETA_EDGE_RIGHT-dtheta_lvl0, 1.2) + moves.append(tmp_pose + [acc, speed, 0.05]) + #moves += controller.blocklvl2arc(1.3, config.THETA_EDGE_RIGHT - math.pi/16, 1, vel=speed, blend_last=0.03) + moves += controller.pick_block(0, config.THETA_EDGE_RIGHT, 1, speed) + tmp_pose = controller.blocklvl2pose(0, config.THETA_EDGE_RIGHT-dtheta_lvl0, 1.2) + moves.append(tmp_pose + [acc, speed, 0.03]) + moves += controller.place_block(0, dtheta_lvl0, 0, speed) + + # Pick right (r1, edge, z0), place lvl1 right + moves += controller.pick_block(1, config.THETA_EDGE_RIGHT, 0, speed) + tmp_pose = controller.blocklvl2pose(1.1, config.THETA_EDGE_RIGHT-dtheta_lvl0, 1.1) + moves.append(tmp_pose + [acc, speed, 0.03]) + moves += controller.place_block(0, 0.5*dtheta_lvl0, 1, speed) + + # Pick left (r0, edge, z0), place lvl1 left + moves += controller.blocklvl2arc(1.3, config.THETA_EDGE_LEFT + math.pi/16, 0.1, vel=speed, blend_last=0.03) + moves += controller.pick_block(0, config.THETA_EDGE_LEFT, 0, speed) + tmp_pose = controller.blocklvl2pose(0, config.THETA_EDGE_LEFT+dtheta_lvl0, 1.1) + moves.append(tmp_pose + [acc, speed, 0.03]) + moves += controller.place_block(0, -0.5*dtheta_lvl0, 1, speed) + + # Pick right (r0, edge, z0), place on top + moves += controller.blocklvl2arc(1.3, config.THETA_EDGE_RIGHT - math.pi/16, 0, vel=speed, blend_last=0.03) + moves += controller.pick_block(0, config.THETA_EDGE_RIGHT, 0, speed) + tmp_pose = controller.blocklvl2pose(0, config.THETA_EDGE_RIGHT-dtheta_lvl0, 2.1) + moves.append(tmp_pose + [acc, speed, 0.03]) + moves += controller.place_block(0, 0, 2, speed) + + # Execute moves + controller.movels(moves, wait=False) + looping = True + while looping: + looping = controller.is_looping(1.0) # blocks for 1 sec + + moves.reverse() + controller.movels(moves, wait=True) + looping = True + while looping: + looping = controller.is_looping(1.0) # blocks for 1 sec + + +def big_pyramid(controller): dtheta_lvl0 = config.BLOCK_DIM / config.R_LVL0 + math.pi/64 #medium gap #dtheta_lvl0 = config.BLOCK_DIM / config.R_LVL0 #small gap #dtheta_lvl0 = config.BLOCK_DIM / config.R_LVL0 - math.pi/200#no gap + # Initial block placement. + # On each side + # (r0, edge, z0) + # (r1, edge, z0) + # (r0, edge, z1) + # (r1, edge, z1) + # (r0, edge, z2) + # (r1, edge, z2) + speed = 0.2 - # Initial: 2x2 on each side + moves = [] + #move += controller.blocklvl2arc(0, config.THETA_EDGE_LEFT + math.pi/16, 1, vel=speed, blend_last=0.03) + moves += controller.pick_block(0, config.THETA_EDGE_LEFT, 1, speed) # Pick left (r0, edge, z1) + moves += controller.place_block(0, -0.5*dtheta_lvl0, 0) + moves += controller.pick_block(1, config.THETA_EDGE_LEFT, 0, speed) # Pick left (r1, edge, z0) + moves += controller.place_block(0, -1.5*dtheta_lvl0, 0) - controller.blocklvl2pose(r_lvl, theta, z_lvl) + [acc, vel, rad] + moves += controller.blocklvl2arc(0.1, config.THETA_EDGE_RIGHT - math.pi/16, 1, vel=speed, blend_last=0.03) + moves += controller.pick_block(0, config.THETA_EDGE_RIGHT, 1, speed) # Pick right (r0, edge, z1) + moves += controller.place_block(0, 0.5*dtheta_lvl0, 0) + moves += controller.pick_block(1, config.THETA_EDGE_RIGHT, 0, speed) # Pick right (r1, edge, z0) + moves += controller.place_block(0, 1.5*dtheta_lvl0, 0) - controller.pick_block(1, config.THETA_EDGE_LEFT, 1) # Pick left - controller.place_block(0, -0.5*dtheta_lvl0, 0) - controller.pick_block(1, config.THETA_EDGE_RIGHT, 1) # pick right - controller.place_block(0, 0.5*dtheta_lvl0, 0) - - controller.pick_block(0, config.THETA_EDGE_LEFT, 1) # Pick left - controller.place_block(0, -1.5*dtheta_lvl0, 0) - controller.pick_block(0, config.THETA_EDGE_RIGHT, 1) # pick right - controller.place_block(0, 1.5*dtheta_lvl0, 0) # foundation complete - - controller.pick_block(1, config.THETA_EDGE_LEFT, 0) # Pick left - controller.place_block(0, 0, 1) - controller.pick_block(1, config.THETA_EDGE_RIGHT, 0) # pick right - controller.place_block(0, dtheta_lvl0, 1) - controller.pick_block(0, config.THETA_EDGE_LEFT, 0) # Pick left - controller.place_block(0, -dtheta_lvl0, 1) - - controller.pick_block(0, config.THETA_EDGE_RIGHT, 0) # pick right - controller.place_block(0, 0, 2) if __name__ == '__main__': @@ -143,12 +208,14 @@ if __name__ == '__main__': #check_edges(controller) #test_movec_hax(controller) #check_theta_edges(controller) - test_pick_place(controller) + #test_pick_place(controller) #pick_place_small_pyramid(controller) + small_pyramid(controller) # NEXT: test radial params and arbitrary paths. except Exception, e: print e + print traceback.format_exc() time.sleep(1) if controller: diff --git a/ur5controller.py b/ur5controller.py index 75d9e56..f17fcf9 100644 --- a/ur5controller.py +++ b/ur5controller.py @@ -24,6 +24,15 @@ class DemoController(object): self.vel = config.VELOCITY self.acc = config.ACCELERATION self.dec = config.DECELERATION + self.chcmd_decel = config.CHCMD_DECEL + self.t_chcmd = config.CHCMD_T + self.prev_vec = (0,0,0) + self.t_ch = 0 + + self.force_mon = config.USE_FORCE_MONITOR + self.force_constraint = config.FORCE_CONSTRAINT + self.ft = config.FORCE_T + #self.controllable = False # Cylinder params self.quadrant = config.TABLE_QUADRANT @@ -54,6 +63,22 @@ class DemoController(object): self.j_home = config.QUADRANT_JOINT_CONFIG + def set_pose_home(self): + """Move to "home" configuration. The resulting pose has correct orientation.""" + try: + self.robot.movej(self.j_home, acc=0.1, vel=0.5, radius=0, wait=True, relative=False) + pose = self.robot.getl() + pose[0] = self.r_min*math.cos(self.phi) + self.cyl_ox + pose[1] = self.r_min*math.sin(self.phi) + self.cyl_oy + pose[2] = self.z_min + self.robot.movel(pose, acc=0.1, vel=0.2, radius=0, wait=True, relative=False) + self.current_cyl = (self.r_min, 0, self.z_min) + if self.debug: + print "initial cyl coords: %s" % str([('%.2f' % i) for i in self.current_cyl]) + except Exception, e: + raise UR5Exception('Move to home configuration failed: %s' % str(e)) + + def calibrate_csys(self): """Calibrate the reference cylinder coordinate system.""" # Move to reference coordinate system base @@ -65,6 +90,12 @@ class DemoController(object): self.trans_base = self.robot.get_transform() self.cyl_ox = self.cyl_offset self.cyl_oy = 0 + #self.controllable = True + + self.set_current_cyl() + if self.debug: + print "Current cyl coords: %s" % str([('%.2f' % i) for i in self.current_cyl]) + def set_freedrive(self, mode): @@ -72,20 +103,6 @@ class DemoController(object): self.robot.set_freedrive(mode) - def set_pose_home(self): - """Move to "home" configuration. The resulting pose has correct orientation.""" - try: - self.robot.movej(self.j_home, acc=0.1, vel=0.5, radius=0, wait=True, relative=False) - pose = self.robot.getl() - pose[0] = self.r_min*math.cos(self.phi) + self.cyl_ox - pose[1] = self.r_min*math.sin(self.phi) + self.cyl_oy - pose[2] = self.z_min - self.robot.movel(pose, acc=0.1, vel=0.2, radius=0, wait=True, relative=False) - self.current_cyl = (self.r_min, 0, self.z_min) - except Exception, e: - raise UR5Exception('Move to home configuration failed: %s' % str(e)) - - def cylinder2pose(self, r, theta, z): """Translate table cylinder coordinates to robot cartesian pose.""" trans = math3d.Transform(self.trans_base) #deep copy @@ -95,15 +112,20 @@ class DemoController(object): z) return trans.pose_vector.tolist() - - def blocklvl2pose(self, r_lvl, theta, z_lvl): - return self.cylinder2pose(self.r_lvl0 - r_lvl*self.block_dim, theta, self.z_lvl0 + z_lvl*self.block_dim) + def set_current_cyl(self): + p = self.robot.getl() + x = p[0] - self.cyl_ox + y = p[1] - self.cyl_oy + z = p[2] + r = (x**2 + y**2)**0.5 + theta = math.atan2(y, x) + self.current_cyl = (r, theta, z) - def movel(self, r, theta, z): + def movel(self, r, theta, z, wait=True): """Linear move.""" pose = self.cylinder2pose(r, theta, z) - self.robot.movel(pose, acc=self.acc, vel=self.vel, radius=0, wait=True, relative=False) + self.robot.movel(pose, acc=self.acc, vel=self.vel, radius=0, wait=wait, relative=False) self.current_cyl = (r, theta, z) @@ -136,11 +158,10 @@ class DemoController(object): self.robot.wait_for_move() return self.robot.getl() - - def movec_hax(self, r, theta, z): + def linearize_arc(self, r, theta, z, acc=None, vel=None, blend_last=0): """movec is unreliable and does not finish in the correct pose. We work around this by linearizing the arc into segments of a predefined angular resolution, and use - movepls with blend. + movels with blending. IMPORTANT: blending radius have to be smaller than the angular resolution! If not, the robot will not finish the last move, because it is within the target @@ -154,20 +175,34 @@ class DemoController(object): theta_incr = dtheta/segments r_incr = (r-curr_r)/segments z_incr = (z-curr_z)/segments + for i in range(1, segments): pose = self.cylinder2pose(curr_r + i*r_incr, curr_theta + i*theta_incr, curr_z + i*z_incr) - move_list.append(pose + [self.acc, self.vel, 0.03]) - move_list.append(self.cylinder2pose(r, theta, z) + [self.acc, self.vel, 0]) + move_list.append(pose + [acc or self.acc, vel or self.vel, 0.03]) + move_list.append(self.cylinder2pose(r, theta, z) + [acc or self.acc, vel or self.vel, blend_last]) + return move_list + def movec_hax(self, r, theta, z, blend_last=0, wait=True): + """movec with linearized arc.""" + move_list = self.linearize_arc(r, theta, z) if self.debug: print "move list for movec_hax" for p in move_list: print [('%.3f' % i) for i in p] - - self.movels(move_list, wait=True) + self.movels(move_list, wait=wait) self.current_cyl = (r, theta, z) + def blocklvl2pose(self, r_lvl, theta, z_lvl): + return self.cylinder2pose(self.r_lvl0 - r_lvl*self.block_dim, theta, self.z_lvl0 + z_lvl*self.block_dim) + + + def blocklvl2arc(self, r_lvl, theta, z_lvl, acc=None, vel=None, blend_last=0): + r = self.r_lvl0 - r_lvl*self.block_dim + z = self.z_lvl0 + z_lvl*self.block_dim + return self.linearize_arc(r, theta, z, acc=acc, vel=vel, blend_last=blend_last) + + def pick_block(self, r_lvl, theta, z_lvl, speed): """This function concatenates 3 moves: @@ -216,5 +251,71 @@ class DemoController(object): print "Velocity vector, dt=%.2f: %s. SUM %.3f" % (dt, str([('%.2f' % i) for i in v]), _sum) return sum(map(abs, v)) > threshold + + def move(self, vec, t=0): + vr, vtheta, vz = vec + r, theta, z = self.current_cyl + + if vr != 0: + self.movel(r+vr, theta, z, wait=False) + elif vtheta != 0: + self.movec_hax(r, theta+vtheta, z, wait=False) + elif vz != 0: + self.movel(r, theta, z+vz, wait=False) + + if t: + time.sleep(t) + self.stopl(acc=self.chcmd_decel) + + + def update(self, vec, dt): + """Update movements based on vec (and dt?)""" + + force = 10 # dummy + if self.force_mon and force > self.force_constraint: + self.move([i*-1 for i in vec], t=self.ft) + self.set_current_cyl() + return + + self.set_current_cyl() + vr, vtheta, vz = vec + r, theta, z = self.current_cyl + + # move? + if sum(map(abs, vec)) == 0: + if vec != self.prev_vec: + self.robot.stopl(acc=self.chcmd_decel) + prev_vec = (0,0,0) + return + + # check bounds + rnext = r + (vr*self.vel*dt) + if rnext < self.r_min or rnext > self.r_max: + vr = 0 + thetanext = theta + (vtheta*self.vel*dt) # this is angular speed, but the diff is not significant + if thetanext < self.theta_min or thetanext > self.theta_max: + vtheta = 0 + znext = z + (vz*self.vel*dt) + if znext < self.z_min or znext > self.z_max: + vz = 0 + vec = (vr, vtheta, vz) + + # command change + if vec != self.prev_vec: + # from stand still + if sum(map(abs, self.prev_vec)) == 0: + self.move(vec) + self.prev_vec = vec + self.t_ch = 0 + + # from another command + elif not self.t_ch: + self.t_ch = time.time() + self.robot.stopl(acc=self.chcmd_decel) + elif time.time() - self.t_ch > self.t_chcmd: + self.move(vec) + self.prev_vec = vec + self.t_ch = 0 + def cleanup(self): self.robot.cleanup() diff --git a/ur5controller.pyc b/ur5controller.pyc index 035e720266f69433e641b2281638b1edbf78a03c..8c867d28a200d5bc6da71d9e89868f1b73621cea 100644 GIT binary patch delta 5567 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 delta 2550 zcmZ8iTWl0%6h8mV?rdkf_wJ=^+wHxbmfnknqR zuuGXlzz#8qf*ocO13SVb4sz6rs($SgaZBCTURwp2i-VhhxiU!FI;08!6GWZ(-ibd@J)d=IzWon0GSoV&2WXhj}maZOk7-UI!C>+}O??JDB$~-wC!4MlHYk z%lBe{@-bpPuCDk$TK~cLQmY74a&{ts781Is6H$@XLjoUNeQmZ!Xo8OKUVNPyH65Pr zv;mhR+lcE!oN_KXv-l4dt>yB9d3R!Y$;nTgSz5@eE0JDNu0D%w&t`FetR%?Mcv@Qy z5bRa=a{X#4nn?Fy(}^H}7DPYF@JWb}dLwF;(6$0Q)bG)HF`#0xJ=QQbSzfdhh$-qO z&qDaJ$5FIg`V~|#_*3uLVhi}(7`zaKm7ows4TKvfiJ@!?8mS?T@5=%f1&pKMMy-e% zE`3*(hqk85a6ws$;QA33md@wrsh?$8!)VTJlMZ!j3vQh~4|}b&?36>7pBx(tuA`bcjmiYBF}GpGL^12o4eq5$s2B z4TNL)DR*gQgIgOsQuFaP4Bw6TTyhkLI^-8<2@w-jB7>nGu9hv6Xp$WSZ3JBi7UkM= zm_o2JF@Jtuo>E^`AB~^Dw(}qZG|I2svicz(sc#O?g$4y5*n*on*w{onT9709DWtYR zey<6?z!QtPDCjV)vw8#Sm$Ig@!?-GIOddfdpFtQQL4agWDKYXGL7X5;K;I^h5{w~O zlf+IDOsMYYE`ujNzy4loc=$0G_ku~{FZdya zfB3@`t*&WM3{p=ah-;NnJt-Ja&aDvTr%>{s6^4s8xB*UV5OCh$$x$dqcz4_pmq#9g zWTp^cT1pf<&>BX%hVDg4A#=DF68r$%SE+1j)w7`iNB<`a9R6Sk+VSr2!Hf5(l7{#U#!$yq!LapK6=~Eh+@6=qFbUPD)ewLR{Z(l*6nXM`XIJ z23q@6YjwEo6*M^w2w)mmG7=)FHHx5!;FA{ZSVlT^6AxNg)N5tyyKCRl4n2C%J*oHL ze7TK)Dze-`K-YwPf`DAK_|&nVr^&`gYD%><>=KJy&fji`q~5_EPsZV-NQzdG60N%W zqG3T?Ro#uL)aL25(5vdXM%nr~&7{bHUXJchQGKBk|4{VbU;~9-)YOfp@$evtoRVn3 z$_MJ7rc5nX5dm(HlW%y*$3lbh@q5X?uKJo=g-=a3KW)zZee2$x;nPP3)a~Yh19UvO zD!a1_c|PyXd_KP{FOi+D7fie)*SO)FTj2kN$z^#Fjb3@@;xwRot#+}dCaq)H@36~@ zgl1q34~wYQEli9@kNUpj02XAd{Rw#!NBvAAy>emX$5CL6yd_j0wvZ=|1 zkE)AT^H@kE0(q6-8o}!Xw6+DbkLv95@1hTI=zcQ%hGw>yAyYRarfK@jfEfwJ%vv*x XZN7IwQq;ZcWFN8Xs@S!tYia#o0k@;L