From ea8f0dea916828f98647cc9e40bb999039bc9b99 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:47:21 +0200 Subject: [PATCH 01/13] feat(collision): stop robots at map obstacles (2D) UNTESTED in the running sim: unit tests and the launch smoke test pass, but nothing has been driven against the map in RViz yet. At startup the ground robot sim and the Crazyflie server each read the scenario's .bt octomap directly, keep the occupied voxels overlapping sim.collision_z (default 0.2-2.0 m: trunks and posts, not the floor, ceilings or tree canopies) and flatten them into one 2D grid with a precomputed clearance per cell. Each robot is a circle of its radius (scout 0.42, herover 0.17, other ground robots 0.3, Crazyflies 0.1; `radius:` overrides). Every move is checked along its path and stops before the circle touches an obstacle; height changes always go through. set_position stays an unchecked teleport. A robot spawned inside an obstacle can only move out of it. On by default; sim: {collision: false} turns it off. Known conflicts with existing scenarios: - hero_mrs_control_stack test_branch: approach points of perch_b1 and perch_b10 sit on icuas26_1 trunks, so drones can't reach them. - config/herover.yaml: herover_4 spawns inside a trunk (warned at launch). Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++- config/example.yaml | 6 ++ hero_sim/config.py | 37 ++++++++- hero_sim/crazyflie.py | 21 ++++- hero_sim/crazyflie_server.py | 16 +++- hero_sim/ground_robots.py | 23 +++++- hero_sim/obstacles.py | 152 +++++++++++++++++++++++++++++++++++ test/conftest.py | 9 +++ test/test_config.py | 22 +++++ test/test_crazyflie.py | 32 +++++++- test/test_nodes.py | 18 +++++ test/test_obstacles.py | 97 ++++++++++++++++++++++ 12 files changed, 437 insertions(+), 15 deletions(-) create mode 100644 hero_sim/obstacles.py create mode 100644 test/test_obstacles.py diff --git a/README.md b/README.md index e791459..cb51de4 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,21 @@ ros2 service call /cf_1/takeoff crazyflie_interfaces/srv/Takeoff "{height: 1.0, ros2 service call /all/land crazyflie_interfaces/srv/Land "{height: 0.0, duration: {sec: 2}}" ``` -Ground robots hold the last `cmd_vel` until a new one arrives. Poses are published on `//pose` and as TF from `world`. \ No newline at end of file +Ground robots hold the last `cmd_vel` until a new one arrives. Poses are published on `//pose` and as TF from `world`. + +## Map collisions + +Robots can't drive or fly into the map. The octomap's voxels between 0.2 m and 2.0 m high (`sim.collision_z`) are flattened into a 2D obstacle grid, which keeps the trunks and posts and leaves out the floor, ceilings and tree canopies. Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk can still climb or land, but it can't fly over the trunk. + +```yaml +ground_robots: + - name: scout_1 + model: scout # radius defaults to the model's: scout 0.42, herover 0.17, else 0.3 + radius: 0.45 # or set it +crazyflies: + - pose: {x: 1.5, y: 0.5, z: 0.03} # radius 0.1 by default +sim: + collision: false # turn it off +``` + +Every motion command is checked, including `go_to`, `cmd_hover`, `cmd_position` and the dock ride-along. `//set_position` is a teleport and isn't checked. A robot that starts inside an obstacle gets a warning at launch and can move out but not further in. \ No newline at end of file diff --git a/config/example.yaml b/config/example.yaml index c8ffb84..84490d1 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -7,6 +7,8 @@ # drive: omni holonomic, honors linear.x, linear.y and angular.z (default) # drive: diff non-holonomic, linear.y is ignored # model: scout | herover RViz mesh drawn on the robot's frame; leave out for TF axes only +# radius: 0.42 footprint (m) kept out of map obstacles (see sim.collision). Default +# by model: scout 0.42, herover 0.17, no model 0.3. 0 ignores the map ground_robots: - name: scout_1 pose: {x: 0.0, y: 0.0, yaw: 0.0} # m, m, rad @@ -22,6 +24,7 @@ ground_robots: # gripper: true starts with an open two-claw gripper on top, drawn in RViz # (default false). Needs sim_attach (see attach below), which # shuts it on a branch lock and serves /sim//add_gripper. +# radius: 0.1 footprint (m) kept out of map obstacles (default 0.1) crazyflies: - pose: {x: 1.5, y: 0.5, z: 0.03} # cf_1 gripper: true @@ -39,3 +42,6 @@ sim: rviz: true # RViz with config/sim.rviz models: true # robot meshes in RViz (crazyflies, and ground robots with a model) map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none + collision: true # robots stop where their footprint would hit a map obstacle + collision_z: [0.2, 2.0] # m, map heights that count as obstacles, flattened to 2D: + # trunks and posts, not the floor, ceiling or tree canopies diff --git a/hero_sim/config.py b/hero_sim/config.py index ede833a..e2d6486 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -24,6 +24,8 @@ 'rviz': True, # open RViz with config/sim.rviz 'models': True, # robot meshes in RViz (crazyflies + ground robots with a model) 'map': 'icuas26_1', # octomap: maps/.bt, a path to a .bt, or null + 'collision': True, # stop robots at map obstacles (2D, see hero_sim/obstacles.py) + 'collision_z': (0.2, 2.0), # m, map heights counted as obstacles: trunks, not floor/canopy } # RViz meshes by model name, relative to models/. Crazyflies always use @@ -34,6 +36,16 @@ 'scout': 'scout/scout.glb', } +# Footprint radius (m) for map collisions, by model: the circle around the +# mesh seen from above. Ground robots without a model get +# DEFAULT_GROUND_RADIUS. Any robot can set its own with `radius:`. +FOOTPRINT_RADII = { + 'crazyflie': 0.1, + 'herover': 0.17, + 'scout': 0.42, +} +DEFAULT_GROUND_RADIUS = 0.3 + DRIVES = ('omni', 'diff') CONFIG_ENV = 'HERO_SIM_CONFIG' DEFAULT_CONFIG = 'example.yaml' @@ -47,6 +59,7 @@ class GroundRobot: yaw: float drive: str model: str = None # key of MODEL_MESHES, or None for no mesh + radius: float = DEFAULT_GROUND_RADIUS # m, footprint for map collisions @dataclass(frozen=True) @@ -56,6 +69,7 @@ class Crazyflie: y: float z: float gripper: bool = False # starts with an open gripper on top (see hero_sim/gripper.py) + radius: float = FOOTPRINT_RADII['crazyflie'] # m, footprint for map collisions @dataclass(frozen=True) @@ -103,6 +117,13 @@ def parse(raw): raise ValueError(f'sim.cf_battery_drain must be >= 0, got {sim["cf_battery_drain"]}') if not (isinstance(sim['attach'], bool) or sim['attach'] == 'auto'): raise ValueError(f'sim.attach must be auto, true or false, got {sim["attach"]!r}') + if not isinstance(sim['collision'], bool): + raise ValueError(f'sim.collision must be true or false, got {sim["collision"]!r}') + band = sim['collision_z'] + if not (isinstance(band, (list, tuple)) and len(band) == 2 + and float(band[0]) < float(band[1])): + raise ValueError(f'sim.collision_z must be [z_min, z_max], z_min < z_max, got {band!r}') + sim['collision_z'] = (float(band[0]), float(band[1])) return Scenario(ground, cfs, sim) @@ -147,9 +168,16 @@ def _pose(entry, where, axes): return [float(pose.get(a, 0.0)) for a in axes] +def _radius(entry, where, default): + radius = float(entry.get('radius', default)) + if radius < 0.0: + raise ValueError(f'{where}: radius must be >= 0, got {radius}') + return radius + + def _ground_robot(i, entry): where = f'ground_robots[{i}]' - _check_keys(entry, ('name', 'pose', 'drive', 'model'), where) + _check_keys(entry, ('name', 'pose', 'drive', 'model', 'radius'), where) name = entry.get('name') if not isinstance(name, str) or not name: raise ValueError(f'{where}: name is required') @@ -161,18 +189,19 @@ def _ground_robot(i, entry): raise ValueError(f'{where} ({name}): model must be one of {sorted(MODEL_MESHES)} ' f'or left out, got {model!r}') x, y, yaw = _pose(entry, f'{where} ({name})', ('x', 'y', 'yaw')) - return GroundRobot(name, x, y, yaw, drive, model) + radius = _radius(entry, f'{where} ({name})', FOOTPRINT_RADII.get(model, DEFAULT_GROUND_RADIUS)) + return GroundRobot(name, x, y, yaw, drive, model, radius) def _crazyflie(i, entry): name = f'cf_{i + 1}' where = f'crazyflies[{i}] ({name})' - _check_keys(entry, ('pose', 'gripper'), where) + _check_keys(entry, ('pose', 'gripper', 'radius'), where) gripper = entry.get('gripper', False) if not isinstance(gripper, bool): raise ValueError(f'{where}: gripper must be true or false, got {gripper!r}') x, y, z = _pose(entry, where, ('x', 'y', 'z')) - return Crazyflie(name, x, y, z, gripper) + return Crazyflie(name, x, y, z, gripper, _radius(entry, where, FOOTPRINT_RADII['crazyflie'])) # ---------------------------------------------------------------- resolution diff --git a/hero_sim/crazyflie.py b/hero_sim/crazyflie.py index c5e54bd..46725a6 100644 --- a/hero_sim/crazyflie.py +++ b/hero_sim/crazyflie.py @@ -19,6 +19,10 @@ class SimCrazyflie: low-level cmd_position / cmd_full_state / cmd_velocity_world / cmd_vel_legacy, active for LOW_LEVEL_TIMEOUT after the last msg high-level takeoff / land / go_to, linear interpolation over the duration + + With an ObstacleMap (hero_sim/obstacles.py), every source's horizontal + motion stops where the footprint of `radius` would hit an obstacle; height + changes always go through. set_position teleports without a check. """ # Supervisor info bits (crazyflie_interfaces/msg/Status) @@ -36,15 +40,18 @@ class SimCrazyflie: BATTERY_LOW = 3.3 # V, below this pm_state reports LOW_POWER def __init__(self, node: Node, name, initial_position=(0.0, 0.0, 0.0), dt=0.1, - battery_drain=0.025, frame='world'): + battery_drain=0.025, frame='world', radius=0.1, obstacles=None): self.node = node self.name = name self.frame = frame self.dt = dt self.battery_drain = battery_drain # V/s while flying + self.radius = radius # m, footprint checked against `obstacles` + self.obstacles = obstacles # ObstacleMap, or None for no map collisions self.position = np.array(initial_position, dtype=float) self.yaw = 0.0 + self._checked_xy = tuple(self.position[:2]) # x, y the obstacle check last let through # High-level motion (linear interpolation start -> goal) self.start_position = self.position.copy() @@ -120,6 +127,7 @@ def update(self): pass # position already set by the low-level callbacks elif self.motion_active: self._update_high_level(dt) + self._keep_clear() if self.flying: self.battery_voltage = max(self.battery_voltage - self.battery_drain * dt, @@ -153,6 +161,15 @@ def _update_high_level(self, dt): if self.position[2] < 0.05: self.flying = False + def _keep_clear(self): + """Stop x, y where the straight path from the last checked point meets an obstacle.""" + if self.obstacles is None: + return + x, y = self.obstacles.move(*self._checked_xy, self.position[0], self.position[1], + self.radius) + self.position[0], self.position[1] = x, y + self._checked_xy = (x, y) + def _publish_pose(self): stamp = self.node.get_clock().now().to_msg() x, y, z = (float(v) for v in self.position) @@ -280,6 +297,7 @@ def _upload_trajectory_cb(self, request, response): def _set_position_cb(self, request, response): self.position = np.array([request.goal.x, request.goal.y, request.goal.z]) self.yaw = request.yaw + self._checked_xy = (request.goal.x, request.goal.y) # a teleport skips the path check self.goal = self.position.copy() self.start_position = self.position.copy() self.motion_active = False @@ -290,6 +308,7 @@ def _set_position_cb(self, request, response): # ------------------------------------------------------------ cmd topics def _mark_low_level(self): + self._keep_clear() self.goal = self.position.copy() self.motion_active = False self.last_low_level_time = self.node.get_clock().now() diff --git a/hero_sim/crazyflie_server.py b/hero_sim/crazyflie_server.py index 7d6620a..2668521 100644 --- a/hero_sim/crazyflie_server.py +++ b/hero_sim/crazyflie_server.py @@ -10,7 +10,7 @@ from rclpy.node import Node from tf2_ros import TransformBroadcaster -from hero_sim import config +from hero_sim import config, obstacles from hero_sim.crazyflie import SimCrazyflie # (service type, /all/, SimCrazyflie callback) @@ -34,12 +34,19 @@ def __init__(self): config_path = config.resolve_config(self.get_parameter('config_path').value) scenario = config.load(config_path) dt = 1.0 / scenario.sim['cf_rate'] + obstacle_map = obstacles.load(scenario.sim, [cf.radius for cf in scenario.crazyflies]) self.drones = { cf.name: SimCrazyflie(self, cf.name, (cf.x, cf.y, cf.z), dt=dt, - battery_drain=scenario.sim['cf_battery_drain']) + battery_drain=scenario.sim['cf_battery_drain'], + radius=cf.radius, obstacles=obstacle_map) for cf in scenario.crazyflies } + if obstacle_map is not None: + for cf in scenario.crazyflies: + if obstacle_map.clearance(cf.x, cf.y) < cf.radius: + self.get_logger().warn(f'{cf.name} starts overlapping a map obstacle; ' + 'it can only move away from it') for srv_type, name, method in _BROADCASTS: self.create_service(srv_type, f'/all/{name}', partial(self._broadcast, name, method)) @@ -48,9 +55,10 @@ def __init__(self): self.create_timer(dt, self._update_all) self.create_timer(STATUS_PERIOD, self._publish_status_all) + collisions = 'off' if obstacle_map is None else 'on' self.get_logger().info( - f'{len(self.drones)} crazyflies at {scenario.sim["cf_rate"]:g} Hz from {config_path}: ' - f'{list(self.drones)}') + f'{len(self.drones)} crazyflies at {scenario.sim["cf_rate"]:g} Hz from {config_path}, ' + f'map collisions {collisions}: {list(self.drones)}') def _update_all(self): transforms = [drone.update() for drone in self.drones.values()] diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index 791bb9b..b025b56 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -3,6 +3,9 @@ cmd_vel is a body-frame Twist held until the next message. omni robots honor linear.x, linear.y and angular.z; diff robots ignore linear.y. Poses are published in 'world' at z=0, along with a world -> transform. + +With a map and sim.collision on, a robot stops where its footprint would hit +an obstacle (hero_sim/obstacles.py) and resumes when cmd_vel points away. """ from geometry_msgs.msg import PoseStamped, TransformStamped, Twist import rclpy @@ -10,18 +13,19 @@ from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy from tf2_ros import TransformBroadcaster -from hero_sim import config +from hero_sim import config, obstacles from hero_sim.kinematics import integrate_ground, limit_ground_cmd, yaw_to_quat FRAME = 'world' class _Robot: - __slots__ = ('name', 'drive', 'x', 'y', 'yaw', 'vx', 'vy', 'wz', 'pose_pub') + __slots__ = ('name', 'drive', 'radius', 'x', 'y', 'yaw', 'vx', 'vy', 'wz', 'pose_pub') def __init__(self, spec, pose_pub): self.name = spec.name self.drive = spec.drive + self.radius = spec.radius self.x, self.y, self.yaw = spec.x, spec.y, spec.yaw self.vx = self.vy = self.wz = 0.0 self.pose_pub = pose_pub @@ -48,13 +52,21 @@ def __init__(self): lambda msg, r=robot: self._on_cmd_vel(r, msg), qos) self._robots.append(robot) + self._obstacles = obstacles.load(sim, [r.radius for r in self._robots]) + if self._obstacles is not None: + for r in self._robots: + if self._obstacles.clearance(r.x, r.y) < r.radius: + self.get_logger().warn(f'{r.name} starts overlapping a map obstacle; ' + 'it can only move away from it') + self._tf = TransformBroadcaster(self) self.create_timer(self._dt, self._tick) summary = ', '.join(f'{r.name}({r.drive})' for r in self._robots) or 'none' + collisions = 'off' if self._obstacles is None else 'on' self.get_logger().info( f'ground robots at {sim["ground_rate"]:g} Hz, max {self._max_speed:g} m/s, ' - f'{self._max_yaw_rate:g} rad/s: {summary}') + f'{self._max_yaw_rate:g} rad/s, map collisions {collisions}: {summary}') def _on_cmd_vel(self, robot, msg): robot.vx, robot.vy, robot.wz = limit_ground_cmd( @@ -65,7 +77,10 @@ def _tick(self): stamp = self.get_clock().now().to_msg() transforms = [] for r in self._robots: - r.x, r.y, r.yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) + x, y, r.yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) + if self._obstacles is not None: + x, y = self._obstacles.move(r.x, r.y, x, y, r.radius) + r.x, r.y = x, y qx, qy, qz, qw = yaw_to_quat(r.yaw) pose = PoseStamped() diff --git a/hero_sim/obstacles.py b/hero_sim/obstacles.py new file mode 100644 index 0000000..26b23d7 --- /dev/null +++ b/hero_sim/obstacles.py @@ -0,0 +1,152 @@ +"""2D map collisions: keep robot footprints out of the octomap's obstacles. No ROS imports. + +The scenario's octomap (.bt) is flattened once at startup: every occupied +voxel overlapping the height band sim.collision_z is an obstacle, seen from +above. The default band leaves out the floor slab, ceilings and the tree +canopies the drones perch in, and keeps trunks, posts and walls. + +Every robot is a disc of its footprint radius (config.FOOTPRINT_RADII). A move +is checked along its straight path, so fast robots can't tunnel through thin +obstacles, and stops at the last free point before the disc would touch one. +Height is ignored: a drone is blocked by a trunk at any altitude. + +Lookups are O(1): ObstacleMap precomputes each cell's distance to the nearest +obstacle, up to the largest radius it will be asked about. +""" +import math + +import numpy as np + +from hero_sim import config + +_TREE_DEPTH = 16 +_KEY_ORIGIN = 1 << (_TREE_DEPTH - 1) # octomap key of coordinate 0 + + +def read_bt(path): + """Occupied leaves of an octomap .bt file. + + Returns (res, boxes): the voxel size in m, and an int array of rows + (kx, ky, kz, size) giving each occupied leaf's min corner in octomap keys + and its edge length in voxels (pruned leaves span several). + """ + with open(path, 'rb') as f: + if not f.readline().startswith(b'# Octomap OcTree binary file'): + raise ValueError(f'{path}: not an octomap .bt file') + header = {} + for line in f: + line = line.decode().strip() + if line == 'data': + break + if line and not line.startswith('#'): + key, value = line.split(None, 1) + header[key] = value + data = f.read() + if header.get('id') != 'OcTree': + raise ValueError(f'{path}: expected an OcTree, got {header.get("id")!r}') + + boxes = [] + pos = 0 + + def read_node(kx, ky, kz, size): + # 2 bits per child, children 0-7 in order: 01 free leaf, 10 occupied + # leaf, 11 inner node (read next, depth first). Child bit 0 is +x, + # bit 1 +y, bit 2 +z. + nonlocal pos + bits = data[pos] | data[pos + 1] << 8 + pos += 2 + half = size // 2 + inner = [] + for i in range(8): + kind = bits >> 2 * i & 3 + if kind >= 2: + child = (kx + half * (i & 1), ky + half * (i >> 1 & 1), kz + half * (i >> 2), half) + (boxes if kind == 2 else inner).append(child) + for child in inner: + read_node(*child) + + read_node(0, 0, 0, 1 << _TREE_DEPTH) + if pos != len(data): + raise ValueError(f'{path}: {len(data) - pos} trailing bytes after the tree') + return float(header['res']), np.array(boxes, dtype=np.int64).reshape(-1, 4) + + +class ObstacleMap: + """Obstacle cells on a 2D grid, with each cell's clearance to the nearest one.""" + + def __init__(self, occupied, x0, y0, res, max_radius): + """occupied: bool array [row = y, col = x] with cell (0, 0) at (x0, y0).""" + self.res = res + self.max_radius = max_radius + # Pad by the farthest reach so robots just outside the obstacles' extent are covered. + n = math.ceil(max_radius / res + 0.5) + self.x0, self.y0 = x0 - n * res, y0 - n * res + rows, cols = np.nonzero(occupied) + self._clearance = np.full((occupied.shape[0] + 2 * n, occupied.shape[1] + 2 * n), np.inf) + # Distance from a cell's centre to the nearest point of an obstacle cell + # (di, dj) cells away. Nearest last, so it wins where reaches overlap. + reach = sorted( + ((math.hypot(max(abs(di) - 0.5, 0.0), max(abs(dj) - 0.5, 0.0)) * res, di, dj) + for di in range(-n, n + 1) for dj in range(-n, n + 1)), reverse=True) + for d, di, dj in reach: + if d < max_radius: + self._clearance[rows + n + di, cols + n + dj] = d + + @classmethod + def from_bt(cls, path, z_min, z_max, max_radius): + """Flatten the occupied voxels of a .bt that overlap z_min..z_max (m).""" + res, boxes = read_bt(path) + z_lo = (boxes[:, 2] - _KEY_ORIGIN) * res + z_hi = z_lo + boxes[:, 3] * res + # Extruded walls repeat the same footprint on every layer: keep one. + boxes = np.unique(boxes[(z_hi > z_min) & (z_lo < z_max)][:, [0, 1, 3]], axis=0) + if not len(boxes): + return cls(np.zeros((0, 0), bool), 0.0, 0.0, res, max_radius) + kx0, ky0 = boxes[:, :2].min(axis=0) + kx1, ky1 = (boxes[:, :2] + boxes[:, 2:]).max(axis=0) + occupied = np.zeros((ky1 - ky0, kx1 - kx0), bool) + for kx, ky, size in boxes - (kx0, ky0, 0): + occupied[ky:ky + size, kx:kx + size] = True + return cls(occupied, (kx0 - _KEY_ORIGIN) * res, (ky0 - _KEY_ORIGIN) * res, res, max_radius) + + def clearance(self, x, y): + """Distance (m) from (x, y) to the nearest obstacle; inf if beyond max_radius.""" + row = math.floor((y - self.y0) / self.res) + col = math.floor((x - self.x0) / self.res) + if 0 <= row < self._clearance.shape[0] and 0 <= col < self._clearance.shape[1]: + return float(self._clearance[row, col]) + return math.inf + + def move(self, x0, y0, x1, y1, radius): + """Where a disc of `radius` moving straight from (x0, y0) to (x1, y1) ends up. + + Samples the path every half cell and stops at the last sample before + the disc would touch an obstacle. A disc that already overlaps one + (spawned or teleported there) may move, just not deeper into it. + Radius 0 never collides. + """ + if radius > self.max_radius: + raise ValueError(f'radius {radius} m is over max_radius {self.max_radius} m') + steps = int(math.hypot(x1 - x0, y1 - y0) / (0.5 * self.res)) + 1 + x, y = x0, y0 + clear = self.clearance(x, y) + for i in range(1, steps + 1): + nx, ny = x0 + (x1 - x0) * i / steps, y0 + (y1 - y0) * i / steps + next_clear = self.clearance(nx, ny) + if next_clear < radius and next_clear < clear: + return x, y + x, y, clear = nx, ny, next_clear + return x1, y1 + + +def load(sim, radii): + """The scenario's ObstacleMap for robots of these radii, or None when there's nothing to check. + + None when sim.collision is off, sim.map is null, or there are no robots. + """ + if not sim['collision'] or not radii: + return None + bt_file = config.resolve_map(sim['map']) + if bt_file is None: + return None + return ObstacleMap.from_bt(bt_file, *sim['collision_z'], max_radius=max(radii)) diff --git a/test/conftest.py b/test/conftest.py index 9ad6fdc..4e8ed9b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -36,3 +36,12 @@ def write(text): def duration(sec, nanosec=0): from builtin_interfaces.msg import Duration return Duration(sec=sec, nanosec=nanosec) + + +def wall_map(max_radius=0.5): + """ObstacleMap with a 1 m long wall along y, face at x = 1.0, 0.1 m thick, on a 0.1 m grid.""" + import numpy as np + from hero_sim.obstacles import ObstacleMap + occupied = np.zeros((10, 2), bool) + occupied[:, 0] = True + return ObstacleMap(occupied, 1.0, -0.5, 0.1, max_radius) diff --git a/test/test_config.py b/test/test_config.py index 9604a1f..67ea85f 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -45,6 +45,23 @@ def test_model_meshes_exist(): assert (CONFIG_DIR.parent / 'models' / rel).is_file(), rel +def test_footprint_radius_defaults_by_model_and_overrides(): + s = parse(''' +ground_robots: + - {name: s, model: scout} + - {name: h, model: herover} + - {name: r} + - {name: w, model: scout, radius: 0.5} +crazyflies: [{pose: {}}, {pose: {}, radius: 0.2}] +''') + assert [r.radius for r in s.ground_robots] == [0.42, 0.17, config.DEFAULT_GROUND_RADIUS, 0.5] + assert [cf.radius for cf in s.crazyflies] == [config.FOOTPRINT_RADII['crazyflie'], 0.2] + + +def test_collision_z_becomes_a_float_pair(): + assert parse('sim: {collision_z: [0, 3]}').sim['collision_z'] == (0.0, 3.0) + + def test_crazyflie_gripper_is_optional(): s = parse('crazyflies: [{pose: {}, gripper: true}, {pose: {}}]') assert [cf.gripper for cf in s.crazyflies] == [True, False] @@ -71,6 +88,11 @@ def test_drive_is_case_insensitive(): ('sim: {ground_rate: 0}', 'ground_rate must be > 0'), ('sim: {cf_battery_drain: -1}', 'cf_battery_drain must be >= 0'), ('sim: {attach: maybe}', 'attach must be auto, true or false'), + ('sim: {collision: maybe}', 'collision must be true or false'), + ('sim: {collision_z: 1.0}', r'collision_z must be \[z_min, z_max\]'), + ('sim: {collision_z: [2.0, 1.0]}', r'collision_z must be \[z_min, z_max\]'), + ('ground_robots: [{name: a, radius: -1}]', r'\(a\): radius must be >= 0'), + ('crazyflies: [{radius: -0.1}]', r'\(cf_1\): radius must be >= 0'), ]) def test_rejects_bad_config(text, match): with pytest.raises(ValueError, match=match): diff --git a/test/test_crazyflie.py b/test/test_crazyflie.py index d73d9d5..00e29ef 100644 --- a/test/test_crazyflie.py +++ b/test/test_crazyflie.py @@ -5,7 +5,7 @@ pytest.importorskip('rclpy') pytest.importorskip('crazyflie_interfaces') -from conftest import duration # noqa: E402 +from conftest import duration, wall_map # noqa: E402 from crazyflie_interfaces.msg import FullState, Hover, Position, Status, VelocityWorld # noqa: E402 from crazyflie_interfaces.srv import Arm, GoTo, Land, NotifySetpointsStop, Takeoff # noqa: E402 from geometry_msgs.msg import Point, Quaternion # noqa: E402 @@ -194,3 +194,33 @@ def test_update_returns_world_transform(cf): assert (t.header.frame_id, t.child_frame_id) == ('world', 'cf_test') assert (t.transform.translation.x, t.transform.translation.y, t.transform.translation.z) == (1.0, 2.0, 3.0) + + +@pytest.fixture() +def walled_cf(node): + """Drone at the origin with a wall 1 m ahead in +x (conftest.wall_map).""" + return SimCrazyflie(node, 'cf_test', (0.0, 0.0, 0.0), dt=0.1, radius=0.1, + obstacles=wall_map()) + + +def test_obstacle_stops_go_to_but_not_height(walled_cf): + takeoff(walled_cf, height=1.0, sec=1) + run(walled_cf, 12) + go_to(walled_cf, 2, 0, 1.5, sec=2) + run(walled_cf, 25) + assert 0.7 < walled_cf.position[0] < 1.0 - walled_cf.radius + assert walled_cf.position[2] == pytest.approx(1.5) + + +def test_obstacle_stops_setpoints(walled_cf): + walled_cf._cmd_position_cb(Position(x=1.5, y=0.0, z=1.0, yaw=0.0)) + assert walled_cf.position[0] < 0.9 and walled_cf.position[2] == 1.0 + walled_cf._cmd_position_cb(Position(x=0.0, y=2.0, z=1.0, yaw=0.0)) # clear of the wall + np.testing.assert_allclose(walled_cf.position, [0, 2, 1]) + + +def test_set_position_teleports_past_obstacles(walled_cf): + req = GoTo.Request(goal=Point(x=1.5, y=0.0, z=1.0)) + walled_cf._set_position_cb(req, GoTo.Response()) + walled_cf.update() + assert walled_cf.position[0] == 1.5 diff --git a/test/test_nodes.py b/test/test_nodes.py index 22c90e2..c1148e9 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -116,6 +116,24 @@ def test_ground_robots_publish_pose(make): assert got[-1].pose.position.x == pytest.approx(1.0) +def test_ground_robots_stop_at_map_obstacles(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + # Heading +x into the icuas26_1 trunk whose west face is at x = -3.3. + scenario_file('ground_robots: [{name: r, pose: {x: -4.5, y: 0.15}, radius: 0.3}]\n' + 'sim: {ground_rate: 10.0}\n') + sim = make(GroundRobotSim) + robot = sim._robots[0] + sim._on_cmd_vel(robot, twist(vx=1.0)) + for _ in range(30): # 3 s at 1 m/s would carry it through the trunk + sim._tick() + assert robot.x == pytest.approx(-3.3 - robot.radius, abs=0.15) + assert sim._obstacles.clearance(robot.x, robot.y) >= robot.radius + + sim._on_cmd_vel(robot, twist(vx=-1.0)) # backing away is never blocked + sim._tick() + assert robot.x == pytest.approx(-3.3 - robot.radius - 0.1, abs=0.15) + + def test_robot_models_latched_and_frame_locked(make): from rclpy.qos import DurabilityPolicy, QoSProfile from visualization_msgs.msg import Marker, MarkerArray diff --git a/test/test_obstacles.py b/test/test_obstacles.py new file mode 100644 index 0000000..d845c47 --- /dev/null +++ b/test/test_obstacles.py @@ -0,0 +1,97 @@ +import math + +import numpy as np +import pytest + +from conftest import CONFIG_DIR, wall_map +from hero_sim import config +from hero_sim.obstacles import ObstacleMap, load, read_bt + +MAPS = sorted((CONFIG_DIR.parent / 'maps').glob('*.bt')) +ICUAS26_1 = CONFIG_DIR.parent / 'maps' / 'icuas26_1.bt' +TRUNK = (-3.0, 0.15) # a tree trunk in icuas26_1 + + +@pytest.mark.parametrize('path', MAPS, ids=lambda p: p.name) +def test_read_bt_shipped_maps(path): + res, boxes = read_bt(path) # raises on trailing bytes, so the whole tree was read + assert res > 0 and len(boxes) > 0 + assert set(np.unique(boxes[:, 3])) <= {1, 2} + + +def test_read_bt_rejects_other_files(tmp_path): + path = tmp_path / 'm.bt' + path.write_bytes(b'# not an octomap\n') + with pytest.raises(ValueError, match='not an octomap'): + read_bt(path) + + +def test_band_keeps_trunks_and_drops_floor(): + trunks = ObstacleMap.from_bt(ICUAS26_1, 0.2, 2.0, max_radius=0.5) + assert trunks.clearance(*TRUNK) == 0.0 + assert trunks.clearance(0.0, 0.0) == math.inf + floor = ObstacleMap.from_bt(ICUAS26_1, 0.0, 0.1, max_radius=0.5) + assert floor.clearance(0.0, 0.0) == 0.0 + + +def test_empty_band_has_no_obstacles(): + empty = ObstacleMap.from_bt(ICUAS26_1, 10.0, 11.0, max_radius=0.5) + assert empty.clearance(*TRUNK) == math.inf + + +def test_clearance_measured_to_the_obstacle_edge(): + m = wall_map() + # From the centre of the cell holding the point to the wall face at x = 1.0. + assert m.clearance(1.05, 0.0) == 0.0 + assert m.clearance(0.95, 0.0) == pytest.approx(0.05) + assert m.clearance(0.82, 0.0) == pytest.approx(0.15) + assert m.clearance(0.4, 0.0) == math.inf # beyond max_radius + + +def test_move_stops_before_the_wall_by_radius(): + m = wall_map() + x, y = m.move(0.0, 0.0, 2.0, 0.0, radius=0.3) + assert y == 0.0 + assert 0.6 <= x <= 0.75 # wall face at 1.0, minus the radius, within a cell + + +def test_wider_robot_stops_further_out(): + m = wall_map() + narrow, _ = m.move(0.0, 0.0, 2.0, 0.0, radius=0.1) + wide, _ = m.move(0.0, 0.0, 2.0, 0.0, radius=0.4) + assert wide < narrow - 0.2 + + +def test_fast_move_cannot_tunnel_through(): + m = wall_map() + # One step that would land well clear on the far side. + x, _ = m.move(0.5, 0.0, 1.8, 0.0, radius=0.2) + assert x < 1.0 + + +def test_free_moves_untouched(): + m = wall_map() + assert m.move(0.0, 0.0, 0.3, 0.2, radius=0.3) == (0.3, 0.2) + assert m.move(0.0, -2.0, 3.0, -2.0, radius=0.3) == (3.0, -2.0) # passes below the wall + + +def test_overlapping_robot_can_back_out_but_not_go_deeper(): + m = wall_map() + assert m.move(0.95, 0.0, 0.5, 0.0, radius=0.3) == (0.5, 0.0) + x, _ = m.move(0.95, 0.0, 1.05, 0.0, radius=0.3) + assert x < 1.0 + + +def test_radius_zero_never_collides_and_too_big_raises(): + m = wall_map(max_radius=0.3) + assert m.move(0.0, 0.0, 2.0, 0.0, radius=0.0) == (2.0, 0.0) + with pytest.raises(ValueError, match='max_radius'): + m.move(0.0, 0.0, 1.0, 0.0, radius=0.4) + + +def test_load_off_without_map_robots_or_collision(): + sim = dict(config.SIM_DEFAULTS, map=str(ICUAS26_1)) + assert load(sim, [0.1]) is not None + assert load(dict(sim, collision=False), [0.1]) is None + assert load(dict(sim, map=None), [0.1]) is None + assert load(sim, []) is None From cb69c7cce2d7a9a2c228f64e0e9b2274e277f8d4 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:57:36 +0200 Subject: [PATCH 02/13] feat(ground): publish ground robot poses at their marker height On hardware the mocap markers sit on top of the scout and herover, so their poses report that height, not 0. The control stack adds a body-frame dock's z to its host's z, so docks on a UGV were lower in the sim than on hardware. Ground robots now take pose.z, published on //pose and in the world -> TF. It defaults by model to the top of the deck in the mesh: scout 0.227, herover 0.095, robots without a model 0. The RViz mesh is shifted down by z so it stays on the floor. Not run: the pytest suite (docker run interrupted). Scenario parsing was checked on the host. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ config/example.yaml | 5 ++++- hero_sim/config.py | 19 +++++++++++++++---- hero_sim/ground_robots.py | 12 +++++++----- hero_sim/robot_models.py | 8 +++++--- test/test_config.py | 14 +++++++++++++- test/test_nodes.py | 7 +++++-- 7 files changed, 51 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cb51de4..e1dbebe 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ ros2 service call /all/land crazyflie_interfaces/srv/Land "{height: 0.0, duratio Ground robots hold the last `cmd_vel` until a new one arrives. Poses are published on `//pose` and as TF from `world`. +As on hardware, where the mocap markers sit on top of the robot, a ground robot's pose is at its marker height rather than on the floor: the top of the deck, scout 0.227 m and herover 0.095 m (0 without a model). Docks the control stack places on a robot are relative to this height. To change it, set `pose: {z: ...}`; the RViz model stays on the floor. + ## Map collisions Robots can't drive or fly into the map. The octomap's voxels between 0.2 m and 2.0 m high (`sim.collision_z`) are flattened into a 2D obstacle grid, which keeps the trunks and posts and leaves out the floor, ceilings and tree canopies. Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk can still climb or land, but it can't fly over the trunk. diff --git a/config/example.yaml b/config/example.yaml index 84490d1..70f3039 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -3,7 +3,10 @@ # Kinematic ground robots. Each one subscribes to //cmd_vel (body-frame # Twist, held until the next message) and publishes //pose plus TF -# world -> at z = 0. +# world -> at the pose's z. +# pose: {z: 0.227} height (m) of the published pose: where the mocap markers sit on +# the real robot. Default by model, the top of its deck: scout 0.227, +# herover 0.095, no model 0. The mesh stays on the floor either way # drive: omni holonomic, honors linear.x, linear.y and angular.z (default) # drive: diff non-holonomic, linear.y is ignored # model: scout | herover RViz mesh drawn on the robot's frame; leave out for TF axes only diff --git a/hero_sim/config.py b/hero_sim/config.py index e2d6486..fb5d9a7 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -46,6 +46,15 @@ } DEFAULT_GROUND_RADIUS = 0.3 +# Height (m) a ground robot's pose reports, by model: on hardware the mocap +# markers sit on top, so the pose is up there and not on the floor. Measured +# as the top of the deck in the model's mesh. Ground robots without a model +# get 0. Any robot can set its own with `pose: {z: ...}`. +MARKER_HEIGHTS = { + 'herover': 0.095, + 'scout': 0.227, +} + DRIVES = ('omni', 'diff') CONFIG_ENV = 'HERO_SIM_CONFIG' DEFAULT_CONFIG = 'example.yaml' @@ -60,6 +69,7 @@ class GroundRobot: drive: str model: str = None # key of MODEL_MESHES, or None for no mesh radius: float = DEFAULT_GROUND_RADIUS # m, footprint for map collisions + z: float = 0.0 # m, height of the published pose (MARKER_HEIGHTS) @dataclass(frozen=True) @@ -162,10 +172,10 @@ def _check_keys(d, allowed, where): raise ValueError(f'{where}: unknown key(s) {unknown}; allowed: {sorted(allowed)}') -def _pose(entry, where, axes): +def _pose(entry, where, axes, **defaults): pose = entry.get('pose') or {} _check_keys(pose, axes, f'{where}.pose') - return [float(pose.get(a, 0.0)) for a in axes] + return [float(pose.get(a, defaults.get(a, 0.0))) for a in axes] def _radius(entry, where, default): @@ -188,9 +198,10 @@ def _ground_robot(i, entry): if model is not None and model not in MODEL_MESHES: raise ValueError(f'{where} ({name}): model must be one of {sorted(MODEL_MESHES)} ' f'or left out, got {model!r}') - x, y, yaw = _pose(entry, f'{where} ({name})', ('x', 'y', 'yaw')) + x, y, z, yaw = _pose(entry, f'{where} ({name})', ('x', 'y', 'z', 'yaw'), + z=MARKER_HEIGHTS.get(model, 0.0)) radius = _radius(entry, f'{where} ({name})', FOOTPRINT_RADII.get(model, DEFAULT_GROUND_RADIUS)) - return GroundRobot(name, x, y, yaw, drive, model, radius) + return GroundRobot(name, x, y, yaw, drive, model, radius, z) def _crazyflie(i, entry): diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index b025b56..c007979 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -2,7 +2,8 @@ cmd_vel is a body-frame Twist held until the next message. omni robots honor linear.x, linear.y and angular.z; diff robots ignore linear.y. Poses are -published in 'world' at z=0, along with a world -> transform. +published in 'world' at the robot's pose z (its marker height, see +config.MARKER_HEIGHTS), along with a world -> transform. With a map and sim.collision on, a robot stops where its footprint would hit an obstacle (hero_sim/obstacles.py) and resumes when cmd_vel points away. @@ -20,13 +21,13 @@ class _Robot: - __slots__ = ('name', 'drive', 'radius', 'x', 'y', 'yaw', 'vx', 'vy', 'wz', 'pose_pub') + __slots__ = ('name', 'drive', 'radius', 'x', 'y', 'z', 'yaw', 'vx', 'vy', 'wz', 'pose_pub') def __init__(self, spec, pose_pub): self.name = spec.name self.drive = spec.drive self.radius = spec.radius - self.x, self.y, self.yaw = spec.x, spec.y, spec.yaw + self.x, self.y, self.z, self.yaw = spec.x, spec.y, spec.z, spec.yaw self.vx = self.vy = self.wz = 0.0 self.pose_pub = pose_pub @@ -86,7 +87,7 @@ def _tick(self): pose = PoseStamped() pose.header.stamp = stamp pose.header.frame_id = FRAME - pose.pose.position.x, pose.pose.position.y = r.x, r.y + pose.pose.position.x, pose.pose.position.y, pose.pose.position.z = r.x, r.y, r.z o = pose.pose.orientation o.x, o.y, o.z, o.w = qx, qy, qz, qw r.pose_pub.publish(pose) @@ -95,7 +96,8 @@ def _tick(self): t.header.stamp = stamp t.header.frame_id = FRAME t.child_frame_id = r.name - t.transform.translation.x, t.transform.translation.y = r.x, r.y + t.transform.translation.x, t.transform.translation.y, t.transform.translation.z = ( + r.x, r.y, r.z) t.transform.rotation = o transforms.append(t) if transforms: diff --git a/hero_sim/robot_models.py b/hero_sim/robot_models.py index 00d8007..1912192 100644 --- a/hero_sim/robot_models.py +++ b/hero_sim/robot_models.py @@ -15,8 +15,8 @@ TOPIC = '/hero_sim/robot_models' -def mesh_marker(frame, model, marker_id): - """Mesh marker for `model` drawn at the origin of TF frame `frame`.""" +def mesh_marker(frame, model, marker_id, z=0.0): + """Mesh marker for `model` drawn at height `z` in TF frame `frame`.""" m = Marker() m.header.frame_id = frame # stamp 0: RViz uses the latest transform m.ns = model @@ -26,6 +26,7 @@ def mesh_marker(frame, model, marker_id): m.mesh_resource = f'package://hero_sim/models/{config.MODEL_MESHES[model]}' m.mesh_use_embedded_materials = True # color left at 0,0,0,0 so RViz doesn't tint m.frame_locked = True + m.pose.position.z = z m.pose.orientation.w = 1.0 m.scale.x = m.scale.y = m.scale.z = 1.0 return m @@ -34,7 +35,8 @@ def mesh_marker(frame, model, marker_id): def scenario_markers(scenario): markers = [mesh_marker(cf.name, 'crazyflie', i) for i, cf in enumerate(scenario.crazyflies)] - markers += [mesh_marker(r.name, r.model, i) + # A ground robot's frame is at its marker height: drop the mesh back to the floor. + markers += [mesh_marker(r.name, r.model, i, -r.z) for i, r in enumerate(scenario.ground_robots) if r.model] return markers diff --git a/test/test_config.py b/test/test_config.py index 67ea85f..83562c8 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -58,6 +58,18 @@ def test_footprint_radius_defaults_by_model_and_overrides(): assert [cf.radius for cf in s.crazyflies] == [config.FOOTPRINT_RADII['crazyflie'], 0.2] +def test_ground_pose_z_defaults_to_marker_height_and_overrides(): + s = parse(''' +ground_robots: + - {name: s, model: scout} + - {name: h, model: herover, pose: {x: 1.0}} + - {name: r} + - {name: w, model: scout, pose: {z: 0.3}} +''') + assert [r.z for r in s.ground_robots] == [config.MARKER_HEIGHTS['scout'], + config.MARKER_HEIGHTS['herover'], 0.0, 0.3] + + def test_collision_z_becomes_a_float_pair(): assert parse('sim: {collision_z: [0, 3]}').sim['collision_z'] == (0.0, 3.0) @@ -74,7 +86,7 @@ def test_drive_is_case_insensitive(): @pytest.mark.parametrize('text, match', [ ('robots: []', "unknown key.*'robots'"), - ('ground_robots: [{name: a, pose: {x: 1, z: 2}}]', r"ground_robots\[0\] \(a\)\.pose: unknown key.*'z'"), + ('ground_robots: [{name: a, pose: {x: 1, roll: 2}}]', r"ground_robots\[0\] \(a\)\.pose: unknown key.*'roll'"), ('ground_robots: [{name: a, speed: 1}]', "unknown key.*'speed'"), ('ground_robots: [{pose: {x: 1}}]', 'name is required'), ('ground_robots: [{name: a, drive: tank}]', 'drive must be one of'), diff --git a/test/test_nodes.py b/test/test_nodes.py index c1148e9..208c9c5 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -11,6 +11,7 @@ from std_srvs.srv import Trigger # noqa: E402 from geometry_msgs.msg import PoseStamped, Twist # noqa: E402 +from hero_sim import config # noqa: E402 from hero_sim.kinematics import yaw_to_quat # noqa: E402 SCENARIO = """ @@ -114,6 +115,7 @@ def test_ground_robots_publish_pose(make): assert got, 'no pose received on /scout_1/pose' assert got[-1].header.frame_id == 'world' assert got[-1].pose.position.x == pytest.approx(1.0) + assert got[-1].pose.position.z == pytest.approx(config.MARKER_HEIGHTS['scout']) def test_ground_robots_stop_at_map_obstacles(make, scenario_file): @@ -162,6 +164,9 @@ def test_robot_models_latched_and_frame_locked(make): assert all(m.type == Marker.MESH_RESOURCE and m.frame_locked and m.mesh_use_embedded_materials for m in markers) assert markers[2].mesh_resource == 'package://hero_sim/models/scout/scout.glb' + # CF meshes sit on their frame; the scout's drops from its marker height to the floor + assert [m.pose.position.z for m in markers] == pytest.approx( + [0.0, 0.0, -config.MARKER_HEIGHTS['scout']]) assert len({(m.ns, m.id) for m in markers}) == len(markers) @@ -205,7 +210,6 @@ def test_gripper_claws_shut_around_the_bar(): def test_grippers_lifecycle(rclpy_context, node): from visualization_msgs.msg import Marker - from hero_sim import config from hero_sim.gripper import CLOSED, OPEN, Grippers cfs = (config.Crazyflie('cf_1', 0, 0, 0, gripper=True), config.Crazyflie('cf_2', 0, 0, 0)) @@ -236,7 +240,6 @@ def test_grippers_lifecycle(rclpy_context, node): def test_grippers_not_drawn_without_models(rclpy_context, node): - from hero_sim import config from hero_sim.gripper import TOPIC, Grippers grippers = Grippers(node, (config.Crazyflie('cf_1', 0, 0, 0, gripper=True),), draw=False) From 05793a1a43d41324ee27d556000095ebb52668e6 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:37:02 +0100 Subject: [PATCH 03/13] feat(tether): winch, tether and object task A winch drags an object across the floor; the tether runs through a pulley on a ground robot and a ring on a Crazyflie. A scenario sets it up with an optional tether: section (config/tether_demo.yaml). Without one, nothing extra runs. - hero_sim/tether.py: ROS-free quasi-static model. Slack leaves the object, taut slides it toward the point under the UAV, it is never lifted, and it jams against pillars; the winch slips in both cases. - hero_sim/tether_sim.py: node publishing /winch/* and /tether/* plus RViz markers, started by sim.launch.py only for a tether: section. - config: validated tether: section, documented in README and example.yaml; RViz gets a Tether display. - Tests for the model, config and node; CI smoke tests check tether_sim starts with a tether: section and not without one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 ++ README.md | 51 ++++++++- config/example.yaml | 15 +++ config/sim.rviz | 12 +++ config/tether_demo.yaml | 34 ++++++ hero_sim/config.py | 76 ++++++++++++- hero_sim/tether.py | 102 ++++++++++++++++++ hero_sim/tether_sim.py | 228 +++++++++++++++++++++++++++++++++++++++ launch/sim.launch.py | 3 + setup.py | 1 + test/test_config.py | 60 +++++++++++ test/test_nodes.py | 60 +++++++++++ test/test_tether.py | 75 +++++++++++++ 13 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 config/tether_demo.yaml create mode 100644 hero_sim/tether.py create mode 100644 hero_sim/tether_sim.py create mode 100644 test/test_tether.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a771797..d7d60a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,4 +56,15 @@ jobs: cat launch.log grep -q 'crazyflies at' launch.log || { echo "crazyflie_server did not start"; exit 1; } grep -q 'ground robots at' launch.log || { echo "ground_robot_sim did not start"; exit 1; } + if grep -q 'tether_sim' launch.log; then echo "tether_sim started without a tether: section"; exit 1; fi if grep -E 'Traceback|process has died' launch.log; then exit 1; fi + + - name: Tether smoke test + working-directory: ws + run: | + source install/setup.bash + printf 'ground_robots: [{name: r1}]\ncrazyflies: [{pose: {}}]\ntether: {winch: {x: -1, y: 0}, object: {x: 1, y: 0}, goal: {x: 2, y: 0}}\nsim: {rviz: false}\n' > /tmp/tether.yaml + timeout 10 ros2 launch hero_sim sim.launch.py config:=/tmp/tether.yaml > tether.log 2>&1 || true + cat tether.log + grep -q 'tether ready' tether.log || { echo "tether_sim did not start"; exit 1; } + if grep -E 'Traceback|process has died' tether.log; then exit 1; fi diff --git a/README.md b/README.md index e1dbebe..f87babe 100644 --- a/README.md +++ b/README.md @@ -130,4 +130,53 @@ sim: collision: false # turn it off ``` -Every motion command is checked, including `go_to`, `cmd_hover`, `cmd_position` and the dock ride-along. `//set_position` is a teleport and isn't checked. A robot that starts inside an obstacle gets a warning at launch and can move out but not further in. \ No newline at end of file +Every motion command is checked, including `go_to`, `cmd_hover`, `cmd_position` and the dock ride-along. `//set_position` is a teleport and isn't checked. A robot that starts inside an obstacle gets a warning at launch and can move out but not further in. + +## Tether task + +A winch drags an object across the floor. The tether runs from the winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), down to the object. A scenario sets it up with a `tether:` section; `config/tether_demo.yaml` is a ready one: + +```bash +bash start.sh tether_demo.yaml +``` + +```yaml +tether: + winch: {x: -3.0, y: 0.0, z: 0.3} # m; z is where the tether leaves the drum (default 0.3) + ugv: herover_1 # pulley on its deck, at its pose z (default: the first ground robot) + uav: cf_1 # ring (default cf_1) + object: {x: 0.5, y: -0.5} # start, on the floor + object_size: 0.3 # m, cube edge (default 0.3) + goal: {x: 2.5, y: 1.5, radius: 0.4} # radius defaults to 0.4 + obstacles: # optional pillars + - {x: 1.5, y: 0.5, radius: 0.35} + slack: 3.0 # m of tether beyond taut at the start (default 0.5) + max_reel_speed: 0.5 # m/s (default 0.5) +``` + +The winch pays out a length L. What the two pulleys don't use is the free tether below the UAV: l = L − |winch − UGV| − |UGV − UAV|. The model is quasi-static (no swing): + +- While the tether is slack, the object stays where it is. +- Once it's taut, the object slides along the floor straight toward the point under the UAV. +- The object is never lifted. Once it is under the UAV, the winch slips instead. +- Pulled into a pillar, the object jams where it touches it and the winch slips. A pull away from the pillar frees it. +- The robots never feel the tether: moving them only drags the object or makes the winch slip. The tether and the robots pass through pillars, and the object ignores the map's obstacles; only pillars block the object. + +Drive the UGV with `//cmd_vel` and fly the UAV with its Crazyflie services, as in [Driving the robots](#driving-the-robots). The winch and the object: + +| Topic | Type | | +|---|---|---| +| `/winch/cmd_rate` | `std_msgs/Float64` | in, m/s: + pays out, − reels in. Held until the next message | +| `/winch/length` | `std_msgs/Float64` | L | +| `/tether/free_length` | `std_msgs/Float64` | l | +| `/tether/taut` | `std_msgs/Bool` | | +| `/tether/stuck` | `std_msgs/Bool` | object jammed against a pillar | +| `/tether/object_pose` | `geometry_msgs/PoseStamped` | object center | +| `/tether/goal` | `geometry_msgs/PoseStamped` | goal center, latched | +| `/tether/goal_reached` | `std_msgs/Bool` | object center inside the goal circle | + +```bash +ros2 topic pub /winch/cmd_rate std_msgs/msg/Float64 "{data: -0.3}" # reel in +``` + +Without a `tether:` section, nothing extra runs. diff --git a/config/example.yaml b/config/example.yaml index 70f3039..3978033 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -48,3 +48,18 @@ sim: collision: true # robots stop where their footprint would hit a map obstacle collision_z: [0.2, 2.0] # m, map heights that count as obstacles, flattened to 2D: # trunks and posts, not the floor, ceiling or tree canopies + +# Optional: a winch drags an object across the floor with a tether through a +# pulley on a ground robot and a ring on a Crazyflie (README "Tether task", +# config/tether_demo.yaml). Leave it out and nothing extra runs. +# tether: +# winch: {x: -3.0, y: 0.0, z: 0.3} # m, x and y required; z: where the tether leaves the drum +# ugv: scout_1 # pulley on its deck (default: the first ground robot) +# uav: cf_1 # ring (default cf_1) +# object: {x: 0.5, y: -0.5} # required, start on the floor +# object_size: 0.3 # m, cube edge; half of it is its footprint against pillars +# goal: {x: 2.5, y: 1.5, radius: 0.4} # x and y required +# obstacles: # pillars only the object can't pass through +# - {x: 1.5, y: 0.5, radius: 0.35} +# slack: 0.5 # m of tether beyond taut at the start +# max_reel_speed: 0.5 # m/s, clamp on /winch/cmd_rate diff --git a/config/sim.rviz b/config/sim.rviz index 7675c63..c19ebc2 100644 --- a/config/sim.rviz +++ b/config/sim.rviz @@ -127,6 +127,18 @@ Visualization Manager: Reliability Policy: Reliable Value: /hero_sim/grippers Value: true + - Class: rviz_default_plugins/MarkerArray + Enabled: true + Name: Tether + Namespaces: + {} + Topic: + Depth: 5 + Durability Policy: Volatile + History Policy: Keep Last + Reliability Policy: Reliable + Value: /hero_sim/tether + Value: true - Class: rviz_default_plugins/MarkerArray Enabled: false Name: MarkerArray diff --git a/config/tether_demo.yaml b/config/tether_demo.yaml new file mode 100644 index 0000000..143d3d4 --- /dev/null +++ b/config/tether_demo.yaml @@ -0,0 +1,34 @@ +# Tether demo: winch -> UGV pulley -> UAV ring -> object on the floor. +# Get the object into the goal circle. It slides on the floor, never lifts, +# and jams if pulled into the pillar: route it around. +# +# bash start.sh tether_demo.yaml # sim + RViz +# +# Controls: /herover_1/cmd_vel, /cf_1/takeoff|go_to|land, /winch/cmd_rate (m/s, + out, - in). +# Feedback: /tether/object_pose, /tether/goal, /tether/taut, /tether/free_length, +# /tether/stuck, /tether/goal_reached, /winch/length. See README "Tether task". + +ground_robots: + - name: herover_1 + pose: {x: -1.5, y: -1.5, yaw: 0.0} + drive: omni + model: herover + +crazyflies: + - pose: {x: 0.0, y: 1.0, z: 0.03} # cf_1 + +tether: + winch: {x: -3.0, y: 0.0, z: 0.3} # m, where the tether leaves the drum + ugv: herover_1 # pulley on its deck (pose z) + uav: cf_1 # guide ring + object: {x: 0.5, y: -0.5} # start on the floor + object_size: 0.3 # m, cube + goal: {x: 2.5, y: 1.5, radius: 0.4} + obstacles: # pillars the object can't pass through + - {x: 1.5, y: 0.5, radius: 0.35} # right on the straight line to the goal + slack: 3.0 # m of tether beyond taut at the start + max_reel_speed: 0.5 # m/s + +sim: + map: null + collision: false diff --git a/hero_sim/config.py b/hero_sim/config.py index fb5d9a7..fd281af 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -1,14 +1,16 @@ """Load and validate a hero_sim scenario config. No ROS imports at module level. -A scenario file has three sections (see config/example.yaml): +A scenario file has these sections (see config/example.yaml): ground_robots kinematic ground robots, one entry per robot crazyflies Crazyflies, named cf_1..cf_N in list order sim optional overrides of SIM_DEFAULTS + tether optional winch, tether and object (hero_sim/tether.py) Unknown keys are rejected so typos fail loudly instead of being ignored. """ from dataclasses import dataclass +import math import os from pathlib import Path @@ -82,11 +84,31 @@ class Crazyflie: radius: float = FOOTPRINT_RADII['crazyflie'] # m, footprint for map collisions +@dataclass(frozen=True) +class Tether: + """Winch -> pulley on a ground robot -> ring on a Crazyflie -> object on the floor.""" + winch: tuple # x, y, z (m): where the tether leaves the drum + ugv: str # ground robot carrying the first pulley (at its pose z) + uav: str # Crazyflie carrying the guide ring + object: tuple # x, y (m): start position on the floor + goal: tuple # x, y, radius (m) + obstacles: tuple = () # (x, y, radius) pillars (m) the object can't pass through + object_size: float = 0.3 # m, cube edge + slack: float = 0.5 # m of tether beyond taut at the start + max_reel_speed: float = 0.5 # m/s, clamp on /winch/cmd_rate + + @property + def object_radius(self): + """m, the object's footprint against obstacles: half its edge.""" + return self.object_size / 2.0 + + @dataclass(frozen=True) class Scenario: ground_robots: tuple crazyflies: tuple sim: dict + tether: Tether = None def load(path): @@ -102,7 +124,7 @@ def load(path): def parse(raw): """Validate an already-loaded scenario mapping and return a Scenario.""" raw = {} if raw is None else raw - _check_keys(raw, ('ground_robots', 'crazyflies', 'sim'), 'top level') + _check_keys(raw, ('ground_robots', 'crazyflies', 'sim', 'tether'), 'top level') ground = tuple(_ground_robot(i, r) for i, r in enumerate(_list(raw, 'ground_robots'))) names = [r.name for r in ground] @@ -135,7 +157,8 @@ def parse(raw): raise ValueError(f'sim.collision_z must be [z_min, z_max], z_min < z_max, got {band!r}') sim['collision_z'] = (float(band[0]), float(band[1])) - return Scenario(ground, cfs, sim) + tether = _tether(raw['tether'], ground, cfs) if raw.get('tether') is not None else None + return Scenario(ground, cfs, sim, tether) def attach_enabled(sim): @@ -215,6 +238,53 @@ def _crazyflie(i, entry): return Crazyflie(name, x, y, z, gripper, _radius(entry, where, FOOTPRINT_RADII['crazyflie'])) +def _point(value, where, axes, **defaults): + """A mapping of `axes` as a tuple of floats. Axes without a default are required.""" + value = value or {} + _check_keys(value, axes, where) + missing = [a for a in axes if a not in value and a not in defaults] + if missing: + raise ValueError(f'{where}: missing {missing}') + return tuple(float(value.get(a, defaults.get(a))) for a in axes) + + +def _tether(entry, ground, cfs): + where = 'tether' + _check_keys(entry, ('winch', 'ugv', 'uav', 'object', 'goal', 'obstacles', 'object_size', + 'slack', 'max_reel_speed'), where) + ugv = entry.get('ugv', ground[0].name if ground else None) + if ugv not in {r.name for r in ground}: + raise ValueError(f'{where}.ugv must name a ground robot, got {ugv!r}') + uav = entry.get('uav', 'cf_1') + if uav not in {c.name for c in cfs}: + raise ValueError(f'{where}.uav must name a crazyflie (cf_1, ...), got {uav!r}') + tether = Tether( + winch=_point(entry.get('winch'), f'{where}.winch', ('x', 'y', 'z'), z=0.3), + ugv=ugv, + uav=uav, + object=_point(entry.get('object'), f'{where}.object', ('x', 'y')), + goal=_point(entry.get('goal'), f'{where}.goal', ('x', 'y', 'radius'), radius=0.4), + obstacles=tuple(_point(o, f'{where}.obstacles[{i}]', ('x', 'y', 'radius')) + for i, o in enumerate(_list(entry, 'obstacles'))), + object_size=float(entry.get('object_size', Tether.object_size)), + slack=float(entry.get('slack', Tether.slack)), + max_reel_speed=float(entry.get('max_reel_speed', Tether.max_reel_speed)), + ) + for key in ('object_size', 'max_reel_speed'): + if getattr(tether, key) <= 0.0: + raise ValueError(f'{where}.{key} must be > 0, got {getattr(tether, key)}') + if tether.slack < 0.0: + raise ValueError(f'{where}.slack must be >= 0, got {tether.slack}') + if tether.goal[2] <= 0.0: + raise ValueError(f'{where}.goal.radius must be > 0, got {tether.goal[2]}') + for i, (x, y, r) in enumerate(tether.obstacles): + if r <= 0.0: + raise ValueError(f'{where}.obstacles[{i}].radius must be > 0, got {r}') + if math.hypot(tether.object[0] - x, tether.object[1] - y) < r + tether.object_radius: + raise ValueError(f'{where}.object starts inside obstacles[{i}]') + return tether + + # ---------------------------------------------------------------- resolution def share_dir(): diff --git a/hero_sim/tether.py b/hero_sim/tether.py new file mode 100644 index 0000000..2f85493 --- /dev/null +++ b/hero_sim/tether.py @@ -0,0 +1,102 @@ +"""Tether model: a winch drags an object across the floor. No ROS imports. + +The tether runs from a fixed winch, through a pulley on a ground robot (UGV), +through a guide ring on a Crazyflie (UAV), down to an object on the floor: + + W (winch) -> G (UGV pulley) -> A (UAV ring) -> O (object) + +The winch pays out a total length L. What is left after the two moving +pulleys is the free length below the UAV: + + l = L - |W - G| - |G - A| + +Quasi-static (no swing), and the robots never feel the tether: + + slack |A - O| < l: the object stays put. + taut the object slides along the floor toward the point under the UAV + until |A - O| = l. + lifted never: the object is too heavy, so once it sits under the UAV the + winch slips (pays out) instead. + jammed pulled into a pillar, the object stops where it touches it and the + winch slips, until a pull away from the pillar frees it. The + tether itself passes over pillars, and the robots through them. + +The node in hero_sim/tether_sim.py runs this for the scenario's tether: section. +""" +import math +from typing import NamedTuple + + +class State(NamedTuple): + length: float # m, paid out by the winch (grows when it slips) + obj: tuple # x, y (m) of the object on the floor + free: float # m, tether from the UAV ring to the object + taut: bool + stuck: bool # jammed against a pillar + + +def initial_length(winch, ugv, uav, obj, attach_z, slack): + """Tether paid out with the robots where they are, leaving `slack` m to spare.""" + return (math.dist(winch, ugv) + math.dist(ugv, uav) + + math.dist(uav, (*obj, attach_z)) + slack) + + +def reached(obj, goal): + """Whether the object's center is inside the goal circle (x, y, radius).""" + return math.hypot(obj[0] - goal[0], obj[1] - goal[1]) <= goal[2] + + +def first_contact(p, q, obstacles, radius): + """Where an object of `radius` moving straight from p to q first touches an + obstacle (x, y, r), or None if the move is clear. Moving away from an + obstacle it already touches is clear.""" + dx, dy = q[0] - p[0], q[1] - p[1] + a = dx * dx + dy * dy + if a == 0.0: + return None + best = None + for cx, cy, r in obstacles: + r += radius + px, py = p[0] - cx, p[1] - cy + b = dx * px + dy * py # < 0: heading toward the center + if px * px + py * py <= r * r + 1e-9: + if b < 0.0: + return tuple(p) # touching it and pushed in: jammed + continue + c = px * px + py * py - r * r + disc = b * b - a * c + if b >= 0.0 or disc < 0.0: + continue + t = (-b - math.sqrt(disc)) / a + if t <= 1.0 and (best is None or t < best): + best = t + if best is None: + return None + return p[0] + best * dx, p[1] + best * dy + + +def step(length, winch, ugv, uav, obj, attach_z, obstacles=(), radius=0.0): + """One quasi-static update of the tether and the object. Returns a State. + + length: tether paid out by the winch (m). winch, ugv, uav: (x, y, z) the + tether runs through, in order. obj: object (x, y) on the floor, tied at + height attach_z, with footprint `radius` against obstacles (x, y, r). + """ + routed = math.dist(winch, ugv) + math.dist(ugv, uav) + dz = max(uav[2] - attach_z, 0.0) + # The object can't leave the floor: the winch slips rather than lift it. + length = max(length, routed + dz) + free = length - routed + reach = math.sqrt(max(free * free - dz * dz, 0.0)) # horizontal, from under the UAV + dx, dy = obj[0] - uav[0], obj[1] - uav[1] + h = math.hypot(dx, dy) + if h <= reach: + return State(length, tuple(obj), free, h > reach - 1e-3, False) + # Taut: slide toward the point under the UAV. + target = (uav[0] + dx * reach / h, uav[1] + dy * reach / h) + contact = first_contact(obj, target, obstacles, radius) + if contact is None: + return State(length, target, free, True, False) + # Jammed against an obstacle: the object stops there and the winch slips. + free = math.dist(uav, (*contact, attach_z)) + return State(routed + free, contact, free, True, True) diff --git a/hero_sim/tether_sim.py b/hero_sim/tether_sim.py new file mode 100644 index 0000000..cb972fe --- /dev/null +++ b/hero_sim/tether_sim.py @@ -0,0 +1,228 @@ +"""Winch, tether and object for the scenario's tether: section (model in hero_sim/tether.py). + +sim.launch.py starts this node only when the scenario has a tether: section. +It follows the UGV's and UAV's /pose and steps the model at sim.ground_rate. + +Topics: + /winch/cmd_rate std_msgs/Float64 in, m/s: + pays out, - reels in. Held until + the next msg, clamped to tether.max_reel_speed + /winch/length std_msgs/Float64 L, tether paid out + /tether/free_length std_msgs/Float64 l, from the UAV ring to the object + /tether/taut std_msgs/Bool + /tether/stuck std_msgs/Bool object jammed against an obstacle + /tether/object_pose geometry_msgs/PoseStamped object center + /tether/goal geometry_msgs/PoseStamped goal center (latched) + /tether/goal_reached std_msgs/Bool object center inside the goal circle + /hero_sim/tether visualization_msgs/MarkerArray +""" +from geometry_msgs.msg import Point, PoseStamped +import rclpy +from rclpy.node import Node +from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy +from std_msgs.msg import Bool, Float64 +from visualization_msgs.msg import Marker, MarkerArray + +from hero_sim import config, tether + +FRAME = 'world' +MARKER_TOPIC = '/hero_sim/tether' +OBSTACLE_HEIGHT = 0.6 # m, drawn only: the tether passes over obstacles + + +def _color(m, r, g, b, a=1.0): + m.color.r, m.color.g, m.color.b, m.color.a = r, g, b, a + + +class TetherSim(Node): + + def __init__(self): + super().__init__('tether_sim') + self.declare_parameter('config_path', '') + scenario = config.load(config.resolve_config(self.get_parameter('config_path').value)) + if scenario.tether is None: + raise RuntimeError('scenario has no tether: section') + t = self._t = scenario.tether + self._dt = 1.0 / scenario.sim['ground_rate'] + # Winch and pillars never change: resend them once a second, not every tick. + self._static_every = max(1, round(scenario.sim['ground_rate'])) + self._ticks = 0 + + self._attach_z = t.object_size / 2.0 + self._length = None # set from the first poses: taut + t.slack + self._state = None + self._reached = False + self._rate = 0.0 + self._ugv = self._uav = None + + self.create_subscription(PoseStamped, f'/{t.ugv}/pose', self._on_ugv, 10) + self.create_subscription(PoseStamped, f'/{t.uav}/pose', self._on_uav, 10) + self.create_subscription(Float64, '/winch/cmd_rate', self._on_rate, 10) + + self._length_pub = self.create_publisher(Float64, '/winch/length', 10) + self._free_pub = self.create_publisher(Float64, '/tether/free_length', 10) + self._taut_pub = self.create_publisher(Bool, '/tether/taut', 10) + self._stuck_pub = self.create_publisher(Bool, '/tether/stuck', 10) + self._obj_pub = self.create_publisher(PoseStamped, '/tether/object_pose', 10) + self._reached_pub = self.create_publisher(Bool, '/tether/goal_reached', 10) + self._marker_pub = self.create_publisher(MarkerArray, MARKER_TOPIC, 10) + + latched = QoSProfile(depth=1, reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL) + goal = self._pose(t.goal[0], t.goal[1], 0.0) + self.create_publisher(PoseStamped, '/tether/goal', latched).publish(goal) + + self.create_timer(self._dt, self._tick) + self.get_logger().info( + f'tether: winch {t.winch[:2]} -> {t.ugv} -> {t.uav} -> object {t.object}, ' + f'goal {t.goal[:2]} r={t.goal[2]:g}, {len(t.obstacles)} obstacle(s); ' + f'waiting for {t.ugv} and {t.uav} poses') + + def _on_ugv(self, msg): + p = msg.pose.position + self._ugv = (p.x, p.y, p.z) + + def _on_uav(self, msg): + p = msg.pose.position + self._uav = (p.x, p.y, p.z) + + def _on_rate(self, msg): + limit = self._t.max_reel_speed + self._rate = max(-limit, min(limit, msg.data)) + + def _pose(self, x, y, z): + pose = PoseStamped() + pose.header.stamp = self.get_clock().now().to_msg() + pose.header.frame_id = FRAME + pose.pose.position.x, pose.pose.position.y, pose.pose.position.z = x, y, z + pose.pose.orientation.w = 1.0 + return pose + + def _tick(self): + if self._ugv is None or self._uav is None: + return + t = self._t + if self._state is None: + self._length = tether.initial_length(t.winch, self._ugv, self._uav, t.object, + self._attach_z, t.slack) + self.get_logger().info(f'tether ready: L = {self._length:.2f} m') + obj = t.object if self._state is None else self._state.obj + + was_stuck = self._state is not None and self._state.stuck + s = self._state = tether.step( + max(self._length + self._rate * self._dt, 0.0), t.winch, self._ugv, self._uav, + obj, self._attach_z, t.obstacles, t.object_radius) + self._length = s.length + if s.stuck and not was_stuck: + self.get_logger().info('object jammed against an obstacle') + + reached = tether.reached(s.obj, t.goal) + if reached and not self._reached: + self.get_logger().info('object reached the goal!') + self._reached = reached + + self._length_pub.publish(Float64(data=s.length)) + self._free_pub.publish(Float64(data=s.free)) + self._taut_pub.publish(Bool(data=s.taut)) + self._stuck_pub.publish(Bool(data=s.stuck)) + self._obj_pub.publish(self._pose(*s.obj, self._attach_z)) + self._reached_pub.publish(Bool(data=reached)) + + markers = self._dynamic_markers() + if self._ticks % self._static_every == 0: + markers += self._static_markers() + self._ticks += 1 + self._marker_pub.publish(MarkerArray(markers=markers)) + + def _marker(self, ns, marker_id, marker_type, x=0.0, y=0.0, z=0.0): + m = Marker() + m.header.frame_id = FRAME + m.ns = ns + m.id = marker_id + m.type = marker_type + m.action = Marker.ADD + m.pose.position.x, m.pose.position.y, m.pose.position.z = x, y, z + m.pose.orientation.w = 1.0 + return m + + def _static_markers(self): + t = self._t + wx, wy, wz = t.winch + base = self._marker('winch', 0, Marker.CUBE, wx, wy, wz / 2.0) + base.scale.x, base.scale.y, base.scale.z = 0.4, 0.4, wz + _color(base, 0.3, 0.33, 0.36) + drum = self._marker('winch', 1, Marker.CYLINDER, wx, wy, wz) + drum.scale.x, drum.scale.y, drum.scale.z = 0.3, 0.3, 0.12 + _color(drum, 0.91, 0.35, 0.05) + + pillars = [] + for i, (x, y, r) in enumerate(t.obstacles): + m = self._marker('obstacles', i, Marker.CYLINDER, x, y, OBSTACLE_HEIGHT / 2.0) + m.scale.x = m.scale.y = 2.0 * r + m.scale.z = OBSTACLE_HEIGHT + _color(m, 0.55, 0.45, 0.4) + pillars.append(m) + return [base, drum, *pillars] + + def _dynamic_markers(self): + t = self._t + s = self._state + wx, wy, wz = t.winch + gx, gy, gr = t.goal + ox, oy = s.obj + size = t.object_size + + line = self._marker('tether', 0, Marker.LINE_STRIP) + line.scale.x = 0.03 + line.points = [Point(x=p[0], y=p[1], z=p[2]) + for p in (t.winch, self._ugv, self._uav, (ox, oy, self._attach_z))] + if s.taut: + _color(line, 0.91, 0.35, 0.05) + else: + _color(line, 0.98, 0.7, 0.45, 0.8) + + pulleys = [] + for i, p in enumerate((self._ugv, self._uav)): + m = self._marker('pulleys', i, Marker.SPHERE, *p) + m.scale.x = m.scale.y = m.scale.z = 0.08 + _color(m, 1.0, 1.0, 1.0) + pulleys.append(m) + + box = self._marker('object', 0, Marker.CUBE, ox, oy, size / 2.0) + box.scale.x = box.scale.y = box.scale.z = size + _color(box, 0.69, 0.54, 0.41) + + goal = self._marker('goal', 0, Marker.CYLINDER, gx, gy, 0.01) + goal.scale.x = goal.scale.y = 2.0 * gr + goal.scale.z = 0.02 + if self._reached: + _color(goal, 0.25, 0.75, 0.34, 0.9) + else: + _color(goal, 0.17, 0.54, 0.24, 0.35) + label = self._marker('goal', 1, Marker.TEXT_VIEW_FACING, gx, gy, 0.5) + label.scale.z = 0.25 + label.text = 'GOAL REACHED!' if self._reached else 'GOAL' + _color(label, 0.4, 0.9, 0.45) + + status = self._marker('status', 0, Marker.TEXT_VIEW_FACING, wx, wy, wz + 0.5) + status.scale.z = 0.18 + state = 'STUCK' if s.stuck else 'TAUT' if s.taut else 'slack' + status.text = f'L = {s.length:.2f} m l = {s.free:.2f} m {state}' + if s.stuck: + _color(status, 1.0, 0.4, 0.4) + else: + _color(status, 1.0, 1.0, 1.0) + + return [line, *pulleys, box, goal, label, status] + + +def main(args=None): + rclpy.init(args=args) + node = TetherSim() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + if rclpy.ok(): + rclpy.shutdown() diff --git a/launch/sim.launch.py b/launch/sim.launch.py index bf7276c..43fde01 100644 --- a/launch/sim.launch.py +++ b/launch/sim.launch.py @@ -29,6 +29,9 @@ def _actions(context): Node(package='hero_sim', executable='ground_robot_sim', name='ground_robot_sim', output='screen', parameters=params), ] + if scenario.tether is not None: + actions.append(Node(package='hero_sim', executable='tether_sim', name='tether_sim', + output='screen', parameters=params)) if config.attach_enabled(sim): actions.append(Node(package='hero_sim', executable='sim_attach', name='sim_attach_node', output='screen', parameters=params)) diff --git a/setup.py b/setup.py index f0b4701..84eb088 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'ground_robot_sim = hero_sim.ground_robots:main', 'sim_attach = hero_sim.attach:main', 'robot_models = hero_sim.robot_models:main', + 'tether_sim = hero_sim.tether_sim:main', ], }, ) diff --git a/test/test_config.py b/test/test_config.py index 83562c8..fa9bd82 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -158,3 +158,63 @@ def test_resolve_map_disabled_and_path(tmp_path): @pytest.mark.parametrize('path', sorted(CONFIG_DIR.glob('*.yaml')), ids=lambda p: p.name) def test_shipped_configs_load(path): config.load(path) + + +TETHER_ROBOTS = 'ground_robots: [{name: g1}, {name: g2}]\ncrazyflies: [{pose: {}}, {pose: {}}]\n' +TETHER_MIN = '{winch: {x: -1, y: 0}, object: {x: 1, y: 2}, goal: {x: 3, y: 4}' + + +def test_tether_is_optional(): + assert parse(TETHER_ROBOTS).tether is None + + +def test_tether_defaults(): + t = parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}}}').tether + assert (t.ugv, t.uav) == ('g1', 'cf_1') + assert t.winch == (-1.0, 0.0, 0.3) + assert t.object == (1.0, 2.0) + assert t.goal == (3.0, 4.0, 0.4) + assert t.obstacles == () + assert (t.object_size, t.object_radius, t.slack, t.max_reel_speed) == (0.3, 0.15, 0.5, 0.5) + + +def test_tether_overrides(): + t = parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}, ugv: g2, uav: cf_2, object_size: 0.2, ' + 'obstacles: [{x: 2, y: 2, radius: 0.5}]}').tether + assert (t.ugv, t.uav, t.object_radius) == ('g2', 'cf_2', 0.1) + assert t.obstacles == ((2.0, 2.0, 0.5),) + + +@pytest.mark.parametrize('extra, match', [ + (', rope: 1', "tether: unknown key.*'rope'"), + (', ugv: nope', 'tether.ugv must name a ground robot'), + (', uav: cf_9', r'tether.uav must name a crazyflie'), + (', goal: {x: 3, y: 4, z: 0}', r"tether.goal: unknown key.*'z'"), + (', goal: {x: 3, y: 4, radius: 0}', 'tether.goal.radius must be > 0'), + (', object: {x: 1}', r"tether.object: missing \['y'\]"), + (', obstacles: [{x: 0, y: 0}]', r"tether.obstacles\[0\]: missing \['radius'\]"), + (', obstacles: [[0, 0, 1]]', r'tether.obstacles\[0\] must be a mapping'), + (', obstacles: [{x: 0, y: 0, radius: 0}]', r'tether.obstacles\[0\].radius must be > 0'), + (', obstacles: [{x: 1.3, y: 2, radius: 0.2}]', r'tether.object starts inside obstacles\[0\]'), + (', object_size: 0', 'tether.object_size must be > 0'), + (', max_reel_speed: -1', 'tether.max_reel_speed must be > 0'), + (', slack: -1', 'tether.slack must be >= 0'), +]) +def test_rejects_bad_tether(extra, match): + with pytest.raises(ValueError, match=match): + parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}{extra}}}') + + +@pytest.mark.parametrize('text, match', [ + ('tether: [1]', 'tether must be a mapping'), + ('tether: {winch: {x: 0, y: 0}, object: {x: 1, y: 0}, goal: {x: 2, y: 0}}', + 'tether.ugv must name a ground robot'), + ('ground_robots: [{name: g1}]\n' + 'tether: {winch: {x: 0, y: 0}, object: {x: 1, y: 0}, goal: {x: 2, y: 0}}', + 'tether.uav must name a crazyflie'), + (TETHER_ROBOTS + 'tether: {object: {x: 1, y: 0}, goal: {x: 2, y: 0}}', + r"tether.winch: missing \['x', 'y'\]"), +]) +def test_rejects_tether_without_its_parts(text, match): + with pytest.raises(ValueError, match=match): + parse(text) diff --git a/test/test_nodes.py b/test/test_nodes.py index 208c9c5..b12556b 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -315,3 +315,63 @@ def test_attach_fails_without_pose(make): req = AttachAgent.Request(child_id='cf_9', parent_id='ghost', attach=True) resp = node._on_request(req, AttachAgent.Response()) assert not resp.success and 'ghost' in resp.message + + +TETHER_SCENARIO = """ +ground_robots: + - name: ugv + pose: {x: -1.0, y: 0.0} +crazyflies: + - pose: {x: 1.0, y: 0.0, z: 1.0} +tether: + winch: {x: -2.0, y: 0.0, z: 0.0} + object: {x: 3.0, y: 0.0} + object_size: 0.2 + goal: {x: 1.0, y: 0.0, radius: 0.3} + slack: 0.0 + max_reel_speed: 1.0 +sim: {ground_rate: 10.0} +""" + + +def pose_msg(x, y, z): + msg = PoseStamped() + msg.pose.position.x, msg.pose.position.y, msg.pose.position.z = x, y, z + return msg + + +def test_tether_sim_reels_the_object_in(rclpy_context, scenario_file): + from std_msgs.msg import Float64 + from hero_sim.tether_sim import TetherSim + + scenario_file(TETHER_SCENARIO) + node = TetherSim() + try: + node._tick() # no poses yet: waits + assert node._state is None + + node._on_ugv(pose_msg(-1.0, 0.0, 0.0)) + node._on_uav(pose_msg(1.0, 0.0, 1.0)) + node._tick() + start = node._length + assert node._state.obj == pytest.approx((3.0, 0.0)) + assert node._state.taut # slack 0: taut from the start + + node._on_rate(Float64(data=-5.0)) # clamped to max_reel_speed 1.0 + node._tick() + assert node._length == pytest.approx(start - 0.1) # 1 m/s at 10 Hz + assert 1.0 < node._state.obj[0] < 3.0 + assert not node._reached + + for _ in range(30): + node._tick() + assert node._state.obj == pytest.approx((1.0, 0.0)) # under the UAV, never lifted + assert node._reached + finally: + node.destroy_node() + + +def test_tether_sim_needs_a_tether_section(make): + from hero_sim.tether_sim import TetherSim + with pytest.raises(RuntimeError, match='no tether'): + make(TetherSim) diff --git a/test/test_tether.py b/test/test_tether.py new file mode 100644 index 0000000..cbbf8d5 --- /dev/null +++ b/test/test_tether.py @@ -0,0 +1,75 @@ +import math + +import pytest + +from hero_sim.tether import first_contact, initial_length, reached, step + +# Winch, UGV pulley and UAV ring on the x axis, UAV 1 m up, object tied at the floor. +WINCH, UGV, UAV = (-2.0, 0.0, 0.0), (-1.0, 0.0, 0.0), (1.0, 0.0, 1.0) +ROUTED = 1.0 + math.sqrt(5.0) # |W - G| + |G - A| +OBJ = (3.0, 0.0) # |A - O| = sqrt(5) +PILLAR = (2.0, 0.0, 0.2) # between the object and the point under the UAV + + +def test_initial_length_leaves_the_slack(): + assert initial_length(WINCH, UGV, UAV, OBJ, 0.0, 0.5) == pytest.approx( + ROUTED + math.sqrt(5.0) + 0.5) + + +def test_slack_tether_leaves_the_object(): + s = step(ROUTED + math.sqrt(5.0) + 0.5, WINCH, UGV, UAV, OBJ, 0.0) + assert s.obj == OBJ + assert s.free == pytest.approx(math.sqrt(5.0) + 0.5) + assert not s.taut and not s.stuck + + +def test_taut_tether_slides_object_toward_the_point_under_the_uav(): + s = step(ROUTED + 1.5, WINCH, UGV, UAV, OBJ, 0.0) + assert s.obj == pytest.approx((1.0 + math.sqrt(1.5 ** 2 - 1.0), 0.0)) + assert math.dist(UAV, (*s.obj, 0.0)) == pytest.approx(1.5) + assert s.taut and not s.stuck + + +def test_object_is_never_lifted_the_winch_slips(): + s = step(ROUTED + 0.2, WINCH, UGV, UAV, OBJ, 0.0) # less than the UAV's height + assert s.length == pytest.approx(ROUTED + 1.0) + assert s.obj == pytest.approx((1.0, 0.0)) + assert s.free == pytest.approx(1.0) + + +def test_attach_height_shortens_the_drop(): + s = step(0.0, WINCH, UGV, UAV, OBJ, 0.25) + assert s.free == pytest.approx(0.75) + + +def test_object_jams_against_a_pillar_and_the_winch_slips(): + s = step(ROUTED + 1.5, WINCH, UGV, UAV, OBJ, 0.0, [PILLAR], 0.1) + assert s.obj == pytest.approx((2.3, 0.0)) # touching: pillar 0.2 + object 0.1 + assert s.stuck and s.taut + assert s.free == pytest.approx(math.dist(UAV, (2.3, 0.0, 0.0))) + assert s.length == pytest.approx(ROUTED + s.free) + + again = step(s.length - 0.5, WINCH, UGV, UAV, s.obj, 0.0, [PILLAR], 0.1) + assert again.obj == pytest.approx(s.obj) and again.stuck + + +def test_pull_away_from_the_pillar_frees_the_object(): + uav = (4.0, 0.0, 1.0) # now on the far side of the object from the pillar + s = step(0.0, WINCH, UGV, uav, (2.3, 0.0), 0.0, [PILLAR], 0.1) + assert s.obj == pytest.approx((4.0, 0.0)) + assert not s.stuck + + +def test_first_contact(): + pillar = [(0.0, 0.0, 0.5)] + assert first_contact((-2.0, 0.0), (2.0, 0.0), pillar, 0.0) == pytest.approx((-0.5, 0.0)) + assert first_contact((-2.0, 1.0), (2.0, 1.0), pillar, 0.0) is None # passes beside + assert first_contact((-2.0, 0.0), (-1.0, 0.0), pillar, 0.0) is None # stops short + assert first_contact((-0.5, 0.0), (-2.0, 0.0), pillar, 0.0) is None # moves away + assert first_contact((-0.5, 0.0), (0.0, 0.0), pillar, 0.0) == (-0.5, 0.0) # pushed in + assert first_contact((-1.0, 0.0), (-1.0, 0.0), pillar, 0.0) is None # no move + + +def test_reached(): + assert reached((1.0, 1.0), (1.0, 1.3, 0.4)) + assert not reached((1.0, 1.0), (1.0, 1.5, 0.4)) From f2bee813984b4acf06de6a56bf04a86edd7ddac2 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:57:14 +0100 Subject: [PATCH 04/13] feat(obstacles): pillars as shared obstacles for robots and the tether object Pillars move from tether.obstacles to a top-level obstacles: list and live in the same ObstacleMap as the octomap grid (kept as exact circles, also with map: null). Ground robots, Crazyflies (at any height) and the tether object all stop at the same obstacles, under the one sim.collision switch. ground_robot_sim publishes the pillars latched on /hero_sim/obstacles (one CYLINDER each), drawn by a new Obstacles display in sim.rviz. tether.obstacles now fails with a pointer to the new key. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 +++++--- config/example.yaml | 19 +++--- config/sim.rviz | 12 ++++ config/tether_demo.yaml | 13 ++-- hero_sim/config.py | 34 +++++++---- hero_sim/crazyflie_server.py | 7 ++- hero_sim/ground_robots.py | 42 +++++++++++-- hero_sim/obstacles.py | 73 ++++++++++++++-------- hero_sim/tether.py | 49 ++++----------- hero_sim/tether_sim.py | 46 +++++--------- test/test_config.py | 31 +++++++--- test/test_nodes.py | 115 +++++++++++++++++++++++++++++++---- test/test_obstacles.py | 44 +++++++++++++- test/test_tether.py | 42 ++++++++----- 14 files changed, 382 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index f87babe..e8b6b35 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,14 @@ Ground robots hold the last `cmd_vel` until a new one arrives. Poses are publish As on hardware, where the mocap markers sit on top of the robot, a ground robot's pose is at its marker height rather than on the floor: the top of the deck, scout 0.227 m and herover 0.095 m (0 without a model). Docks the control stack places on a robot are relative to this height. To change it, set `pose: {z: ...}`; the RViz model stays on the floor. -## Map collisions +## Obstacles -Robots can't drive or fly into the map. The octomap's voxels between 0.2 m and 2.0 m high (`sim.collision_z`) are flattened into a 2D obstacle grid, which keeps the trunks and posts and leaves out the floor, ceilings and tree canopies. Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk can still climb or land, but it can't fly over the trunk. +Robots can't drive or fly into obstacles. There are two kinds, and both block in 2D: + +- **The map.** The octomap's voxels between 0.2 m and 2.0 m high (`sim.collision_z`) are flattened into a 2D obstacle grid, which keeps the trunks and posts and leaves out the floor, ceilings and tree canopies. +- **Pillars.** Circles listed under a top-level `obstacles:` key. They work with or without a map (`map: null`). + +Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk or a pillar can still climb or land, but it can't fly over it. ```yaml ground_robots: @@ -126,12 +131,20 @@ ground_robots: radius: 0.45 # or set it crazyflies: - pose: {x: 1.5, y: 0.5, z: 0.03} # radius 0.1 by default +obstacles: # optional pillars, m + - {x: 1.5, y: 0.5, radius: 0.35} sim: - collision: false # turn it off + collision: false # turn all of it off: map, pillars, tether object ``` Every motion command is checked, including `go_to`, `cmd_hover`, `cmd_position` and the dock ride-along. `//set_position` is a teleport and isn't checked. A robot that starts inside an obstacle gets a warning at launch and can move out but not further in. +Pillars are drawn in RViz as 2 m columns and published latched on `/hero_sim/obstacles` (`visualization_msgs/MarkerArray`), even with collisions off. Each pillar is a `CYLINDER` marker at its `x`, `y`, with `scale.x` its diameter: + +```bash +ros2 topic echo --once /hero_sim/obstacles +``` + ## Tether task A winch drags an object across the floor. The tether runs from the winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), down to the object. A scenario sets it up with a `tether:` section; `config/tether_demo.yaml` is a ready one: @@ -148,8 +161,6 @@ tether: object: {x: 0.5, y: -0.5} # start, on the floor object_size: 0.3 # m, cube edge (default 0.3) goal: {x: 2.5, y: 1.5, radius: 0.4} # radius defaults to 0.4 - obstacles: # optional pillars - - {x: 1.5, y: 0.5, radius: 0.35} slack: 3.0 # m of tether beyond taut at the start (default 0.5) max_reel_speed: 0.5 # m/s (default 0.5) ``` @@ -159,8 +170,8 @@ The winch pays out a length L. What the two pulleys don't use is the free tether - While the tether is slack, the object stays where it is. - Once it's taut, the object slides along the floor straight toward the point under the UAV. - The object is never lifted. Once it is under the UAV, the winch slips instead. -- Pulled into a pillar, the object jams where it touches it and the winch slips. A pull away from the pillar frees it. -- The robots never feel the tether: moving them only drags the object or makes the winch slip. The tether and the robots pass through pillars, and the object ignores the map's obstacles; only pillars block the object. +- Pulled into an obstacle, a pillar or the map, the object jams where it touches it and the winch slips. A pull away from the obstacle frees it. The object is a circle of half its `object_size`, and like the robots it only collides while `sim.collision` is on (see [Obstacles](#obstacles)). +- The robots never feel the tether: moving them only drags the object or makes the winch slip. The robots stop at the same obstacles as the object, but the tether line itself passes through them. Drive the UGV with `//cmd_vel` and fly the UAV with its Crazyflie services, as in [Driving the robots](#driving-the-robots). The winch and the object: @@ -170,7 +181,7 @@ Drive the UGV with `//cmd_vel` and fly the UAV with its Crazyflie services, | `/winch/length` | `std_msgs/Float64` | L | | `/tether/free_length` | `std_msgs/Float64` | l | | `/tether/taut` | `std_msgs/Bool` | | -| `/tether/stuck` | `std_msgs/Bool` | object jammed against a pillar | +| `/tether/stuck` | `std_msgs/Bool` | object jammed against an obstacle | | `/tether/object_pose` | `geometry_msgs/PoseStamped` | object center | | `/tether/goal` | `geometry_msgs/PoseStamped` | goal center, latched | | `/tether/goal_reached` | `std_msgs/Bool` | object center inside the goal circle | diff --git a/config/example.yaml b/config/example.yaml index 3978033..bf11ce1 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -10,8 +10,8 @@ # drive: omni holonomic, honors linear.x, linear.y and angular.z (default) # drive: diff non-holonomic, linear.y is ignored # model: scout | herover RViz mesh drawn on the robot's frame; leave out for TF axes only -# radius: 0.42 footprint (m) kept out of map obstacles (see sim.collision). Default -# by model: scout 0.42, herover 0.17, no model 0.3. 0 ignores the map +# radius: 0.42 footprint (m) kept out of the map and pillars (see sim.collision). +# Default by model: scout 0.42, herover 0.17, no model 0.3. 0 ignores them ground_robots: - name: scout_1 pose: {x: 0.0, y: 0.0, yaw: 0.0} # m, m, rad @@ -27,7 +27,7 @@ ground_robots: # gripper: true starts with an open two-claw gripper on top, drawn in RViz # (default false). Needs sim_attach (see attach below), which # shuts it on a branch lock and serves /sim//add_gripper. -# radius: 0.1 footprint (m) kept out of map obstacles (default 0.1) +# radius: 0.1 footprint (m) kept out of the map and pillars (default 0.1) crazyflies: - pose: {x: 1.5, y: 0.5, z: 0.03} # cf_1 gripper: true @@ -45,10 +45,17 @@ sim: rviz: true # RViz with config/sim.rviz models: true # robot meshes in RViz (crazyflies, and ground robots with a model) map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none - collision: true # robots stop where their footprint would hit a map obstacle + collision: true # robots and the tether object stop where their footprint + # would hit a map obstacle or a pillar (obstacles: below) collision_z: [0.2, 2.0] # m, map heights that count as obstacles, flattened to 2D: # trunks and posts, not the floor, ceiling or tree canopies +# Optional pillars (m): circles that block the robots (drones at any height) +# and the tether object while sim.collision is on, with or without a map. +# Drawn in RViz and published latched on /hero_sim/obstacles. +# obstacles: +# - {x: 1.5, y: 0.5, radius: 0.35} + # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", # config/tether_demo.yaml). Leave it out and nothing extra runs. @@ -57,9 +64,7 @@ sim: # ugv: scout_1 # pulley on its deck (default: the first ground robot) # uav: cf_1 # ring (default cf_1) # object: {x: 0.5, y: -0.5} # required, start on the floor -# object_size: 0.3 # m, cube edge; half of it is its footprint against pillars +# object_size: 0.3 # m, cube edge; half of it is its footprint against obstacles # goal: {x: 2.5, y: 1.5, radius: 0.4} # x and y required -# obstacles: # pillars only the object can't pass through -# - {x: 1.5, y: 0.5, radius: 0.35} # slack: 0.5 # m of tether beyond taut at the start # max_reel_speed: 0.5 # m/s, clamp on /winch/cmd_rate diff --git a/config/sim.rviz b/config/sim.rviz index c19ebc2..442f9db 100644 --- a/config/sim.rviz +++ b/config/sim.rviz @@ -139,6 +139,18 @@ Visualization Manager: Reliability Policy: Reliable Value: /hero_sim/tether Value: true + - Class: rviz_default_plugins/MarkerArray + Enabled: true + Name: Obstacles + Namespaces: + obstacles: true + Topic: + Depth: 1 + Durability Policy: Transient Local + History Policy: Keep Last + Reliability Policy: Reliable + Value: /hero_sim/obstacles + Value: true - Class: rviz_default_plugins/MarkerArray Enabled: false Name: MarkerArray diff --git a/config/tether_demo.yaml b/config/tether_demo.yaml index 143d3d4..653aa0d 100644 --- a/config/tether_demo.yaml +++ b/config/tether_demo.yaml @@ -1,12 +1,14 @@ # Tether demo: winch -> UGV pulley -> UAV ring -> object on the floor. # Get the object into the goal circle. It slides on the floor, never lifts, -# and jams if pulled into the pillar: route it around. +# and jams if pulled into the pillar: route it around. The pillar blocks the +# robots too, the drone at any height. # # bash start.sh tether_demo.yaml # sim + RViz # # Controls: /herover_1/cmd_vel, /cf_1/takeoff|go_to|land, /winch/cmd_rate (m/s, + out, - in). # Feedback: /tether/object_pose, /tether/goal, /tether/taut, /tether/free_length, -# /tether/stuck, /tether/goal_reached, /winch/length. See README "Tether task". +# /tether/stuck, /tether/goal_reached, /winch/length, /hero_sim/obstacles. +# See README "Tether task". ground_robots: - name: herover_1 @@ -24,11 +26,12 @@ tether: object: {x: 0.5, y: -0.5} # start on the floor object_size: 0.3 # m, cube goal: {x: 2.5, y: 1.5, radius: 0.4} - obstacles: # pillars the object can't pass through - - {x: 1.5, y: 0.5, radius: 0.35} # right on the straight line to the goal slack: 3.0 # m of tether beyond taut at the start max_reel_speed: 0.5 # m/s +obstacles: # pillars: block the object and the robots + - {x: 1.5, y: 0.5, radius: 0.35} # right on the straight line to the goal + sim: map: null - collision: false + collision: true diff --git a/hero_sim/config.py b/hero_sim/config.py index fd281af..23175ff 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -5,6 +5,7 @@ ground_robots kinematic ground robots, one entry per robot crazyflies Crazyflies, named cf_1..cf_N in list order sim optional overrides of SIM_DEFAULTS + obstacles optional pillars (x, y, radius) that block robots and the tether object tether optional winch, tether and object (hero_sim/tether.py) Unknown keys are rejected so typos fail loudly instead of being ignored. @@ -26,7 +27,7 @@ 'rviz': True, # open RViz with config/sim.rviz 'models': True, # robot meshes in RViz (crazyflies + ground robots with a model) 'map': 'icuas26_1', # octomap: maps/.bt, a path to a .bt, or null - 'collision': True, # stop robots at map obstacles (2D, see hero_sim/obstacles.py) + 'collision': True, # stop robots at the map and pillars (2D, hero_sim/obstacles.py) 'collision_z': (0.2, 2.0), # m, map heights counted as obstacles: trunks, not floor/canopy } @@ -92,7 +93,6 @@ class Tether: uav: str # Crazyflie carrying the guide ring object: tuple # x, y (m): start position on the floor goal: tuple # x, y, radius (m) - obstacles: tuple = () # (x, y, radius) pillars (m) the object can't pass through object_size: float = 0.3 # m, cube edge slack: float = 0.5 # m of tether beyond taut at the start max_reel_speed: float = 0.5 # m/s, clamp on /winch/cmd_rate @@ -109,6 +109,7 @@ class Scenario: crazyflies: tuple sim: dict tether: Tether = None + obstacles: tuple = () # (x, y, radius) pillars (m), see hero_sim/obstacles.py def load(path): @@ -124,7 +125,7 @@ def load(path): def parse(raw): """Validate an already-loaded scenario mapping and return a Scenario.""" raw = {} if raw is None else raw - _check_keys(raw, ('ground_robots', 'crazyflies', 'sim', 'tether'), 'top level') + _check_keys(raw, ('ground_robots', 'crazyflies', 'sim', 'obstacles', 'tether'), 'top level') ground = tuple(_ground_robot(i, r) for i, r in enumerate(_list(raw, 'ground_robots'))) names = [r.name for r in ground] @@ -157,8 +158,10 @@ def parse(raw): raise ValueError(f'sim.collision_z must be [z_min, z_max], z_min < z_max, got {band!r}') sim['collision_z'] = (float(band[0]), float(band[1])) - tether = _tether(raw['tether'], ground, cfs) if raw.get('tether') is not None else None - return Scenario(ground, cfs, sim, tether) + pillars = tuple(_pillar(i, o) for i, o in enumerate(_list(raw, 'obstacles'))) + tether = (_tether(raw['tether'], ground, cfs, pillars) + if raw.get('tether') is not None else None) + return Scenario(ground, cfs, sim, tether, pillars) def attach_enabled(sim): @@ -248,9 +251,20 @@ def _point(value, where, axes, **defaults): return tuple(float(value.get(a, defaults.get(a))) for a in axes) -def _tether(entry, ground, cfs): +def _pillar(i, entry): + where = f'obstacles[{i}]' + x, y, r = _point(entry, where, ('x', 'y', 'radius')) + if r <= 0.0: + raise ValueError(f'{where}.radius must be > 0, got {r}') + return x, y, r + + +def _tether(entry, ground, cfs, pillars): where = 'tether' - _check_keys(entry, ('winch', 'ugv', 'uav', 'object', 'goal', 'obstacles', 'object_size', + if isinstance(entry, dict) and 'obstacles' in entry: + raise ValueError(f'{where}.obstacles moved to the top-level obstacles: list, ' + 'which blocks the robots too') + _check_keys(entry, ('winch', 'ugv', 'uav', 'object', 'goal', 'object_size', 'slack', 'max_reel_speed'), where) ugv = entry.get('ugv', ground[0].name if ground else None) if ugv not in {r.name for r in ground}: @@ -264,8 +278,6 @@ def _tether(entry, ground, cfs): uav=uav, object=_point(entry.get('object'), f'{where}.object', ('x', 'y')), goal=_point(entry.get('goal'), f'{where}.goal', ('x', 'y', 'radius'), radius=0.4), - obstacles=tuple(_point(o, f'{where}.obstacles[{i}]', ('x', 'y', 'radius')) - for i, o in enumerate(_list(entry, 'obstacles'))), object_size=float(entry.get('object_size', Tether.object_size)), slack=float(entry.get('slack', Tether.slack)), max_reel_speed=float(entry.get('max_reel_speed', Tether.max_reel_speed)), @@ -277,9 +289,7 @@ def _tether(entry, ground, cfs): raise ValueError(f'{where}.slack must be >= 0, got {tether.slack}') if tether.goal[2] <= 0.0: raise ValueError(f'{where}.goal.radius must be > 0, got {tether.goal[2]}') - for i, (x, y, r) in enumerate(tether.obstacles): - if r <= 0.0: - raise ValueError(f'{where}.obstacles[{i}].radius must be > 0, got {r}') + for i, (x, y, r) in enumerate(pillars): if math.hypot(tether.object[0] - x, tether.object[1] - y) < r + tether.object_radius: raise ValueError(f'{where}.object starts inside obstacles[{i}]') return tether diff --git a/hero_sim/crazyflie_server.py b/hero_sim/crazyflie_server.py index 2668521..0e3fab8 100644 --- a/hero_sim/crazyflie_server.py +++ b/hero_sim/crazyflie_server.py @@ -34,7 +34,8 @@ def __init__(self): config_path = config.resolve_config(self.get_parameter('config_path').value) scenario = config.load(config_path) dt = 1.0 / scenario.sim['cf_rate'] - obstacle_map = obstacles.load(scenario.sim, [cf.radius for cf in scenario.crazyflies]) + obstacle_map = obstacles.load(scenario.sim, [cf.radius for cf in scenario.crazyflies], + scenario.obstacles) self.drones = { cf.name: SimCrazyflie(self, cf.name, (cf.x, cf.y, cf.z), dt=dt, @@ -45,7 +46,7 @@ def __init__(self): if obstacle_map is not None: for cf in scenario.crazyflies: if obstacle_map.clearance(cf.x, cf.y) < cf.radius: - self.get_logger().warn(f'{cf.name} starts overlapping a map obstacle; ' + self.get_logger().warn(f'{cf.name} starts overlapping an obstacle; ' 'it can only move away from it') for srv_type, name, method in _BROADCASTS: @@ -58,7 +59,7 @@ def __init__(self): collisions = 'off' if obstacle_map is None else 'on' self.get_logger().info( f'{len(self.drones)} crazyflies at {scenario.sim["cf_rate"]:g} Hz from {config_path}, ' - f'map collisions {collisions}: {list(self.drones)}') + f'collisions {collisions}: {list(self.drones)}') def _update_all(self): transforms = [drone.update() for drone in self.drones.values()] diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index c007979..f512cf3 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -5,19 +5,25 @@ published in 'world' at the robot's pose z (its marker height, see config.MARKER_HEIGHTS), along with a world -> transform. -With a map and sim.collision on, a robot stops where its footprint would hit -an obstacle (hero_sim/obstacles.py) and resumes when cmd_vel points away. +With sim.collision on, a robot stops where its footprint would hit the map or +a pillar (hero_sim/obstacles.py) and resumes when cmd_vel points away. + +This node also draws the scenario's pillars (its obstacles: list), latched on +/hero_sim/obstacles: one CYLINDER per pillar, at its x, y, scale.x = diameter. """ from geometry_msgs.msg import PoseStamped, TransformStamped, Twist import rclpy from rclpy.node import Node from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy from tf2_ros import TransformBroadcaster +from visualization_msgs.msg import Marker, MarkerArray from hero_sim import config, obstacles from hero_sim.kinematics import integrate_ground, limit_ground_cmd, yaw_to_quat FRAME = 'world' +OBSTACLE_TOPIC = '/hero_sim/obstacles' +PILLAR_HEIGHT = 2.0 # m, drawn only: pillars block at any height class _Robot: @@ -53,12 +59,14 @@ def __init__(self): lambda msg, r=robot: self._on_cmd_vel(r, msg), qos) self._robots.append(robot) - self._obstacles = obstacles.load(sim, [r.radius for r in self._robots]) + self._obstacles = obstacles.load(sim, [r.radius for r in self._robots], + scenario.obstacles) if self._obstacles is not None: for r in self._robots: if self._obstacles.clearance(r.x, r.y) < r.radius: - self.get_logger().warn(f'{r.name} starts overlapping a map obstacle; ' + self.get_logger().warn(f'{r.name} starts overlapping an obstacle; ' 'it can only move away from it') + self._publish_pillars(scenario.obstacles) self._tf = TransformBroadcaster(self) self.create_timer(self._dt, self._tick) @@ -67,7 +75,31 @@ def __init__(self): collisions = 'off' if self._obstacles is None else 'on' self.get_logger().info( f'ground robots at {sim["ground_rate"]:g} Hz, max {self._max_speed:g} m/s, ' - f'{self._max_yaw_rate:g} rad/s, map collisions {collisions}: {summary}') + f'{self._max_yaw_rate:g} rad/s, collisions {collisions}, ' + f'{len(scenario.obstacles)} pillar(s): {summary}') + + def _publish_pillars(self, pillars): + latched = QoSProfile(depth=1, reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL) + self._pillar_pub = self.create_publisher(MarkerArray, OBSTACLE_TOPIC, latched) + clear = Marker() + clear.header.frame_id = FRAME + clear.action = Marker.DELETEALL + markers = [clear] + for i, (x, y, radius) in enumerate(pillars): + m = Marker() + m.header.frame_id = FRAME + m.ns = 'obstacles' + m.id = i + m.type = Marker.CYLINDER + m.action = Marker.ADD + m.pose.position.x, m.pose.position.y, m.pose.position.z = x, y, PILLAR_HEIGHT / 2.0 + m.pose.orientation.w = 1.0 + m.scale.x = m.scale.y = 2.0 * radius + m.scale.z = PILLAR_HEIGHT + m.color.r, m.color.g, m.color.b, m.color.a = 0.55, 0.45, 0.4, 1.0 + markers.append(m) + self._pillar_pub.publish(MarkerArray(markers=markers)) def _on_cmd_vel(self, robot, msg): robot.vx, robot.vy, robot.wz = limit_ground_cmd( diff --git a/hero_sim/obstacles.py b/hero_sim/obstacles.py index 26b23d7..3740775 100644 --- a/hero_sim/obstacles.py +++ b/hero_sim/obstacles.py @@ -1,17 +1,23 @@ -"""2D map collisions: keep robot footprints out of the octomap's obstacles. No ROS imports. - -The scenario's octomap (.bt) is flattened once at startup: every occupied -voxel overlapping the height band sim.collision_z is an obstacle, seen from -above. The default band leaves out the floor slab, ceilings and the tree -canopies the drones perch in, and keeps trunks, posts and walls. - -Every robot is a disc of its footprint radius (config.FOOTPRINT_RADII). A move -is checked along its straight path, so fast robots can't tunnel through thin -obstacles, and stops at the last free point before the disc would touch one. -Height is ignored: a drone is blocked by a trunk at any altitude. - -Lookups are O(1): ObstacleMap precomputes each cell's distance to the nearest -obstacle, up to the largest radius it will be asked about. +"""2D collisions: keep footprints out of the map and the pillars. No ROS imports. + +Two kinds of obstacle, both in one ObstacleMap: + + map the scenario's octomap (.bt), flattened once at startup: every + occupied voxel overlapping the height band sim.collision_z is an + obstacle, seen from above. The default band leaves out the floor + slab, ceilings and the tree canopies the drones perch in, and keeps + trunks, posts and walls. + pillars the scenario's top-level obstacles: list, circles (x, y, radius) + kept exact rather than gridded. + +Every robot is a disc of its footprint radius (config.FOOTPRINT_RADII), and so +is the tether object (hero_sim/tether.py). A move is checked along its +straight path, so fast robots can't tunnel through thin obstacles, and stops at +the last free point before the disc would touch one. Height is ignored: a +drone is blocked by a trunk or a pillar at any altitude. + +Map lookups are O(1): ObstacleMap precomputes each cell's distance to the +nearest map obstacle, up to the largest radius it will be asked about. """ import math @@ -21,6 +27,7 @@ _TREE_DEPTH = 16 _KEY_ORIGIN = 1 << (_TREE_DEPTH - 1) # octomap key of coordinate 0 +PILLAR_RES = 0.05 # m, cell size without a map; move() samples paths every half cell def read_bt(path): @@ -72,12 +79,16 @@ def read_node(kx, ky, kz, size): class ObstacleMap: - """Obstacle cells on a 2D grid, with each cell's clearance to the nearest one.""" + """Obstacle cells on a 2D grid plus circular pillars, with the clearance to the nearest one.""" - def __init__(self, occupied, x0, y0, res, max_radius): - """occupied: bool array [row = y, col = x] with cell (0, 0) at (x0, y0).""" + def __init__(self, occupied, x0, y0, res, max_radius, circles=()): + """occupied: bool array [row = y, col = x] with cell (0, 0) at (x0, y0). + + circles: pillars (x, y, radius), in m. + """ self.res = res self.max_radius = max_radius + self.circles = tuple(tuple(float(v) for v in c) for c in circles) # Pad by the farthest reach so robots just outside the obstacles' extent are covered. n = math.ceil(max_radius / res + 0.5) self.x0, self.y0 = x0 - n * res, y0 - n * res @@ -93,29 +104,33 @@ def __init__(self, occupied, x0, y0, res, max_radius): self._clearance[rows + n + di, cols + n + dj] = d @classmethod - def from_bt(cls, path, z_min, z_max, max_radius): - """Flatten the occupied voxels of a .bt that overlap z_min..z_max (m).""" + def from_bt(cls, path, z_min, z_max, max_radius, circles=()): + """Flatten the occupied voxels of a .bt that overlap z_min..z_max (m), plus `circles`.""" res, boxes = read_bt(path) z_lo = (boxes[:, 2] - _KEY_ORIGIN) * res z_hi = z_lo + boxes[:, 3] * res # Extruded walls repeat the same footprint on every layer: keep one. boxes = np.unique(boxes[(z_hi > z_min) & (z_lo < z_max)][:, [0, 1, 3]], axis=0) if not len(boxes): - return cls(np.zeros((0, 0), bool), 0.0, 0.0, res, max_radius) + return cls(np.zeros((0, 0), bool), 0.0, 0.0, res, max_radius, circles) kx0, ky0 = boxes[:, :2].min(axis=0) kx1, ky1 = (boxes[:, :2] + boxes[:, 2:]).max(axis=0) occupied = np.zeros((ky1 - ky0, kx1 - kx0), bool) for kx, ky, size in boxes - (kx0, ky0, 0): occupied[ky:ky + size, kx:kx + size] = True - return cls(occupied, (kx0 - _KEY_ORIGIN) * res, (ky0 - _KEY_ORIGIN) * res, res, max_radius) + return cls(occupied, (kx0 - _KEY_ORIGIN) * res, (ky0 - _KEY_ORIGIN) * res, res, + max_radius, circles) def clearance(self, x, y): """Distance (m) from (x, y) to the nearest obstacle; inf if beyond max_radius.""" + best = math.inf row = math.floor((y - self.y0) / self.res) col = math.floor((x - self.x0) / self.res) if 0 <= row < self._clearance.shape[0] and 0 <= col < self._clearance.shape[1]: - return float(self._clearance[row, col]) - return math.inf + best = float(self._clearance[row, col]) + for cx, cy, r in self.circles: + best = min(best, max(math.hypot(x - cx, y - cy) - r, 0.0)) + return best if best < self.max_radius else math.inf def move(self, x0, y0, x1, y1, radius): """Where a disc of `radius` moving straight from (x0, y0) to (x1, y1) ends up. @@ -139,14 +154,18 @@ def move(self, x0, y0, x1, y1, radius): return x1, y1 -def load(sim, radii): +def load(sim, radii, pillars=()): """The scenario's ObstacleMap for robots of these radii, or None when there's nothing to check. - None when sim.collision is off, sim.map is null, or there are no robots. + pillars: the scenario's obstacles, (x, y, radius) each. None when + sim.collision is off, there are no robots, or neither a map nor pillars. """ if not sim['collision'] or not radii: return None bt_file = config.resolve_map(sim['map']) if bt_file is None: - return None - return ObstacleMap.from_bt(bt_file, *sim['collision_z'], max_radius=max(radii)) + if not pillars: + return None + return ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, max(radii), pillars) + return ObstacleMap.from_bt(bt_file, *sim['collision_z'], max_radius=max(radii), + circles=pillars) diff --git a/hero_sim/tether.py b/hero_sim/tether.py index 2f85493..8e24e4c 100644 --- a/hero_sim/tether.py +++ b/hero_sim/tether.py @@ -17,9 +17,10 @@ until |A - O| = l. lifted never: the object is too heavy, so once it sits under the UAV the winch slips (pays out) instead. - jammed pulled into a pillar, the object stops where it touches it and the - winch slips, until a pull away from the pillar frees it. The - tether itself passes over pillars, and the robots through them. + jammed pulled into an obstacle (a pillar or the map, hero_sim/obstacles.py), + the object stops where it touches it and the winch slips, until a + pull away from the obstacle frees it. The robots stop at the same + obstacles; the tether line itself passes through them. The node in hero_sim/tether_sim.py runs this for the scenario's tether: section. """ @@ -32,7 +33,7 @@ class State(NamedTuple): obj: tuple # x, y (m) of the object on the floor free: float # m, tether from the UAV ring to the object taut: bool - stuck: bool # jammed against a pillar + stuck: bool # jammed against an obstacle def initial_length(winch, ugv, uav, obj, attach_z, slack): @@ -46,41 +47,13 @@ def reached(obj, goal): return math.hypot(obj[0] - goal[0], obj[1] - goal[1]) <= goal[2] -def first_contact(p, q, obstacles, radius): - """Where an object of `radius` moving straight from p to q first touches an - obstacle (x, y, r), or None if the move is clear. Moving away from an - obstacle it already touches is clear.""" - dx, dy = q[0] - p[0], q[1] - p[1] - a = dx * dx + dy * dy - if a == 0.0: - return None - best = None - for cx, cy, r in obstacles: - r += radius - px, py = p[0] - cx, p[1] - cy - b = dx * px + dy * py # < 0: heading toward the center - if px * px + py * py <= r * r + 1e-9: - if b < 0.0: - return tuple(p) # touching it and pushed in: jammed - continue - c = px * px + py * py - r * r - disc = b * b - a * c - if b >= 0.0 or disc < 0.0: - continue - t = (-b - math.sqrt(disc)) / a - if t <= 1.0 and (best is None or t < best): - best = t - if best is None: - return None - return p[0] + best * dx, p[1] + best * dy - - -def step(length, winch, ugv, uav, obj, attach_z, obstacles=(), radius=0.0): +def step(length, winch, ugv, uav, obj, attach_z, obstacles=None, radius=0.0): """One quasi-static update of the tether and the object. Returns a State. length: tether paid out by the winch (m). winch, ugv, uav: (x, y, z) the tether runs through, in order. obj: object (x, y) on the floor, tied at - height attach_z, with footprint `radius` against obstacles (x, y, r). + height attach_z, with footprint `radius` against `obstacles`, an + obstacles.ObstacleMap or None for none. """ routed = math.dist(winch, ugv) + math.dist(ugv, uav) dz = max(uav[2] - attach_z, 0.0) @@ -94,8 +67,10 @@ def step(length, winch, ugv, uav, obj, attach_z, obstacles=(), radius=0.0): return State(length, tuple(obj), free, h > reach - 1e-3, False) # Taut: slide toward the point under the UAV. target = (uav[0] + dx * reach / h, uav[1] + dy * reach / h) - contact = first_contact(obj, target, obstacles, radius) - if contact is None: + if obstacles is None: + return State(length, target, free, True, False) + contact = obstacles.move(obj[0], obj[1], target[0], target[1], radius) + if contact == target: return State(length, target, free, True, False) # Jammed against an obstacle: the object stops there and the winch slips. free = math.dist(uav, (*contact, attach_z)) diff --git a/hero_sim/tether_sim.py b/hero_sim/tether_sim.py index cb972fe..32c5b90 100644 --- a/hero_sim/tether_sim.py +++ b/hero_sim/tether_sim.py @@ -2,6 +2,8 @@ sim.launch.py starts this node only when the scenario has a tether: section. It follows the UGV's and UAV's /pose and steps the model at sim.ground_rate. +With sim.collision on, the object stops at the scenario's pillars and map +obstacles (hero_sim/obstacles.py); ground_robot_sim draws the pillars. Topics: /winch/cmd_rate std_msgs/Float64 in, m/s: + pays out, - reels in. Held until @@ -22,11 +24,10 @@ from std_msgs.msg import Bool, Float64 from visualization_msgs.msg import Marker, MarkerArray -from hero_sim import config, tether +from hero_sim import config, obstacles, tether FRAME = 'world' MARKER_TOPIC = '/hero_sim/tether' -OBSTACLE_HEIGHT = 0.6 # m, drawn only: the tether passes over obstacles def _color(m, r, g, b, a=1.0): @@ -43,7 +44,7 @@ def __init__(self): raise RuntimeError('scenario has no tether: section') t = self._t = scenario.tether self._dt = 1.0 / scenario.sim['ground_rate'] - # Winch and pillars never change: resend them once a second, not every tick. + # The winch never changes: resend it once a second, not every tick. self._static_every = max(1, round(scenario.sim['ground_rate'])) self._ticks = 0 @@ -53,6 +54,11 @@ def __init__(self): self._reached = False self._rate = 0.0 self._ugv = self._uav = None + self._obstacles = obstacles.load(scenario.sim, [t.object_radius], scenario.obstacles) + if (self._obstacles is not None + and self._obstacles.clearance(*t.object) < t.object_radius): + self.get_logger().warn('the object starts overlapping an obstacle; ' + 'it can only be pulled away from it') self.create_subscription(PoseStamped, f'/{t.ugv}/pose', self._on_ugv, 10) self.create_subscription(PoseStamped, f'/{t.uav}/pose', self._on_uav, 10) @@ -72,9 +78,10 @@ def __init__(self): self.create_publisher(PoseStamped, '/tether/goal', latched).publish(goal) self.create_timer(self._dt, self._tick) + collisions = 'off' if self._obstacles is None else 'on' self.get_logger().info( f'tether: winch {t.winch[:2]} -> {t.ugv} -> {t.uav} -> object {t.object}, ' - f'goal {t.goal[:2]} r={t.goal[2]:g}, {len(t.obstacles)} obstacle(s); ' + f'goal {t.goal[:2]} r={t.goal[2]:g}, collisions {collisions}; ' f'waiting for {t.ugv} and {t.uav} poses') def _on_ugv(self, msg): @@ -110,7 +117,7 @@ def _tick(self): was_stuck = self._state is not None and self._state.stuck s = self._state = tether.step( max(self._length + self._rate * self._dt, 0.0), t.winch, self._ugv, self._uav, - obj, self._attach_z, t.obstacles, t.object_radius) + obj, self._attach_z, self._obstacles, t.object_radius) self._length = s.length if s.stuck and not was_stuck: self.get_logger().info('object jammed against an obstacle') @@ -153,20 +160,11 @@ def _static_markers(self): drum = self._marker('winch', 1, Marker.CYLINDER, wx, wy, wz) drum.scale.x, drum.scale.y, drum.scale.z = 0.3, 0.3, 0.12 _color(drum, 0.91, 0.35, 0.05) - - pillars = [] - for i, (x, y, r) in enumerate(t.obstacles): - m = self._marker('obstacles', i, Marker.CYLINDER, x, y, OBSTACLE_HEIGHT / 2.0) - m.scale.x = m.scale.y = 2.0 * r - m.scale.z = OBSTACLE_HEIGHT - _color(m, 0.55, 0.45, 0.4) - pillars.append(m) - return [base, drum, *pillars] + return [base, drum] def _dynamic_markers(self): t = self._t s = self._state - wx, wy, wz = t.winch gx, gy, gr = t.goal ox, oy = s.obj size = t.object_size @@ -180,13 +178,6 @@ def _dynamic_markers(self): else: _color(line, 0.98, 0.7, 0.45, 0.8) - pulleys = [] - for i, p in enumerate((self._ugv, self._uav)): - m = self._marker('pulleys', i, Marker.SPHERE, *p) - m.scale.x = m.scale.y = m.scale.z = 0.08 - _color(m, 1.0, 1.0, 1.0) - pulleys.append(m) - box = self._marker('object', 0, Marker.CUBE, ox, oy, size / 2.0) box.scale.x = box.scale.y = box.scale.z = size _color(box, 0.69, 0.54, 0.41) @@ -203,16 +194,7 @@ def _dynamic_markers(self): label.text = 'GOAL REACHED!' if self._reached else 'GOAL' _color(label, 0.4, 0.9, 0.45) - status = self._marker('status', 0, Marker.TEXT_VIEW_FACING, wx, wy, wz + 0.5) - status.scale.z = 0.18 - state = 'STUCK' if s.stuck else 'TAUT' if s.taut else 'slack' - status.text = f'L = {s.length:.2f} m l = {s.free:.2f} m {state}' - if s.stuck: - _color(status, 1.0, 0.4, 0.4) - else: - _color(status, 1.0, 1.0, 1.0) - - return [line, *pulleys, box, goal, label, status] + return [line, box, goal, label] def main(args=None): diff --git a/test/test_config.py b/test/test_config.py index fa9bd82..b0839bb 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -79,6 +79,12 @@ def test_crazyflie_gripper_is_optional(): assert [cf.gripper for cf in s.crazyflies] == [True, False] +def test_obstacles_are_optional_and_parsed(): + assert parse('{}').obstacles == () + s = parse('obstacles: [{x: 1, y: 2, radius: 0.5}, {x: -1, y: 0, radius: 0.1}]') + assert s.obstacles == ((1.0, 2.0, 0.5), (-1.0, 0.0, 0.1)) + + def test_drive_is_case_insensitive(): s = parse('ground_robots: [{name: h, drive: DIFF}]') assert s.ground_robots[0].drive == 'diff' @@ -105,6 +111,12 @@ def test_drive_is_case_insensitive(): ('sim: {collision_z: [2.0, 1.0]}', r'collision_z must be \[z_min, z_max\]'), ('ground_robots: [{name: a, radius: -1}]', r'\(a\): radius must be >= 0'), ('crazyflies: [{radius: -0.1}]', r'\(cf_1\): radius must be >= 0'), + ('obstacles: {x: 0}', 'obstacles must be a list'), + ('obstacles: [{x: 0, y: 0}]', r"obstacles\[0\]: missing \['radius'\]"), + ('obstacles: [[0, 0, 1]]', r'obstacles\[0\] must be a mapping'), + ('obstacles: [{x: 0, y: 0, radius: 1, z: 2}]', r"obstacles\[0\]: unknown key.*'z'"), + ('obstacles: [{x: 0, y: 0, radius: 1}, {x: 0, y: 0, radius: 0}]', + r'obstacles\[1\].radius must be > 0'), ]) def test_rejects_bad_config(text, match): with pytest.raises(ValueError, match=match): @@ -174,15 +186,13 @@ def test_tether_defaults(): assert t.winch == (-1.0, 0.0, 0.3) assert t.object == (1.0, 2.0) assert t.goal == (3.0, 4.0, 0.4) - assert t.obstacles == () assert (t.object_size, t.object_radius, t.slack, t.max_reel_speed) == (0.3, 0.15, 0.5, 0.5) def test_tether_overrides(): - t = parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}, ugv: g2, uav: cf_2, object_size: 0.2, ' - 'obstacles: [{x: 2, y: 2, radius: 0.5}]}').tether + t = parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}, ugv: g2, uav: cf_2, ' + 'object_size: 0.2}').tether assert (t.ugv, t.uav, t.object_radius) == ('g2', 'cf_2', 0.1) - assert t.obstacles == ((2.0, 2.0, 0.5),) @pytest.mark.parametrize('extra, match', [ @@ -192,10 +202,7 @@ def test_tether_overrides(): (', goal: {x: 3, y: 4, z: 0}', r"tether.goal: unknown key.*'z'"), (', goal: {x: 3, y: 4, radius: 0}', 'tether.goal.radius must be > 0'), (', object: {x: 1}', r"tether.object: missing \['y'\]"), - (', obstacles: [{x: 0, y: 0}]', r"tether.obstacles\[0\]: missing \['radius'\]"), - (', obstacles: [[0, 0, 1]]', r'tether.obstacles\[0\] must be a mapping'), - (', obstacles: [{x: 0, y: 0, radius: 0}]', r'tether.obstacles\[0\].radius must be > 0'), - (', obstacles: [{x: 1.3, y: 2, radius: 0.2}]', r'tether.object starts inside obstacles\[0\]'), + (', obstacles: [{x: 0, y: 0, radius: 1}]', 'tether.obstacles moved to the top-level'), (', object_size: 0', 'tether.object_size must be > 0'), (', max_reel_speed: -1', 'tether.max_reel_speed must be > 0'), (', slack: -1', 'tether.slack must be >= 0'), @@ -205,6 +212,14 @@ def test_rejects_bad_tether(extra, match): parse(TETHER_ROBOTS + f'tether: {TETHER_MIN}{extra}}}') +def test_tether_object_must_start_clear_of_pillars(): + clear = TETHER_ROBOTS + 'obstacles: [{x: 1.4, y: 2, radius: 0.2}]\n' + assert parse(clear + f'tether: {TETHER_MIN}}}').obstacles == ((1.4, 2.0, 0.2),) + inside = TETHER_ROBOTS + 'obstacles: [{x: 0, y: 0, radius: 0.1}, {x: 1.3, y: 2, radius: 0.2}]' + with pytest.raises(ValueError, match=r'tether.object starts inside obstacles\[1\]'): + parse(inside + f'\ntether: {TETHER_MIN}}}') + + @pytest.mark.parametrize('text, match', [ ('tether: [1]', 'tether must be a mapping'), ('tether: {winch: {x: 0, y: 0}, object: {x: 1, y: 0}, goal: {x: 2, y: 0}}', diff --git a/test/test_nodes.py b/test/test_nodes.py index b12556b..3f53d21 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -7,9 +7,9 @@ pytest.importorskip('crazyflie_interfaces') from conftest import duration # noqa: E402 -from crazyflie_interfaces.srv import Arm, Takeoff # noqa: E402 +from crazyflie_interfaces.srv import Arm, GoTo, Takeoff # noqa: E402 from std_srvs.srv import Trigger # noqa: E402 -from geometry_msgs.msg import PoseStamped, Twist # noqa: E402 +from geometry_msgs.msg import Point, PoseStamped, Twist # noqa: E402 from hero_sim import config # noqa: E402 from hero_sim.kinematics import yaw_to_quat # noqa: E402 @@ -136,16 +136,69 @@ def test_ground_robots_stop_at_map_obstacles(make, scenario_file): assert robot.x == pytest.approx(-3.3 - robot.radius - 0.1, abs=0.15) -def test_robot_models_latched_and_frame_locked(make): - from rclpy.qos import DurabilityPolicy, QoSProfile - from visualization_msgs.msg import Marker, MarkerArray - from hero_sim.robot_models import TOPIC, RobotModels +PILLAR_SCENARIO = """ +ground_robots: + - name: r + pose: {x: 0.0, y: 0.0} + radius: 0.3 +crazyflies: + - pose: {x: 0.0, y: 1.0, z: 1.0} +obstacles: + - {x: 1.0, y: 0.0, radius: 0.2} + - {x: 1.0, y: 1.0, radius: 0.2} +sim: {map: null, ground_rate: 10.0, cf_rate: 10.0} +""" + + +def test_ground_robots_stop_at_pillars(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(PILLAR_SCENARIO) + sim = make(GroundRobotSim) + robot = sim._robots[0] + sim._on_cmd_vel(robot, twist(vx=1.0)) + for _ in range(20): # 2 s at 1 m/s would carry it through the pillar + sim._tick() + assert 0.5 - 0.05 <= robot.x <= 0.5 # pillar edge at 0.8, minus the radius + + sim._on_cmd_vel(robot, twist(vy=1.0)) # sideways past it is free + sim._tick() + assert robot.y == pytest.approx(0.1) + + +def test_crazyflies_stop_at_pillars_at_any_height(make, scenario_file): + from hero_sim.crazyflie_server import CrazyflieServer + scenario_file(PILLAR_SCENARIO) + server = make(CrazyflieServer) + cf = server.drones['cf_1'] + req = GoTo.Request(goal=Point(x=2.0, y=1.0, z=1.5), duration=duration(1)) + cf._go_to_cb(req, GoTo.Response()) + for _ in range(20): + server._update_all() + assert 0.7 - 0.05 <= cf.position[0] <= 0.7 # pillar edge at 0.8, minus 0.1 + assert cf.position[2] == pytest.approx(1.5) # height still goes through + + +def test_collision_off_ignores_pillars(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(PILLAR_SCENARIO.replace('map: null', 'map: null, collision: false')) + sim = make(GroundRobotSim) + assert sim._obstacles is None + robot = sim._robots[0] + sim._on_cmd_vel(robot, twist(vx=1.0)) + for _ in range(20): + sim._tick() + assert robot.x == pytest.approx(2.0) - publisher = make(RobotModels) # publishes once, before anyone subscribes + +def _latched(make, topic, publisher_cls): + """The first MarkerArray a late TRANSIENT_LOCAL subscriber gets on `topic`.""" + from rclpy.qos import DurabilityPolicy, QoSProfile + from visualization_msgs.msg import MarkerArray + publisher = make(publisher_cls) # publishes once, before anyone subscribes got = [] listener = make(lambda: rclpy.create_node('rviz_stand_in')) listener.create_subscription( - MarkerArray, TOPIC, got.append, + MarkerArray, topic, got.append, QoSProfile(depth=1, durability=DurabilityPolicy.TRANSIENT_LOCAL)) executor = rclpy.executors.SingleThreadedExecutor() executor.add_node(publisher) @@ -155,9 +208,30 @@ def test_robot_models_latched_and_frame_locked(make): if got: break executor.shutdown() - assert got, 'late subscriber did not get the latched MarkerArray' + assert got, f'late subscriber did not get the latched MarkerArray on {topic}' + return got[0] + + +def test_pillars_drawn_latched_even_with_collision_off(make, scenario_file): + from visualization_msgs.msg import Marker + from hero_sim.ground_robots import OBSTACLE_TOPIC, PILLAR_HEIGHT, GroundRobotSim + + scenario_file(PILLAR_SCENARIO.replace('map: null', 'map: null, collision: false')) + markers = _latched(make, OBSTACLE_TOPIC, GroundRobotSim).markers + assert markers[0].action == Marker.DELETEALL + pillars = markers[1:] + assert [(m.ns, m.id, m.type, m.header.frame_id) for m in pillars] == [ + ('obstacles', 0, Marker.CYLINDER, 'world'), ('obstacles', 1, Marker.CYLINDER, 'world')] + assert [(m.pose.position.x, m.pose.position.y) for m in pillars] == [(1.0, 0.0), (1.0, 1.0)] + assert all(m.scale.x == m.scale.y == pytest.approx(0.4) for m in pillars) + assert all(m.scale.z == PILLAR_HEIGHT for m in pillars) - markers = got[0].markers + +def test_robot_models_latched_and_frame_locked(make): + from visualization_msgs.msg import Marker + from hero_sim.robot_models import TOPIC, RobotModels + + markers = _latched(make, TOPIC, RobotModels).markers # herover_1 has no model -> TF only; scout_1 and both CFs get meshes assert [(m.header.frame_id, m.ns) for m in markers] == [ ('cf_1', 'crazyflie'), ('cf_2', 'crazyflie'), ('scout_1', 'scout')] @@ -330,7 +404,7 @@ def test_attach_fails_without_pose(make): goal: {x: 1.0, y: 0.0, radius: 0.3} slack: 0.0 max_reel_speed: 1.0 -sim: {ground_rate: 10.0} +sim: {ground_rate: 10.0, map: null} """ @@ -371,6 +445,25 @@ def test_tether_sim_reels_the_object_in(rclpy_context, scenario_file): node.destroy_node() +def test_tether_sim_object_jams_on_a_pillar(rclpy_context, scenario_file): + from std_msgs.msg import Float64 + from hero_sim.tether_sim import TetherSim + + # A pillar between the object (3, 0) and the point under the UAV (1, 0). + scenario_file(TETHER_SCENARIO + 'obstacles: [{x: 2.0, y: 0.0, radius: 0.2}]\n') + node = TetherSim() + try: + node._on_ugv(pose_msg(-1.0, 0.0, 0.0)) + node._on_uav(pose_msg(1.0, 0.0, 1.0)) + node._on_rate(Float64(data=-1.0)) + for _ in range(30): + node._tick() + assert node._state.stuck and not node._reached + assert 2.3 <= node._state.obj[0] <= 2.35 # pillar 0.2 + object 0.1 + finally: + node.destroy_node() + + def test_tether_sim_needs_a_tether_section(make): from hero_sim.tether_sim import TetherSim with pytest.raises(RuntimeError, match='no tether'): diff --git a/test/test_obstacles.py b/test/test_obstacles.py index d845c47..0062f04 100644 --- a/test/test_obstacles.py +++ b/test/test_obstacles.py @@ -5,13 +5,18 @@ from conftest import CONFIG_DIR, wall_map from hero_sim import config -from hero_sim.obstacles import ObstacleMap, load, read_bt +from hero_sim.obstacles import PILLAR_RES, ObstacleMap, load, read_bt MAPS = sorted((CONFIG_DIR.parent / 'maps').glob('*.bt')) ICUAS26_1 = CONFIG_DIR.parent / 'maps' / 'icuas26_1.bt' TRUNK = (-3.0, 0.15) # a tree trunk in icuas26_1 +def pillar_map(max_radius=0.5): + """No map grid, one pillar of radius 0.2 at (1, 0).""" + return ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, max_radius, [(1.0, 0.0, 0.2)]) + + @pytest.mark.parametrize('path', MAPS, ids=lambda p: p.name) def test_read_bt_shipped_maps(path): res, boxes = read_bt(path) # raises on trailing bytes, so the whole tree was read @@ -95,3 +100,40 @@ def test_load_off_without_map_robots_or_collision(): assert load(dict(sim, collision=False), [0.1]) is None assert load(dict(sim, map=None), [0.1]) is None assert load(sim, []) is None + + +def test_pillar_clearance_is_exact_to_its_edge(): + m = pillar_map() + assert m.clearance(1.5, 0.0) == pytest.approx(0.3) + assert m.clearance(1.0, -0.45) == pytest.approx(0.25) + assert m.clearance(1.0, 0.1) == 0.0 # inside + assert m.clearance(2.0, 0.0) == math.inf # 0.8 m: beyond max_radius + + +def test_clearance_is_the_nearer_of_map_and_pillar(): + occupied = np.zeros((10, 2), bool) + occupied[:, 0] = True # wall_map's wall, face at x = 1.0 + m = ObstacleMap(occupied, 1.0, -0.5, 0.1, 0.5, [(0.3, 0.0, 0.1)]) + assert m.clearance(0.62, 0.0) == pytest.approx(0.22) # pillar edge at 0.4 + assert m.clearance(0.88, 0.0) == pytest.approx(0.15) # the wall, from the cell centre + + +def test_move_stops_before_a_pillar_by_radius(): + m = pillar_map() + x, y = m.move(0.0, 0.0, 2.0, 0.0, radius=0.3) + assert y == 0.0 + assert 0.5 - PILLAR_RES / 2 <= x <= 0.5 # pillar edge at 0.8, minus the radius + assert m.move(0.0, 0.6, 2.0, 0.6, radius=0.3) == (2.0, 0.6) # passes beside it + + +def test_load_pillars_with_and_without_map(): + pillars = [(1.0, 0.0, 0.2)] + sim = dict(config.SIM_DEFAULTS, map=None) + m = load(sim, [0.1], pillars) + assert m.clearance(1.0, 0.0) == 0.0 and m.res == PILLAR_RES + assert load(dict(sim, collision=False), [0.1], pillars) is None + assert load(sim, [], pillars) is None + + both = load(dict(sim, map=str(ICUAS26_1)), [0.1], [(0.0, 0.0, 0.2)]) + assert both.clearance(0.0, 0.0) == 0.0 + assert both.clearance(*TRUNK) == 0.0 diff --git a/test/test_tether.py b/test/test_tether.py index cbbf8d5..0c7a267 100644 --- a/test/test_tether.py +++ b/test/test_tether.py @@ -1,14 +1,18 @@ import math +import numpy as np import pytest -from hero_sim.tether import first_contact, initial_length, reached, step +from conftest import wall_map +from hero_sim.obstacles import PILLAR_RES, ObstacleMap +from hero_sim.tether import initial_length, reached, step # Winch, UGV pulley and UAV ring on the x axis, UAV 1 m up, object tied at the floor. WINCH, UGV, UAV = (-2.0, 0.0, 0.0), (-1.0, 0.0, 0.0), (1.0, 0.0, 1.0) ROUTED = 1.0 + math.sqrt(5.0) # |W - G| + |G - A| OBJ = (3.0, 0.0) # |A - O| = sqrt(5) -PILLAR = (2.0, 0.0, 0.2) # between the object and the point under the UAV +# A pillar between the object and the point under the UAV. +PILLAR = ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, 0.5, [(2.0, 0.0, 0.2)]) def test_initial_length_leaves_the_slack(): @@ -43,31 +47,37 @@ def test_attach_height_shortens_the_drop(): def test_object_jams_against_a_pillar_and_the_winch_slips(): - s = step(ROUTED + 1.5, WINCH, UGV, UAV, OBJ, 0.0, [PILLAR], 0.1) - assert s.obj == pytest.approx((2.3, 0.0)) # touching: pillar 0.2 + object 0.1 + s = step(ROUTED + 1.5, WINCH, UGV, UAV, OBJ, 0.0, PILLAR, 0.1) + # Touching: pillar 0.2 + object 0.1, stopped within a path sample. + assert 2.3 <= s.obj[0] <= 2.3 + PILLAR_RES / 2 and s.obj[1] == 0.0 assert s.stuck and s.taut - assert s.free == pytest.approx(math.dist(UAV, (2.3, 0.0, 0.0))) + assert s.free == pytest.approx(math.dist(UAV, (*s.obj, 0.0))) assert s.length == pytest.approx(ROUTED + s.free) - again = step(s.length - 0.5, WINCH, UGV, UAV, s.obj, 0.0, [PILLAR], 0.1) - assert again.obj == pytest.approx(s.obj) and again.stuck + again = step(s.length - 0.5, WINCH, UGV, UAV, s.obj, 0.0, PILLAR, 0.1) + assert again.obj == s.obj and again.stuck def test_pull_away_from_the_pillar_frees_the_object(): uav = (4.0, 0.0, 1.0) # now on the far side of the object from the pillar - s = step(0.0, WINCH, UGV, uav, (2.3, 0.0), 0.0, [PILLAR], 0.1) + s = step(0.0, WINCH, UGV, uav, (2.3, 0.0), 0.0, PILLAR, 0.1) assert s.obj == pytest.approx((4.0, 0.0)) assert not s.stuck -def test_first_contact(): - pillar = [(0.0, 0.0, 0.5)] - assert first_contact((-2.0, 0.0), (2.0, 0.0), pillar, 0.0) == pytest.approx((-0.5, 0.0)) - assert first_contact((-2.0, 1.0), (2.0, 1.0), pillar, 0.0) is None # passes beside - assert first_contact((-2.0, 0.0), (-1.0, 0.0), pillar, 0.0) is None # stops short - assert first_contact((-0.5, 0.0), (-2.0, 0.0), pillar, 0.0) is None # moves away - assert first_contact((-0.5, 0.0), (0.0, 0.0), pillar, 0.0) == (-0.5, 0.0) # pushed in - assert first_contact((-1.0, 0.0), (-1.0, 0.0), pillar, 0.0) is None # no move +def test_pillar_beside_the_path_lets_it_through(): + s = step(ROUTED + 1.5, WINCH, UGV, UAV, (3.0, 1.0), 0.0, PILLAR, 0.1) # ends at (2, 0.5) + assert not s.stuck + assert math.dist(UAV, (*s.obj, 0.0)) == pytest.approx(1.5) + + +def test_object_jams_against_the_map(): + # Pulled from x = 2 toward x ~ 0.66 through wall_map's wall at x = 1.0..1.1. + uav = (0.0, 0.0, 1.0) + routed = math.dist(WINCH, UGV) + math.dist(UGV, uav) + s = step(routed + 1.2, WINCH, UGV, uav, (2.0, 0.0), 0.0, wall_map(), 0.1) + assert s.stuck + assert 1.2 - 0.05 <= s.obj[0] <= 1.2 + 0.1 # far face + radius, within a cell def test_reached(): From 35672c8f3f8ac4518feb6a4cbc3df6c75eab0012 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:00:17 +0100 Subject: [PATCH 05/13] feat(ground): go_to service for ground robots Every ground robot serves //go_to with crazyflie_interfaces/srv/GoTo, the Crazyflies' type. It reaches goal x, y and yaw in `duration`, stretched to stay within ground_max_speed and ground_max_yaw_rate (duration 0: as fast as allowed). omni drives straight while turning; diff turns, drives, then turns (kinematics.GroundGoTo). relative offsets x, y in the world frame; goal.z and group_mask are ignored. Any cmd_vel cancels it, and it stops at the map and pillars like every other move. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++ config/example.yaml | 7 ++- config/tether_demo.yaml | 3 +- hero_sim/config.py | 4 +- hero_sim/ground_robots.py | 40 +++++++++++++++-- hero_sim/kinematics.py | 45 +++++++++++++++++++ test/test_kinematics.py | 52 +++++++++++++++++++++- test/test_nodes.py | 94 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 245 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e8b6b35..adfde55 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ The attach node runs the grippers, so they also need `hero_mrs_msgs`. Like the r ```bash # ground robot ros2 topic pub /scout_1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}" +ros2 service call /scout_1/go_to crazyflie_interfaces/srv/GoTo \ + "{goal: {x: 1.0, y: 0.5}, yaw: 1.57, duration: {sec: 3}}" # crazyflie ros2 service call /cf_1/takeoff crazyflie_interfaces/srv/Takeoff "{height: 1.0, duration: {sec: 2}}" @@ -113,6 +115,13 @@ ros2 service call /all/land crazyflie_interfaces/srv/Land "{height: 0.0, duratio Ground robots hold the last `cmd_vel` until a new one arrives. Poses are published on `//pose` and as TF from `world`. +A ground robot's `go_to` uses the same service type as the Crazyflies': + +- It reaches the goal's x, y and yaw in `duration`, or more slowly if that's needed to stay within `sim.ground_max_speed` and `sim.ground_max_yaw_rate` (2 m/s and 2 rad/s by default; `duration: 0` means as fast as allowed). +- An omni robot drives straight while it turns. A diff robot turns to face the goal, drives, then turns to the yaw. +- `relative: true` makes x and y an offset from where the robot is, in the world frame; yaw stays absolute. `goal.z` and `group_mask` are ignored. +- Any `cmd_vel` message cancels it, and it stops at [obstacles](#obstacles) like every other move. + As on hardware, where the mocap markers sit on top of the robot, a ground robot's pose is at its marker height rather than on the floor: the top of the deck, scout 0.227 m and herover 0.095 m (0 without a model). Docks the control stack places on a robot are relative to this height. To change it, set `pose: {z: ...}`; the RViz model stays on the floor. ## Obstacles diff --git a/config/example.yaml b/config/example.yaml index bf11ce1..a2a1974 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -12,6 +12,9 @@ # model: scout | herover RViz mesh drawn on the robot's frame; leave out for TF axes only # radius: 0.42 footprint (m) kept out of the map and pillars (see sim.collision). # Default by model: scout 0.42, herover 0.17, no model 0.3. 0 ignores them +# Each one also serves //go_to (crazyflie_interfaces/srv/GoTo, like the +# Crazyflies): drives to goal x, y and yaw in `duration`, slower if the speed +# limits below need it. Any cmd_vel message cancels it. ground_robots: - name: scout_1 pose: {x: 0.0, y: 0.0, yaw: 0.0} # m, m, rad @@ -36,8 +39,8 @@ crazyflies: # Optional. Leave out any key to keep its default. sim: ground_rate: 50.0 # Hz, ground robot integration + /pose rate - ground_max_speed: 2.0 # m/s, clamp on |(linear.x, linear.y)| - ground_max_yaw_rate: 2.0 # rad/s, clamp on |angular.z| + ground_max_speed: 2.0 # m/s, clamp on |(linear.x, linear.y)| and go_to speed + ground_max_yaw_rate: 2.0 # rad/s, clamp on |angular.z| and go_to turns cf_rate: 10.0 # Hz, Crazyflie update + /pose rate cf_battery_drain: 0.025 # V/s while flying (starts 4.2 V, floors at 3.0 V) attach: auto # sim_attach (/sim/attach_agent dock ride-along): auto runs it diff --git a/config/tether_demo.yaml b/config/tether_demo.yaml index 653aa0d..a78b432 100644 --- a/config/tether_demo.yaml +++ b/config/tether_demo.yaml @@ -5,7 +5,8 @@ # # bash start.sh tether_demo.yaml # sim + RViz # -# Controls: /herover_1/cmd_vel, /cf_1/takeoff|go_to|land, /winch/cmd_rate (m/s, + out, - in). +# Controls: /herover_1/cmd_vel or /herover_1/go_to, /cf_1/takeoff|go_to|land, +# /winch/cmd_rate (m/s, + out, - in). # Feedback: /tether/object_pose, /tether/goal, /tether/taut, /tether/free_length, # /tether/stuck, /tether/goal_reached, /winch/length, /hero_sim/obstacles. # See README "Tether task". diff --git a/hero_sim/config.py b/hero_sim/config.py index 23175ff..e19855c 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -19,8 +19,8 @@ SIM_DEFAULTS = { 'ground_rate': 50.0, # Hz, ground robot integration + /pose rate - 'ground_max_speed': 2.0, # m/s, clamp on |(linear.x, linear.y)| - 'ground_max_yaw_rate': 2.0, # rad/s, clamp on |angular.z| + 'ground_max_speed': 2.0, # m/s, clamp on |(linear.x, linear.y)| and go_to + 'ground_max_yaw_rate': 2.0, # rad/s, clamp on |angular.z| and go_to 'cf_rate': 10.0, # Hz, Crazyflie update + /pose rate 'cf_battery_drain': 0.025, # V/s while flying (starts at 4.2 V, floor 3.0 V) 'attach': 'auto', # sim_attach dock ride-along: auto | true | false (see attach_enabled) diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index f512cf3..5430c12 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -5,12 +5,18 @@ published in 'world' at the robot's pose z (its marker height, see config.MARKER_HEIGHTS), along with a world -> transform. +//go_to (crazyflie_interfaces/srv/GoTo, as for the Crazyflies) drives +to goal x, y and yaw in `duration`, or slower to stay within the speed limits +(kinematics.GroundGoTo). relative makes x, y an offset in the world frame; +goal.z and group_mask are ignored. Any cmd_vel message cancels it. + With sim.collision on, a robot stops where its footprint would hit the map or a pillar (hero_sim/obstacles.py) and resumes when cmd_vel points away. This node also draws the scenario's pillars (its obstacles: list), latched on /hero_sim/obstacles: one CYLINDER per pillar, at its x, y, scale.x = diameter. """ +from crazyflie_interfaces.srv import GoTo from geometry_msgs.msg import PoseStamped, TransformStamped, Twist import rclpy from rclpy.node import Node @@ -19,7 +25,7 @@ from visualization_msgs.msg import Marker, MarkerArray from hero_sim import config, obstacles -from hero_sim.kinematics import integrate_ground, limit_ground_cmd, yaw_to_quat +from hero_sim.kinematics import GroundGoTo, integrate_ground, limit_ground_cmd, yaw_to_quat FRAME = 'world' OBSTACLE_TOPIC = '/hero_sim/obstacles' @@ -27,7 +33,8 @@ class _Robot: - __slots__ = ('name', 'drive', 'radius', 'x', 'y', 'z', 'yaw', 'vx', 'vy', 'wz', 'pose_pub') + __slots__ = ('name', 'drive', 'radius', 'x', 'y', 'z', 'yaw', 'vx', 'vy', 'wz', 'pose_pub', + 'go_to', 'go_to_t') def __init__(self, spec, pose_pub): self.name = spec.name @@ -36,6 +43,8 @@ def __init__(self, spec, pose_pub): self.x, self.y, self.z, self.yaw = spec.x, spec.y, spec.z, spec.yaw self.vx = self.vy = self.wz = 0.0 self.pose_pub = pose_pub + self.go_to = None # GroundGoTo while one runs + self.go_to_t = 0.0 # s into it class GroundRobotSim(Node): @@ -57,6 +66,8 @@ def __init__(self): robot = _Robot(spec, self.create_publisher(PoseStamped, f'/{spec.name}/pose', qos)) self.create_subscription(Twist, f'/{spec.name}/cmd_vel', lambda msg, r=robot: self._on_cmd_vel(r, msg), qos) + self.create_service(GoTo, f'/{spec.name}/go_to', + lambda req, resp, r=robot: self._on_go_to(r, req, resp)) self._robots.append(robot) self._obstacles = obstacles.load(sim, [r.radius for r in self._robots], @@ -102,15 +113,38 @@ def _publish_pillars(self, pillars): self._pillar_pub.publish(MarkerArray(markers=markers)) def _on_cmd_vel(self, robot, msg): + robot.go_to = None robot.vx, robot.vy, robot.wz = limit_ground_cmd( msg.linear.x, msg.linear.y, msg.angular.z, robot.drive, self._max_speed, self._max_yaw_rate) + def _on_go_to(self, robot, request, response): + x, y = request.goal.x, request.goal.y + if request.relative: + x, y = robot.x + x, robot.y + y + duration = request.duration.sec + request.duration.nanosec * 1e-9 + robot.go_to = GroundGoTo((robot.x, robot.y, robot.yaw), (x, y, request.yaw), robot.drive, + duration, self._max_speed, self._max_yaw_rate) + robot.go_to_t = 0.0 + robot.vx = robot.vy = robot.wz = 0.0 + total = robot.go_to.total + slower = f' (asked {duration:.2f}s, held to the speed limits)' if total > duration else '' + self.get_logger().info( + f'[{robot.name}] GO_TO ({x:.2f}, {y:.2f}) yaw={request.yaw:.2f} ' + f'in {total:.2f}s{slower}') + return response + def _tick(self): stamp = self.get_clock().now().to_msg() transforms = [] for r in self._robots: - x, y, r.yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) + if r.go_to is not None: + r.go_to_t += self._dt + x, y, r.yaw = r.go_to.at(r.go_to_t) + if r.go_to_t >= r.go_to.total: + r.go_to = None + else: + x, y, r.yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) if self._obstacles is not None: x, y = self._obstacles.move(r.x, r.y, x, y, r.radius) r.x, r.y = x, y diff --git a/hero_sim/kinematics.py b/hero_sim/kinematics.py index 8089049..9069775 100644 --- a/hero_sim/kinematics.py +++ b/hero_sim/kinematics.py @@ -40,3 +40,48 @@ def integrate_ground(x, y, yaw, vx, vy, wz, dt): x += (vx * c - vy * s) * dt y += (vx * s + vy * c) * dt return x, y, wrap_angle(yaw + wz * dt) + + +class GroundGoTo: + """A ground robot's go_to: from `start` to `goal` (x, y, yaw), within the speed limits. + + omni drives straight while turning to the goal yaw. diff turns to face the + goal, drives straight, then turns to the goal yaw (closer than MIN_DRIVE + it only turns). The move takes `duration` s, or longer if that would + exceed max_speed or max_yaw_rate: then all of its phases stretch alike. + """ + + MIN_DRIVE = 0.01 # m + + def __init__(self, start, goal, drive, duration, max_speed, max_yaw_rate): + x0, y0, yaw0 = start + x1, y1, yaw1 = goal + dx, dy = x1 - x0, y1 - y0 + dist = math.hypot(dx, dy) + # Phases (shortest time, start pose, change in x, y, yaw), in order. + if drive == 'diff' and dist >= self.MIN_DRIVE: + heading = math.atan2(dy, dx) + turn_in, turn_out = wrap_angle(heading - yaw0), wrap_angle(yaw1 - heading) + phases = [ + (abs(turn_in) / max_yaw_rate, (x0, y0, yaw0), (0.0, 0.0, turn_in)), + (dist / max_speed, (x0, y0, heading), (dx, dy, 0.0)), + (abs(turn_out) / max_yaw_rate, (x1, y1, heading), (0.0, 0.0, turn_out)), + ] + else: + turn = wrap_angle(yaw1 - yaw0) + shortest = max(dist / max_speed, abs(turn) / max_yaw_rate) + phases = [(shortest, (x0, y0, yaw0), (dx, dy, turn))] + shortest = sum(p[0] for p in phases) + self.total = max(duration, shortest) # s + stretch = self.total / shortest if shortest > 0.0 else 0.0 + self._phases = [(t * stretch, pose, delta) for t, pose, delta in phases] + self.goal = (x1, y1, wrap_angle(yaw1)) + + def at(self, t): + """Pose (x, y, yaw) t s into the move; the goal from `total` on.""" + for span, (x, y, yaw), (dx, dy, dyaw) in self._phases: + if t < span: + a = t / span + return x + a * dx, y + a * dy, wrap_angle(yaw + a * dyaw) + t -= span + return self.goal diff --git a/test/test_kinematics.py b/test/test_kinematics.py index 753586a..7d9fda6 100644 --- a/test/test_kinematics.py +++ b/test/test_kinematics.py @@ -3,7 +3,7 @@ import pytest from hero_sim.kinematics import ( - integrate_ground, limit_ground_cmd, quat_to_yaw, wrap_angle, yaw_to_quat, + GroundGoTo, integrate_ground, limit_ground_cmd, quat_to_yaw, wrap_angle, yaw_to_quat, ) @@ -52,3 +52,53 @@ def test_integrate_full_turn_returns_to_start(): for _ in range(steps): x, y, yaw = integrate_ground(x, y, yaw, 1.0, 0.0, 1.0, dt) assert (x, y) == pytest.approx((0.0, 0.0), abs=0.02) + + +def test_go_to_omni_goes_straight_in_the_duration(): + g = GroundGoTo((0.0, 0.0, 0.0), (1.0, 1.0, 1.0), 'omni', 4.0, 2.0, 2.0) + assert g.total == 4.0 + assert g.at(2.0) == pytest.approx((0.5, 0.5, 0.5)) + assert g.at(4.0) == pytest.approx((1.0, 1.0, 1.0)) + assert g.at(9.0) == pytest.approx((1.0, 1.0, 1.0)) + + +def test_go_to_held_to_the_speed_limits(): + assert GroundGoTo((0, 0, 0), (4, 0, 0), 'omni', 1.0, 2.0, 2.0).total == pytest.approx(2.0) + assert GroundGoTo((0, 0, 0), (0, 0, 3), 'omni', 0.0, 2.0, 1.0).total == pytest.approx(3.0) + + +def test_go_to_turns_the_short_way(): + g = GroundGoTo((0.0, 0.0, 3.0), (0.0, 0.0, -3.0), 'omni', 0.0, 2.0, 1.0) + assert g.total == pytest.approx(2 * math.pi - 6.0) # through pi, not 6 rad back + assert abs(g.at(g.total / 2)[2]) == pytest.approx(math.pi) + + +def test_go_to_diff_turns_drives_then_turns(): + # Facing +x, goal 2 m up +y facing -x: 90 deg in place, 2 m straight, 90 deg in place. + g = GroundGoTo((0.0, 0.0, 0.0), (0.0, 2.0, math.pi), 'diff', 0.0, + max_speed=1.0, max_yaw_rate=math.pi / 2) + assert g.total == pytest.approx(4.0) + assert g.at(0.5) == pytest.approx((0.0, 0.0, math.pi / 4)) + assert g.at(2.0) == pytest.approx((0.0, 1.0, math.pi / 2)) + assert g.at(3.5) == pytest.approx((0.0, 2.0, 3 * math.pi / 4)) + assert g.at(4.0) == pytest.approx((0.0, 2.0, math.pi)) + + +def test_go_to_diff_stretches_every_phase_alike(): + g = GroundGoTo((0.0, 0.0, 0.0), (0.0, 2.0, math.pi), 'diff', 8.0, 1.0, math.pi / 2) + assert g.total == 8.0 + assert g.at(1.0) == pytest.approx((0.0, 0.0, math.pi / 4)) # half the yaw rate + assert g.at(4.0) == pytest.approx((0.0, 1.0, math.pi / 2)) # half the speed + + +def test_go_to_diff_barely_moving_only_turns(): + g = GroundGoTo((1.0, 1.0, 0.0), (1.005, 1.0, 1.0), 'diff', 0.0, 1.0, 2.0) + assert g.total == pytest.approx(0.5) + assert g.at(0.25)[2] == pytest.approx(0.5) + assert g.at(0.5) == pytest.approx((1.005, 1.0, 1.0)) + + +def test_go_to_nowhere_is_done_at_once(): + g = GroundGoTo((1.0, 2.0, 0.5), (1.0, 2.0, 0.5), 'omni', 0.0, 2.0, 2.0) + assert g.total == 0.0 + assert g.at(0.0) == pytest.approx((1.0, 2.0, 0.5)) diff --git a/test/test_nodes.py b/test/test_nodes.py index 3f53d21..c4ed879 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -190,6 +190,100 @@ def test_collision_off_ignores_pillars(make, scenario_file): assert robot.x == pytest.approx(2.0) +GO_TO_SCENARIO = """ +ground_robots: + - name: omni_1 + pose: {x: 0.0, y: 0.0} + - name: diff_1 + pose: {x: 0.0, y: 2.0} + drive: diff +obstacles: + - {x: 3.0, y: 0.0, radius: 0.2} +sim: {map: null, ground_rate: 10.0, ground_max_speed: 1.0, ground_max_yaw_rate: 2.0} +""" + + +def go_to(x, y, yaw=0.0, sec=0, relative=False): + return GoTo.Request(goal=Point(x=x, y=y, z=5.0), yaw=yaw, duration=duration(sec), + relative=relative) + + +@pytest.fixture() +def ground(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(GO_TO_SCENARIO) + return make(GroundRobotSim) + + +def test_ground_go_to_reaches_the_goal(ground): + omni, _ = ground._robots + ground._on_go_to(omni, go_to(1.0, 0.5, yaw=0.5, sec=2), GoTo.Response()) + assert omni.go_to.total == 2.0 + for _ in range(10): + ground._tick() + assert (omni.x, omni.y, omni.yaw) == pytest.approx((0.5, 0.25, 0.25)) + for _ in range(15): + ground._tick() + assert (omni.x, omni.y, omni.yaw) == pytest.approx((1.0, 0.5, 0.5)) + assert omni.go_to is None # done: the robot holds still + ground._tick() + assert (omni.x, omni.y) == pytest.approx((1.0, 0.5)) + + +def test_ground_go_to_held_to_max_speed(ground): + omni, _ = ground._robots + ground._on_go_to(omni, go_to(2.0, 0.0), GoTo.Response()) # duration 0 + assert omni.go_to.total == pytest.approx(2.0) # ground_max_speed 1 m/s + for _ in range(10): + ground._tick() + assert omni.x == pytest.approx(1.0) + + +def test_ground_go_to_relative_and_diff(ground): + _, diff = ground._robots + ground._on_go_to(diff, go_to(1.0, 0.0, relative=True), GoTo.Response()) + for _ in range(15): + ground._tick() + assert (diff.x, diff.y, diff.yaw) == pytest.approx((1.0, 2.0, 0.0)) + + +def test_cmd_vel_cancels_a_go_to(ground): + omni, _ = ground._robots + ground._on_go_to(omni, go_to(2.0, 0.0, sec=4), GoTo.Response()) + for _ in range(5): + ground._tick() + ground._on_cmd_vel(omni, twist()) + assert omni.go_to is None + stopped = omni.x + for _ in range(5): + ground._tick() + assert omni.x == stopped == pytest.approx(0.25) + + +def test_ground_go_to_stops_at_a_pillar(ground): + omni, _ = ground._robots + ground._on_go_to(omni, go_to(5.0, 0.0), GoTo.Response()) + for _ in range(60): + ground._tick() + assert 2.5 - 0.05 <= omni.x <= 2.5 # pillar edge at 2.8, minus the radius 0.3 + assert omni.go_to is None + + +def test_ground_go_to_service_call(ground, make): + client = make(lambda: rclpy.create_node('go_to_client')) + cli = client.create_client(GoTo, '/diff_1/go_to') + assert cli.wait_for_service(timeout_sec=2.0) + executor = rclpy.executors.SingleThreadedExecutor() + executor.add_node(ground) + executor.add_node(client) + future = cli.call_async(go_to(1.0, 3.0, sec=2)) + executor.spin_until_future_complete(future, timeout_sec=2.0) + executor.shutdown() + assert future.done() + goal = ground._robots[1].go_to.goal + assert goal == pytest.approx((1.0, 3.0, 0.0)) + + def _latched(make, topic, publisher_cls): """The first MarkerArray a late TRANSIENT_LOCAL subscriber gets on `topic`.""" from rclpy.qos import DurabilityPolicy, QoSProfile From 9f463e8acf76985322dd42945d36377b1e8ff99e Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:06:15 +0100 Subject: [PATCH 06/13] feat(ground): ground robots collide with each other Under sim.collision, each ground robot's move is checked against the other ground robots as circles of their radius, in the same pass as the map and the pillars (ObstacleMap.move's new `others`). Robots move one after another each tick against the others' current positions, so they never overlap; radius 0 opts out. load() now returns an empty map without a map or pillars, so this works in bare scenarios too. A blocked go_to now waits where it stopped, its clock paused, and carries on once the way clears, instead of jumping to where its schedule got to. config/two_ugvs.yaml: two herovers and the tether demo's pillar, no tether. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++- config/example.yaml | 8 +++-- config/two_ugvs.yaml | 25 +++++++++++++ hero_sim/config.py | 2 +- hero_sim/ground_robots.py | 37 ++++++++++++++----- hero_sim/obstacles.py | 35 ++++++++++-------- test/test_nodes.py | 75 ++++++++++++++++++++++++++++++++++++++- test/test_obstacles.py | 29 +++++++++++++-- 8 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 config/two_ugvs.yaml diff --git a/README.md b/README.md index adfde55..04461eb 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ A ground robot's `go_to` uses the same service type as the Crazyflies': - It reaches the goal's x, y and yaw in `duration`, or more slowly if that's needed to stay within `sim.ground_max_speed` and `sim.ground_max_yaw_rate` (2 m/s and 2 rad/s by default; `duration: 0` means as fast as allowed). - An omni robot drives straight while it turns. A diff robot turns to face the goal, drives, then turns to the yaw. - `relative: true` makes x and y an offset from where the robot is, in the world frame; yaw stays absolute. `goal.z` and `group_mask` are ignored. -- Any `cmd_vel` message cancels it, and it stops at [obstacles](#obstacles) like every other move. +- Any `cmd_vel` message cancels it. It stops at [obstacles](#obstacles) like every other move; a blocked `go_to` waits where it stopped and carries on once the way is clear, such as when the other robot drives off. As on hardware, where the mocap markers sit on top of the robot, a ground robot's pose is at its marker height rather than on the floor: the top of the deck, scout 0.227 m and herover 0.095 m (0 without a model). Docks the control stack places on a robot are relative to this height. To change it, set `pose: {z: ...}`; the RViz model stays on the floor. @@ -133,6 +133,8 @@ Robots can't drive or fly into obstacles. There are two kinds, and both block in Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk or a pillar can still climb or land, but it can't fly over it. +Ground robots also stop at each other, under the same switch. Drones aren't checked against the ground robots, so they can dock on them, or against each other. A robot with `radius: 0` ignores all of it. + ```yaml ground_robots: - name: scout_1 diff --git a/config/example.yaml b/config/example.yaml index a2a1974..1623d88 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -10,8 +10,9 @@ # drive: omni holonomic, honors linear.x, linear.y and angular.z (default) # drive: diff non-holonomic, linear.y is ignored # model: scout | herover RViz mesh drawn on the robot's frame; leave out for TF axes only -# radius: 0.42 footprint (m) kept out of the map and pillars (see sim.collision). -# Default by model: scout 0.42, herover 0.17, no model 0.3. 0 ignores them +# radius: 0.42 footprint (m) kept out of the map, pillars and other ground robots +# (sim.collision). Default by model: scout 0.42, herover 0.17, no +# model 0.3. 0 ignores them # Each one also serves //go_to (crazyflie_interfaces/srv/GoTo, like the # Crazyflies): drives to goal x, y and yaw in `duration`, slower if the speed # limits below need it. Any cmd_vel message cancels it. @@ -49,7 +50,8 @@ sim: models: true # robot meshes in RViz (crazyflies, and ground robots with a model) map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none collision: true # robots and the tether object stop where their footprint - # would hit a map obstacle or a pillar (obstacles: below) + # would hit a map obstacle or a pillar (obstacles: below); + # ground robots also stop at each other collision_z: [0.2, 2.0] # m, map heights that count as obstacles, flattened to 2D: # trunks and posts, not the floor, ceiling or tree canopies diff --git a/config/two_ugvs.yaml b/config/two_ugvs.yaml new file mode 100644 index 0000000..a75b169 --- /dev/null +++ b/config/two_ugvs.yaml @@ -0,0 +1,25 @@ +# Two UGVs, no tether: the tether demo's floor and pillar, with a second +# herover and no drone. +# +# bash start.sh two_ugvs.yaml # sim + RViz +# +# Controls: /herover_/cmd_vel (Twist, body frame) or /herover_/go_to +# (crazyflie_interfaces/srv/GoTo). Feedback: /herover_/pose, +# /hero_sim/obstacles. See README "Driving the robots" and "Obstacles". + +ground_robots: + - name: herover_1 + pose: {x: -1.5, y: -1.5, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.5, y: 1.5, yaw: 0.0} + drive: omni + model: herover + +obstacles: # pillars: block both robots + - {x: 1.5, y: 0.5, radius: 0.35} + +sim: + map: null + collision: true diff --git a/hero_sim/config.py b/hero_sim/config.py index e19855c..20d3f01 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -27,7 +27,7 @@ 'rviz': True, # open RViz with config/sim.rviz 'models': True, # robot meshes in RViz (crazyflies + ground robots with a model) 'map': 'icuas26_1', # octomap: maps/.bt, a path to a .bt, or null - 'collision': True, # stop robots at the map and pillars (2D, hero_sim/obstacles.py) + 'collision': True, # stop robots at the map, pillars and each other (obstacles.py) 'collision_z': (0.2, 2.0), # m, map heights counted as obstacles: trunks, not floor/canopy } diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index 5430c12..2663c40 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -10,12 +10,17 @@ (kinematics.GroundGoTo). relative makes x, y an offset in the world frame; goal.z and group_mask are ignored. Any cmd_vel message cancels it. -With sim.collision on, a robot stops where its footprint would hit the map or -a pillar (hero_sim/obstacles.py) and resumes when cmd_vel points away. +With sim.collision on, a robot stops where its footprint would hit the map, a +pillar or another ground robot (hero_sim/obstacles.py), and resumes when +cmd_vel points away. A blocked go_to waits where it stopped and carries on +once the way is clear. The robots move one after another each tick, each +against the others' current positions, so they never end up overlapping. This node also draws the scenario's pillars (its obstacles: list), latched on /hero_sim/obstacles: one CYLINDER per pillar, at its x, y, scale.x = diameter. """ +import math + from crazyflie_interfaces.srv import GoTo from geometry_msgs.msg import PoseStamped, TransformStamped, Twist import rclpy @@ -77,6 +82,12 @@ def __init__(self): if self._obstacles.clearance(r.x, r.y) < r.radius: self.get_logger().warn(f'{r.name} starts overlapping an obstacle; ' 'it can only move away from it') + for i, a in enumerate(self._robots): + for b in self._robots[i + 1:]: + if (a.radius > 0.0 and b.radius > 0.0 + and math.hypot(a.x - b.x, a.y - b.y) < a.radius + b.radius): + self.get_logger().warn(f'{a.name} and {b.name} start overlapping; ' + 'they can only move apart') self._publish_pillars(scenario.obstacles) self._tf = TransformBroadcaster(self) @@ -134,19 +145,29 @@ def _on_go_to(self, robot, request, response): f'in {total:.2f}s{slower}') return response + def _others(self, robot): + """The other ground robots, as circles (x, y, radius) for `robot` to keep clear of.""" + return [(o.x, o.y, o.radius) for o in self._robots if o is not robot and o.radius > 0.0] + def _tick(self): stamp = self.get_clock().now().to_msg() transforms = [] for r in self._robots: if r.go_to is not None: - r.go_to_t += self._dt - x, y, r.yaw = r.go_to.at(r.go_to_t) - if r.go_to_t >= r.go_to.total: - r.go_to = None + t = r.go_to_t + self._dt + x, y, yaw = r.go_to.at(t) else: - x, y, r.yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) + x, y, yaw = integrate_ground(r.x, r.y, r.yaw, r.vx, r.vy, r.wz, self._dt) + wanted = (x, y) if self._obstacles is not None: - x, y = self._obstacles.move(r.x, r.y, x, y, r.radius) + x, y = self._obstacles.move(r.x, r.y, x, y, r.radius, self._others(r)) + if r.go_to is None: + r.yaw = yaw + elif (x, y) == wanted: + r.go_to_t, r.yaw = t, yaw + if t >= r.go_to.total: + r.go_to = None + # else blocked: the go_to waits here, its clock stopped, until the way clears r.x, r.y = x, y qx, qy, qz, qw = yaw_to_quat(r.yaw) diff --git a/hero_sim/obstacles.py b/hero_sim/obstacles.py index 3740775..46da39b 100644 --- a/hero_sim/obstacles.py +++ b/hero_sim/obstacles.py @@ -1,6 +1,6 @@ -"""2D collisions: keep footprints out of the map and the pillars. No ROS imports. +"""2D collisions: keep footprints out of the map, the pillars and each other. No ROS imports. -Two kinds of obstacle, both in one ObstacleMap: +Two kinds of fixed obstacle, both in one ObstacleMap: map the scenario's octomap (.bt), flattened once at startup: every occupied voxel overlapping the height band sim.collision_z is an @@ -10,6 +10,9 @@ pillars the scenario's top-level obstacles: list, circles (x, y, radius) kept exact rather than gridded. +Moving robots are circles too, passed to each move as `others` +(hero_sim/ground_robots.py does that for the ground robots). + Every robot is a disc of its footprint radius (config.FOOTPRINT_RADII), and so is the tether object (hero_sim/tether.py). A move is checked along its straight path, so fast robots can't tunnel through thin obstacles, and stops at @@ -121,33 +124,36 @@ def from_bt(cls, path, z_min, z_max, max_radius, circles=()): return cls(occupied, (kx0 - _KEY_ORIGIN) * res, (ky0 - _KEY_ORIGIN) * res, res, max_radius, circles) - def clearance(self, x, y): - """Distance (m) from (x, y) to the nearest obstacle; inf if beyond max_radius.""" + def clearance(self, x, y, others=()): + """Distance (m) from (x, y) to the nearest obstacle; inf if beyond max_radius. + + others: more circles (x, y, radius) to count, such as other robots. + """ best = math.inf row = math.floor((y - self.y0) / self.res) col = math.floor((x - self.x0) / self.res) if 0 <= row < self._clearance.shape[0] and 0 <= col < self._clearance.shape[1]: best = float(self._clearance[row, col]) - for cx, cy, r in self.circles: + for cx, cy, r in (*self.circles, *others): best = min(best, max(math.hypot(x - cx, y - cy) - r, 0.0)) return best if best < self.max_radius else math.inf - def move(self, x0, y0, x1, y1, radius): + def move(self, x0, y0, x1, y1, radius, others=()): """Where a disc of `radius` moving straight from (x0, y0) to (x1, y1) ends up. Samples the path every half cell and stops at the last sample before - the disc would touch an obstacle. A disc that already overlaps one - (spawned or teleported there) may move, just not deeper into it. - Radius 0 never collides. + the disc would touch an obstacle or one of `others` (circles x, y, + radius). A disc that already overlaps one (spawned or teleported + there) may move, just not deeper into it. Radius 0 never collides. """ if radius > self.max_radius: raise ValueError(f'radius {radius} m is over max_radius {self.max_radius} m') steps = int(math.hypot(x1 - x0, y1 - y0) / (0.5 * self.res)) + 1 x, y = x0, y0 - clear = self.clearance(x, y) + clear = self.clearance(x, y, others) for i in range(1, steps + 1): nx, ny = x0 + (x1 - x0) * i / steps, y0 + (y1 - y0) * i / steps - next_clear = self.clearance(nx, ny) + next_clear = self.clearance(nx, ny, others) if next_clear < radius and next_clear < clear: return x, y x, y, clear = nx, ny, next_clear @@ -155,17 +161,16 @@ def move(self, x0, y0, x1, y1, radius): def load(sim, radii, pillars=()): - """The scenario's ObstacleMap for robots of these radii, or None when there's nothing to check. + """The scenario's ObstacleMap for robots of these radii, or None when collisions are off. pillars: the scenario's obstacles, (x, y, radius) each. None when - sim.collision is off, there are no robots, or neither a map nor pillars. + sim.collision is off or there are no robots. Without a map or pillars the + map is empty, but still checks the `others` passed to move(). """ if not sim['collision'] or not radii: return None bt_file = config.resolve_map(sim['map']) if bt_file is None: - if not pillars: - return None return ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, max(radii), pillars) return ObstacleMap.from_bt(bt_file, *sim['collision_z'], max_radius=max(radii), circles=pillars) diff --git a/test/test_nodes.py b/test/test_nodes.py index c4ed879..bef0209 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -20,7 +20,7 @@ pose: {x: 1.0, y: 0.0, yaw: 1.5707963267948966} model: scout - name: herover_1 - pose: {x: 0.0, y: 0.0, yaw: 0.0} + pose: {x: 0.0, y: -1.0, yaw: 0.0} # clear of scout_1's path in the cmd_vel test drive: diff crazyflies: - pose: {x: 0.5, y: 0.5, z: 0.03} @@ -266,6 +266,8 @@ def test_ground_go_to_stops_at_a_pillar(ground): for _ in range(60): ground._tick() assert 2.5 - 0.05 <= omni.x <= 2.5 # pillar edge at 2.8, minus the radius 0.3 + assert omni.go_to is not None # past its 5 s, still waiting for the way to clear + ground._on_cmd_vel(omni, twist()) assert omni.go_to is None @@ -284,6 +286,77 @@ def test_ground_go_to_service_call(ground, make): assert goal == pytest.approx((1.0, 3.0, 0.0)) +TWO_SCENARIO = """ +ground_robots: + - name: a + pose: {x: 0.0, y: 0.0} + radius: 0.3 + - name: b + pose: {x: 2.0, y: 0.0, yaw: 3.141592653589793} + radius: 0.2 +sim: {map: null, ground_rate: 10.0, ground_max_speed: 1.0} +""" + + +def gap(a, b): + """Distance between the edges of two robots' footprints; < 0 when they overlap.""" + return math.hypot(a.x - b.x, a.y - b.y) - a.radius - b.radius + + +def test_ground_robots_stop_at_each_other(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(TWO_SCENARIO) + sim = make(GroundRobotSim) + a, b = sim._robots + sim._on_cmd_vel(a, twist(vx=1.0)) + sim._on_cmd_vel(b, twist(vx=1.0)) # b faces -x: head-on + for _ in range(20): + sim._tick() + assert gap(a, b) >= 0.0 + assert gap(a, b) <= 0.05 # touching, within a path sample each + assert (a.y, b.y) == pytest.approx((0.0, 0.0)) + + sim._on_cmd_vel(a, twist(vx=-1.0)) # backing away is free, b still pushing + sim._on_cmd_vel(b, twist()) + sim._tick() + assert gap(a, b) == pytest.approx(0.1, abs=0.05) + + +@pytest.mark.parametrize('change', ['radius: 0.2|radius: 0.0', + 'map: null|map: null, collision: false']) +def test_ground_robots_pass_through_without_collisions(make, scenario_file, change): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(TWO_SCENARIO.replace(*change.split('|'))) + sim = make(GroundRobotSim) + a, _ = sim._robots + sim._on_cmd_vel(a, twist(vx=1.0)) + for _ in range(30): + sim._tick() + assert a.x == pytest.approx(3.0) + + +def test_blocked_go_to_waits_then_carries_on(make, scenario_file): + from hero_sim.ground_robots import GroundRobotSim + scenario_file(TWO_SCENARIO) + sim = make(GroundRobotSim) + a, b = sim._robots + sim._on_go_to(a, go_to(4.0, 0.0), GoTo.Response()) # 4 s at 1 m/s, b in the way + for _ in range(50): + sim._tick() + assert 1.5 - 0.05 <= a.x <= 1.5 and a.go_to is not None # waiting at b + + sim._on_cmd_vel(b, twist(vy=1.0)) # b faces -x: its left is -y + for i in range(50): + if i == 10: + sim._on_cmd_vel(b, twist()) # b parked at (2, -1) + before = a.x + sim._tick() + assert gap(a, b) >= 0.0 + assert a.x - before <= 0.1 + 0.03 # resumes at its speed, no jump to the schedule + assert b.y == pytest.approx(-1.0) + assert (a.x, a.y) == pytest.approx((4.0, 0.0)) and a.go_to is None + + def _latched(make, topic, publisher_cls): """The first MarkerArray a late TRANSIENT_LOCAL subscriber gets on `topic`.""" from rclpy.qos import DurabilityPolicy, QoSProfile diff --git a/test/test_obstacles.py b/test/test_obstacles.py index 0062f04..c4199f6 100644 --- a/test/test_obstacles.py +++ b/test/test_obstacles.py @@ -94,14 +94,21 @@ def test_radius_zero_never_collides_and_too_big_raises(): m.move(0.0, 0.0, 1.0, 0.0, radius=0.4) -def test_load_off_without_map_robots_or_collision(): +def test_load_off_without_robots_or_collision(): sim = dict(config.SIM_DEFAULTS, map=str(ICUAS26_1)) assert load(sim, [0.1]) is not None assert load(dict(sim, collision=False), [0.1]) is None - assert load(dict(sim, map=None), [0.1]) is None assert load(sim, []) is None +def test_load_without_map_or_pillars_still_checks_others(): + m = load(dict(config.SIM_DEFAULTS, map=None), [0.3]) + assert m.clearance(0.0, 0.0) == math.inf + assert m.move(0.0, 0.0, 2.0, 0.0, 0.3) == (2.0, 0.0) + x, _ = m.move(0.0, 0.0, 2.0, 0.0, 0.3, others=[(1.0, 0.0, 0.2)]) + assert 0.5 - PILLAR_RES / 2 <= x <= 0.5 + + def test_pillar_clearance_is_exact_to_its_edge(): m = pillar_map() assert m.clearance(1.5, 0.0) == pytest.approx(0.3) @@ -126,6 +133,24 @@ def test_move_stops_before_a_pillar_by_radius(): assert m.move(0.0, 0.6, 2.0, 0.6, radius=0.3) == (2.0, 0.6) # passes beside it +def test_others_count_like_pillars(): + m = ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, 0.5) + robot = [(1.0, 0.0, 0.2)] + assert m.clearance(1.5, 0.0) == math.inf + assert m.clearance(1.5, 0.0, robot) == pytest.approx(0.3) + x, y = m.move(0.0, 0.0, 2.0, 0.0, 0.3, robot) + assert 0.5 - PILLAR_RES / 2 <= x <= 0.5 and y == 0.0 + assert m.move(0.0, 0.6, 2.0, 0.6, 0.3, robot) == (2.0, 0.6) # passes beside it + assert m.move(0.7, 0.0, 0.0, 0.0, 0.3, robot) == (0.0, 0.0) # overlapping: backs away + assert m.move(0.7, 0.0, 0.9, 0.0, 0.3, robot) == (0.7, 0.0) # but not deeper + + +def test_nearest_of_pillars_and_others(): + m = pillar_map() # pillar edge at x = 0.8 + assert m.clearance(0.0, 0.0, [(0.0, 0.5, 0.1)]) == pytest.approx(0.4) + assert m.clearance(0.0, 0.0, [(0.0, 2.0, 0.1)]) == math.inf # both beyond max_radius + + def test_load_pillars_with_and_without_map(): pillars = [(1.0, 0.0, 0.2)] sim = dict(config.SIM_DEFAULTS, map=None) From f79cea110efd58664cf2b80a865f36ebe6687fd0 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:27:43 +0100 Subject: [PATCH 07/13] config(two_ugvs): spell out each herover's radius and what sets robot size Co-Authored-By: Claude Opus 5.5 --- config/two_ugvs.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/two_ugvs.yaml b/config/two_ugvs.yaml index a75b169..821a7eb 100644 --- a/config/two_ugvs.yaml +++ b/config/two_ugvs.yaml @@ -6,16 +6,24 @@ # Controls: /herover_/cmd_vel (Twist, body frame) or /herover_/go_to # (crazyflie_interfaces/srv/GoTo). Feedback: /herover_/pose, # /hero_sim/obstacles. See README "Driving the robots" and "Obstacles". +# +# Robot size: in collisions each robot is a circle of its `radius` (m), the +# only size the sim uses: against the pillar and against the other robot. +# Without `radius:` it's the model's default (hero_sim/config.py +# FOOTPRINT_RADII: herover 0.17, scout 0.42; no model 0.3). 0 turns its +# collisions off. The RViz mesh keeps its real size whatever you set here. ground_robots: - name: herover_1 pose: {x: -1.5, y: -1.5, yaw: 0.0} drive: omni model: herover + radius: 0.17 # m, the herover default - name: herover_2 pose: {x: -1.5, y: 1.5, yaw: 0.0} drive: omni model: herover + radius: 0.17 # m, the herover default obstacles: # pillars: block both robots - {x: 1.5, y: 0.5, radius: 0.35} From b818358d1626802592781fe9b1db5de7d62ed6fd Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:42:06 +0100 Subject: [PATCH 08/13] feat(config): scenario folders, found by filename Configs can sit in config/ or a folder in it: tether/ for the tether task, mrs/ for the control stack's Crazyflie scenarios, the rest on top (example, ground-robot-only setups). A bare filename is searched in every folder (names are unique; two matches are an error), folder/name.yaml is relative to config/ unless it's an existing path. Same rules in config.find_config (launch files, nodes) and startup/_setup.sh. setup.py installs the folders and skips the broken links a --symlink-install build keeps to moved configs, so existing workspaces rebuild without a clean. Tests cover both resolvers and run every config in every folder. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++++- config/example.yaml | 2 +- config/{ => mrs}/backup.yaml | 0 config/{ => mrs}/collision_avoid.yaml | 0 config/{ => mrs}/dock_hardware.yaml | 0 config/{ => mrs}/dock_redeploy12.yaml | 0 config/{ => mrs}/dock_redeploy3.yaml | 0 config/{ => mrs}/hardware_dock1.yaml | 0 config/{ => mrs}/hero_demo1.yaml | 0 config/{ => mrs}/hero_sitl_config.yaml | 0 config/{ => mrs}/herover.yaml | 0 config/{ => mrs}/hw_dock_1.yaml | 0 config/{ => mrs}/hw_dock_2.yaml | 0 config/{ => mrs}/hw_dock_3.yaml | 0 config/{ => mrs}/hw_rs_2.yaml | 0 config/{ => mrs}/icuas.yaml | 0 config/{ => mrs}/integrated_test1.yaml | 0 config/{ => mrs}/integrated_test2.yaml | 0 config/{ => mrs}/integrated_test3.yaml | 0 config/{ => mrs}/integrated_test4.yaml | 0 config/{ => mrs}/peer_avoid.yaml | 0 config/{ => mrs}/peer_avoid_orig.yaml | 0 config/{ => mrs}/perch_1_iso.yaml | 0 config/{ => mrs}/readme_fig.yaml | 0 config/{ => mrs}/renato.yaml | 0 config/{ => mrs}/test_9.yaml | 0 config/{ => mrs}/test_branch.yaml | 0 config/{ => mrs}/test_deploy_2.yaml | 0 config/{ => mrs}/test_deploy_case2.yaml | 0 config/{ => mrs}/test_many.yaml | 0 config/{ => mrs}/test_redeploy_1.yaml | 0 config/{ => mrs}/test_redeploy_2.yaml | 0 config/{ => mrs}/v2_dock4.yaml | 0 config/{ => tether}/tether_demo.yaml | 0 hero_sim/config.py | 40 +++++++++++++--- launch/octomap.launch.py | 2 +- launch/sim.launch.py | 2 +- setup.py | 11 ++++- startup/_setup.sh | 36 ++++++++++---- startup/start.sh | 10 ++-- test/test_config.py | 48 ++++++++++++++++++- test/test_startup.py | 63 +++++++++++++++++++++++++ 42 files changed, 202 insertions(+), 27 deletions(-) rename config/{ => mrs}/backup.yaml (100%) rename config/{ => mrs}/collision_avoid.yaml (100%) rename config/{ => mrs}/dock_hardware.yaml (100%) rename config/{ => mrs}/dock_redeploy12.yaml (100%) rename config/{ => mrs}/dock_redeploy3.yaml (100%) rename config/{ => mrs}/hardware_dock1.yaml (100%) rename config/{ => mrs}/hero_demo1.yaml (100%) rename config/{ => mrs}/hero_sitl_config.yaml (100%) rename config/{ => mrs}/herover.yaml (100%) rename config/{ => mrs}/hw_dock_1.yaml (100%) rename config/{ => mrs}/hw_dock_2.yaml (100%) rename config/{ => mrs}/hw_dock_3.yaml (100%) rename config/{ => mrs}/hw_rs_2.yaml (100%) rename config/{ => mrs}/icuas.yaml (100%) rename config/{ => mrs}/integrated_test1.yaml (100%) rename config/{ => mrs}/integrated_test2.yaml (100%) rename config/{ => mrs}/integrated_test3.yaml (100%) rename config/{ => mrs}/integrated_test4.yaml (100%) rename config/{ => mrs}/peer_avoid.yaml (100%) rename config/{ => mrs}/peer_avoid_orig.yaml (100%) rename config/{ => mrs}/perch_1_iso.yaml (100%) rename config/{ => mrs}/readme_fig.yaml (100%) rename config/{ => mrs}/renato.yaml (100%) rename config/{ => mrs}/test_9.yaml (100%) rename config/{ => mrs}/test_branch.yaml (100%) rename config/{ => mrs}/test_deploy_2.yaml (100%) rename config/{ => mrs}/test_deploy_case2.yaml (100%) rename config/{ => mrs}/test_many.yaml (100%) rename config/{ => mrs}/test_redeploy_1.yaml (100%) rename config/{ => mrs}/test_redeploy_2.yaml (100%) rename config/{ => mrs}/v2_dock4.yaml (100%) rename config/{ => tether}/tether_demo.yaml (100%) create mode 100644 test/test_startup.py diff --git a/README.md b/README.md index 04461eb..8248311 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ Optional: if `hero_mrs_msgs` (from hero_mrs_control_stack) is also in the worksp cd ~/ros2_ws/src/hero_sim/startup bash start.sh # default config -bash start.sh test_many.yaml # a config from config/ +bash start.sh test_many.yaml # a config from config/, found in whichever folder it's in +bash start.sh mrs/test_many.yaml # the same, naming its folder bash start.sh /path/to/my.yaml # any config file tmux kill-session -t hero_sim # stop @@ -48,6 +49,16 @@ To change the default config, edit `DEFAULT_CONFIG_NAME` at the top of `startup/ One YAML file per scenario in `config/`. To make a new one, copy `config/example.yaml`, which explains every option. +Scenarios can sit in `config/` itself or in a folder inside it: + +| Folder | Scenarios | +|---|---| +| `config/tether/` | the tether task (`tether:` section) | +| `config/mrs/` | the control stack's, with Crazyflies | +| `config/` | everything else: the example, ground-robot-only setups | + +A config is named by its filename alone (`test_many.yaml`) and found in whichever folder it's in, so filenames must be unique across folders. You can also name the folder (`mrs/test_many.yaml`) or give a path. New folders work without code changes; rebuild the package (`colcon build`) after adding or moving files. + ```yaml ground_robots: - name: scout_1 @@ -158,7 +169,7 @@ ros2 topic echo --once /hero_sim/obstacles ## Tether task -A winch drags an object across the floor. The tether runs from the winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), down to the object. A scenario sets it up with a `tether:` section; `config/tether_demo.yaml` is a ready one: +A winch drags an object across the floor. The tether runs from the winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), down to the object. A scenario sets it up with a `tether:` section; `config/tether/tether_demo.yaml` is a ready one: ```bash bash start.sh tether_demo.yaml diff --git a/config/example.yaml b/config/example.yaml index 1623d88..7ab59d4 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -63,7 +63,7 @@ sim: # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", -# config/tether_demo.yaml). Leave it out and nothing extra runs. +# config/tether/tether_demo.yaml). Leave it out and nothing extra runs. # tether: # winch: {x: -3.0, y: 0.0, z: 0.3} # m, x and y required; z: where the tether leaves the drum # ugv: scout_1 # pulley on its deck (default: the first ground robot) diff --git a/config/backup.yaml b/config/mrs/backup.yaml similarity index 100% rename from config/backup.yaml rename to config/mrs/backup.yaml diff --git a/config/collision_avoid.yaml b/config/mrs/collision_avoid.yaml similarity index 100% rename from config/collision_avoid.yaml rename to config/mrs/collision_avoid.yaml diff --git a/config/dock_hardware.yaml b/config/mrs/dock_hardware.yaml similarity index 100% rename from config/dock_hardware.yaml rename to config/mrs/dock_hardware.yaml diff --git a/config/dock_redeploy12.yaml b/config/mrs/dock_redeploy12.yaml similarity index 100% rename from config/dock_redeploy12.yaml rename to config/mrs/dock_redeploy12.yaml diff --git a/config/dock_redeploy3.yaml b/config/mrs/dock_redeploy3.yaml similarity index 100% rename from config/dock_redeploy3.yaml rename to config/mrs/dock_redeploy3.yaml diff --git a/config/hardware_dock1.yaml b/config/mrs/hardware_dock1.yaml similarity index 100% rename from config/hardware_dock1.yaml rename to config/mrs/hardware_dock1.yaml diff --git a/config/hero_demo1.yaml b/config/mrs/hero_demo1.yaml similarity index 100% rename from config/hero_demo1.yaml rename to config/mrs/hero_demo1.yaml diff --git a/config/hero_sitl_config.yaml b/config/mrs/hero_sitl_config.yaml similarity index 100% rename from config/hero_sitl_config.yaml rename to config/mrs/hero_sitl_config.yaml diff --git a/config/herover.yaml b/config/mrs/herover.yaml similarity index 100% rename from config/herover.yaml rename to config/mrs/herover.yaml diff --git a/config/hw_dock_1.yaml b/config/mrs/hw_dock_1.yaml similarity index 100% rename from config/hw_dock_1.yaml rename to config/mrs/hw_dock_1.yaml diff --git a/config/hw_dock_2.yaml b/config/mrs/hw_dock_2.yaml similarity index 100% rename from config/hw_dock_2.yaml rename to config/mrs/hw_dock_2.yaml diff --git a/config/hw_dock_3.yaml b/config/mrs/hw_dock_3.yaml similarity index 100% rename from config/hw_dock_3.yaml rename to config/mrs/hw_dock_3.yaml diff --git a/config/hw_rs_2.yaml b/config/mrs/hw_rs_2.yaml similarity index 100% rename from config/hw_rs_2.yaml rename to config/mrs/hw_rs_2.yaml diff --git a/config/icuas.yaml b/config/mrs/icuas.yaml similarity index 100% rename from config/icuas.yaml rename to config/mrs/icuas.yaml diff --git a/config/integrated_test1.yaml b/config/mrs/integrated_test1.yaml similarity index 100% rename from config/integrated_test1.yaml rename to config/mrs/integrated_test1.yaml diff --git a/config/integrated_test2.yaml b/config/mrs/integrated_test2.yaml similarity index 100% rename from config/integrated_test2.yaml rename to config/mrs/integrated_test2.yaml diff --git a/config/integrated_test3.yaml b/config/mrs/integrated_test3.yaml similarity index 100% rename from config/integrated_test3.yaml rename to config/mrs/integrated_test3.yaml diff --git a/config/integrated_test4.yaml b/config/mrs/integrated_test4.yaml similarity index 100% rename from config/integrated_test4.yaml rename to config/mrs/integrated_test4.yaml diff --git a/config/peer_avoid.yaml b/config/mrs/peer_avoid.yaml similarity index 100% rename from config/peer_avoid.yaml rename to config/mrs/peer_avoid.yaml diff --git a/config/peer_avoid_orig.yaml b/config/mrs/peer_avoid_orig.yaml similarity index 100% rename from config/peer_avoid_orig.yaml rename to config/mrs/peer_avoid_orig.yaml diff --git a/config/perch_1_iso.yaml b/config/mrs/perch_1_iso.yaml similarity index 100% rename from config/perch_1_iso.yaml rename to config/mrs/perch_1_iso.yaml diff --git a/config/readme_fig.yaml b/config/mrs/readme_fig.yaml similarity index 100% rename from config/readme_fig.yaml rename to config/mrs/readme_fig.yaml diff --git a/config/renato.yaml b/config/mrs/renato.yaml similarity index 100% rename from config/renato.yaml rename to config/mrs/renato.yaml diff --git a/config/test_9.yaml b/config/mrs/test_9.yaml similarity index 100% rename from config/test_9.yaml rename to config/mrs/test_9.yaml diff --git a/config/test_branch.yaml b/config/mrs/test_branch.yaml similarity index 100% rename from config/test_branch.yaml rename to config/mrs/test_branch.yaml diff --git a/config/test_deploy_2.yaml b/config/mrs/test_deploy_2.yaml similarity index 100% rename from config/test_deploy_2.yaml rename to config/mrs/test_deploy_2.yaml diff --git a/config/test_deploy_case2.yaml b/config/mrs/test_deploy_case2.yaml similarity index 100% rename from config/test_deploy_case2.yaml rename to config/mrs/test_deploy_case2.yaml diff --git a/config/test_many.yaml b/config/mrs/test_many.yaml similarity index 100% rename from config/test_many.yaml rename to config/mrs/test_many.yaml diff --git a/config/test_redeploy_1.yaml b/config/mrs/test_redeploy_1.yaml similarity index 100% rename from config/test_redeploy_1.yaml rename to config/mrs/test_redeploy_1.yaml diff --git a/config/test_redeploy_2.yaml b/config/mrs/test_redeploy_2.yaml similarity index 100% rename from config/test_redeploy_2.yaml rename to config/mrs/test_redeploy_2.yaml diff --git a/config/v2_dock4.yaml b/config/mrs/v2_dock4.yaml similarity index 100% rename from config/v2_dock4.yaml rename to config/mrs/v2_dock4.yaml diff --git a/config/tether_demo.yaml b/config/tether/tether_demo.yaml similarity index 100% rename from config/tether_demo.yaml rename to config/tether/tether_demo.yaml diff --git a/hero_sim/config.py b/hero_sim/config.py index 20d3f01..b45bff7 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -303,17 +303,45 @@ def share_dir(): return Path(get_package_share_directory('hero_sim')) +def find_config(arg, root): + """The scenario file `arg` names, under the config dir `root` (startup/_setup.sh does the same). + + Configs sit in root or in folders below it (config/tether/, config/mrs/): + tether_demo.yaml a bare filename, searched in root and every folder below. + Filenames are unique across folders; two matches raise + tether/tether_demo.yaml with a '/': a path if that file exists, else relative to root + """ + root = Path(root) + if '/' in arg: + for path in (Path(arg), root / arg): + if path.is_file(): + return path + raise FileNotFoundError(f'hero_sim config not found: {arg} (as a path or under {root})') + matches = sorted(p for p in root.rglob(arg) if p.is_file()) + if len(matches) > 1: + names = ', '.join(str(p.relative_to(root)) for p in matches) + raise ValueError(f'hero_sim config {arg} is in more than one folder: {names}; ' + 'name the folder too, e.g. tether/tether_demo.yaml. If a config ' + 'moved, the install may still hold its old copy: remove ' + 'build/hero_sim and install/hero_sim and rebuild') + if not matches: + raise FileNotFoundError(f'hero_sim config not found: {arg} (searched {root})') + return matches[0] + + def resolve_config(arg=''): """Turn a config argument into an existing file path. - Empty falls back to $HERO_SIM_CONFIG, then DEFAULT_CONFIG. A bare filename - is looked up in the package's config/ dir; anything with a '/' is a path. + Empty falls back to $HERO_SIM_CONFIG, then DEFAULT_CONFIG. Names are found + in the package's config/ dir and its folders (find_config); anything + else with a '/' is a path. """ arg = arg or os.environ.get(CONFIG_ENV, '') or DEFAULT_CONFIG - path = Path(arg) if '/' in arg else share_dir() / 'config' / arg - if not path.is_file(): - raise FileNotFoundError(f'hero_sim config not found: {path}') - return str(path.resolve()) + if Path(arg).is_absolute(): # a path: no package lookup, so no sourced ROS env needed + if not Path(arg).is_file(): + raise FileNotFoundError(f'hero_sim config not found: {arg}') + return str(Path(arg).resolve()) + return str(find_config(arg, share_dir() / 'config').resolve()) def resolve_map(value): diff --git a/launch/octomap.launch.py b/launch/octomap.launch.py index cfef92a..aeba38e 100644 --- a/launch/octomap.launch.py +++ b/launch/octomap.launch.py @@ -26,6 +26,6 @@ def _actions(context): def generate_launch_description(): return LaunchDescription([ DeclareLaunchArgument('config', default_value='', - description='Scenario YAML: a name in config/ or a path'), + description='Scenario YAML: a name in config/ or its folders, or a path'), OpaqueFunction(function=_actions), ]) diff --git a/launch/sim.launch.py b/launch/sim.launch.py index 43fde01..fb3d261 100644 --- a/launch/sim.launch.py +++ b/launch/sim.launch.py @@ -51,6 +51,6 @@ def _actions(context): def generate_launch_description(): return LaunchDescription([ DeclareLaunchArgument('config', default_value='', - description='Scenario YAML: a name in config/ or a path'), + description='Scenario YAML: a name in config/ or its folders, or a path'), OpaqueFunction(function=_actions), ]) diff --git a/setup.py b/setup.py index 84eb088..877170c 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,14 @@ package_name = 'hero_sim' +# Scenario configs: config/*.yaml and its folders, layout kept (config.find_config). isfile +# skips broken links a --symlink-install build keeps to configs that moved or were deleted. +configs = [f for f in glob('config/**/*.yaml', recursive=True) if os.path.isfile(f)] +config_files = [ + (f'share/{package_name}/{d}', sorted(f for f in configs if os.path.dirname(f) == d)) + for d in sorted({os.path.dirname(f) for f in configs}) +] + # RViz meshes: models//*.{dae,glb,stl}. models/source/ (raw files) is not installed. model_files = [ (f'share/{package_name}/{d}', [f for ext in ('dae', 'glb', 'stl') for f in glob(f'{d}/*.{ext}')]) @@ -19,7 +27,8 @@ data_files=[ ('share/ament_index/resource_index/packages', ['resource/' + package_name]), ('share/' + package_name, ['package.xml']), - ('share/' + package_name + '/config', glob('config/*.yaml') + glob('config/*.rviz')), + ('share/' + package_name + '/config', glob('config/*.rviz')), + *config_files, ('share/' + package_name + '/launch', glob('launch/*.launch.py')), ('share/' + package_name + '/maps', glob('maps/*.bt')), *model_files, diff --git a/startup/_setup.sh b/startup/_setup.sh index 393fe53..1635fec 100755 --- a/startup/_setup.sh +++ b/startup/_setup.sh @@ -2,8 +2,13 @@ # Source me: picks the scenario config and exports HERO_SIM_CONFIG. # # source _setup.sh # DEFAULT_CONFIG_NAME below -# source _setup.sh my.yaml # bare filename -> ../config/my.yaml -# source _setup.sh path/to/my.yaml # any path +# source _setup.sh my.yaml # bare filename, found in ../config/ or any folder in it +# source _setup.sh tether/my.yaml # relative to ../config/ ... +# source _setup.sh path/to/my.yaml # ... unless it's an existing path +# +# Filenames are unique across config/ and its folders (tether/, mrs/); a name +# in two folders is an error, so name the folder. Same rules as +# hero_sim/config.py find_config, which the launch files use. # # Order: the argument, then an already-exported HERO_SIM_CONFIG, then # DEFAULT_CONFIG_NAME. Edit DEFAULT_CONFIG_NAME to change your day-to-day default. @@ -13,26 +18,37 @@ _hero_sim_config_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../config" && pwd) _hero_sim_resolve() { case "$1" in - */*) echo "$1" ;; - *) echo "$_hero_sim_config_dir/$1" ;; + /*) echo "$1" ;; + */*) + if [ -f "$1" ]; then echo "$1"; else echo "$_hero_sim_config_dir/$1"; fi ;; + *) + local matches + matches=$(find "$_hero_sim_config_dir" -type f -name "$1" | sort) + if [ "$(printf '%s\n' "$matches" | grep -c .)" -gt 1 ]; then + echo "[hero_sim] ERROR: $1 is in more than one config folder; name the folder too:" >&2 + printf '%s\n' "$matches" | sed "s|^$_hero_sim_config_dir/| |" >&2 + return 1 + fi + echo "${matches:-$_hero_sim_config_dir/$1}" ;; esac } if [ -n "$1" ]; then - _hero_sim_cfg=$(_hero_sim_resolve "$1") + _hero_sim_name=$1 elif [ -n "$HERO_SIM_CONFIG" ] && [ -f "$HERO_SIM_CONFIG" ]; then - _hero_sim_cfg=$HERO_SIM_CONFIG + _hero_sim_name=$HERO_SIM_CONFIG else - _hero_sim_cfg=$(_hero_sim_resolve "$DEFAULT_CONFIG_NAME") + _hero_sim_name=$DEFAULT_CONFIG_NAME fi -if [ ! -f "$_hero_sim_cfg" ]; then - echo "[hero_sim] ERROR: config not found: $_hero_sim_cfg" >&2 +if ! _hero_sim_cfg=$(_hero_sim_resolve "$_hero_sim_name") || [ ! -f "$_hero_sim_cfg" ]; then + [ -n "$_hero_sim_cfg" ] && echo "[hero_sim] ERROR: config not found: $_hero_sim_cfg" >&2 unset -f _hero_sim_resolve + unset _hero_sim_cfg _hero_sim_name _hero_sim_config_dir return 1 2>/dev/null || exit 1 fi export HERO_SIM_CONFIG=$(realpath "$_hero_sim_cfg") echo "[hero_sim] HERO_SIM_CONFIG=$HERO_SIM_CONFIG" unset -f _hero_sim_resolve -unset _hero_sim_cfg _hero_sim_config_dir +unset _hero_sim_cfg _hero_sim_name _hero_sim_config_dir diff --git a/startup/start.sh b/startup/start.sh index ea67ac3..9c6d7c3 100755 --- a/startup/start.sh +++ b/startup/start.sh @@ -2,16 +2,18 @@ # Start the hero_sim tmux session (layout in session.yml). # # bash start.sh # DEFAULT_CONFIG_NAME from _setup.sh -# bash start.sh my.yaml # bare filename -> config/my.yaml -# bash start.sh path/to/my.yaml # any path (relative to where you run this) +# bash start.sh my.yaml # bare filename, found in config/ or any folder in it +# bash start.sh tether/my.yaml # relative to config/ ... +# bash start.sh path/to/my.yaml # ... unless it's an existing path (relative to where you run this) # # Stop it with: tmux kill-session -t hero_sim set -e CONFIG_ARG=$1 -# Resolve a relative path before changing directory. +# Resolve an existing relative path before changing directory; anything else +# (tether/my.yaml) is looked up under config/ by _setup.sh. case "$CONFIG_ARG" in - */*) CONFIG_ARG=$(realpath "$CONFIG_ARG") ;; + */*) [ -f "$CONFIG_ARG" ] && CONFIG_ARG=$(realpath "$CONFIG_ARG") ;; esac cd "$(dirname "$(readlink -f "$0")")" diff --git a/test/test_config.py b/test/test_config.py index b0839bb..bd6f9eb 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest import yaml @@ -167,11 +169,55 @@ def test_resolve_map_disabled_and_path(tmp_path): assert config.resolve_map(str(bt)) == str(bt) -@pytest.mark.parametrize('path', sorted(CONFIG_DIR.glob('*.yaml')), ids=lambda p: p.name) +SHIPPED = sorted(CONFIG_DIR.rglob('*.yaml')) + + +@pytest.mark.parametrize('path', SHIPPED, ids=lambda p: str(p.relative_to(CONFIG_DIR))) def test_shipped_configs_load(path): config.load(path) +def test_shipped_config_names_are_unique_across_folders(): + names = [p.name for p in SHIPPED] + assert sorted({n for n in names if names.count(n) > 1}) == [] + assert any(p.parent != CONFIG_DIR for p in SHIPPED) # the folders are searched at all + + +@pytest.fixture() +def config_tree(tmp_path): + """config/ with a.yaml on top, tether/b.yaml, and dup.yaml in two folders.""" + root = tmp_path / 'config' + for rel in ('a.yaml', 'tether/b.yaml', 'mrs/dup.yaml', 'tether/dup.yaml'): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text('{}\n') + return root + + +def test_find_config_by_name_in_any_folder(config_tree): + assert config.find_config('a.yaml', config_tree) == config_tree / 'a.yaml' + assert config.find_config('b.yaml', config_tree) == config_tree / 'tether' / 'b.yaml' + assert config.find_config('tether/b.yaml', config_tree) == config_tree / 'tether' / 'b.yaml' + assert config.find_config('mrs/dup.yaml', config_tree) == config_tree / 'mrs' / 'dup.yaml' + + +def test_find_config_prefers_an_existing_path(config_tree, tmp_path, monkeypatch): + (tmp_path / 'tether').mkdir() + (tmp_path / 'tether' / 'b.yaml').write_text('{}\n') + monkeypatch.chdir(tmp_path) + assert config.find_config('tether/b.yaml', config_tree) == Path('tether/b.yaml') + assert config.find_config(str(tmp_path / 'config' / 'a.yaml'), config_tree).is_file() + + +def test_find_config_errors(config_tree): + with pytest.raises(ValueError, match=r'dup.yaml is in more than one folder: mrs/dup.yaml, ' + r'tether/dup.yaml'): + config.find_config('dup.yaml', config_tree) + with pytest.raises(FileNotFoundError, match='nope.yaml'): + config.find_config('nope.yaml', config_tree) + with pytest.raises(FileNotFoundError, match='mrs/b.yaml'): + config.find_config('mrs/b.yaml', config_tree) # wrong folder + + TETHER_ROBOTS = 'ground_robots: [{name: g1}, {name: g2}]\ncrazyflies: [{pose: {}}, {pose: {}}]\n' TETHER_MIN = '{winch: {x: -1, y: 0}, object: {x: 1, y: 2}, goal: {x: 3, y: 4}' diff --git a/test/test_startup.py b/test/test_startup.py new file mode 100644 index 0000000..d1a9600 --- /dev/null +++ b/test/test_startup.py @@ -0,0 +1,63 @@ +"""startup/_setup.sh finds configs by the same rules as config.find_config. Needs bash.""" +from pathlib import Path +import shutil +import subprocess + +import pytest + +SETUP = Path(__file__).resolve().parent.parent / 'startup' / '_setup.sh' + +pytestmark = pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash') + + +@pytest.fixture() +def tree(tmp_path): + """A copy of _setup.sh in startup/, next to a config/ like the repo's, default b.yaml.""" + (tmp_path / 'startup').mkdir() + text = SETUP.read_text() + default = next(line for line in text.splitlines() if line.startswith('DEFAULT_CONFIG_NAME=')) + (tmp_path / 'startup' / '_setup.sh').write_text( + text.replace(default, 'DEFAULT_CONFIG_NAME="b.yaml"')) + for rel in ('config/a.yaml', 'config/tether/b.yaml', 'config/mrs/dup.yaml', + 'config/tether/dup.yaml', 'here/c.yaml'): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text('{}\n') + return tmp_path + + +def source_setup(tree, *args): + """Source _setup.sh from `tree` with args. Returns (exit code, HERO_SIM_CONFIG, stderr).""" + script = 'source startup/_setup.sh "$@" > /dev/null && printf %s "$HERO_SIM_CONFIG"' + done = subprocess.run(['bash', '-c', script, 'bash', *args], cwd=tree, + capture_output=True, text=True, + env={'PATH': '/usr/bin:/bin', 'HOME': str(tree)}) + return done.returncode, done.stdout, done.stderr + + +@pytest.mark.parametrize('arg, found', [ + ('a.yaml', 'config/a.yaml'), # on top of config/ + ('b.yaml', 'config/tether/b.yaml'), # in a folder, by name + ('tether/b.yaml', 'config/tether/b.yaml'), + ('mrs/dup.yaml', 'config/mrs/dup.yaml'), + ('here/c.yaml', 'here/c.yaml'), # an existing path, relative to where it runs +]) +def test_setup_finds_configs(tree, arg, found): + code, path, err = source_setup(tree, arg) + assert code == 0, err + assert path == str(tree / found) + + +def test_setup_defaults_and_absolute_paths(tree): + assert source_setup(tree)[1] == str(tree / 'config' / 'tether' / 'b.yaml') + assert source_setup(tree, str(tree / 'here' / 'c.yaml'))[1] == str(tree / 'here' / 'c.yaml') + + +@pytest.mark.parametrize('arg, message', [ + ('dup.yaml', 'more than one config folder'), + ('nope.yaml', 'config not found'), + ('mrs/b.yaml', 'config not found'), + ('/no/such/file.yaml', 'config not found: /no/such/file.yaml'), +]) +def test_setup_errors(tree, arg, message): + code, _, err = source_setup(tree, arg) + assert code != 0 and message in err From e82c2502be91f4b8ed672e2128cf7b10bce81fae Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:54:05 +0100 Subject: [PATCH 09/13] docs: README as setup and usage instructions for students Setup, start/stop, scenarios and folders, moving the robots, obstacles, the tether task and troubleshooting, without the internals. Every command in it was run against the sim. Mesh-making notes move to models/README.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 233 +++++++++++++++++++++-------------------------- models/README.md | 12 +++ 2 files changed, 114 insertions(+), 131 deletions(-) create mode 100644 models/README.md diff --git a/README.md b/README.md index 8248311..483850f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ - # hero_sim [![CI](https://github.com/KhAlamdar11/hero_sim/actions/workflows/ci.yml/badge.svg)](https://github.com/KhAlamdar11/hero_sim/actions/workflows/ci.yml) -Lightweight HERO simulation without Gazebo. Crazyflies and ground robots are simulated kinematically (velocities are integrated straight into poses), with the same ROS 2 topics and services the control stack uses. +A lightweight simulator for the HERO robots: Crazyflie drones and ground robots (herover, scout), shown in RViz. There is no physics engine, so robots move exactly as commanded. That makes it quick to start and easy to test your code. You command the robots through the same ROS 2 topics and services as the real ones.

@@ -11,157 +10,122 @@ Lightweight HERO simulation without Gazebo. Crazyflies and ground robots are sim ## Setup -Runs inside the `icuas26_finals` docker container. +You need the `icuas26_finals` docker container. Run every command in this README inside it. -1. Mount this repo in the container. `~/dockers/icuas26_finals/first_run.sh` already has the line below; recreate the container if it was created before that line was added: +1. Get the code into your workspace: ```bash - --volume /home/khawaja/HERO/hero_sim:/root/ros2_ws/src/hero_sim:rw \ + cd ~/ros2_ws/src + git clone https://github.com/KhAlamdar11/hero_sim.git ``` -2. Build (inside the container): +2. Build it, together with the Crazyflie messages it needs: ```bash cd ~/ros2_ws - colcon build --symlink-install --packages-select hero_sim - source install/setup.bash + colcon build --symlink-install --packages-up-to hero_sim ``` +3. Open a new terminal, which loads the workspace automatically, or run `source ~/ros2_ws/install/setup.bash` in this one. -Requires `crazyflie_interfaces` (crazyswarm2) in the same workspace. - -Optional: if `hero_mrs_msgs` (from hero_mrs_control_stack) is also in the workspace, the dock ride-along node (`/sim/attach_agent`) starts automatically. Without it, that node is skipped and everything else runs normally. Called with an empty `parent_id`, the same service locks a UAV in place instead (used for a UAV hanging from a branch perch, see [Grippers](#grippers)). +After you pull new changes, run step 2 again. -## Usage +## Start and stop ```bash cd ~/ros2_ws/src/hero_sim/startup - -bash start.sh # default config -bash start.sh test_many.yaml # a config from config/, found in whichever folder it's in -bash start.sh mrs/test_many.yaml # the same, naming its folder -bash start.sh /path/to/my.yaml # any config file - -tmux kill-session -t hero_sim # stop +bash start.sh tether_demo.yaml # start a scenario +tmux kill-session -t hero_sim # stop ``` -This opens a tmux session running the sim (CF server, ground robots, attach node, RViz) and the octomap server. - -To change the default config, edit `DEFAULT_CONFIG_NAME` at the top of `startup/_setup.sh`. +`start.sh` opens a tmux session with the simulator and RViz. Without a scenario name, it starts the default one, set by `DEFAULT_CONFIG_NAME` at the top of `startup/_setup.sh`. -## Config +To run it without tmux, use `ros2 launch hero_sim sim.launch.py config:=tether_demo.yaml`. The map isn't drawn in RViz then, but robots still bump into it. -One YAML file per scenario in `config/`. To make a new one, copy `config/example.yaml`, which explains every option. +## Scenarios -Scenarios can sit in `config/` itself or in a folder inside it: +A scenario is one YAML file in `config/`. It says which robots there are, where they start, and what's in the world. -| Folder | Scenarios | +| Where | What | |---|---| -| `config/tether/` | the tether task (`tether:` section) | -| `config/mrs/` | the control stack's, with Crazyflies | -| `config/` | everything else: the example, ground-robot-only setups | +| `config/tether/` | the tether task | +| `config/mrs/` | multi-robot scenarios with Crazyflies | +| `config/` | the example and everything else | -A config is named by its filename alone (`test_many.yaml`) and found in whichever folder it's in, so filenames must be unique across folders. You can also name the folder (`mrs/test_many.yaml`) or give a path. New folders work without code changes; rebuild the package (`colcon build`) after adding or moving files. +Start a scenario by its filename. It's found in whichever folder it's in: `bash start.sh test_many.yaml`. You can also name the folder (`mrs/test_many.yaml`) or give the path to a file anywhere. + +To make your own, copy `config/example.yaml` (it explains every option) to, say, `config/my_test.yaml`. Edit it and run `bash start.sh my_test.yaml`. Filenames must be unique across the folders. + +A small scenario: ```yaml ground_robots: - - name: scout_1 - pose: {x: 0.0, y: 0.0, yaw: 0.0} - drive: omni # omni or diff - model: scout # 3D model in RViz: scout or herover; leave out for TF axes only - name: herover_1 - pose: {x: 0.0, y: 0.6, yaw: 0.0} - drive: omni - model: herover + pose: {x: 0.0, y: 0.0, yaw: 0.0} # m, m, rad + drive: omni # omni (can move sideways) or diff (can't) + model: herover # herover or scout crazyflies: # named cf_1, cf_2, ... in this order - - pose: {x: 1.5, y: 0.5, z: 0.03} # cf_1 - - pose: {x: 1.5, y: -0.5, z: 0.03} # cf_2 - -sim: # optional, all keys have defaults - rviz: true - models: true # robot 3D models in RViz; false = TF axes only - map: icuas26_1 # octomap from maps/, or null for none -``` + - pose: {x: 1.0, y: 0.0, z: 0.03} -## Robot models in RViz +obstacles: # optional pillars + - {x: 2.0, y: 1.0, radius: 0.3} -Crazyflies, and ground robots with a `model:`, are drawn as 3D models that follow their TF frames. To hide them, untick the **Robot models** display in RViz, or set `sim: {models: false}` to not load them at all. - -The meshes in `models//` are simplified for RViz (herover 309k → 20k triangles, scout 349k → 12k). To add or re-make one, put the raw mesh in `models/source/` (not committed) and run: - -```bash -blender -b --factory-startup -P models/bake_mesh.py -- \ - "models/source/herover/hero mini rover.obj" models/herover/herover.glb 6000 -90 Y +sim: + map: null # no map: an empty floor ``` -Robots made of several `.dae` parts (like the scout, from agilex_sitl's meshes) are first combined into one OBJ with `models/assemble_dae.py`; its header has the command. Then register the mesh in `MODEL_MESHES` in `hero_sim/config.py`. +## Moving the robots -## Grippers +Every robot's pose is on `//pose` (`geometry_msgs/PoseStamped`, in the `world` frame) and on TF. -A Crazyflie with `gripper: true` carries a two-claw gripper on top, drawn in RViz and open at the start: +### Ground robots -```yaml -crazyflies: - - pose: {x: 1.5, y: 0.5, z: 0.03} - gripper: true -``` - -Locking the UAV in place (`/sim/attach_agent` with an empty `parent_id`, as when it hangs from a branch) closes the gripper. Detaching leaves the gripper on the branch, and the UAV flies off without it. To fit a new one: +| Name | Type | | +|---|---|---| +| `//cmd_vel` | topic, `geometry_msgs/Twist` | velocity in the robot's own frame. Kept until the next message, so one is enough | +| `//go_to` | service, `crazyflie_interfaces/srv/GoTo` | drive to x, y and yaw in about `duration` seconds | ```bash -ros2 service call /sim/cf_1/add_gripper std_srvs/srv/Trigger +ros2 topic pub --once /herover_1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}" +ros2 topic pub --once /herover_1/cmd_vel geometry_msgs/msg/Twist "{}" # stop +ros2 service call /herover_1/go_to crazyflie_interfaces/srv/GoTo \ + "{goal: {x: 1.0, y: -1.0}, yaw: 1.57, duration: {sec: 3}}" ``` -The attach node runs the grippers, so they also need `hero_mrs_msgs`. Like the robot models, they are hidden with `sim: {models: false}`. +- Speed is limited to 2 m/s and turning to 2 rad/s. A `go_to` that asks to go faster just takes longer. +- Any `cmd_vel` message cancels a `go_to`. +- `go_to` exists only in the simulator. On the real ground robots, use `cmd_vel`. +- A ground robot's pose height is the top of its deck, where the motion-capture markers sit on the real robot, not 0. -## Driving the robots +### Crazyflies -```bash -# ground robot -ros2 topic pub /scout_1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}" -ros2 service call /scout_1/go_to crazyflie_interfaces/srv/GoTo \ - "{goal: {x: 1.0, y: 0.5}, yaw: 1.57, duration: {sec: 3}}" +The same services as the real Crazyflies (crazyswarm2): -# crazyflie +| Name | Type | | +|---|---|---| +| `/cf_1/takeoff` | `crazyflie_interfaces/srv/Takeoff` | rise to `height` | +| `/cf_1/go_to` | `crazyflie_interfaces/srv/GoTo` | fly to x, y, z and yaw in `duration` | +| `/cf_1/land` | `crazyflie_interfaces/srv/Land` | come down to `height` | +| `/all/takeoff`, `/all/go_to`, `/all/land` | | the same, for every Crazyflie at once | + +```bash ros2 service call /cf_1/takeoff crazyflie_interfaces/srv/Takeoff "{height: 1.0, duration: {sec: 2}}" -ros2 service call /all/land crazyflie_interfaces/srv/Land "{height: 0.0, duration: {sec: 2}}" +ros2 service call /cf_1/go_to crazyflie_interfaces/srv/GoTo "{goal: {x: 1.0, y: 1.0, z: 1.0}, duration: {sec: 3}}" +ros2 service call /cf_1/land crazyflie_interfaces/srv/Land "{height: 0.0, duration: {sec: 2}}" ``` -Ground robots hold the last `cmd_vel` until a new one arrives. Poses are published on `//pose` and as TF from `world`. - -A ground robot's `go_to` uses the same service type as the Crazyflies': - -- It reaches the goal's x, y and yaw in `duration`, or more slowly if that's needed to stay within `sim.ground_max_speed` and `sim.ground_max_yaw_rate` (2 m/s and 2 rad/s by default; `duration: 0` means as fast as allowed). -- An omni robot drives straight while it turns. A diff robot turns to face the goal, drives, then turns to the yaw. -- `relative: true` makes x and y an offset from where the robot is, in the world frame; yaw stays absolute. `goal.z` and `group_mask` are ignored. -- Any `cmd_vel` message cancels it. It stops at [obstacles](#obstacles) like every other move; a blocked `go_to` waits where it stopped and carries on once the way is clear, such as when the other robot drives off. - -As on hardware, where the mocap markers sit on top of the robot, a ground robot's pose is at its marker height rather than on the floor: the top of the deck, scout 0.227 m and herover 0.095 m (0 without a model). Docks the control stack places on a robot are relative to this height. To change it, set `pose: {z: ...}`; the RViz model stays on the floor. +The low-level setpoint topics (`cmd_position`, `cmd_hover`, ...) work too. ## Obstacles -Robots can't drive or fly into obstacles. There are two kinds, and both block in 2D: - -- **The map.** The octomap's voxels between 0.2 m and 2.0 m high (`sim.collision_z`) are flattened into a 2D obstacle grid, which keeps the trunks and posts and leaves out the floor, ceilings and tree canopies. -- **Pillars.** Circles listed under a top-level `obstacles:` key. They work with or without a map (`map: null`). +A robot stops when it would hit something, and moves again once you command it away. These block it: -Each robot is a circle of its `radius`, and a move stops where that circle would touch an obstacle. The robot moves again as soon as it's commanded away. Height changes always go through, so a drone blocked by a trunk or a pillar can still climb or land, but it can't fly over it. +- the map's trunks and posts, in scenarios with a `map:`; +- pillars, from the scenario's `obstacles:` list; +- other ground robots. -Ground robots also stop at each other, under the same switch. Drones aren't checked against the ground robots, so they can dock on them, or against each other. A robot with `radius: 0` ignores all of it. - -```yaml -ground_robots: - - name: scout_1 - model: scout # radius defaults to the model's: scout 0.42, herover 0.17, else 0.3 - radius: 0.45 # or set it -crazyflies: - - pose: {x: 1.5, y: 0.5, z: 0.03} # radius 0.1 by default -obstacles: # optional pillars, m - - {x: 1.5, y: 0.5, radius: 0.35} -sim: - collision: false # turn all of it off: map, pillars, tether object -``` +Drones are blocked at any height: they have to fly around a trunk or a pillar, not over it. Drones don't collide with ground robots or with each other. -Every motion command is checked, including `go_to`, `cmd_hover`, `cmd_position` and the dock ride-along. `//set_position` is a teleport and isn't checked. A robot that starts inside an obstacle gets a warning at launch and can move out but not further in. +Each robot is a circle, and its size is its `radius:`, which you can set per robot (defaults: herover 0.17 m, scout 0.42 m, Crazyflie 0.1 m). To turn all collisions off, set `sim: {collision: false}`. -Pillars are drawn in RViz as 2 m columns and published latched on `/hero_sim/obstacles` (`visualization_msgs/MarkerArray`), even with collisions off. Each pillar is a `CYLINDER` marker at its `x`, `y`, with `scale.x` its diameter: +Your code can read the pillars from `/hero_sim/obstacles` (`visualization_msgs/MarkerArray`). There's one cylinder per pillar: `pose.position` is its center, and `scale.x` its diameter. ```bash ros2 topic echo --once /hero_sim/obstacles @@ -169,47 +133,54 @@ ros2 topic echo --once /hero_sim/obstacles ## Tether task -A winch drags an object across the floor. The tether runs from the winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), down to the object. A scenario sets it up with a `tether:` section; `config/tether/tether_demo.yaml` is a ready one: +Get an object into a goal circle on the floor. A tether runs from a fixed winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), and down to the object. ```bash bash start.sh tether_demo.yaml ``` -```yaml -tether: - winch: {x: -3.0, y: 0.0, z: 0.3} # m; z is where the tether leaves the drum (default 0.3) - ugv: herover_1 # pulley on its deck, at its pose z (default: the first ground robot) - uav: cf_1 # ring (default cf_1) - object: {x: 0.5, y: -0.5} # start, on the floor - object_size: 0.3 # m, cube edge (default 0.3) - goal: {x: 2.5, y: 1.5, radius: 0.4} # radius defaults to 0.4 - slack: 3.0 # m of tether beyond taut at the start (default 0.5) - max_reel_speed: 0.5 # m/s (default 0.5) -``` - -The winch pays out a length L. What the two pulleys don't use is the free tether below the UAV: l = L − |winch − UGV| − |UGV − UAV|. The model is quasi-static (no swing): +How the tether behaves: +- The winch pays out a tether length L. The part hanging below the UAV is l = L − |winch − UGV| − |UGV − UAV|. - While the tether is slack, the object stays where it is. -- Once it's taut, the object slides along the floor straight toward the point under the UAV. -- The object is never lifted. Once it is under the UAV, the winch slips instead. -- Pulled into an obstacle, a pillar or the map, the object jams where it touches it and the winch slips. A pull away from the obstacle frees it. The object is a circle of half its `object_size`, and like the robots it only collides while `sim.collision` is on (see [Obstacles](#obstacles)). -- The robots never feel the tether: moving them only drags the object or makes the winch slip. The robots stop at the same obstacles as the object, but the tether line itself passes through them. +- Once it's taut, the object slides along the floor toward the point under the UAV. +- The object is never lifted. Reeling in further only makes the winch slip. +- Pulled into an obstacle, the object gets stuck until you pull it away from it. +- The robots don't feel the tether, and the tether line passes through obstacles. Only the object gets stuck. -Drive the UGV with `//cmd_vel` and fly the UAV with its Crazyflie services, as in [Driving the robots](#driving-the-robots). The winch and the object: +Drive the UGV and fly the UAV as in [Moving the robots](#moving-the-robots). The winch and the object: | Topic | Type | | |---|---|---| -| `/winch/cmd_rate` | `std_msgs/Float64` | in, m/s: + pays out, − reels in. Held until the next message | +| `/winch/cmd_rate` | `std_msgs/Float64` | your input, m/s: positive pays out, negative reels in. Kept until the next message | | `/winch/length` | `std_msgs/Float64` | L | | `/tether/free_length` | `std_msgs/Float64` | l | -| `/tether/taut` | `std_msgs/Bool` | | -| `/tether/stuck` | `std_msgs/Bool` | object jammed against an obstacle | -| `/tether/object_pose` | `geometry_msgs/PoseStamped` | object center | -| `/tether/goal` | `geometry_msgs/PoseStamped` | goal center, latched | -| `/tether/goal_reached` | `std_msgs/Bool` | object center inside the goal circle | +| `/tether/taut` | `std_msgs/Bool` | the tether is taut | +| `/tether/stuck` | `std_msgs/Bool` | the object is stuck against an obstacle | +| `/tether/object_pose` | `geometry_msgs/PoseStamped` | the object's center | +| `/tether/goal` | `geometry_msgs/PoseStamped` | the goal's center | +| `/tether/goal_reached` | `std_msgs/Bool` | the object's center is inside the goal circle | ```bash -ros2 topic pub /winch/cmd_rate std_msgs/msg/Float64 "{data: -0.3}" # reel in +ros2 topic pub --once /winch/cmd_rate std_msgs/msg/Float64 "{data: -0.3}" # reel in +ros2 topic pub --once /winch/cmd_rate std_msgs/msg/Float64 "{data: 0.0}" # hold ``` -Without a `tether:` section, nothing extra runs. +The `tether:` section of `config/tether/tether_demo.yaml` sets where the winch, the object and the goal are, and which robots carry the tether. `config/example.yaml` explains each key. + +## Troubleshooting + +| Problem | What to do | +|---|---| +| `config not found` | Check the filename: `ls ~/ros2_ws/src/hero_sim/config/*` | +| `... is in more than one folder` | Two configs share a filename: name the folder (`mrs/my.yaml`) or rename one. After configs were moved, an old build can also cause it: `rm -rf ~/ros2_ws/build/hero_sim ~/ros2_ws/install/hero_sim`, then build again | +| `Package 'hero_sim' not found` | Build ([Setup](#setup) step 2), then open a new terminal | +| `ros2 launch` doesn't find a config you just added | Build again. `start.sh` finds new files without a rebuild | +| A robot doesn't move | It may be pressed against an obstacle: command it away. Check that the simulator is running: `ros2 node list` | +| A `go_to` stops right away | Something keeps publishing `cmd_vel` to that robot, such as a `ros2 topic pub` without `--once` | + +## For hero_mrs_control_stack users + +- With `hero_mrs_msgs` in the workspace, `/sim/attach_agent` starts: robots carry docked Crazyflies, and an empty `parent_id` locks a Crazyflie in place (perched on a branch). +- `gripper: true` on a Crazyflie draws a two-claw gripper. To fit a new one: `ros2 service call /sim/cf_1/add_gripper std_srvs/srv/Trigger`. +- `sim: {models: false}` hides the robot models in RViz. The mesh tools are described in [models/README.md](models/README.md). diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..329fe30 --- /dev/null +++ b/models/README.md @@ -0,0 +1,12 @@ +# Robot meshes + +RViz draws Crazyflies, and ground robots with a `model:`, with the meshes in `models//`. They are simplified for RViz (herover 309k → 20k triangles, scout 349k → 12k). + +To add or re-make one, put the raw mesh in `models/source/` (not committed) and run: + +```bash +blender -b --factory-startup -P models/bake_mesh.py -- \ + "models/source/herover/hero mini rover.obj" models/herover/herover.glb 6000 -90 Y +``` + +Robots made of several `.dae` parts (like the scout, from agilex_sitl's meshes) are first combined into one OBJ with `models/assemble_dae.py`; its header has the command. Then register the mesh in `MODEL_MESHES` in `hero_sim/config.py`. From 9d97a98f4a3d5a6c1330e4a9b19ceeaa9fe544c0 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:12:24 +0100 Subject: [PATCH 10/13] config: sim_env_a, perching and unperch scenarios; tether diagram Adds the sim_env_a / sim_env_a12 experiment scenarios, the test_perching_full(2) and test_unperch scenarios to config/mrs/, moves dock_hardware2 there, and makes test_perching_full2 the default. The example scenario gets its pillar, sim.rviz the current view, and the README's tether section the task diagram (media/tether_demo.png). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + config/example.yaml | 4 +- config/{ => mrs}/dock_hardware2.yaml | 0 config/mrs/sim_env_a.yaml | 45 ++++++++ config/mrs/sim_env_a12.yaml | 52 ++++++++++ config/mrs/sim_env_a12_floor.yaml | 50 +++++++++ config/mrs/sim_env_a12_view.yaml | 53 ++++++++++ config/mrs/sim_env_a_floor.yaml | 43 ++++++++ config/mrs/sim_env_a_view.yaml | 46 +++++++++ config/mrs/test_perching_full.yaml | 47 +++++++++ config/mrs/test_perching_full2.yaml | 55 ++++++++++ config/mrs/test_unperch.yaml | 29 ++++++ config/sim.rviz | 122 ++++++++++++++-------- media/tether_demo.png | Bin 0 -> 181309 bytes media/tether_demo.svg | 149 +++++++++++++++++++++++++++ startup/_setup.sh | 2 +- 16 files changed, 653 insertions(+), 48 deletions(-) rename config/{ => mrs}/dock_hardware2.yaml (100%) create mode 100644 config/mrs/sim_env_a.yaml create mode 100644 config/mrs/sim_env_a12.yaml create mode 100644 config/mrs/sim_env_a12_floor.yaml create mode 100644 config/mrs/sim_env_a12_view.yaml create mode 100644 config/mrs/sim_env_a_floor.yaml create mode 100644 config/mrs/sim_env_a_view.yaml create mode 100644 config/mrs/test_perching_full.yaml create mode 100644 config/mrs/test_perching_full2.yaml create mode 100644 config/mrs/test_unperch.yaml create mode 100644 media/tether_demo.png create mode 100644 media/tether_demo.svg diff --git a/README.md b/README.md index 483850f..8c5a78a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ ros2 topic echo --once /hero_sim/obstacles Get an object into a goal circle on the floor. A tether runs from a fixed winch, through a pulley on a ground robot (UGV), through a ring on a Crazyflie (UAV), and down to the object. +

+ +

+ ```bash bash start.sh tether_demo.yaml ``` diff --git a/config/example.yaml b/config/example.yaml index 7ab59d4..28ab6c7 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -58,8 +58,8 @@ sim: # Optional pillars (m): circles that block the robots (drones at any height) # and the tether object while sim.collision is on, with or without a map. # Drawn in RViz and published latched on /hero_sim/obstacles. -# obstacles: -# - {x: 1.5, y: 0.5, radius: 0.35} +obstacles: + - {x: 1.5, y: 0.5, radius: 0.35} # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", diff --git a/config/dock_hardware2.yaml b/config/mrs/dock_hardware2.yaml similarity index 100% rename from config/dock_hardware2.yaml rename to config/mrs/dock_hardware2.yaml diff --git a/config/mrs/sim_env_a.yaml b/config/mrs/sim_env_a.yaml new file mode 100644 index 0000000..c0fcd7d --- /dev/null +++ b/config/mrs/sim_env_a.yaml @@ -0,0 +1,45 @@ +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Every dock starts occupied (the stack's init_docked): cf_1..cf_4 on scout_1's +# dock_0..dock_3, cf_5 on herover_1's dock_4 and cf_6 on herover_2's dock_5 +# (z 0.23 = where a CF comes to rest on a herover dock; the stack attaches them +# so they ride along). cf_7 and cf_8 on the floor next to the base. Every CF +# carries a gripper; one left on a branch is replaced on a base dock. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.513226, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_1, dock_0 + - {pose: {x: -1.513226, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_2, dock_1 + - {pose: {x: -1.286774, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_3, dock_2 + - {pose: {x: -1.286774, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_4, dock_3 + - {pose: {x: -0.5, y: -1.4, z: 0.23}, gripper: true, radius: 0.07} # cf_5, dock_4 (herover_1) + - {pose: {x: -1.4, y: -0.5, z: 0.23}, gripper: true, radius: 0.07} # cf_6, dock_5 (herover_2) + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 +sim: + rviz: false # data runs; true to watch a pilot + models: false # meshes and grippers in RViz; true together with rviz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/sim_env_a12.yaml b/config/mrs/sim_env_a12.yaml new file mode 100644 index 0000000..417b297 --- /dev/null +++ b/config/mrs/sim_env_a12.yaml @@ -0,0 +1,52 @@ +# sim_env_a12: sim_env_a.yaml with 12 CFs instead of 8 (hero_mrs_ros2 scenario +# sim_env_a12). cf_9..cf_12 start on the floor near the base, like cf_7 and cf_8, +# at least 0.4 m from any pole. Otherwise identical to sim_env_a.yaml. +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Every dock starts occupied (the stack's init_docked): cf_1..cf_4 on scout_1's +# dock_0..dock_3, cf_5 on herover_1's dock_4 and cf_6 on herover_2's dock_5 +# (z 0.23 = where a CF comes to rest on a herover dock; the stack attaches them +# so they ride along). cf_7 and cf_8 on the floor next to the base. Every CF +# carries a gripper; one left on a branch is replaced on a base dock. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.513226, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_1, dock_0 + - {pose: {x: -1.513226, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_2, dock_1 + - {pose: {x: -1.286774, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_3, dock_2 + - {pose: {x: -1.286774, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_4, dock_3 + - {pose: {x: -0.5, y: -1.4, z: 0.23}, gripper: true, radius: 0.07} # cf_5, dock_4 (herover_1) + - {pose: {x: -1.4, y: -0.5, z: 0.23}, gripper: true, radius: 0.07} # cf_6, dock_5 (herover_2) + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 + - {pose: {x: -1.85, y: 0.0, z: 0.03}, gripper: true, radius: 0.07} # cf_9 + - {pose: {x: -1.85, y: 0.25, z: 0.03}, gripper: true, radius: 0.07} # cf_10 + - {pose: {x: 0.0, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_11 + - {pose: {x: -1.6, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_12 +sim: + rviz: false # data runs; true to watch a pilot + models: false # meshes and grippers in RViz; true together with rviz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/sim_env_a12_floor.yaml b/config/mrs/sim_env_a12_floor.yaml new file mode 100644 index 0000000..4373da8 --- /dev/null +++ b/config/mrs/sim_env_a12_floor.yaml @@ -0,0 +1,50 @@ +# sim_env_a12_floor: sim_env_a_floor.yaml with 12 CFs (scenario sim_env_a12), for +# the conditions without docks or with the herovers' docks removed. Every CF +# starts on the floor; cf_9..cf_12 as in sim_env_a12.yaml. +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Floor start, for the stack's E1 conditions without docks (overlays +# e1_no_dock, e1_bare): every CF starts on the floor next to the base, +# none on a dock. Otherwise identical to sim_env_a.yaml. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.85, y: -1.0, z: 0.03}, gripper: true, radius: 0.07} # cf_1, floor + - {pose: {x: -1.85, y: -0.25, z: 0.03}, gripper: true, radius: 0.07} # cf_2, floor + - {pose: {x: -1.0, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_3, floor + - {pose: {x: -0.75, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_4, floor + - {pose: {x: -0.5, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_5, floor + - {pose: {x: -0.25, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_6, floor + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 + - {pose: {x: -1.85, y: 0.0, z: 0.03}, gripper: true, radius: 0.07} # cf_9 + - {pose: {x: -1.85, y: 0.25, z: 0.03}, gripper: true, radius: 0.07} # cf_10 + - {pose: {x: 0.0, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_11 + - {pose: {x: -1.6, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_12 +sim: + rviz: false # data runs; true to watch a pilot + models: false # meshes and grippers in RViz; true together with rviz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/sim_env_a12_view.yaml b/config/mrs/sim_env_a12_view.yaml new file mode 100644 index 0000000..2a99096 --- /dev/null +++ b/config/mrs/sim_env_a12_view.yaml @@ -0,0 +1,53 @@ +# COPY of sim_env_a12.yaml with RViz on, to watch a run. Keep the two in sync. +# sim_env_a12: sim_env_a.yaml with 12 CFs instead of 8 (hero_mrs_ros2 scenario +# sim_env_a12). cf_9..cf_12 start on the floor near the base, like cf_7 and cf_8, +# at least 0.4 m from any pole. Otherwise identical to sim_env_a.yaml. +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Every dock starts occupied (the stack's init_docked): cf_1..cf_4 on scout_1's +# dock_0..dock_3, cf_5 on herover_1's dock_4 and cf_6 on herover_2's dock_5 +# (z 0.23 = where a CF comes to rest on a herover dock; the stack attaches them +# so they ride along). cf_7 and cf_8 on the floor next to the base. Every CF +# carries a gripper; one left on a branch is replaced on a base dock. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.513226, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_1, dock_0 + - {pose: {x: -1.513226, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_2, dock_1 + - {pose: {x: -1.286774, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_3, dock_2 + - {pose: {x: -1.286774, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_4, dock_3 + - {pose: {x: -0.5, y: -1.4, z: 0.23}, gripper: true, radius: 0.07} # cf_5, dock_4 (herover_1) + - {pose: {x: -1.4, y: -0.5, z: 0.23}, gripper: true, radius: 0.07} # cf_6, dock_5 (herover_2) + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 + - {pose: {x: -1.85, y: 0.0, z: 0.03}, gripper: true, radius: 0.07} # cf_9 + - {pose: {x: -1.85, y: 0.25, z: 0.03}, gripper: true, radius: 0.07} # cf_10 + - {pose: {x: 0.0, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_11 + - {pose: {x: -1.6, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_12 +sim: + rviz: true # watched run (sim_env_a12.yaml: the same with RViz off, for data runs) + models: true # meshes and grippers in RViz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/sim_env_a_floor.yaml b/config/mrs/sim_env_a_floor.yaml new file mode 100644 index 0000000..d1bbd19 --- /dev/null +++ b/config/mrs/sim_env_a_floor.yaml @@ -0,0 +1,43 @@ +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Floor start, for the stack's E1 conditions without docks (overlays +# e1_no_dock, e1_bare): every CF starts on the floor next to the base, +# none on a dock. Otherwise identical to sim_env_a.yaml. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.85, y: -1.0, z: 0.03}, gripper: true, radius: 0.07} # cf_1, floor + - {pose: {x: -1.85, y: -0.25, z: 0.03}, gripper: true, radius: 0.07} # cf_2, floor + - {pose: {x: -1.0, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_3, floor + - {pose: {x: -0.75, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_4, floor + - {pose: {x: -0.5, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_5, floor + - {pose: {x: -0.25, y: -1.85, z: 0.03}, gripper: true, radius: 0.07} # cf_6, floor + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 +sim: + rviz: false # data runs; true to watch a pilot + models: false # meshes and grippers in RViz; true together with rviz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/sim_env_a_view.yaml b/config/mrs/sim_env_a_view.yaml new file mode 100644 index 0000000..ad2afd7 --- /dev/null +++ b/config/mrs/sim_env_a_view.yaml @@ -0,0 +1,46 @@ +# COPY of sim_env_a.yaml with RViz on, to watch a run. Keep the two in sync. +# sim_env_a: Env A of the simulation experiments — 4 x 4 m working area on a +# 5 x 5 m floor, scout_1 as the base in the (-x, -y) corner, 2 herovers with a +# dock each, 8 perch poles, +# 8 gripper CFs (hero_mrs_ros2 scenario sim_env_a; world from the stack's +# sim_experiments/worlds/make_sim_env_a.py). All keys, and the sim: block +# defaults, are documented in example.yaml. +ground_robots: + - name: scout_1 + pose: {x: -1.4, y: -1.4, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: -0.5, y: -1.4, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: -1.4, y: -0.5, z: 0.080, yaw: 0.0} + drive: omni + model: herover +# Every dock starts occupied (the stack's init_docked): cf_1..cf_4 on scout_1's +# dock_0..dock_3, cf_5 on herover_1's dock_4 and cf_6 on herover_2's dock_5 +# (z 0.23 = where a CF comes to rest on a herover dock; the stack attaches them +# so they ride along). cf_7 and cf_8 on the floor next to the base. Every CF +# carries a gripper; one left on a branch is replaced on a base dock. +# radius 0.07: a Crazyflie 2.1's real reach (motors 46 mm out + 22.5 mm props). +# The 0.10 default doesn't fit: the pads and branches facing 137.5 deg are +# 0.091 m from their voxelised pole, so collision would stop a CF 1 cm short. +crazyflies: + - {pose: {x: -1.513226, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_1, dock_0 + - {pose: {x: -1.513226, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_2, dock_1 + - {pose: {x: -1.286774, y: -1.490581, z: 0.23}, gripper: true, radius: 0.07} # cf_3, dock_2 + - {pose: {x: -1.286774, y: -1.309419, z: 0.23}, gripper: true, radius: 0.07} # cf_4, dock_3 + - {pose: {x: -0.5, y: -1.4, z: 0.23}, gripper: true, radius: 0.07} # cf_5, dock_4 (herover_1) + - {pose: {x: -1.4, y: -0.5, z: 0.23}, gripper: true, radius: 0.07} # cf_6, dock_5 (herover_2) + - {pose: {x: -1.85, y: -0.75, z: 0.03}, gripper: true, radius: 0.07} # cf_7 + - {pose: {x: -1.85, y: -0.5, z: 0.03}, gripper: true, radius: 0.07} # cf_8 +sim: + rviz: true # watched run (sim_env_a.yaml: the same with RViz off, for data runs) + models: true # meshes and grippers in RViz + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/sim_env_a.bt + # Off since 2026-10-26 (P3e): a stop at a pole has no sliding along it, so a + # herover pushed against a pole froze there for up to 30 s. Robots now pass + # through poles; contacts are counted from the tracks instead. + # Platforms and branches aren't in the map, only the poles. + collision: false diff --git a/config/mrs/test_perching_full.yaml b/config/mrs/test_perching_full.yaml new file mode 100644 index 0000000..af20f89 --- /dev/null +++ b/config/mrs/test_perching_full.yaml @@ -0,0 +1,47 @@ +# test_perching_full: 20 CFs on the floor for the platform-swap stress test +# (hero_mrs_ros2 scenario test_perching_full: 16 platforms, no docks). +# Layout from test_deploy_2; all keys, and the sim: block defaults, are +# documented in example.yaml. + +ground_robots: + - name: scout_1 + pose: {x: 0.0, y: 0.0, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: 0.0, y: 0.6, yaw: 0.0} + drive: omni + model: herover + +# Two rows of 10, 0.2 m apart, south of the scout and clear of the poles +# at (+-1.5, +-1) and their platforms. +crazyflies: + - pose: {x: -0.9, y: -0.55, z: 0.03} # cf_1 + - pose: {x: -0.7, y: -0.55, z: 0.03} # cf_2 + - pose: {x: -0.5, y: -0.55, z: 0.03} # cf_3 + - pose: {x: -0.3, y: -0.55, z: 0.03} # cf_4 + - pose: {x: -0.1, y: -0.55, z: 0.03} # cf_5 + - pose: {x: 0.1, y: -0.55, z: 0.03} # cf_6 + - pose: {x: 0.3, y: -0.55, z: 0.03} # cf_7 + - pose: {x: 0.5, y: -0.55, z: 0.03} # cf_8 + - pose: {x: 0.7, y: -0.55, z: 0.03} # cf_9 + - pose: {x: 0.9, y: -0.55, z: 0.03} # cf_10 + - pose: {x: -0.9, y: -0.8, z: 0.03} # cf_11 + - pose: {x: -0.7, y: -0.8, z: 0.03} # cf_12 + - pose: {x: -0.5, y: -0.8, z: 0.03} # cf_13 + - pose: {x: -0.3, y: -0.8, z: 0.03} # cf_14 + - pose: {x: -0.1, y: -0.8, z: 0.03} # cf_15 + - pose: {x: 0.1, y: -0.8, z: 0.03} # cf_16 + - pose: {x: 0.3, y: -0.8, z: 0.03} # cf_17 + - pose: {x: 0.5, y: -0.8, z: 0.03} # cf_18 + - pose: {x: 0.7, y: -0.8, z: 0.03} # cf_19 + - pose: {x: 0.9, y: -0.8, z: 0.03} # cf_20 + +sim: + models: true # 20 CF meshes slow RViz down (test_many does the same) + # The perch world's poles (the platforms are not in the .bt). + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/perch2_world.bt + # Off: a perch approach flies straight with obstacle avoidance off, and + # hero_sim stops a drone at a trunk at any altitude, so a CF whose line to + # its platform crosses a pole would sit there. Turn on to see which do. + collision: false diff --git a/config/mrs/test_perching_full2.yaml b/config/mrs/test_perching_full2.yaml new file mode 100644 index 0000000..e72666b --- /dev/null +++ b/config/mrs/test_perching_full2.yaml @@ -0,0 +1,55 @@ +# test_perching_full2: 20 CFs on the floor, cf_1..cf_10 with a gripper, for +# branches and platforms together (hero_mrs_ros2 scenario test_perching_full2: +# branch and platform perches from the Blender generator, docks on scout_1 +# and both herovers). Layout from test_perching_full; all keys, and the sim: +# block defaults, are documented in example.yaml. + +ground_robots: + - name: scout_1 + pose: {x: 0.0, y: 0.0, z: 0.20, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: 0.0, y: 0.6, z: 0.080, yaw: 0.0} + drive: omni + model: herover + - name: herover_2 + pose: {x: 0.5, y: 0.6, z: 0.080, yaw: 0.0} + drive: omni + model: herover + +# Two rows of 10, 0.2 m apart, south of the scout and clear of the poles +# at (+-1.5, +-1). The front row carries the grippers (gripper.enabled in +# the stack's cf_1..cf_10.yaml); a gripper left on a branch is replaced on +# a base dock. +crazyflies: + - {pose: {x: -0.9, y: -0.55, z: 0.03}, gripper: true} # cf_1 + - {pose: {x: -0.7, y: -0.55, z: 0.03}, gripper: true} # cf_2 + - {pose: {x: -0.5, y: -0.55, z: 0.03}, gripper: true} # cf_3 + - {pose: {x: -0.3, y: -0.55, z: 0.03}, gripper: true} # cf_4 + - {pose: {x: -0.1, y: -0.55, z: 0.03}, gripper: true} # cf_5 + - {pose: {x: 0.1, y: -0.55, z: 0.03}, gripper: true} # cf_6 + - {pose: {x: 0.3, y: -0.55, z: 0.03}, gripper: true} # cf_7 + - {pose: {x: 0.5, y: -0.55, z: 0.03}, gripper: true} # cf_8 + - {pose: {x: 0.7, y: -0.55, z: 0.03}, gripper: true} # cf_9 + - {pose: {x: 0.9, y: -0.55, z: 0.03}, gripper: true} # cf_10 + - pose: {x: -0.9, y: -0.8, z: 0.03} # cf_11 + - pose: {x: -0.7, y: -0.8, z: 0.03} # cf_12 + - pose: {x: -0.5, y: -0.8, z: 0.03} # cf_13 + - pose: {x: -0.3, y: -0.8, z: 0.03} # cf_14 + - pose: {x: -0.1, y: -0.8, z: 0.03} # cf_15 + - pose: {x: 0.1, y: -0.8, z: 0.03} # cf_16 + - pose: {x: 0.3, y: -0.8, z: 0.03} # cf_17 + - pose: {x: 0.5, y: -0.8, z: 0.03} # cf_18 + - pose: {x: 0.7, y: -0.8, z: 0.03} # cf_19 + - pose: {x: 0.9, y: -0.8, z: 0.03} # cf_20 + +sim: + models: true # grippers are drawn only with the robot meshes + # The generator's map for this world (poles only: branches and platforms + # are not in the .bt). + map: /root/ros2_ws/src/hero_mrs_control_stack/hero_mrs_ros2/worlds/perch3_world.bt + # Off: a perch approach flies straight with obstacle avoidance off, and + # hero_sim stops a drone at a trunk at any altitude. Perch paths are now + # checked for clearance, so this could be turned on to catch a regression. + collision: false diff --git a/config/mrs/test_unperch.yaml b/config/mrs/test_unperch.yaml new file mode 100644 index 0000000..b05759b --- /dev/null +++ b/config/mrs/test_unperch.yaml @@ -0,0 +1,29 @@ +# test_deploy_2: ported from agilex_sitl hero_sitl/config/test_deploy_2.yaml. +# All keys, and the sim: block defaults, are documented in example.yaml. + +ground_robots: + - name: scout_1 + pose: {x: 0.0, y: 0.0, yaw: 0.0} + drive: omni + model: scout + - name: herover_1 + pose: {x: 0.0, y: 0.6, yaw: 0.0} + drive: omni + model: herover + # - name: herover_2 + # pose: {x: 0.0, y: -0.8, yaw: 0.0} + # drive: omni + # model: herover + # - name: herover_3 + # pose: {x: 0.0127, y: -1.25144, yaw: 0.0} + # drive: omni + # model: herover + +crazyflies: + - {pose: {x: 0.5, y: 0.090581, z: 0.01}, gripper: true} # cf_1 + - {pose: {x: 0.5, y: -0.090581, z: 0.01}, gripper: true} # cf_2 + # - {pose: {x: 0.113226, y: -0.090581, z: 0.23}, gripper: true} # cf_3 + # - {pose: {x: 0.113226, y: 0.090581, z: 0.23}, gripper: true} # cf_4 + +sim: + models: true diff --git a/config/sim.rviz b/config/sim.rviz index 442f9db..09d4400 100644 --- a/config/sim.rviz +++ b/config/sim.rviz @@ -8,13 +8,9 @@ Panels: - /Grid1 - /Axes1 - /TF1 - - /TF1/Frames1 - - /Map1 - - /Map2 - - /Map3 - - /Map4 + - /MarkerArray2 Splitter Ratio: 0.5 - Tree Height: 437 + Tree Height: 1285 - Class: rviz_common/Selection Name: Selection - Class: rviz_common/Tool Properties @@ -65,38 +61,74 @@ Visualization Manager: Enabled: true Frame Timeout: 15 Frames: - All Enabled: false + All Enabled: true cf_1: - Value: false + Value: true + cf_10: + Value: true + cf_11: + Value: true + cf_12: + Value: true cf_2: - Value: false + Value: true cf_3: - Value: false + Value: true cf_4: - Value: false + Value: true + cf_5: + Value: true + cf_6: + Value: true + cf_7: + Value: true + cf_8: + Value: true + cf_9: + Value: true herover_1: - Value: false + Value: true + herover_2: + Value: true scout_1: - Value: false + Value: true world: Value: true Marker Scale: 1 Name: TF Show Arrows: false - Show Axes: true + Show Axes: false Show Names: true Tree: world: cf_1: {} + cf_10: + {} + cf_11: + {} + cf_12: + {} cf_2: {} cf_3: {} cf_4: {} + cf_5: + {} + cf_6: + {} + cf_7: + {} + cf_8: + {} + cf_9: + {} herover_1: {} + herover_2: + {} scout_1: {} Update Interval: 0 @@ -119,7 +151,8 @@ Visualization Manager: Enabled: true Name: Grippers Namespaces: - {} + gripper: true + gripper_left: true Topic: Depth: 1 Durability Policy: Transient Local @@ -152,47 +185,34 @@ Visualization Manager: Value: /hero_sim/obstacles Value: true - Class: rviz_default_plugins/MarkerArray - Enabled: false - Name: MarkerArray - Namespaces: - {} - Topic: - Depth: 5 - Durability Policy: Volatile - History Policy: Keep Last - Reliability Policy: Reliable - Value: /swarm/agent_markers - Value: false - - Class: rviz_default_plugins/MarkerArray - Enabled: false + Enabled: true Name: MarkerArray Namespaces: - {} + perching_positions: true Topic: Depth: 5 Durability Policy: Volatile History Policy: Keep Last Reliability Policy: Reliable - Value: /swarm/battery_levels - Value: false + Value: /swarm/perching_markers + Value: true - Class: rviz_default_plugins/MarkerArray Enabled: true - Name: MarkerArray + Name: Docks Namespaces: - perching_positions: true - perching_positions_axis: true + charging_docks: true Topic: Depth: 5 Durability Policy: Volatile History Policy: Keep Last Reliability Policy: Reliable - Value: /swarm/perching_markers + Value: /swarm/dock_markers Value: true - Class: rviz_default_plugins/MarkerArray Enabled: true Name: MarkerArray Namespaces: - map: true + {} Topic: Depth: 5 Durability Policy: Volatile @@ -493,6 +513,18 @@ Visualization Manager: Value: /cf_4/occupancy_grid_updates Use Timestamp: false Value: true + - Class: rviz_default_plugins/MarkerArray + Enabled: false + Name: MarkerArray + Namespaces: + {} + Topic: + Depth: 5 + Durability Policy: Volatile + History Policy: Keep Last + Reliability Policy: Reliable + Value: /occupied_cells_vis_array + Value: false Enabled: true Global Options: Background Color: 90; 90; 90 @@ -539,25 +571,25 @@ Visualization Manager: Views: Current: Class: rviz_default_plugins/Orbit - Distance: 7.194181442260742 + Distance: 6.790585517883301 Enable Stereo Rendering: Stereo Eye Separation: 0.05999999865889549 Stereo Focal Distance: 1 Swap Stereo Eyes: false Value: false Focal Point: - X: -0.7366625070571899 - Y: 0.22696661949157715 - Z: 0.531701922416687 + X: -0.25788095593452454 + Y: 0.3019346594810486 + Z: 0.32738953828811646 Focal Shape Fixed Size: false Focal Shape Size: 0.05000000074505806 Invert Z Axis: false Name: Current View Near Clip Distance: 0.009999999776482582 - Pitch: 0.7697970271110535 + Pitch: 0.7347971200942993 Target Frame: Value: Orbit (rviz) - Yaw: 3.4754130840301514 + Yaw: 3.945415735244751 Saved: ~ Window Geometry: Displays: @@ -565,7 +597,7 @@ Window Geometry: Height: 1403 Hide Left Dock: true Hide Right Dock: true - QMainWindow State: 000000ff00000000fd00000004000000000000015a00000546fc020000000afb0000001200530065006c0065006300740069006f006e00000001e10000009b0000006b00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073000000001700000546000000eb00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb0000000a0049006d0061006700650000000217000003460000000000000000fb0000000a0049006d00610067006501000002c70000030e00000000000000000000000100000102000005befc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a005600690065007700730000000017000005be000000b900fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004fa0000003efc0100000002fb0000000800540069006d00650000000000000004fa000002d200fffffffb0000000800540069006d006501000000000000045000000000000000000000060c0000054600000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 + QMainWindow State: 000000ff00000000fd00000004000000000000017000000546fc020000000afb0000001200530065006c0065006300740069006f006e00000001e10000009b0000006b00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073000000001700000546000000eb00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb0000000a0049006d0061006700650000000217000003460000000000000000fb0000000a0049006d00610067006501000002c70000030e00000000000000000000000100000102000005befc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a005600690065007700730000000017000005be000000b900fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004fa0000003efc0100000002fb0000000800540069006d00650000000000000004fa000002d200fffffffb0000000800540069006d00650100000000000004500000000000000000000006ad0000054600000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 Selection: collapsed: false Time: @@ -574,6 +606,6 @@ Window Geometry: collapsed: false Views: collapsed: true - Width: 1548 - X: 1012 + Width: 1709 + X: 851 Y: 0 diff --git a/media/tether_demo.png b/media/tether_demo.png new file mode 100644 index 0000000000000000000000000000000000000000..db04751f5c097d25b6fc5b6139a1bbfcdb8b1dce GIT binary patch literal 181309 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íxTfZF3r+petPd#^z4=t$f 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

W(zSa-v`FWJ3Y()Zv>48GYXl%E+$RY6^b> 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%0AcK_cLwjt8jnB_d*g$sI0@Yi=IB8 zup5}3=G8jXK}7^|B#IJ3`S?i3w%~?Cb?Xtd?#_l&2p%7e`In35U+3lZ1;^ShF~VQ$@x5|ECRuo=`33bGB%y3 z@@9=)&v_m1>T{pb_l%H^u6F9Pe!k?hG%Pr=ft8r{k%KcXT)e*skPiLC4~Halde>R-gN z^IY=@^uU_EI;>7#*h87GuwMud0-zhKlp+!z@6JOXP20}2|F46QTX(e%qnDGzgmLR& zL%)V_vWy3kF4==`6+UEBDsbNq)|r=#M@Dk7!~dbWE7a)-v=?+y&QnA4fS3*O+U#)C zhXg$eptGaZrBm1L;Oq6Ao2Z=^5%)u6x%nKC%*<9;JFkhc=VhUBDEqn_cAwyZ2rhzA zkxyuXut;*a=6m@mYdD;jI9r2Ao$lP^K7Q#MkvQ2coa4GbjrS3XRPFK=4B_EuNm zw%%G}e49&r(5_H_Zso-lVBR;h*!XI-w2!)hqiXP2jtN!qjTjam%p5<0g7dqJ6I$I^ zJ$iKc>%;zb<1r(F3#Jh%mU`~&P&D17TYZj+tdsCq(q7{In8y6NUV-amLPd`_CuX$}k*n`S!a1R4J=SE-VT_GU*Wy-f*+4RXElDoP&x$~y+kn>2@ z=&6Mv{2dIpju!7N5xbjHij|FCp1ForL%+`3it=}y{c0ufg8D24)5YG=p;n2$_#SuZ z<8L3!XkS-C`)4G2&$p1qUxa?^HU?0RtO7Pzswz1I8@+OCBXS#wMzpb$q^GTN`wfTk zt{`$4_X4x@V1^sq4jTCg)@TJc^{j-M9QK;T8REMVjh8!Mn!O-o!>x8?+wy66-;u|n zLPQ%vRX$_tLn^RO4`H86qaDv|1)dU=l?mtBp%noPP(|6i-xgxI{MBDe*by8>O z&3s zIGW>TVq%N1B61p_HPHEiGs6S5^k-DklpeKF}QqnbBNeYMo2$1R@&n_ghF?-t2 zL0v*R@xknIj*w}Rc)lG~#d9L#lz%;xgztA+Zs)e!-R!AGmuMP8l3f8D2czy@-{MIB zKZO{MLpLpEn1v4Aumon`XU}OiuZ{Cfh&8-!+X9 z@w9l7L_d%JP&hf{Z{tkDy?DNI7&b&NEJsw$0v|Ngq4I7Qk(7F0Y~74P%H-sdj`DX9 zRy6(7NtOzONy)|4YH|L5R1Gh%TSB#or0qjPg5Wjk&CAjHz6_mh->FN zOvtRVyy$d4GoFK&M0~F0A+dm5b-Au>3RT?@3~K2G4F^~lpw&K7e_%h@ml`E+CdqBu zMtm*I<6Dm--KKH^|2>Lh+q3l3-$I5=nO|F?Ghe{B$1D*@2Z?D1m&h@j4-=@-#dL(M^@SWC^~eoK;&BmOZ=4h=KcQ# z=@b1gNPh-hlDeaVGrisNfXiI$P4Sv-y-`#_VduSuK%!rr@MDL@`XlG zYfqqC8PntpsMoGBgXrhYU*L3+V0?PyF)Q#ipTk}a#n3_01S}Dq>Pd?~wdTu7*ZNj0qFIleH(3K$lHp4Up@1qtRiriPty!Y(<-+vGSO@6MFhKX}tDu%VhW$isA0| z=}M=W2!i|WFhmVidHB00`q^neQ zzB^q+oG9J)Hi-=L-m!?Oh;?NSNShYC#WOp1<+Yz0l0Z^wyrC}Ack}A~JUW%kChl9x z!xIbSCfASPh8zTk%mL)Wb&zsX+W^}G3pEdg7$9$^0<7}ZaFvk7p_Po!zG*7Y!lzPo zlU^$G3poAB09pd-kG2GI8*2=ILW{lMd9_f(V|}=jbk|d5!y2SGh_<{sOBWC1f1+5m zl*{^5fgO;kgOiRY8~JWQsrZwleP(mUpffVQQUMrowcA<}IROhbtIz&x(X)gG4pZNG zDpv|b9Rw&gLMOdc7ezB_jYKX!cIk1<7FdRdI}Pri6Py@z&F{4B{XkDg<%H;nUWZ%U zjrR+s?G^SmY}FK!S%*d5rQ!_T8-_&eUpg8!&fOXE)52?hOyAeqA-#O+C&5OGJ%Ok* zhUhkcgp z>Z<(a{YKMN86t80RjmnRE}?R!N+k71TGK>;Gx?At&zy-aylk#Ao{osm3sM7;qqebY zHbVRR?N}cRIkBqBmsq%1C^*t&qy}wmCpL~`4f{|25M2dD(W3mxK>L9sC5{spi5+F? zrbBtTrW$~nQ2sIl9=UpVv5Az9<3w6!fN!maD$? zyu3WeSE>EzClA$k(IQpkq&EXBSrct{nb2?4@eCo$l2t(!kyKVDM8SwE37NPa(4hDd z(7O=DVYVLyKry_i*f!ylQ*42ef%p@>AODkA;IT5Jg<@cL_B$RcZK`0Y!2DUYbbuzw zsLzh0Xt|=)7+s$=Gj)J3;b%okO%NQCVS6|1f*IFr^x${*&s?gd$(iLr-EqV>6N|V{ zm!ju8yi>O4K};E`>_hB)NYC*4ge}eA~Nx@z0QqU535rRJ6~Gieo7*q@3}#Ox{Id-hpaBdaTl_#_I(P9?4b_S zRHo=?1O2H77F+iMz6ZlXBCqzzi){PIWpA=)*O|oLxVR-Ia6BgVBV&igIkk)Vhy|_$*MnrFu%n{w{IcFObbMhGvFA zz=*e{t)Xkpr#UsA+x0@gZ;AJoQ=^VW%LXY`6~Lx)_g!J;#r|nTG;34INqxaa9hIc1 z=eUb*R=$FVfH&5|^s73SF^ZaymRTA<{50qSGpD>O zt5`Q{{L`Kfy6Lq|1roSU7uH&1PStj*Ex?@Dm(7)KYot`qif0Nugl+Fk@;x>aT7faa=*nL>#{@O{(y#eqk8Y z@Umku0cEqtrmj`*d-dh{0BjZ1rIn@gQJ%wMH&?Fyj8Rdo+dNt`@Mb?D4;qcoVK;Z> zsd@;u%O*f90Hq{3T1;aBW>~0 zgPX5z)=7Ph!%PHZ8+#`_aehauM#(xbH6kjzM^f4xmhH~MrEjy#>$5!Za%z{zN%VSd zoLMtc%yv6Wb!W5?30p1Je&(HitGhu{_^vLn(|^r$;$7KjwXEJrN>Qy9sGo&FkJd9_ zGbk+O$Q4*oA!low%KQxi83^rNKDaCd&t|Es$LO;w)^=QlP4~`gHk|QL!T3e3|I9S* zd(8{6-~rCQl378Aa*h<^GkLIXD~Yl{X`7vAlDY zWPQjD#&z?J-oWBmT89TFO+Fr@vD8GO590Fpg7uvgFxdH`dT`;=M>S9DKrry;8%cy% zhBjBYply3o3m2pFnx6yVl=U^@3>WLN-E#ZyE;fair@``Rt+2PDKOSAe=YN3>OA@{JWr?)Js-g|W^FF*5UYUIWIkn?2= z$$|hg&R;N7!vg{c@-?n4A!#U1GK^*wpc&TrePf1xDKx81X_c%LP0h{Wcr+kR5xv)3 zP~X*Y1#HY=ZS|ALC&y!bmu%KvTBqq+bRvjCIIL{m7Vg>fI}aWp$PSkK;Nd7~O@Uw$ z!!<vi_jYke` zzskQR`UZ1>m#>a$;$enf=1rF(H6Jc-w^q~w;9G_k%ICX-7ia7=mX~e6^Ec9Pkn{BV zwqHp7(IFjldpW55galS^lZm|g|6l%ImZ#e1_O`f!C*bl!IynijK+W&EiIVyVafr2_=jHN*{?7*olsFo0i9u;t`NdBTN(GeSTyT1Lhf zz$(PGpd=^YGp<|%VwLN40{~KHgTSe`WN147%ukkn%dm1te`&kFNH?B4k$!Qq^E?%z zRtyb+o(m@&dZ)0tjF^Gew?s7lV*=poFPQ|f%}i%`Cp|sJBx}4~O}9|>=zlP0r>_kp z5aHm*x;~ylu~&!$ON?u&Z?JYZNj-|g0s0H%md{jBcWty1)<}jvP|L)XgBXcvYa@y; z3<6sC-Y@d=GpxvwsgD8DwGDtS3=|KxlXS{=Q<4Cxzo2IIS6D+GqlIQo!@13}n@U!f zhKh5k6w^Z!>yS97vU1jX7xus>h*OWyo{FL-$4*&ItWqX4F~|ziUXf+f;ro}~>6QDy zCq}&p)cy6FKEg^6+EgRa=|$I9bWfc;JF_${T1*oxfi?nP2Txty%KE6x>o z&%~R5JL%P9Q&5=B?ey(m;UYtx>jM-!jTdKljH~_hwmgc7l%lXBI7C@^aw>1_^>U#) zV9f@2r+N>DX*%JzYZhL46|&>&41mZT9NNZ3hy;*=;i@6NI{&hdCt_mA^yNSS$XAG; zq$^P^tg3q4EHIK2qlD$DG+C|VncM5&<1!v^EYGc7b)|akcWBV%|L!$t{Yld*?p3^} zfm6!;`X5&`^ndD7ESSPVF90~`OdZ9fk)Uz=uBXgOqi*)6 z!wfW)%tf3`kK7W(1`cZC^#2=euc;*h$P1PvU&B5|KbE55bA#$pI2>#>Pdoj~Q6IwZ zR`*u93swDAHui&Q%*wk_H^?myRWI{DV=@&WOD2=7TKxjAK@W#%XN##>LCEEi5G&%_!|MiEiux z&zD&^R(QmaC+7U;h;|Z48B~TP9R>&adxc;%v2O)|BHv~VH9xs(Fy!xc6t}J_>U4HM zA+Vi2F@BwKC%Yg*ip6L;NH5cv!aWA`o9+L_um9(1Vq$JTu9BG|TAlr_4kIOyk`iDv zo3Y+&5(H%`jc;%SR3I{B$O1L=z!SYM8)f;6J1hanK7>s+Ar}oJp6B4$WFr|F1({xc zM>BO@fD*wwU-ISVi%K26KTTr77vOc@0cPpqI~jC|oPd2xi&Hu;QWR~OWy;oohWvQ7)hfglMd6KxO* zX$$^j8K-;|MjI_gF_r@ajXdX0d-E)V@E;a1&zpWDZa+HMvl{eI*}Ajpf8RGS%SWq~ z90Ly0Go;ES?*Nk~xL=Lo(>|y3P)^<*;OEssmyA{Uh}?tW*GXha+e>~3K?C^$-T%57 zS}={s%Bdl|^@~9XKcf3^pw@k$=IB`Shon<1Nnq^jgLU>|+#N&DeD_<2nA45J8~G1^ zK>(T5fBt;2Ds6vAp7!qU_O#ZAzZj^pyu8*e9a$9WLO!fByn*K^;P@g4PSt=72H*r? zprD*h&Qd@8`SD4JWpXW{2zUGPB7`iNu>b~Q$n~o*q;P0TP1IN@vd<#Ej0x!=vnyd2 z{w_o<8yNljDPp_qYk?Gk9%vN*`h^x1BkaA6s@FHwPXCk|Rd z(&BLt$Q1u0tr3EOd81CyV>Ec~>pz~=NgO};yDeDuh>(gRzUi!NP9pj$o69F7=)=c9 zGg9$u=+~Eqv!~wS(jyX(G8?Fs5cBeIhHj=d(bVldPnw9ELjEp46E&^wL0d|NvEr7J zZR>2*Z$W*9`?c6Ms;xSrxh8b@+`A>@Oty6uvJZco+Gf5*Bq^t1fUwf`$=NTQS1Ynr zrKCE<+iRC=^afImf7u@GKu~6b)rOLhQLnUQ)wDv@#RbCU#igp-iLEWlzD4e&(YRm- z#D$KH0ql_pJ>X!ah&6LOkcY!?JbzJH67|5qL6T zPXe)Ais3s^ZS-7A7VMKeYJC)cpF1Ox-R7&GB0l}h6`R5N1AGa`EB(HhU>z&)SNN~!P0|C zx9!F6tZSk(MPJ0QxV(#F#alYI~-e@goPOIg|>))mQvE(9~?OSfK`=f4BdNt zN8AlL0VjA?>*~TmF6p|8h?c$=h<`KiS`&5kLaRs+fph0!%-iFq<+c`0T<1D79TKQT zs-S?R>~|j}YRgSF$Mi_uM~|pl`Z`A~ida6{89O{>39B^56c}t0wdYxG6&Z(iidB;) z>GDuFn+W6W7Y%x)6PI&;2BB)d?FD2=9XE%y0rdSH#)U1v!E|@m`*BqiqlKhf}wTN2u-L%Qv)K+z#j5C|rDJd*_Bu ze$WEwA$OJa^hRMpCS-M>2Ro6w|4ir5`#yREu=r4^E=VpsSi*2G%(8i<+lwL=?!$AL zw2xd{e(WJ^0@qYN*6FN4`FZ-&`VW2&WipKg^0>=FIgFfxzQ2y&5q4yuBNZY-`@7pBI@{*4|AD`)xo;Hhhx-B zHX$o*2);R{_z+NvlxJOzT8jS4OXWF=UJ==Oa3&8orbm^vO^Ob4a&L|JJj7Y@8#a=d z+->Y!L625Sl5rG^bR@=XO2PBSiywKa7Nl0cVKlXwqax-8mac&sJ8wu6WsPObN#U_b zn|+oYo{|TpHUId$&N&6<-=0e{W9TRk0r{JozNaJNz!~O#0l8KjN-;7F>AS+LeKOka zFQnEl8_ZQH`aTB^i=W!r04LdjfH` zNiYYi^&WdOjq$zuebkIUo=qmZOugKo8z90=SX_0;axxI^siiJ|;4OHs$TCe4aNFvj z#dtlT?~SKhAzrLwcOIP`u4jg4gV3dv<}h6AZ|byTLDXM$FeQW90&$WghN-}LlS0A> z@|1Eoto>@|f7K*ngUnH2g~w=S>|^YbJf5?O$RjQZ=}@}hpke&L5`WJTiFBRYGGbQM zbvc_6|1rg!dBIP*5oICDD&k8bMfR3~^qd1u8RuZam;Crh6^!;>m)7QC`>j&){1yt{ zT`UCVkzSEu=9cX~)0bS`1)K0V{ymcd{ z-QqQT_@7Y}@X^Vlq>>&MCKiELRK@Ub%dNr^;K%!jVR3{m>-pSD zr}S`jFQ*N`#q#j|mPQyR+korb;%kweJtgU6pn)8!09t%D5_^Y$5m#64*tpfnQ@ao} zeBkDDhr~eV-rx?ToNJ9}UEe@TvZ@qfD4UituTvWQ0r{#>@vF2hq=n=F7qLO2ek{QL ztm&l;vRD~O>#@}sse_i^C(h5wl#&5+RUAmCk#C)#Jq6L$U0+y|M&gFPxu4hH-2 ztX#kJPd2K+TcMX0o5lIHh|XI@jI+xr*ZcFC<*(n_uN7r08a#(LKXnSH&F^u;RE5Tw zTemQm6h@^X1%>?*#g)5HY(b-0)J_ zcfi5Og@^q$$T`?&F)_ZYtn!j7fxDb#feT)xGD%nG!RmY`H^o^GPwaHcf^6DXKHw@@ zVOAZ(!K6ZuE|o*3-dI7Yvw=qrL`Y*dwCtXtrnDpM6JZFWKfb8-WbZ$ncbKYfDFW>; zBqwC3ZN;M*-;%gIEj-*U_8cYuIoU%mY76rXj+5#)hlBd%qax9RH_dW82pUnyCPWYV zE~f=^>gG%WRLf}v4ea0Zjt?mJdLhRpL#vM#U$;_tsEOrVI9dQ>qETqFla!*eib68 zEv-Y$*I=KdQ&Rd3!Cy-yykR^b@ugw_J*S@bwE&YIt({Fe6|VvE{$soSS~6MD9(3$R za_H^_dn>*ym5C82J^j7D}V zR&->7;IQpLDL3NI2ebq;xWng9l#)#H1L8r1ve97zid-wktoNoV$MO-H-_i!Ym_HDg zw|9M}*Zs^)E;AlS6Fsd-HZ9X_U#@;%#RWg*hx#?L)>stIo|O});2~bB4yV{OU@)6p zvNWqCR3?!_bsCMRsp=8rX=dfz3FLLZey+oeBpXAc(s;&*{M|2KYVk2G@ZG|%fyJs# zKrlulv|X1_JAKI^I{j2a#H6(G7#z`=32eQ=-ynSvmjoqy)xb;P+%mQgKWApCx&g&6Tyk}j z+4j!6-3sH|RjD{oEDYq4Zo>%ZYBfhTHYS7I{8l+>ToX8Z3D0Sm)Er1x;=zuoh*LVO zmF=Iz>#DmJM$bvUp-4_T#d4(!Izn~s8mGt-C!rg$8h=5A8L1xHNC|RQ`5-MJ*UeF& z=8;^m&IKL1vkB~sibrozX63DQo+zH=!YG7W!N4psl^WD6lG6CvgLrUE8*I)>^T`9& z)M4I&ET+H0vj{Owu7OLc+h2BPvcM$#N1pXKSkshl zUe;Lco4x^edzInwQ>MSd5;d=jkf)d{SkbUF>Y01%58Z_#N}E5ElV~*Z%uFiy-mssJ ztv?-f-BZq{1ccud*B~Oxq(GH{{<@*^;D3plZY^e;F@bU#gL*$C9Nx7JH z*XHT~(ZaWw$efEm%6LY!%}=~+yK(IUOk~ABN9nBf&2HCs%K2+P->=(+?ZiY6N)0D=7H#D8R_ZBhyH{XRjxl$4z8Ajm5?-vGr z7SYXeAa*nVa%~j0Yb$-7qRr;Ri7d(3>RfVnx)OIJ44oVR4^zpq$^~zKYcSEF0x9qE z5f|?Z3JQ|NIvR$dqf>v`X>Ad&QLy)2f{(Uqyrbmgkg=Vwf4_92j(Z2 zS0lPREkemef7BExWG4uCR5oKE87a!jettlj$>J6s&Ohd~T~QH$%L?&XAcyxP7y0n{ z_QxXn+Y&@0QNV!zF{D3FilC^+rCLRb_LBYZUVsQ9%0>D@@6ERhOrEaRP3`@WJ# zYJ2sUSLPj64@&K}sQsIYX9jYT7w@qB0Vh-sj9aj#NHzvRR1CFtfUpy?No7W!mU>fg zAe$hak31twwTr}6Fm0>Ha2Eb}dJ9OE1*B$h(Sw6!|H7T?8Zh8QBpsD8sGz{${i2)3 zpvuSlf$}q?)>lPv?m*`TM=8sj+gJKNL1$^sSOGa)evKNFnSEe3om*{KR47A$9zHUL z4^vqdxS@i@^3F54>4y-0AvL`czXT#+9P}Be^;Lw*g3~~-P2T2A2%NK36ec6hT3378 z-+OzqRj!SEpmuT<=Dc?b%D`uA$tej%zVd@AGY%j3dD=>}h3*aX9>Mp)t$}|tQR~p! zkGK_sQ>;Y|Y&bJfW2I{$E=vfGDZyRZlx-DOPCxk%3kW1>r4tH5T4Z#&u`seqVg0>A z@5ty!```r!pwRjKPs^TE>@dBe3O&YqG^mhyb#D&GEhhwI==}~RUlbU@M_8L_> z-s573W+`;?%DJVqH!Ucp=cz2?#5()D_yPoHBzj^}3)@QpiyqTUKGs38Ge<`ra`mp5 zTXBISVsKl|5q0pF*c5yvOlaJ*gM?)DA}WM~;@;p|UFz!Un@t+sSU({vrLg`o8(gl& zTY?M)CiXbh>zXrwQ;?vARDfz`J*%SmwB9*rmOKzO)so$@bwYX!(2r6IqR|Qn4`c;l zWx`^vd6vpnNq*_I6LG`zY5bC)|0;plh2Ay91pOl#Bb&b0V{!o9zp4YHZ}!#O?38~) zL*-85S3*DLaF#e$l`aYJLY>HPyJiVtE;A{iAxII|vw8if%xYXBNzUe_)Gj8(N@#IE=r$B_Ar z(%`QBohcsLW(?*%9WpxRcr3FHkSUJKv3Xx#BJH63qouWFh9szb;y z%*V1c7$R_4bY)b~{i0-5-pLHgjWZ1S#%B9LLF@<9XzoACHF8l_c#)C_J1V05IE5jk zBxH_?G7WdlqOBAtOUNKj;59KKM8der-=AKYya<*?m&fE69u5CxqC61I?@ycfRokjX zGiUjeu&nkylOyXsw{}Lj#q$Rv7jw~1YAn-A#J1Yl_2PIx^b3;x!-xM5UGErPSJ$-- zr)lgqR%6>X8#K1N^ zdF~CxU!B5D@(Ce8S6Obs%@>y1C0W$MY;JrMv(!%19gwE)bSNQ}UPBH?%dAT6(;+m! z+#^P)fMFhD*&%3-SXYc{>|b`2ZoF(!iaOd3OCU%ry!dmal>tZzj1Lbid{a}y1PMGo zLK&}p=^G>h6Rb$!gQZh&q!LEEt8wf?>XYf4**hpgHj*|(rz!I?JG60OSO!(!mt9t$ zxr}w6Zto$4`$0ynHrjItb20xKkefP=O5#VGO63HlbWJRZu(@klrKV0X+UFogIKRky z%7tDMM+oO^&Q>0l!xJH`(TA&eh4-0vvVfrf6y=2|&)Uw*X40W|l{`2R@^J_|k}4_? zTolCDmT)_`TX;1|Z><}=GSr1{>@0a`(g+Nq|v~*hvpD;uHr;GWkW@(-8HI^}*OUB@<@WImcH8{;j`W z8`XLQPXTuo3TBG$#P7WlWY{`kE~y*>d+LQ=(l z{0vxi%uH!s8@n0ZyN8fb_W#gcx0PYcTEk8G zRxIUWVjs85TyK_M&CTLMJ>8eNoNX{xg3usiN(o0rxVfdK@0Pero%j*@34+0$F0~KO zI3T_Tc493FH(77BCT)uLaTWF%ubU%SXl&DR=Rkha5+6CIjJ)3hT4F;H%KS_ zyoPZfiW#qzr#CMfnUQntmmf6{o7Va<47 z)@r>61nV98`B?74!+68}f*?cb9$~3$vr&5obiVSDM--xiSx2sNnr>o}q}qC#M1_|{ z+{$YOPto(!sLn?j^ql#AFujML)yNMvhhUbsy*)p~gh@sx;tSSs7G5ZO80X7~f~gi< z1eY1R#nTW>#_+=8f@H}Vi^aEJ^07iGqY#qDDnIFi2B0Cr4Q!+Cq^Gh%^QRm&bgPvK z#d|Jov1W55gV?NuGFo(P%h{6l>3p!X*!}AlmDiFS7|H#N_&KCpE&9WT%pt@n{Vr#Rw#)ej7W1EfA0E~}dqU^5{Rq?M z|8ZaX zM%;&Q&XPu>!97;cR}3t`N_bGYCe_uzdH2~;98F0e0CeESGt5n^H0I4{U2s4yfT$!; zATst$rv>p#>&icFv#s0jYDt>>Aa&s>Osv-^`>!Ff&m`Aty+e2nRx-g6^S7)IUVzK4 zA+YVe75Bn#Lfd>Q+yjz7RXd}IgUju|ngYTtrW^D8`Gop~aHSCFYP~-|J@FQ56{8#H znFjx^O|D5gT_S>85rl}#K^U};_NiHugl7Wn*|O=MZs)X-syl=o{>fuA%IG{O%52%2 z(#lQ0?UkMBXM&dcW-{6j2hLJ25-S*hM5rpp@+81T zpel9oz@k*&fU+fcrh{RZU)iCQXZ^SGn%`>VdU}U!#WQ}UR=bzb-!OMWiaK>&wH)de zMy>P`cd&wv#fsx88Pv))p^Mq}$LI*H3R5dZrvSARf({jA%Y^kEHLyO5HM-LMxJ|9) zRqL`?!748k!F<-^lFg(%r<~|!=Md+Q?hL3VZA_&*X(aQl46+wr;Y%2?P3*GOOU-@i z4ZNNdPy0s{vRxOYF6irFWm#9(mgt{bLKvL{lce6CQoW%YWkWl2GwMkDp?~0_(v-yOzWU?-#=!TjfAnur(Tu-8I8hhMOa;yX`a##|-x^&S^0v9D+^C0I z7SclBxwZ&8@Z6WY+Lu<*M>g$XQ>Di;dDm)XdUH@mcH3D z(X-yM-Dq8rXUdys_F5avgPQYhwP5!?-gub{4f`SWaP}2$HlE7|_vcE&*38ZN9lA@c zLYE7H%1d)GNGnz6CM)JY;&EVR-%33P!L*lnJkU z#Ygz=h)KPs9vSL#?=BQo0Da=Ql8{zRrGdg(abT65L8F2{N# z8L}Wzmfa}%^XJvBr&zEOqOBN)Jld$L5k?d>0^+p*9+xo46G$sc1za5Pr0Sw$pk+Ta z9b4_y5k$|JusPKOgxX-ad?d#0+FXW;X|A&rSKwKQ5bEQ_f8}ycgq~`Mz_QSTEb`b4 z%6`Q~=nQ#oU(*NIgxNdkS{ysfC!!?%O>}Pw=4I$Oe&mnrKBBRiJgOHmb31QkaFyF7_4NIjWG zRW^ve9MhfU}T9XY=Q5Qb#VR%6;uo*yLUmf9@CZVOfoNDk;86&g5$%yhe6tzmNzKLjWbt8R3hcdhn;Qg3;Z5q* z$pUp%VtHc>)?lKdc3TAv^?)tq-g>0?nWflGTo>Ssh{l2SLmq2qRu=XQJxDb4=WGP> z8)>NZF^)hpEPsQGMwPJ7ke-VqntS_sn{9Yb0f&e|YOrx`7 z>irG!^VC(($p*%;;y6i=`RS|Ur4j6`U_m8SU(u5f3LeF1Whv$CF(p=j>H;dAoad(J zSNCMjO_x`OJNc_2I&vDHd(VpmS~22a+!<;C7pLb30X9|y=i}V8RArq-!C(~^cN0Uy z+A9uGD^F)6?ks+@1wg+tqv^YV*JGt~nhQ&ZduF2N)3@cKlwGe2u4VauYOb_WXQzK{ zU@-P@EGJT{A!il*8nZr16VjHS5!Dqo_(=$5n9s1x@aG8qr$fc6LABl=bMsuE_*rz^ zHSD_t{#=1%T~+d2Cp4UjbAY?MX6-ragWh3E2wJhtHva0eX67eLR~A7Q6Splkm9$i!lg8VPO~y``~;EEqMZzq zm+HjpseGrrB;=K-`Bd`~YdF4T6g4yt**eAhQgyB_o-|#;Fi|&sDg|X)OsO4WJFyp1 zv3_tO#4zbT=6ULtNJ^r~j)~{o1vjUphikz>CA5tGQ|i=O=?i%^qw7o1;SXbMs^~*b zJnA^*YU)qa$P_eGlcz(9`NT{`^6{TZ_&=%ESD3Ot293@fq@uv-J*-f*NdBTQ!2YciK;|73~v^9B$~=jmA@N7+*LW zY<)ls@P<$HwbSB{!W4P>b!5Y&8Oncj0dcC5+ADA`za<#MqdAmT(4!48=CvWur*o@} zwt7Am#*IdZwFQRs7Vc1CqA!xZeMz>mL@7^8D#c0XUeQcKUFOQi_rW|{p+|^H3P&{e zf;NxN3Fmtu(wo-^3%8hJ8`Au0Wjpah^mp`UqNhNL9@1s3s+ySFIYFr*@K-A_Pw{ys zQe>$C7ANaabBL_+Lv-=!Zbz(@@C^b4u_uOKOVC4fpPywQ!tF(M~!kBEfL74$oq`zF87={C&gc2 z1qCNOnf|c{rzxT|lb29#ZA{L8ft4jn%9m-OG@gyKI*dfI%t~Q@be9ua)RTVeR86NV zzCDbHNh>$`tk8TONF0$rHN?DvK)P7w#92r+lUoX9r+5%<79M#e#b(iOBKJi{O9I#I zM_&LmTm%p^3Tu3C)P5LK^l;lU0tdZ7PvIT~%rohp@KqK199K_+!V${*Hk2n0s`ou^ z#`AFwlJd`z+!H$0*zO`Gvw7X(_2h>}$DfQQU;FhY3n&Znf%i9lbce^~ML}NOH*q%5 z>*C>>J7ON}?BqZCjsU_X*QxuRBsAAVLB`XO3eL zzG9)LJm~F^8Dy<+17fK<9Y?(AF4~rLlAv!MWytv$F{9)3>lZgi-$f8b8*OC^L;8*6 znhptWE4em4V>u}1^zm^eFbukS6GD}z)pWa|+=VK*7ojIa!YBEvJg7$3ikv ze}<9OSpio|x#lsePd;U;s27~X$h?^RJ(1j@eQdw`=H&# zy@>hySZUy37%Xh~bUjgtTnzTh-?n7k4%f80t2@5(o%OS2$Y`7}fDHk4$Mi}s4!#ER zopu(un&AKpLAx}N@gg8_uhC@ngc_aycO_Q)g@ieL4^%c*HufJJ^8MpO(!^b3~LV`lNMz_Pg!|mwyvoKR$W_I-{ z2mBtrMwk@M=F#nG*l42mK-7IWDKKSz#)1Nfsc;QtBQPr6$Z?lE?zpKK6!0=^}Xm@eRSuSyFUoK z>QL-H=sgQkZp+~TU>P>~V6q(R9D1nhyB|uy-T%{+zD&vAxj5vwjp!WbPNJHOF`DdI zr8zM=`McT8iYP~~Kn5805bCqVsPnuD^w$larlooh4hU{g_2X_b)Y>~31oIb7r=xG% z{*{c+*`C)EzssFBHs{HFvQLB?1h^APACtrt(z6z^pp9g@Cza2jf&mV%Z0^>-Qf1?U z=}ASYP!*4rC*>Dj%=Bs(@~Rk?{NJKGQat(8K$!`>I(J*-muCG1S+pISJG-ixgut@$ zx<>pTM%|TY+LO;Nn?soWo0pty74w!)0t-BzqXboQlC^FJ9(%OI|x` zHGT2Y(2yPzpAgMFo1=bRc&x=VDc{j(Jd(Zs;>)M-H+^%5^-YHQSHz}qWTt`|lUTWw z85`pPMI#p*`g8m`!5>xip$(TmjEbVXZUC`m^N-ieY(96#`fF+u7Kf|k6Hh;RCzn%9 z7M-VCA)qSm41h@YPoF=9v;YXD>mDbwO#obE$q$T7phy#dEa?Vhf2u;-C=PfC2gUd(C>mkw8P=}Ny@ z{^Pd}L^3O%iC$ew&cjl1YUB7e0(4)P-VX{!-t%mim8sA8Jfcho%z0Q$GOXGI2XtwL zMw(}V@-q!s-sfeTsSsb?>4g!>gAnGRvB=a4v0SG_R?MJ=6TYr>|8^^5#Z;)`YZ#E7 zvDmlyqLT4(l$8d6UU-AIFayvFupQqY|AAhNrgte@j`@)_1I!lEeVJwrtNHhW@(N=* zWlOOOlX5P@yrwT#mP!2gO@H*|FS00@FOQH>t;T+dd8|4n!|T?YXnvFD*2w(_0g@=U zTpLxK@r>5uODJ1wCjGYv!lD|*03khqz-rhtS$26i;w>woV>fJJV8hY11Y)lmRn9&x zEa3K;E!aDGP8?|@m}bv+7yLgIz3bX*2T7Od0o%e+A)3U!m$&X4t7F?GRb3bEiwhuof@o@<3$ATyftFR=sM&^SXNneY_kuk zyF4@0Qm|(jw7N15F#2OiQ~Jyn#bYz61bX9$$;@~SW|Ir7=n)K%umKom6b$UIHuc3T z2_*@IZ`IyjXt$i(q0=((J$y8V4%g z;HAS<>XRln{45pkkVembU&sb!Zlx>F?DeS0hb&Uh*gqvcT`P*?b5r?z6gW_8=N@2P z+RoFH`{&t0uF%70)}@kD{QrTrkh8EdbC2 zlueAy{nFUe*UrOlk12;u{#f1OL@D0RUf`A*bx?YY-}v(}X_}PDBXaV8&p0q{e_mUN zIoM0#u}a>b;W7*^a5hmVDTcJJifn*Dws(P$W`f@B+(1y~3x0;$l&09#U03>H5qm)i z5+P<1mVtip+WEWE?Tyod3U5fCbPmO|WQ^s~p}*(Ua^twQq5MD-pwSk(0_8Qz*n)1QShS7LqeXII=qStRGRy%D;E+ASO)J*p3^af4Kp zler{)9*k|+TegQZ+f?U1if$m=j1?@qqMbErI*TH$ZoN( za3y#Ip~7v?@oZt<^D4%Qj&6j0%;m;?yEv!m(%!);#ro}FzCx{LvHG`qobEw=D(*s+ z|Mg*363I51L(UE+CNbCwyk92aN0jzMkNoS2~D9el_sIaE{1(K+F>#NYQI^6Yznpv{Oog-bM9A#9db>?LTEJ>*5WB(Zg59yOC=qa zG!vf9fgap!`S7`5A}IlZ@o;@4vspTXPuNR4)JQt+dx~G_gQMWPN0Eh@ZuF5@g{pe{ zL908Bl39t zc#lT>alDN{OFNLMNRePxiTET^e}*^ylt@%ZyDND5tF1iAo>*-} zWN1dHK#GD0ZZ`0Hc=&96p|P*Z;yFhPp@t*1E+fFIfF*n%VzpN7BCOarzP=;4Wvp#O#fJZZ(I>dckC!E^-wQ;IyBUq|%=H_vpQ{-nbnTN0ksf zBwg>#m&@4#JUs*ZH^1x1{x)zYi<6|+ZpVAv@G-0t1qJ67{B-?=XGQ@Hv2J4>Y#45W zFQXLz=L6>N{E=h+%Wk(^GYwp{0XLGR{udXx@1;VWzs$%I;i$KY>!})+igBDK__Mur zTty`D8$qMR;n?-A{z_5Td5SV3(L`T0-KqYnGifD6rpQx^D#I(4=cA(M$DRRv_mQQ? zw`8MIDdF=uiqIt*u@tCE=@SWU%GcDNQ^+}-I6al@OPdM|w(_LcO%vscd=BxLs;z-% z)3r489QQp`${w~H6v5u+rvq0s%-f>Z&nS}5%BK^Zic>7(gap_9JAkX!xAlG$3hm_r z8+f_SCkmA1EP+;Eh~212?V4nLvT zt#_`&Wh&`IbRJ9(MrPl#D{(8idY?3Q18 zaT2n$#(#ezu^`ko{BQZyO^>2q3@XCmQ`LrGrJsDk2&?)Gw9qpaktNDJfZu`=aS0GR zKu5K@2oIqD8b`GIH}{^+H-RXE=rcK{z~7{K(O6oTR#FLBL5K5h#=2A);}K-y$?#9w zlix@98Ow>VOJEBP=km8jK_9ZwqJPG+{SPic1iqCj7@~!FCqc|0{zIlZc_}k2_V+xN z?_eVx+CLbJ8-D2|>B7;|pCME4t)XXo_+uGw(F~xod&+~QLjo(3EMcdcCVj%e7?&^L zZS6m|FJ~y^C|T3Bsl@>P9cs2mgXvbMMI3%S5CYy{iOi$xx!|P0?duJt0+Y~J0RPps zN59(YzZ%&?#=SNY8fQOt( zx-2HuS!LQzM?qIU%p0vr9(gu1RMb0MQFtV3&DPYTJ;6_fVMeFP%;*WxBp?Mk=z4H} z*I&>MySvPT?&hHhVnQXPsRlQ{q7ZuzkdTxwxc&wsCVus9CJHb$2>6-d&ebCh8}8zr zouXgPWu2Q$rj_kfvcKB03BiQ(Cl3zM0^?;r(xERKv89ib4p*c+FRGXf6*+aX)iH&D zm4k-M_tPK0pK12G_7_Fc5IANHHrirc>~P`3S)kC=ZHdA9aHLbJuY>zn#a0>Y-tY4T zp`L3&+BAvr!lw5$iw~R$y4ymYy+Qg9%$q)MBu2F1)z!r*m|GDH7I@K*9`eTO9=C9J zXCx`uIg`Q-$@o|%rk1VV5In!9>#?BvR>2jh$JETU1Vrs^j&;!NNw$F)ys@7l3{cGL2Y2tbYf zDo}-ui}+NuH@-q8a%gnq*_C2gNA~uI>@{6l`N&%{q4B^_h|B7F)3`RrIpKh*SYd|qj6$>LZ1ll1{BB|^G@&8&2oSNgL1RDng zlsL+^jIUt+E1~398#+8CUiE$Ko@!s#&FyJU{p#SvmL~bler@&3m#FYaLEA%LRrCIO z(?87P3LC8Q%~jdIf`$IK%pqt}^Md7P|7c1wIYrs&=11tmDlNIZS%nP6pH)NG)YqFk zJ|YYYp;7KP#}}_)E<8LLrK!K^(seDS%%ihRpasb8#rzVeH`@M*ZWii z!gB>xApes&O&9Jh7|QV@fhuTnew$XhG|RbjCj!%qxZmJfw#qiQ;WK3ojp}ZzLfr7r zg~o72B_qNuYt>=Q;h1~^)(}V9A_iTw$t;q|VTCmlnXG~PLz+1|b1Mrkw0nc@P-J>- zAp2@vDzA)T#OIYPygn3Ey}SGU`%4C2$ISGMjGSDT{S9@R);#4qaEf4;$8!4zpN`IZ zlLHtS4xl;jDk28mRa}d3;3ZB7{3|Pqlb_yanz)4;*p8gkD(7&fi8kJb$3kW5hO__y zlDF@x;eevbW?l+5_V&qf*Iuh56FUnN6AKF)+kx;a;aNFcX@c%2h>OGdBQ-8A4%@Zi zp)u;ANla-{P{gw49S-l*?<+4CI$7EOm@o$hn4o0evfqRqc{X9Ljw}cm!Of=O?MK~_ zZ*Fm5v+RH8AcUV;62jv~Lrovu;l@MEy(`p1GVdNHdU~&Uz5gjFKQ%OqurHhdLP1;mnLA{3?+TZ*)UWeZu;wlV;g;PdNt)?Bd<1hO2dM9z z6cEQvTITtuNXkSTY5cBhiFN-xN{@N$x@Txq)gi}>ldfa$UF5LcC&uHKGpVDIxpmp+o zPo_>j5_Jw4`mu^uMO|XF7vA(b=FE{(Kz#(vQI|V`TpIepG8L;}LQ+qTL%*2-7(KOj zS{(3~l$Mkf7k9(Vax{OmD7%6VG!j@_8=LE!TTG8nEUW-nR)}sbm~Wf}jsm1LoIdHJ z_jT;&djO)?*98+oT~J5ah-QF;s0Wc`TU25Gre3)E$Cdf+$S9g=gdRW8@+Ei_3k#u4 z{&PS|s){Wurm-go?lLv2UZu>CN$juED z6;IdRExa1)O;^iVRi<=GqP%d2)p24?{}BT++xY6kAS*~3wcME*f3^xl8JXJk4gi0D z;;GM)+Fq6n*+cwpReMD#j4yf5W6Z*%vBSwtQA~kon}CEp*Zb5n!6JX5RBud_sTne% zmQKkJdvEyzu9XikwcQ!rUS^h1wACHD_D|Lq8ym#H2l!Fi)O6}?;>{jShxFq;J?B$I z?vuLjH8Wsr&Ooqz9?UxE9pmPs=)q^VGZ}yN`+d;SBC5)X0=Bxt{SoDD>Kop{KKQxV z*g!V+{aS&3zt?Q`1^k+u>&XpS$-<~DsJT|H$(u6&*ScE>z&dAE z#;hO;bJIq)#Zi-LLYG^BEGWar_eFM0|J zhc-qgmt}35<`H0`nmv`N4ePYPElCER~Qj2D^oDh;e2rD zMkmFAk5^Ft`gK8cy+s29`?V0NgxkKElNId)7|`r(qcblo1WWLKtUjZLsTKU0_mAfV zma>(+CUv+|uW%!Mm3n{t>|_koBY*S%=U+?!h?hIw)EbRqiXf{ zhp?WyPBMhsLWqL#KB0efAo*$qS$hAEyTPjc*WC_DC$o3IWvIsKGVP5`Yjp$*7yRo4 z+~E62AJYj=d47`eh_9cSyQ$&OB2ebads+>z9Xh-U%BBGp*8_Mt-j9S;FYE%pW*C2ew>SrV-u~C^w_0O zBdlTlD;0G^MzJ98)TaN1;QcMA3Mwx$IX^-oO_~`SLjlW$gZ0xTSI{j|m9@l?z?kp} z8q#_cF`iNSZW_gi=L9~5gAbloJ+f_oSsbYWRK0G7jKjT~l`Vg_KM4BHTkD#bVKCJg9m`xcZWKgB#5z$$P3r#rsXj1YRk6;SSb+l^o2%WA3C$cC ztE;Q+?Ywik7xr$(hzc|XR)@6wVka{dyY4MA%??r)hwqzoE@UFVS31X$19naf7}yOZ zhWr=pf=*8vuZ;F$;WCI0$-)7hyu?s>35crcx+yj^rD*gN6u>JqQx3DB!7C>S*>=)o z$VEW|+U5Cne`8ydN>2%rkM#&(@swTSwI zfq~Grh$I3O%q7L}0m~ zi=c3qFE%@E@<#ww2p2jg<{JYyH5XOb;Lc~!wtK0-N?47Y)HksREjy??oYF^f<6yDr zg{nGTU1iE=iw0VO_l8|_@_r(s2R#PCkqx1ly~!eM24LN8Jx04`l$w?6J{p zYEM(j=-*R~xp&yyGwbZkz>Y~*k+4S0Eg2>C*+qvdJh^Oh0FWGHNh6o==qy%8fI6ur zL1g-OZ!WHyVceD<@DFO$QIVwzwB=4;WIZESK9q-nCgPQPqDat{0#u1;T*XY)$C6Sg zx!r-M)BQVk6te4uocpoq@XPp|vd^9^4x);yC}2ue+BbJcjG5=PP%PnreM~jqU_OTF zEcC|{1ZmB!Y~1m@()5B|q{-f=4jXqOa4Op1k^82^XdCg^ zp!wR5b6E6f1;-1w8lec&GJa$inzZlk9MxlO6C}KQPHJf>CqEtO4zm8Hy!H9PnX=QE(M4ZnDYFk%Ziio{Ubk!{Ek`?y0ST9lVO zr&?3-zvAqy0@b3X@1I9g%9AbD`O_;FaxuUrbGfrI+n--{bDEe>+2o76+%~wr=Qu4M zg=;SIyds20Pz^Js7j3$eWb}OIj}o5$Bd~{Cj*!HL5ZA zan8EJ)HW&@)T`!UT)Fuu_8xDW#>b8C?4p~SPg#37T2;U%eE>Uyxs+w2+G$ayW?`P= zIGQoV7x;ad6+JvveMSUu)K;D+c2HXMfBb%D>L$sevte zwQ<+l%^C4DylRq#`aUwsD~;Jp9F@o@AUz6p2c@XCA}_3S6vkW)vn zxm*kOoD{NwgOXM=n(y$?_5=q9#nJ_&14oOie*Qelnrl1dD2ed5*W?~UqZf(y{pf}U z2{!M}ZlSh8I2V7(K7dedS8>MkJ3!(Tf<=n%&KxvoFnU*)Vq#>g?ckTez|xVnZ*GP26VWk^ zyA&Ug+?YqeKAzo8Gvp}@+v*g7%HI{6ozg%NJyIRqOUYl$N#Tzc13yl--FTtaBo zx;pkRI4!hW_F_+WKRaKL{Men&Ps6x{5!3ywx?1#>mw#iW)+*;T!ZA#|dCbn|bKeSn z@8r4tV_U#MGzH$wKxMDN@=W%|-ce_)2*>d;?Z)znNN0Q=$F=|%$DYXRxW(84g)QJz z+$8&cDzp0vugMtW+}hOvKlj&d`k;H|M{^kwv4QyX&>o0SQN?<4KAO}h0XPL;`+9%> zRgIs@2k5W_oUtuGK7reWQ;f8|)KB~hK){ujQ!9A;=|TY}yYNQP-G%Fs{TB(VHjgn; zgiGw(C*;_i(z4PpRZHx2K%P}IU9IdzNqW4Vm-=c*rbrPs-yb_m;8_FH>5TV}rYt2{OyoI4bymd zA!@INcl-|=5To;M!0mNb(bLYYvFp^;|8BN}aRl=z$*J)Wd)O+yswgPMMBgpY;SJ%z zI`==4yu6s6=tVJCxLlw$t%|54H6bIn?hIp!nO=hKEm90-YT_FlALdcCICJj80qo?) zhp9ajGvWAYDl@A`oJ4ici*%^ZZCa5YzQY>&wEpEGnX$0!LQ(vMQ|I+&w0-9j3=42# znF@M#y}?|IdMwVV0Qnt+ql-mgFhxc|>Us6wp1dJQ?3d#nhrLBb8J)}rKLx$zVZ>i< zBi#sAxnoHA7goJ8Trm5h|;KF{HR3M-pMsfr9~`7;KM=Wva_!n2z4 z$@92BS~e8?*Hl3XKevz{>-Va4WY~)^-@n6T+e_o4f1+Q!k zUMxcq`7NX!z;@l!LOej7kdAdF8zpahNEZNP+2Rr434&gdi|4dwK$GxG7J7g;%mh3#-P`zoAry)wHf|QxM zW2B1IhRxsrs~!t3VUm=?)#WxE~MP0dgIeP@T*e?g2=Q}by#xh`Se4?uWNc% zTy_sAvttAINlCy<+#1OBI*Y4bF+7rPu^H0R%767KI|tF!wW}Ogc!}Ny#eSS1iC2hD zXlmRs+>FxF9T#CkkRz~Gg&@DcMv&5~gw)@?Rl;H(;nV0R#Rct}!Y6Z!bM9MX;$py8 zCU-d)1Lc3G-i}e_bm9cl#3f6TvT@6zW1#=l+)r!x*E~knbm#j~J5uKGN0;N!?nicC zOQql=|EihNos8ZgT#6js*^?WFWfIc@Gsq30UZD!0O$@*ij0pBsZTCRJ$7+9neL-$+ z;y7gd!*kz&4ZR`low?Et)%>teH=7KNMWXC!|N45P5boY|4Z2vmm?57_&+a|@Q$&Ig zwK6qVET}+8Qr;yJ$s9P;+Gw;mRErQ+O^q}(hmbZ`iMc-Bt}5G#!}djunl64$;Y0T1 zUK;G!9F9}L-fP3IUR1DCcg`)U8H3{6H#pe$BX~9}0ucm0a=@^cE%KFES?m>cZ3@rXy2o zb`UcgI~(aQCd)I!?8yb+#~B(;7Vy&l<-{RbqUi*F*;Zrz29s+9L>5ig6i$?Vg6;P~105hw=G3w0VvEDwjT_@w@1 zxn0&AtHuP+`o$Bl?)o$tW6410<9U4EY&0Z)^>JN17hT_a4i6`@dPTP_(SwY7)qoHx zXHvpuCUw_J@nfN)#m9prLH&_EZ1>G^+xP9k7sxH||0xX(Z@PyG&}oCBK(`p~L{P!p z1Cl)ZpE+ksaF?gNzAgXywaeqo94HLI9rntuTjMS(JT7>0YO;pur|Qe$fk2;SPN@~`EN#UqviXZazB2+*x!$|=4*!c78_eB;-81qxMUDv zD+=zkga9&Lk;6Obw#y?K9FxZpP6+JRGW*8-FqvB-S)U6X>C^rJ3Gr|m$t=(=zf3!S1k37 zyRtccf)ss*^*{qH^B#;m>dhZ5MIOaAk}2*ncS7j}K~FuamPQ zRe$Nq5ZR-j9If`bEioZ9)6$ai_-Y-elTuHe`pp;*OFiZ@5k}8V8_J>5r%eyg4;j@y z0;l{v@EkASiN5gqLJ z*AITx{(e}!?$Gjj!x|QgU;6ubBYlWRCsQ!xD+0CQX;4S~Iy?jLgRfbC3guS8^hZWQ zAPPr{H=#LNd7_lk_m$bY^Xq^qFSZBbk=oR7D|bg z&*8wpeE^dkFlJv;NgJPWOZ| z5*ctsUfuGI(3tPxsh}cQ`HNVZ!_Hj}a!#u=3th=3i$L1}O^CLZ17T8U)Kk^7(H`1U zV>y)enq(lJ83AYQP=fb%e4nkE%x@78^quC(hXL! z_zc_~7uq$GE-Gw@1y{5D)#^zp_eu3RzMDTMTy zuah23@$&wCx1tYoCh&6es=`1lWyryBf8PCZE-$UQotf|cd{9Agb60LdEb5V4d9_k7 z%Azl$ORn*ICN)XtLi)^$G#0tAry%9~7)y9Lntr@K?cALmo#Mn`8?_pe^$r0Dam_Rk zs(WG{QPN3*fq`|@608#*s9GGiL=FGM(h+fvXN=+@Xbus<2=<=$OH&3D?jTd-uytLI z8(8<1NYw%g>DT^EWnVI?xTK+Ns)Nff#11sl@N?_jcD^Q?%za-3vgAUzXMRuLNe_fN z`RGX38=*Q-b5KjRC2M>pt^|0@^_uWpkbW!-XbSVlA@6yTIdSq}dWU2+YP&sczb0JY zf9ykcCF{#8&mt?^n@}rO!}wrJpSo#g)3XczU_s_RQ3^rCzM&G@^rlhQ{~;knL*gYV zpm5Kbpc%ituOf{hkkRNgzjubMU@m6Xs!GAyCt{eztOYS?ld1ZDasgt%D<7A*w~f}x z*4CaY=#s)=`$dKmMli#`U}yl+gghU^*fm&;uR#GC$6H~`vcV2`yVvLUkCk_Ju#xSz zi%ew5XItBXhT4Z6@_>{Mo${@0(4V=K?m#mmvJslt?d@HG37W!7+9zGrO6Qkz5Gu5~ zb)G{mU$|_6?Ox%4X(Gb(0P)E4T;cIymHlT_heU63$It>=$##u2>#(O5m;)2yE@bD| zmA^+mDXYBq67jdmV%>qS`G@GDuvr}k3)eYc*okWMYtlwr`B3F0h%a54rXFho=5{7| zZ={cDYyJw)@gh|FHnD2oF8=^++eM)t+CS_rx)Mm>EV~ne38o|@WoJ5GC=1;NJRd(5 z)qkFgbKFXU>37C3hrQ(JU4Yd`@a205L%r!S$|w}%b*uXlOk%|ET9<|#6Kq= z#~MlolQr%sAV+ayt<4WjB!cYRO^D|lyDO&MELhV$?tY@1NMVc)O-}^7Hv!r6+@dgA z2+uDRFW$*A3hrk@jR{_p+rf)as+AtWO!L zb>sqc+zC@Q^w;MOQ?FtZJP*IXT;$Xqt$JET*F5s#BPs@)4>6#scr&uRPX46d>Edo3 zoMJN?9%>yG)F**f^2VaqyF7Qd{|bG`zSi~jV`zU}pwN5$lBFk+0c$bP@-n)Sjkmvb zaEQul_bP{Zl~l^)mV%U3f%R!wpzkN~<8ZMS|MQCyI>By%|E$C7p2qa?Aw$;zG)s~r zdU;OqqcSamh8I;${olB&0GdX;>xnaL6*{V6&b!&3MREwF%UOJ5!KuK5oZ~L_!8E;< zr(qrO%Ry!FMAU$pP!%_~aP=VSQm!d4k z7??3k#cJXL?BxF^n=E{M1 zL$~j%R&dptbCW7n6B(I0fz(0R0T8BqIm8Zj&o;ourjklRuFuK6oh^F{PF+GiP*c$H zZ6?Snt}@V#ccV+{^z&$Eo{rJ|TBzGvrIV22Mg__r=oXG7Oo;lhWN6f0*J#We2 zQoa@&`XYMv_Tw>l1WfYDbredRFnxnXfRb5Somk!scy>>Ldf17ddxsz-C4wog ziTcit=r)LM1{ygJDcJJ!d~L#FV>#Q7T#ZfCb+|>L9d7%c(?SWUA|ILCu2zOlx8`eA`6wu_Z4mSLLHneHGFpe)=8@Kffl;U zz$kcWx(P1ckVV=Oqr3j5(-D=6x_BWwK)<@@wt#w0*3G+9RWB*&!0=mv9SF^i=@pYaPuA>79@zbIjZB40J z%@~Yci@8rcePI2qhu!VSe2({de$MwU|EnCYOC_<@WS@hh`4AR#9FSQpAVL z)z#%{I8qd&g{U~57QvpLVgXZvxbv}J9cH8Ze_c#9_HCaN^<`3Mp}tQO7)b_-(Rt}2 zYr}ND=V#;4#`z||PRij@n6GqVHs3M(&zZGn*ckWP4>@sw2L0ES^^}pVz?N@>;oC`} zQ^}3BLl=ZaBiv$%0xGLy)JQ&5$qzjyUL={PrV&?18V#&cha2rvHa|fAHebN`eEDF` zOt_n+^B#Q9#@~S)hwH=YS04B=sZi{cN1t2akL}~_+9*P~A)|>TYWa3-7qo%cD;e=k zS?<9fZKmqzIkjB*sLmWtG5YfA*CKMJqJ0jv9ipmjvN~d2yVDy2Pu|WkwSi*&I-ol#~y|FqmH3iNR*QM+RaDZ8WzXFkCh2S-1*O;q#PSp7Kd?iNh3hzhNSz zGT%qb{L0+z3mMHgL@0&=56Y2VU+~hDpQt+Q&vIAi(VdC!_3u6X$F7Av;<2(PzD3IU zdGADtv!t9X22o#z+vTub=76jDy9i<@D+Z=XxgD0@lq?6=H1ZikMVG~TE&&wr#xi`u;HNg29 z26GyIaxo>W#|hA+HJ+irWL0SL6lHo-FKcSS?2a{XV`G%YCwv zp@c>))B04WrmwF{m?IQbYsI%W$VmR8Cnu-v76KvxHP0~m%gBf_vrnRB@iSG#o^!63 z1_$gTKXj$WCcB8M82ks^NPaM})t;S|o$w@LWh7^1;bv&5!c}xF{t+CXP&UJy3E>*< zUoUc;mN4l|RG5B|1gC1&h6x(aI}1Yfbgi)yO1iS#yN_m&%$p6-PKanZ)upbE@Lfx^ zWy`oIU$ckW5BD~r@!j+h!T!Ez^Nu%OT2f!lyr-L#AZWAj2{NuhBQJ(S>5_uXz{UzH0j9eZFI>Ea3HjlN*0II+%|8oDH@zcBsHD zpFp$dbVaf#aQAE2i6djrK=f*AH!8mae2u|!PDj%6o^v&P=~4w;(vAj!-JW1AD_$YN z$}``^vK+m`Rj{AH^JNH&&^)b6brd@^fNkvr=uT%au-p@9TKw!vU-TBv9fLXTEi3k8 zux+hM$g;3mb%BPirsN7UEe%if>1N={og0>=5&a2@umz;3KSR|e<(|=Zf(!xE&cH^0 zO&zVeGFk(l6HCcrSGIF8eZ#%w%LiT+n<6|h_RL+CA+9di(8!Pa_we-sB{F@Vg^$zdZdqAUIFlKWZL&Ojh)8_Qc^>w{?%CthyO~KG&68XyU zE1kD={-Rc2lX@SV%jj&j$j2x0$cPifl<=Q5i;;U6f@Viid3*}CDIi0I_*3$~qt~pO z*D5DU3sX2iyoVPqr9K19Hxm*c+wW(-nk4`(%R_!?s(2y4OrGtJXLAV=w;+8Hb2k(=*|qR3jB2F59AgqzX6IuqwO*t7;Ki;v~c)RUK0%E zJE61P-)OItEu-gyRyO`d(>`93=k2Ze#L>jkRd;9KXq$E&c5N#*^}Ix}3W@#hHG~kIwUR*q+eXZpPlze*L)G%ByMn zQ@)yp_m;xJ08lJB3l-KiK$_wdt-6YA;ORRyqc7tDCeqh1rKDs%kCpdMP1b>UWh(7t zu0ov!?DH{}#Z#=egZZuv4vNaiwGNusC43{20HsHlO+PiFf8C~6sBMF4+9LpwhY0IOiMerUs!BH z7n;ha4NLa3n4v;;#sS%P4S1<9hccz!p}FXL`piHOvRBMJrg{>gvo!|D>gJQwC*4sg zwjm*MDA)CSSlOoV$bPr~QVO-?)k3Sq>g}1$?r#bu_uBka4ELYRJvz}B? z<-HA=ic}lL)r}djs^^cXD2>(ET_XaRG=8ZnR?%Dgn5iEz(X+z<$O-Px)H}?iv{szv zS^;Z94*EklxpvRxt|Pr>{rWU{Idq(85x34of~?dl;4}UoCli5o|~PC8{fqD zx+&{>4wXSl=!xpg=OW9{VAB+a4;K6K)2M{Dc9WB1l~!`HCcgQgP5~E_5JcXF=i{-t zkJgNK7g&esc(bi(>}uw0kToaOUK09}?*PaHaIC_M4;bULm!?+7i18Y3+zpH{vX(V? zl|0bncPuWQP*f%;rPxPUkn&EDBHWB>qUWx=h`^wPZz4q;=)^VT#u`t)&<@E%)g&!- zVJ-i}H)1~Ofde%45RiEqI7wKVZiYOY6rOjYtfsxDBNp#i!ZFq6d+rnwM2eISW< z_wkwheO7D~_=6YgiL0en%!hq#?a1JHglBrK@)WEU-`T{^fFGac zSmkV=yOhB?;ey_W@!=TP@G{S)K94;&_#4xjst}lJ#`7(7!ydAQg6!SG%X0s`l#pUL zV@pZ7zJS}<8+=QWO#%%G?R>wL>t)~MexKgql_{wGGBeQdD!}gKkduqeI@xTnyv*uN zHe+DXSf@9eTE^ujv^a>T+0<9m=$yel2b5U#zgU2DuZfjdY1t(DO;W<8tg`zkIe>Kn z^$q|)dnvSG4L=+ zU~Mk9Gn)qrX;VrAI_Uvlu<_43@?Rc|5fM{-i6O?)*g#TB3z*!_#V97$PQvRjg{<~D zW70lZcwy4x|Cs))HRqpRMcZ0ybz8A9&75z4%_-jb#V`ba+%4a>nX4LdBvqw#=czDfa|X*U6{XOTx9;eJ&~Ny`vlBn-;A3c+v|y~# zQeG@1inq~$YwIaRH75@iIs*%|&b8QyzKmx)C2!&aWx?5Q^cv{J0yN9k zH9*;P`pJNy2?qDWJY(MD)fG{oG~QCT;}+6?XUv-^rgO0BLd7Q5s`B>I)sPEpIXj87 zLltKCA}B|z+tClX6o@gl|3@PP0>#K-Ra12&4Go|%$=`Jjf{)U}B5N2nU2d$G0aCVP zNo;Ovj-7CzpORe3Vm+qmFm6ZzOCqQX_rJZ7MF7-jbV*4*nDSXX%#5nyU(_)eTtD;t8@{=q zCG^cH2UPP!y{AKUjzD@?)W;L~e!xX!db(u+PczQ=$#4%p-U?Wn zeC+E_+(bXbDb-#dz$sWG%B1Z?@_v_ARN4BIWxG2Arb^h--a?ay_|rAX$6IgC&A31P z$`3Cs&gTr6pdGl%NcrXb%&g}VJU8~WZWi4LsidqctubRw5me#Y(3CcJM+M#`s zN!+R%bYu#4-P>j^yu1!S;2o)S()Yzt#n=D5z?lq)VSzrmedFIuI0({MTD ztCg(%q?0b0{#zx&>76gXlCI8}t-aeUU*BoH$!3e!!`dBii^S9$*G<&Bl+I%*Yv5C_ zQdH76*_AJCn-#L0>xdT&yNov#c<0_39|vfok|5qb8}{CN2BF0JFJ>xFGn%tLk#}y)p&w9J=e2 z5;>W+!(Qzcz=2xXv%W)WvOdB7nR7cK`=MoL`%lGLh?&)y$+2#iPk(H6lJ}K~v5PnR zUBVJV#TK23Yj60l{-jvRs$54F!$9z@Er@ZQTu7gga8a9SYlOGnyuXdgg}5aMyE%LD zt4^VIoXhpxR8IdpFv4WaV%MMOb!m^YeSD*?mNM<*#)HY6p*_-d@Y$qQ_^fbP3C!Ej zrSBq*xHk)T-19#0j=|1T>-}#mnZw4X-J~r zXOs&w^?B#U4sGUgjSwa!FKEXEJK z8h5Ve*LS4v*j!r(3uALD%qQU@C%0#tBObkGx|fSTvGfUQJIs5+mffJQL#(r}_RZ+z zA_ebFvyt)GD^x{p%aZ8G==ZC1cNT^bM(vI@!4FM$;yIm5fP3@lp_(G{v;EbYVvg@) zJaLRx_>S$Ypj&HTAe6xYL3<8Mr8#0>t#hZ;x(_irzhw|tr0g;DD{Q1IZH-t#`|x0) zocyO6@Ziij@U77Fx&5*KL;J5%z7SLODDTRn?7G+;J*uebIJLL_Ok;Q&_Sj?rY?HGe zW>f?Zn1dn9w{Uu4!gj|(bB7LP|7F<5~kw6II+tXerxal7_Q?M!;dY`sOoCu{Yp z71|=-sr6KDbyX1G-tS{~h93Ky)7)scW0-w({w2M%i~aMsTy$P1%OV2Q1s0Wct#2T*SIij=?xCAHB@Q%f8*V!J&pE zFZ+F!_wsjP!PHVC?mh+V`Y^xI%;_3e(d?bHPu2sJaMQo0@d&T;$BaURs^fM_^M(#7 z`J7+r^RWu#Xs;`dinZUDFjOeuH8>0;5Tp?A`E1>3@}hVeQmD>0Ha#)04q|v&-W|vZ zr>jFF*}bPfRQ3A&#Z8_xGVJ$f#S#ZqTv&WN;i^-aq)+ z`s4|xBS*^C`|dXM@epSM?@fi^2qg=-fREQeT01yqqhWHkev%+vFE8H8Y8_f?>KTkS zFyE_%nd;^+-Y$PVL#I=^;d~gqA?W#3Vde66C~1ARSba7PGKrslwLgFU!P1I@(UJHs z#IUMM?%?khM99zMVMTDxh4y8mWQNY2fLkxks2^H>hep4c`BDdLXi8f1L}5{0xVqE+udC9pf8sE6Gomy#3G$ zESh1TrfPJ)t_JS^fdcF7ipiq2{93`Eq+ur1b^{h-&1yLt|7y%|-^Ati3MGAoq9WSp zagPdu(M%`Pe;cd$?Hmhs$3A-6KoW`De3!3JaFe8pw6oQov2#pDdw_yT_D}N`*tcG( zTUJ4@X2vK7MthDOq$lmu?oOT|Or6@Gp=jGheWG)ZVunn(*Q}nD$XZ7OZ35JNa zAQ05}7EKR>awIB<&(~)yv~B|C$XDQ*v%ulqX>2Wu_tivgYhyu5rU5h4#bIMZm5TDM zUj0*~#kCp*s-WNXAl?*shibc@3D5NH#~0@`ozOogZ2~?$A-axFf1eiwpPCJi4uaYb zZ7K8d1ePuWE^jfb+Zz-)zkfJw{@X@8%Pjeq1aV-{iL7X zU?FE2hffgf>`&ome=)`nS|IXc#=($ewAH!5=Jx0t5@xvJloV{)isZVB74Vrk-MlT* z&z$!){e+^`E^CKka4M+p+oF!e*YkM0p?-?s)Mo)`zRdkbok!m<0M=v2pPVzN^xM_Yu^#gahfW!TVHu!T|sSc zwzhh8?Z=I3sv+X``sL)iNP0?m11W8%;9JP+Dt5y?j zOzOrul8zLu$WP;k|-XW}g z$n;MRea^bw15FMt1>z=RB`UXXw_{T~egp-c=H}@GXHv}#7PEW!=Aa|%)y!{ zxIfsd^)fHn^K(4k(@(^D$M;TH%?pLENs))0@?D7~d>=I;yh@l>I&ap^vPHI%$Nj7y z=3M&tFj?2IrZGVF=P~ZB8-rAouRT1zjn|kc`5M{hY&s-SrWU8`4RirmmhMSHVZ$a`*67; z=+nrWe$46HR89S~)srCkeijWsVyvUxtgY1T+c05*NXwVlKiMf{Czo?NprgX2ZNvz# zsTZBqm$~Nq<=CN>O;bH(wJHLJ$<`JhyUg8DCrWNd`ykX~+L?;;XgWVHY7#yOI$pwa zOINHNEcxniE9IEy`ar__+_-T^afvi%P74HfQth;%bn6l5_y5fU|20f1J{t?qh3Zu7 zHZXKxpw2OzKpdUjvR54k%f7gk5eu*CC3T+*M%ib*bxnsty3{A)oK| zeA-MhPHk;1tVv;PKo?-uB6Hfy&25G=!FKWmeN#Tm$dxb6i~^N~vS9)1sau}c87p9~ zmW{Ep?78r87Qt%E4}|*1w3T{&9JOZ*LY&UJnPJawY4^(KVkOVcx;`fuzCoZuTKagN z{+(ugv|^+>$QI%FXkEN)c4e%vIZ)5<3$~RyvcSlnDu&;qB|>HCeK31uzlHMWP#Z&` zjsbF7j7RzzuPp>B^y(#kY0da7efB!VFRow9^rTx*LcM-&%p! zow%9B?-cb{8ZoJy@qv(OWK})%F66yNZp8t3@&h3A(0NK{HeuE}(*H%kW z7Q@|QpbYBf1clR?AQ@tZHKMWYBe@XUNH)l0pwZd6@r%rij3O~SF3XLd$yT=>R}LPZ zi~TGlF{9NHla&!ljfW{}vLd&Be(3IHV~TU%b>Z0vANS*YY}alotF@im1I=wsZh}0s zRh1toEgh94#Eq`Pnmh}Jj<`R?g}$S_Boy4;UH`qr*(@y<+<{9i&Z%CqPb0CApIDm8 zS8^(r|*ukJ<4YgSyL&lidY$d*rBK6cK&kTD&>J;Wbpc6kpQZ*0I{} zoJYL-iSIfCgdE~s=iaB|qhczNAEGx zP*9kd@8b&N6H_zEGcU8apIra*?17+i0n;iB(%<~~4a++)CP^ z%4_9^gPY?AL|3OLGv0b6zvuC{Gh6Bb3}2H$Q%ZWHLPnzI3?sA z@3eSmNDe>G^9tR_Mj36JtET0Iw;UK`XIAPlb-6u`k+XKu2s|1Q5b}HqF(M)o*I^7v zaPFc~>h6(@7l}B&Zgc!;r5|T24C>Hkns|*sM>-vf!}Z9+&psjV`!W(IT*6emI$DwG zuR(8NOuLnjeBt&%=}rrkJm=UHYg}#&ik1nNdD3R~s9ILJi(uEW z=iJGm!OZoM9=rcC{c)qZpyrLAD)Qk7Jg%bW5os`_Y(Sn7=b3Y^A79{;g({_?r*jsl zI-c#x)fcs{Od>6jf?HFyXysv^1B@6cYsLy5Pg1f#b%8rMXZi;K@((VWYwFCEu7-l5p?Z8~`UZs7K_DIu zsdys%B!-V%6S7RHSc|tfzcddYS}~O(5j)N^+cZ65Pd^wLa^}Gwz7Z=D!)ZS7qs^8< zQTkk>7GC3dx20B9-hrMcVch26-Zl`TXF=Dwp?6% zO=xFlb6{Z4Q{H+<1!r;@0Xi}d^QD1;g9xckS~s%NPpIs&RoybaFO~r!T#$m;eNT(( z+#tSa0=xG(3!ABb%m$D8D!I$2S>!m4Y_@}Lvx+_hVnw$7RbSfwyo;G&S*7^=4BrQ| z#>4;$pahAp@tUDd^$K>V{3`bSg;P!Q-VjU~&Go9p?G;#fIf<5ruia2tDLm^SufR%9 zPS8gKjX=htRN&$-?*GYW-HYY(eQ)w9-BkkO=AIE5UQtEYuQ+k@L!Xu)uz4EU_h^&d^l znswZZOjx(pUL)i$=*{5#>T315UF^faAR|(37%_1b`3u)y%$ae7-@&Zdrg{7ObpbXf zZ)#2WvlqVVO^N*rcYR=XRjg7piC!6@KK=uht|=NGx{uqo_|IsP_%&YVp29#@zscG4 z(N$Kj)eT0K+mbQq@k#;(aw2B0Vs(T~E!bR@Q?gFtB{5&Su8yZwTS}6#2x*swgJVlY z4xw|`D_zl){{DYWHprzU{t8Me(Q~hWu@l!p}`uxBG%>{Xat~XjoAgW`DRvAj6W}nU+-o&qhh%K>H3#3mCvq3 zg7ZvipG{9u#@T3SoZHeW|oKS46VD_6^M zJx>avho$|wdfW&;vx$oty8AD4#p=Cz%EG$Z_7QeXM-4anU3&_;hFpT=jiwb1GCF2e zFV2N6mKB*%v$HF6hdA%$pAEMwK$ASa3OoEXEnvbgYl!9OoY=Peg!3oAzZ?`WsUfQA z(pqqIc)556x5=#EFNp|8EK9T6!U!LRvOAtis|A@%)dtlWXVx)Y0zI6+sZZemke^a) zj*t1gU&NELOIC|()9O~3_Nt-*nKCQqOlAGWuv4O}(N&T0MnJzI;mp0}}1+)ayOjE*qo!Tg_1dZ^eF>52nP#`0X7WED8aDWspL&oxnw zx^cX?oS3o&KJ4-t<(jO|o$yvG*>PB1n_`Dqw+!G-W_J$XeC_-CQR+B{k%3Z-Wflc!)|?QcWKZTouAd%KTRLjD!`x>gO5{P zYY`4}lueUqGI-@ThhIF53lI0J?$bpG%n5seeGxp-EVrr$BI$TfqAD!-u6m#}m!xS<>Ryg~58kP| zn%?@1JE@0{_y?K>u$=d+LYkQm`Jc1wLc0Rr^5~t#jrtL`w(s?ZcTvb!8jW)L%KISj zlVgYCt>142a}un`>^xplt?0oDr^a7#X3H;W486G+Z8OpXd67b7@L*hy2D%mf+g-8v zu>^g%=-xLkrPYH?6<6lgaq7ct5H$EdH_U7V=$206WCf*uLouaynDr{ff3LDFKJs4t zot*3ptI4dl_*so_X)w87PO$TvS5P}^3v78JbC(?Es#U7WP>I$c<+2}PElf>YP5hdL z;lv{+(>Pr#ANSS&D47;0t7u#5ZEkbaD~7B!z(FQW(8G zrm8u;$t_>Wszsi^gyJd8^!!ip@n30RSG$N&y-1I8n|%0Q!o}crx^lVxo$2W0Mgpw7 zjR99X=jrK7j6pO>V`ZiF&Vj=cH_kxzQ>sk;-gEUlhhR3bzm~GLH?j!@@`il$WA&x2 z+vbxXPs{eDa->qsI>?*Jh%yywL@MZHy3$x4{(ozeRn8FovrgI4BqTUEh9aOk3D!ZU zU%f4w^jn>IbErDH3GTYtCEaL{@qDcXD52inb?^gBK{t`C44VI#g1w^D@CQL*EWvb+ZOZ6~K`%AHclW0!-{9Xk3GEXQ-qn}uX7VN@EcB`mMg9b=R_(r4=gBb~fF|#Wf-8F9a@fwpW zPIZ#(GJfmUoE2dRlFt{amqean>w^)Y<)I1me41;AC%u^#dARWiLA zH|B$pNAHARuAXX*{wAct@e=quwj2QGE_WegGajdhPX7=%WHMX}G6_Lh$hP{g+nQXtn%{k1MKjRKova0n`1c zF+Pu6+&sw@qd=#H6yLkk9P!vWNY1YHV@PcrHzXp$4sUh3Vq<-LW~>t;;z7hDzdds| zLVG%5qW(U~mmkco#|H?gzEJ_TTyW7xw~r~1cyE?ljjG-LuBWu#>_H|*F*v^1#o~_D zmIF}xW#_Z2O3ar(hlk6@mu<~<0<&$C_0GX?Xn=OIjAcv3fhz1?;--LCb~(<%Ag~Ui zHOU*%Bw=lZSM(CtrI!4^CQM!Ho0JD;H#NJ`_xohW)NIGm;(L7EO7qd03JO4@E_lvu4b`#K6ibr}-JU-mdYA2rZWE8TNv&)NnM?86a z`r|Jn?jZEhC})jko$9kKdrcbL*^x1UskQM>FlehkKwDIH>1X25U^2$Z>v|uA{~eiO zO>6srcp+fYygz^30-73b9^YX=H{(VZg5%S_-?~6UUWfgKbd+C$V`uKhV5WYUPIM$_ zSR>D&0}<(78Xa7Q5rUY2UWh6;4ksQJutTKfeiLURyEqdz;E3MuGg#+6$nx4{?5(+B zNYV%P+1jv(BEB)fU(_>SM9LG#=;4EUD{mJ;ed(~-2j(~xFzk)L%LE*c;Bt+W@k{}7 zdlJPO#?1R{0^v!1r8#6DX@0mmM=Tl%CH$Ghg7+yBC5bxQC z@YfcXkSAPUTnraAg??@yWGCR^fm~?gKE)b#tGH(XdOKZozIuc|#$p_x)nH^3LLo(u z=!qA|7T;1z-ZMaPzSnIfcn#%dt@%16(boJ5{!v)nh^B+nH4 zqxmO7|EngIVqii3ysuml1G2^v$5DS(RMsK?8|?A zpKA3S^>IzXigqCp*TT?m^?}o+?aD+W=yKj8O)o0!hPbz?WQL$VG^Z8H6frikV4_(o z%DpYR_f?M=San=r)!lIczS!#B?QnCDB9r$`N`(`v2_e;mK)7*Do+q{YX$@kGTwfUz z06h@va+gW9N(8Hw2T3&UNG7IZUG4!cS$!eI_e?^)GTz?CIeiUvJC#yX6^X!!6jxTllgTk_opa-1pN7sJEm;$Lq-a3FX&P;t{rj z)EidgnPJQ_3ex246#tQF^coA5P5-fQ{7Kvc(Yx#W0()nJxlM*RX0_yeO6fLWxNht6 zVLiZH12q?Q0t*5Dv`C3+lLH0w;`D_f9xBl>4<56fGgH5e5dhn=>n5toG?_*NDHq6QpW!Rq?40V2Awi0Pg8;P7bEN6*M%T>@q7Gbv$f2`+5ht zjN$(yk7(1pFa<{m%E?pSe{GfIWoa?R0Va)*QMKFwA)ic`?U$b7-nh?EBTaQKF1y_I=$Inp{;p zTrGjSJL@Wy&nbBb1=v_{{>epTD$R_45>Nq5nQ_7j_#U0vsvAAO@1?N2{Td5+8Fy^7R%7~%~MK_LzPZ>}L71oRjQ zD^(X2|3=J)4J=E;TQN=nH^~<38x)uCYSB0Zjn-Bl?K}`id;U3o!R0QonXs^2M9~Ta zw0o#2)7ngT(j$|e7zb=y$x><*e|xPNm9L``FJ8p)wh_r}nzin6sUSNq}}FE%mZ%qqBaZ5#3k=5K``LfvZEbjGq4@6>R>`ut@(6 z>tk^lg`H%J^EeuTra?4Lp%QFv^f{>{O-=4U%G#ueZf1#8*q1GGRT{is5$3(wGUUml z@5aoC_IQ&mOjWHY%0vL7HHp~)6+04Jai-$+M@9X+gg8}FK)|_q3~40(3|NHn|JuY? zv-(e?o+}EN2Y!<_bang7AVBs+CZb&L8X~4hzw&P6>3VghIs+aKeH(SmBtAkxqe!V% zgBZ$$H#c0qqOuKGba~8rU=FvSu)u<7`H?KxWEZi|LRv9PBI~?5y8ov0cj$>)*3IB^ zA4WOWp&H1Z>?N_*&3u$+`Q<{|xQLHm%cI~~Wb`9sPU@df8P1&MT>Z5_t|>rf&^@m> zu1GlmV|mWT52=I<=zFC1;CzDf@1ghN9{IuyUNJE-;UmFxu~s3kuyt#80_^Y1lwe=O zqawd-$ELibOcxRZ*JWpJmW(}OZ`9FBrOHT)PjDW!$8C*RPJ{@i9qUyco5jPB@9Nyo zX}T;iR(f*uNl~`Wfoic0xM=48b^ybxe;bu#)P~yZRBqs|9^fbn%BA>0S?oMS5UNYy z;ToNrPZAvJ$+|r+sblvSrw}TKxLZ}5y%u@M;QTk)Ea$Q+j8{>Cx#4*+NN_Ep!oDUE zmZv-*_+u{WAjwF6^CoaaQ)@nf$U+lTpVT^2(8~)*x~i|GN`Af_6`M^NbMTRokiDlY z-BrLcF#O<193&GYd&r3l%+xHxzoESr_@ts7uMhT<{6DTBeb)SNABT_pjEv-omExQ` zGUB)5y$=%c5)(}~Tg3l-1WGm^%DyPW@|-GylnE?+ z|6S(Xn*v6fou5PGlP?cQz;1zWMioYB|9N5z4074^{vFuI_LF-$i-#@W%2qo=#%dmy z(FvE&T#y1K*|Whbb@;et#$R444!1{Z8iL=Vv8Az{z%rys1#|7{N~{l>|FrtNoYK9( zj`v0oJ^f!#uJ~@0tW^OUs_NfWW@QkYS{28~7`doo)k=GqX}G{Qkx7aL*9{kNy5%p) z3ggzeh^Z*lkn+jHO|s|5Y7EF@>Y%9!T4;uwl~&dgqRgg=T+nrBNIt_gM+{3E{WdrIYJlT29gdCTkmDAVOkJy5lk(!!yHd!)aNO$f% z_~NY|k%H}a)wi1Se7Tdb*dScM_`~PLU24%b&DHYlZDJ!77c49V$3uNTg_w(j-_j_Fyg1j*et+{R$fs$+nfrUFHfp9 zY~+wLBE)qfj|!V8$|Dx=vGi(^RE)S%G&LMe^q4Nm(No2TTzJN@?JI6(#U$t*s+ee~ z?Zv2&TUFUazl{{qb|iLsYIFFZVoR#vTJ;^{cmvo?sm&+GSbar~DB zt%EJ68x@9JIoA9*>e+CuI_&J6TfXDV+)=M!Y6gY zF&xUBf!*}3AHQce9(zSK2l_#`rc8^%B5*v6_lJnNE)J%C)ycT!@e^}edbq7=28^b$ z|IGN1+c6Mgs`q<#GSS znQx^FOjkPt+C!`si4b|dkZYINw(Bf0pjv)!@wXWjef{3VKowKE?)YGVhGnOzF7B;# zm55A}?47EDSSEqcxbeKCcS|? zkgl8W@He97X&?T04F8B^qGR}XTE)?FrjF^_y5l9`gL1|N=LT@f>>NP0zi=q4lu&Zh znc}&rQ5Z1^*{{KHW6yTeNx&!cUT5!Hl6bfSXV4jqC`K|9+!wD5ezTDirLHFGYWCC* zcQya)VS3jWcN+8kTBDaBxX3rv)_K?cH0?g#nVa9=7D)G^2{dktyi(R51rj>#ct=d` zcwhMrAgDHHg5(<8dHGLcKW*ic9P{WoQl%s+Dq@%#oAeR$bna{Sc>D5?SFzv6N%($s zsAD8x$?oiduB8Vih136z+m4OtPb`%Fm6Y&s?RHExchFKe5kC0{y9;Qs&R3B0IUoGm ze7r~U1A3~Gr;8RNNgwas+(CN@UI3z`cW{y--&HzTAcWTSUn|?ZZct=Z<D-XzYbaJiyoqpB9_{~q7ZO1i)1fULk& zjXvkys@K0jNiniRX}mLVXMlmvy=L=zMCQ}qs|QEa5vjRrNF>jPwYk4m8^g^eArvWJ zdH&A1pE5IyX?0CtPmo;4&xm=9WQc#b)(zj0h;saUK#)~qoW?DkOigS-x!_06-RW5+K!Z(DQ)=ZZ*a|LDyG92%B=El-doi@E1Lf90YvVa6B#9F z_obS~X|c)P8n?N1kuwFnuL%W|FFKt;L@6a;r{{@)H44w*Znogo2*23X=t&jWdE8=| z37D(Q$&e(cjOb~JbF4_sfF6?7*;sF{q%xD;!9w0Jr?=hMC1pl53{7`1TFNXo;A0lyvnU$o0)kJXtY3$#9`<;3~_6 zP){X$&A{Dr;CR!VsLsFn(k{Wy)}fc9ZuMm$;1EYKr0%Rq75r@L&atS2a z8#@`wunXm|8%twc*}oAQawBP7S4kAJ{YGWcth#y6(^2QhLd+}0Hs3{99itL$v1_vl z;uQkz$fTcANoFNvl2#MuQ@@LHHUqe+*XTHhNs1f(B+~mUa+rV58)ChVhCA#GAuz0d zn=#WUJoLuOcBa44#CF^!?Yq~KYsgyj-V5}3({u%$>!!jdbWhk)5q{hh3;cy_51SDq z*CQiqhU+>{MccB(jpNA_&&`i_@wn;XAu@8m)H6}Vf|a9hGYyxJQn}TRX$2D$w5FEF z+WqQXr11rMUbS&BXkmU!_>M^rw~X@tdl$+08RGtr*T983Z2b7tADBk}2{C|aV3!F% zx~h_9WrCKn%L~I3(lckT*K-w$8__J;H7$VHj{=*su4)`C>Y~=crE5ONxYYeG-c)W$_wj(xYq~c}(sv)ncQC z0(;&fgZh=jy_Med-eU&@^5SxI4Zkr8!1_ux06cm9{e$F*`=*LwjNe@e6{@NBo03io zL)9-2oa(%2dC>#MHIJsB*$8+xd~EDFIX=Jmh+cBK9Y8WMPlw;2bh3lm9oxTgR>tnz zymZ*|k-tZGmO^_1r&^jl4p9A0kU`@2V(*d)Dz7PZY^TDj-%^`UPklHa$8?&$;iyb+ z{AFjyJPmW;;YU(V@UH#aLk+@~`=U?pq2ll^q4xBv_d93jf4P+Fe|}71z$w?NJ}Z-u zz6I!~bDFd}LjUc{Tt-ikU^U}jQ~)tk0a^BDzoi5Y^duJ$!_4T=C()!YorNsxW&v}`S{dWS{%yz*NMRQXYkPc-zxPn?p^DCy^u%tV#Yc|$ zNc>LY1rvqxtF*PTH1Eg_bn}c?`80=|R`{T3y8cG+a68(I+OB8Ifm9Q}gc&t)&^w1> zw4-oK?wemu?~Fz0NGAW{c5pO3@u9f9*O~iNf9p;N;zUYlQFf5kks3Z49^GtE2gV6eRhc^NP`5>b0-;8ag86$k10O<`=9BQ1Y4zb7UU>X zu@(x6fk);EqdL%NMv;@*?Q6hEQZWV04{v5_WKx(ua88)1PtYmgsBgRzjvUTjZNoD`!w|7 z$`k$ye-y%*|B!$x5STB1lBuW4-Un_*YHUUViD{c^5bsz5o$z${v(>p9qBl>+x94=C zoitlMGyNQQCxdDYOJ+23`I!Hg-!1;(7d&KDIy4VW|;i_00 z8Grs^A^vC}cWTWi#;Kv?u$5>$&AWGfR+IL-<&d(Jy6bD_{giUoxV7~6-H$SfB+!F9 z-lU|x!`*WvMCVukjsV6vd|rC|GhjmmWL0aev>O}=Z9hm2zhVAsOMW7&3zF&(85I9@ z5eMnbq&ykSDP4=R;J5Fq;sR{tDfGsqHDIrv{V&! zVp~u_G}&-@CUAZ?=GRF4ha_`C$EHt!U~s5+Weo-QA#ccXxMpBi&sR2c)|}4lUiFbR*r}9O4~)?)}|+-~Zsu%$eDH zf7e={RU!#tX}v8Hrd_M8Sbajv521Yah7E~JXd&-M;Q$&m6~^MXNa_iW7mf$x%YSiT ztBHegzP(StyS@`Dl8RtMp^4(m)fr-JRprY%sbO_Y=GG0(QYoZNTb`6tSGSN?8j5{& zu6#eULS0R0NR;>ZnWGd7){u1Vi2di$!liUaJJq1TEmr%161cwaXW4_SPLkxNCuKuQ zHi760C`*&cGPyaf5REuNm-~8a59Bl;NrC=!$jgz$_-%vOR<@isj?;$Pna#MSFEFBO zXEyXk-Ut%?9Vno@WKT}#Jr(YoKUcA4<V2@wt$02y|$|$D#Y=(O%3kOMU8XtLmZI z?Qb|$Hwq(@UeI=Muzr$94UYmV#3Rb+`W5KLdwVd4oBc*p>WH{ zJe5HZr0G-CueJFNir@qDE0B;ktcH?i`{gw1zq3$)FYa?cgW(4g%PRcR#;<_0ZU~KK zz&Cgi#Ru-)Ka|IR2U#S_KfITn#FpKlmObbUrxKN&EGL|DX1n+}R^vqBYX7_yN|38s zt=feyQ`wc#K{#o_W~D2Ndq7^UqNSsww3hl>S(zcOe6|Fa-)xmMu+Lhm5mL-{sfF#q zUNV&{r>~^rZXj-|F|GCVL+(WjyXsy&-w^-Oy@r%zaBGvhbDD;oK|PDBvfV4tDF>>j zKC?KMUa@E{Qjuvh;u0i?bTnJBe*#`kc$KBkQr^)i25tRDMy6P?UEQpxDmrf@X@n)p zZ$a-WDmTn*q2;nNHrcXVJmLJ8=lQfhJv%gXrUTmDN&D=$D#Lu$SPEGkI>dzXYL>(4 zBZL2ePb=KvNzUMGUu%Ytr0astvR;FmpF3|mYa{YW`}e)oNZ-B`=d3irVTNi?5mJGs z9c@3>q*{UbbgK5Z+BZ9-$GQ55dw7eNlqvcz(X7*JxB}JqWn^qfe_e^WJI@+)^wZ1F z#j{3ZrS=PIHy#QfttkJULi?K`0-l2os8R{bEXgs!L@1c%+Nlyj45!PWE(>l-bisFk zd8}_wv+)2R{d^oXKY$k_XOTIEZ+dSj36K+seXGk#=Y90*%SQj)Y(I7x85w{L;`>xn zf+$p3c@U=RTA5L_@XyUYYwkm@skGq=dM#oM4db@F6NhiQ_z>me=Z{*AX!wR=v(1{K z-lGX?=eF^ZkqezvQcjud@WJ1aS=>8Y{)>eYQD?wi^r>;ZOPf!hqbO<0Z`^Jtoa45& zj^8p3WDK&YS*3IZOO_^wy{_&}A9)w^*6E^cr+jDJ&6F7hp83njxRswj<*jSt@Ge{i z1$swc?+RSI1VK8s9>V;e8C&Z~5=Y*aD^rIye-)(q%A?8ZKB8rR?|2IppsJ#08sRRs zW`L+&;t#`v0DGN|@PD2G_zHVy4`!=B4p1J8^2oQ?+LYxi8`jkZOyJ8~!&TxE{vZFB z*80~!{-IK7w*XkUfT8^Mt^GHOuKAQCgjmajp*PysTI9M0A9nebzZB*QW0$IE>4yjH zu?Q5&;>eiOOkN>c%_5$f>6I7h#e~AiWn`wGH<*H5=iag_URI0s@ccgg9Z`=}>Q5Q+ zCf`gNEQ%$3dvX{KeC@^>H)4J))3DExj;Twzbz36>CkU_$nayi|W8tBy9NV~DWsVJ{B-fN@CK$?-aw+kfjP zC%xYCo#Z^$Uvqa!7Z0 z(@lF)Q>hfBieag{{^29Uw05prw97aflS}fee%+CqMe~?v{B^)SYq7eD@gnDi=T7-& zP#vsgc2!v4wgALn@;^9PsJ%}b$Q^CBw?iH2kyhjVmE&W{096$Ne2!A^UwvChUic+Q zr{6}YR@SMIk(4UpE1JX8wx9W2FSvH5_vH+`jySiyzL+(~%X|WJ>C#{a|0g*h#Q-J_ zV9!z3Fw*!O&K8(0YWQ*1v*P8 zgKuT>eQl?Ioy$|1WOr|O*F?#1e>n3(~+oHkvpxeSd$b&1g>L2HCuUaYLznI-F(ZdKU? zeqGgV?4kZHJ6qSB$y2PD2dAIjhWBByX3zpzG_*(XJ>i_Q!*_)J7lE<$Y z{!*d;xN$iL2Tyxa8{)?*pr7=x*ydmhCE82Clku=YGJ$iS$aek1o3YqbjezKSQvZ+< z^fsr>aB1>4?66r%0l~xFapUgWD@-&PZcBhH=lG;`(8Qbi4o#I|Jofpb{T}YhI}8ox z7Crxja)(zQi69Wlf_%GxW?P>G5K;t`bhDO#aZXVxFwRL?f=ACu7=#y|c-L`XKOr=~ z(GX*K3;W|QLss+8z7TE(08Y=4-s@`A z)nPp1Wd6aUGZ*r9o2&?vieJ|1jWUd7`e3)Igul0o-~&KY4984(7+9jq#Qu}PxK5;4##h8?F$IuU9sE>%R95{oHIn zO~jMa!6^Uizq!%H&}|VChxoGHKPjtJkn=a4ULF6| ztU5}xhWJNTTHu}Bcb(wZ^Ot$~Q%g(D+n|5o85x=2Aw8(G!xXArJ47_Z6#D4`$j8|J ziP5~DiynhiM3QdU54Nl5?bugKigCR|zK zO!uzpx(VT7LW&u!{JVST&!84HiU>f)m|u;MRY)9 zwe-CdDR9bCg|6-<#L47R(@cd8B4q_}3hcqs7K}D#KVd%=UcA_)gxJSdA<=0b={_vI zeb`_5aTOV2Ou*=1X2-FT*so2{<+qM6QEi15S=v^#viUwnA)4nkaA*l4sx#_&;dAD1 zvRskQ6!LS1K+ty{9opd=+q>^r{qf?SMS6)-nb^5PQ7@JR$O?2=( z`cH%zR#$YKy=jb0Yleo;*rQ*{Izas&sSX0R4bHc2B=Ie(U* z9%Cr=QSI5eJm(=qHCM;@)eS24?=S$Ll^#2EwDW88#`&nSvRsxjA;Q81ninaSPa5xi z4n#;eScb?hpfk6eGsmIPt({MIA{i6BzWz6J7&yR{0K8%Q^kzr~VioKjk;y_D3Vs{! zw5Nl=G@dK_#d2^bvdHZ|_V2lRb~(9cqKiR8?h7O*%#SX2MRq8k;9&V{p!VLB#7#9v z_2l?Pb$QzBZ-pd-nf|mlbX1zZNgzfQ7gx8T#p*K3XwRM4 zZ73)icC90x=9~gVA0}yqHF$}mSL{||OTsXDb7P_ZS;yY~d0yNf7r;(f1j)AWqT2<4 zRlqU(XhZ?v4q*ELbC|TQ?>S(Xu4YSXSJ$7H`5fcni{aWDh>Cu40eX~<&%Z;Y#d)WH z!5us^vI2X zn*fLITl4`1Ygs;hpn|H*WNujg*}}^JVxuef89F*ZtL=&@uGx zW9_+dUgyVw5C&l(;U^JzzGAjQa+3{B)knvR9qxn8Fu;e_(2x&k#@HiHSK`q(j0V&D zHB2U(fh_2Xt=|Y*gvXxX)u9{cNI1za0I`I5U-NA*#=^}dN(P^0K~%b%uArIvcFO3Uh+uh7kX4ME9&-V-2W1pqj>I!|qsq{AY^Z5W$tc??_q_3qx zt#dyW8ABhUtX**u$xprPUGoO&t`U8~(?M$x=W7V$bTL}HabK46V6d)1AFHa(;PN?C zH2_1<{9<`t(2oZb+qo&+n%83Z=vItZt?;>S6w%v_@cC9QJF9o}=%JIv-K~UGy=iLf zctov#emsi={N9n9UE7euX5st2UYnO(F~$8gJ&-_~5T>#pqqD6?QLn{}S^=E`Rmi zkv?Wij@Fm!%tpU4j{_RCm%hjE#%tS|3)3^3^WeCzm(L?lV`nW*gsP4;d3S@*;&OV; zMJl$uMy7s9Lb}#5-{|udA!u}OpYb|7*;K(E3ZGgJTf97lLS;ogU|7Qx3z~ zM<~@zG{Td0)|`9kug2J=LGf81&JF}Y8T4B%GG6AfK9{j3=d{lHSA{~@GFzYU>%w_R z(%rVoWjV5mH_cFnTk-rJw;t&8Y|DlA3`0d$Ln_NTMWpdw_qTEWhy^F&d;k}&d7tk3)L$2jzKq?5D(1u*>ePnIRm=IdpACp?ePBDbm1kwyK1%3Rt0bGN zPG48Ny_psz-5%Ry(_Yd_-PkIl$>b55XhZ^+qs5yKT#P zp{)-lt;LN9A+P#IvY<~n4P1SvE()YK;qFR5smzD`_`+&&QWo;;0xjmHog_Us0Fr0C zI%lPl9yopmb5@7O@j{o3lcixpeE zx$&VzuF7YRfg$KY0H(Ao=C)Wjf5tHK^O* zuwM(!OsXRmbvWOV#~y26p~aSXf`P#{NISb`TRrCr+pR zoPnsmL_l~LB8~Eb#@>HR&f7*+8!)SexBA?7+YLKqO)oxEE@VIxMb zFE}UboeO&(IoEls9BId>>E^{yhmXeL>$EL;cpCQ!3;NXc)t<@OHzwDiq&mUQl17~r zC&i8@`jfUC0~drnB^e!|)F;%s(QouvPw8L@`+y#r&Qe#q(&U|8^&Ke+pX_H^VRXn3 z>ghSU>5t#%?TtQOtG-}%6OZ`E=*9lHECl*wXc|Zgp}02wJqy^3Ew8sr3Gg1N_D*a-DZSS-`W%sav(af;Pjr*en&D zVX~&F!dtz%BgB2ALRsZvg$P;D%O1?s>UCQLEK)CU1H;>oG{d3zC`z>2b!_gS@f%)yk&O;0?djEW~C{ zr`PGyCwfCFU+?42DsjH{H@#5CGXGVdEw|Fr+In3vk9lfmZyst? zA1PrXZ`NC|W#lUqwxEVA(c20^a+_~D+yU`*YcB6WfxCIrlF0DlG-ud19cy;WPvq} zkJn>cMYgVKY_VQjS*SY59BllMp6hZP(ruc~$?^ej?UfhS@Jc;ps-7oiEXSVI)(ySI zbU0do9>1Z5l=`2*04VPKE{-jca@e`mnx4`)F6BgjlHP{&HPmz^8R^YKUlN>~ZW(+{ z@HoM&&zn#67Sm-7v?h-TG%ZTIf`F?P$yl$?Bruya7tb5_cRI1RysVgzwg`uuUpVC_ zd~D#My#~j!s#hK~+n)xI3O;(jN#}lt!%Sl$^d+BL5_3p0+1F|Pkkb0ZfF^Cux8S7f zlG738bl%-*;Gv$EYqleRRHz^3U7aUS@Q+sBR#!^K0n?r9Q7OhcmrDnyo<0+g?k5t4 z6e~xaw#xEs8D@qiFMmMa$4`|Q+-7-?0n!(|_Zc&GCUdH@(hty7ZU0`BBZtp)5bp(( z$%M@B7GFRp&yYaMVm<(wa-UxAp->M%`ZSEeQV_m$p=XyweSwQ{A86rH@>IAVGB}& zp)6D}ZwhTVoIe+Bi5a=m=x9erEVGCF%iQc;XXw_#9HU-%^qsA*(`x8DR-Np{EXtx* z#KQi9<}u@&m_i9}n$tGd~~KOxpZtCVE7?~Nw1pRJc3T#S0k6uj?v zmt6nc)!kkLhuxFpAw<9qYz+)w|KrR)2++PoM9O9&KkEC$tWCqZwb0B&i>?-dYS zm{wjUvo_1g)=(hi3bCf|W@TbSj!<(Bo|lHa3pH_WK`@;5MDW2>Z}qyonBb98Tpmti zaCCf*0K&O$LKeUsUAIZRTvSbZJH35o0aJ&4M8rf?i3)z_y0bhw-gP8gHmt(#;glCD zUrd42!yQh9U_w+C(^)*WkBI%gYxGlVzjmCV%9(P(?MC0SrGwzF>jH?$#JQcT$tQW+ zweV2J*e>&wOdoFHlOkGFijfw)G-=;@ywWeo_BD;~2(!&kx4-DhOAjVAADSfe4>g1CW4u}n1vX2UhfC6UU*iWwpn4+D0sg-baNrS`tGG3ZXtTKbd9V@j?O8)@@8ELpSqa`~U8nGh zVM1w86ML!yCzf+&#j$-~{jJN73B44eOeoHv2fX#XZ#8KXRd=Q{-4NlRAXx4Q2a;W{ zt8FB^!QuO$!!oJZTe~wCD_a{g$_?tt2{GLr9z+M3t>4RoR2L*MseC#;w;4hHf9zpH4gKH3PeVN%Of*P^Il%JlOO_ zJut44ug)}S57hKjX*v|vx!~J=kx#ZEIgN6dFM;*a)nW9Cq`n%jIcvl*i_Wf1oM2Cc z=*PiPHdRwBLz~s8w)b#e4z-nWZ%^VxD26EvV&}>b`&NCFoDk?ray+sq*r%b=BHhxLHLI&?Oju)ears+VM#a7c;`K3Ph{_5DxY`o z6x* zJdUdctbF%1pEzmti(b?>I6Nd|>(a%|;%|SVn(oHhwfwk)_gE%=qi;RC^=odJ+G4+M zKZs_PY2#hP_gZ8CwEnYd@w_3 z+>mHZ9```IGLfIAf7%#7CiwlQjWHf@N9u)*Nbwq#hZhV)1*X6l({BV%^o)$i*7)6HQTeLeln6V6k>4FvcA4wdSHJUbhhVTd>`A^q~ZHWGdnELIPD%! zD<7Z3QwC0wF`+2mmO9u^wp<81ehD3lGL9aLcFk;b{4(>HL+kqN|Id&RD01N{`X{bY zvj6+P#Zn#XhO)=!cYsL)4J&tC(t0v>5Pk}XlI*aJ_O@UF2jtx{IkJG~)))eFrs?e& z|C<>c_Nkhy5X*vEx9M;Y>g>a5;^J>DK(NCp@~>RHuwtu#es0L&l1}se`r&n?Trm2!2q-WB_rAw|NNhVa))i3j*xfV*3 zaHhi`MiS+nKdD>vT3i(kb>8}B9>(&@nxvVw3q};gRY8GvvoKWi^$o31vg#X z4dh9O=*uv+D+hvuJWqX0G51PtbFFGGHd6Bh3G3c~SlTC<>m0Wk_$2yNOR}c*t5W%j zFfY`z%^VCb0KuUIQB#Zp1PtM19<@nn|7-y}P%jB&%A#=vp7!VBB| zZ|LV+&=;t3)?a+hWKWmonnE8mO@^Had;G6$-F9@@jW%!Ed1OLf8N;T<=CMU4mWgIm zB#|?}FfZ?3{+yVsFmhG>A;hQh=&BUs<>t-&!G0y}N#3M&HEosJ5X^u<|5T!VylPc+ z^dp7;afA)@SPA!klcbUzu)+1F%l#bVWvVz)1v;e*kRz*Oi!@?c|A0*>NdXdVao>V9 ze(U`!3E?5QCJXL!OioqUTcc5LqUhp{OP6Y-vga}@t5siyy(-x2>*;;gbt?y%ruyhWbE=ny#~ zcT7GgkaeDG6F^OpaxgsUgR%Lx4_&rRyU8c*;GjQm?BRf8WrlP1J?F{q>&18f zRN?}-I-n018^cC1C-3!7E)v$qB+FA@EYecFL=5kP3ICJfOph#{+u^6(5ldFVxM9&s z5kKPzZ`QPtdkZhhabt#aHIJxJCz%bUNrZm2ID(EK$#aITB1 zk7M4dX38PfoKCIQkY9s4Se1zMk_?~m7{8k)4Xjy4R!P6`NG86sHw}dA=yJDP?7{|9 z2R)l;Yo2#4CrOiIB_&L0q2KClpWBFhupVRyj;0;J$f(iJ>ayz&#*`H=pp7Lggp|hk z<9Z{D{aXKQ2w3InjBx=!9XUw(C}SY~lJxaA!^G)L^GkZjgiH0auOa~7;rhxu?(aZo zht5MIu%tJZjO9;JHE6d?CC*?aAY*Yluf6-iwD$%22pFCsoFTLkci^R)_N z5K6*40I~9HnVKd-eufwvv%_zm!eevwFfz99d6v))nvouobQ?BP5pOd?6% zN~ZmMxJfvJa+j>rCV{dyWhA9SQc;cdVdnN%P0?QB)gUH@)koYWq;>90j#BIQLDo|I zB~prcxlxU2XukwgCB57-@gpziO}TW}+I5zor8%omHOHCGPiM+>bJ&{aFf7U85qBQ0 zOo&{!H&|Gn-7O#eRiA!qo+#f#Rj8ml=c-R#MX;@rPYc90-1h~-|sgA#1WZm zxy%1O3*Znp98Z1M%PbQHvtf*=Kx<%-iWKcds3~MFD(X0?oR;9x;TCUz-4h)%Xw;}9 z*{31pr9+I1%b%|ubV*q@HM>Q(FgZWSiGK5Vxe_*qA6)9Zxfg3@zd_71~DU#M+}XO2CZ!N`dk|D9ZfAv3=(*su2P8++Zyd1K2Xn z;(G`lK|SdoIv&o+InM5Ai`Xr95Z;IvZqYI+w{|7%PJEPdXDst)JUkef zD!IQbQn|h}C*l!UV?eg2#EqKu59~R;f15uKDm4+vW zep6S`Z4ZayOP5z?`}*du4On9i(Z!n-ZDSE=&=x292`1^(Y8UG&h+Rab79|1e@-TJ< zOR3Zh-UcG;Wj%gZCTAW@c7GJ$o8ahv5&xs5$^GYDDKSBcZE`W_-V>4whNwV`GUS_g z;_l3_^A?p|62vM8i3leroD0%8 z$OY!On=ftnC28km*7zg_xT(j=@@R*_~VR!OW7n~|{ zaG;vHCgj1GG;wOVmcf_&yjIiwM!8LYfI4{X{YRM-Z*LO-8-1cv*;wTA@;)>Il@K)& z%0o)=VSkFdD8L@9@@51)Q{qYJElq|n;3ts1%j@p;jT}SXIRqnD$!>XeW=#y8A>T~K zg+wWiaH%lbk_3vRP!sK*eBb8<*nXDM*pWEN**?3y_?8=1w{ORufRN)fIu~4eE`Hs# z>?#3d)~%TiQi=|CKEZRH={SAERO?-fO?n+Zj~yIs7!1|pRqxh*m^_rbg*fW0SEQ?7 zX~yFs_@1tLY-SecRvP1|=nnw3c3F_hmd)Hy+-O9c45unrYlC%{FNLXOw6Wrrbj51O z@7c^8N|hDLwwrtV2&Kux*`ICYjAxdx&4gvlkUo#)def3HPNBM1raY;q>IT+4+l(;W z#)gDA3-VEE^Oo`PHqPSo-8B8lYcDCh`658VNPJPWY`4L*R3-x5)ZpAG^8OwM39UVA zS?4-*2hGwvu2;gVvm;gNQ%Rw4d*9Y1s5q}45`#6@npU^bS#(m}!RsFmPppr7zcNG3 z>AX!~wj7GAaa}?-XM8f?{CK#>A!4E@2#VI(xoN6g)XFBnNQi}@yEEv>`6BR*z4@wq zs(1=|_okBT^XH#gqV}FOCXr~s06;J*9GtLwDcEw0$x!2pMHJ|r7BvH3vqfDTS3q>S z5QX(Lk>QmL#aVux^W^y0Pvw4~6W$gTmRDy>X@BR^#z2JlxaP4^k33fw*GfJ-#v7t5 zCKpREeN0jF4mtC;VUH+BbsIqz_MfxHwzoN2~twmrU6KadGr3GR&q4KND6 zg)bb}O#nTIH{fI4EIK^Yp)ZQI%uB98EzH0MZDX*hB{@~|w(Op-C z&4ku$V#XkRG;USH;&nLlBX2c>rIb^+?mdfNH6Oa+R5=_13zPAKh*Nc1R!ek^!L!9J zhexY{$wE#@_t5f))X-9N@#>Y8Q_|3KqeTU-lkEk@R?OMafr=6Nf$;?SrZU0M3uT$$Sc3Tq7!=kV+^oumhP7~mkx-0II20BRPHdfYYg3a&Xg%yjiw24SG1TGbTU{E$Hi^8Qpx$cElAc7E(_ zRLtEzlXNV;K-naUGqZ&SK2ai9K%jedO$w) zL@X;Kb7U%|St{Q^JRcTi(TlwXwcpGLp8CDXz~yuxvzb@W8|wBRO6Mwa+joN3PlAtEA&zs& zz&J;RQ5TbRNBa^g+4zZE(#b^SyTNZA&Ynn;gtrlaWT$GW;Ic2*s#Ne~7Ife)Kvt*t z_=IA3_o}(5$dw?gil4DIgRQQ~?}MwKSPrMU?864VyxE_{3N$#z3)PlEo#~WK#okYufA8Z~p(>15e@93G&m95gQgeKh80kVYg=@tMgOA3(?77HStA`HLKH6}+OHxNw9kNNJ^jXc!YH`-6h^r{3v7}Y?7kaL4-?j=H0TwvS2TcEF z$k|@}c;$R*{Rz!i@SSskG6Np^!>EVvSN7ruHpg6F^F2RNzzjzCxmUbQ>DhMCB=E}F zz<$}BZiF)!@Z+Nelj4MIMTeC}J5^#o};^MSH0 zQCB;!@zkW2p;VQ_WkQ1>m^A{nMoUP7qYWtoeigCZ@#jFK(XZLx;zf{hn(l4qnZ$01 z&0gHob6nU2HzqEk^XopYYz0w>$tgE8j&b7e&2J;-d|dEy_(OOCs9{yi<6=N>YcWd& zMltrv9X#J$_|b@>C3xVmYu1&VYjIq_gc)?Ba%;xo>hvLTF3OQt{k8>SGt;OJjU#B# zzO}CipyHqOFH6$F?`nv!?@dyItQ*cGH z;eETrnCz+TT{)j$#A(X+)Ov89rK{IVo-8Tvj0b^1SY=mC`1;xR>-8o@2$R{hcZ#*( z`o0=JrG}j_x3fD9%lVyQaP@r$vrs3dL-ft4x4VldMhVZ2RSjAUL5+at zd4i+p?4}s^?0C%Ra^S#%-pb2U(>!h0q9#0}RK-Bd6fP-sbIqo;&9VWl7iKr_>$Q+4 z+5Fbf83gY*&jI`8-9T5D>Ew4+C*?{;Z$ zxOZi%Bf~b|R=}7{?hlry`jKRfpe-$CU|;aw5*-u^t0}j=Nt_Q-e7|1uZPmq|Q98~s zDCAJyuL(H@aduqj>lYm}RivEJ*1IS7V#&GRT~)7T{Ae$XIErt{w2v>d3r`EWiq%h_ zj4Zv*N}j9Cs3_OFXXp+kN7nWnM3;dqaY2-JAHKgAa-@AD`E|$Wp9j@~ z`Uk0`_ZwP>o&3HKk~aNc6#lQlhs`P8n<{~Ax5*hW1y{d0KC`ZMcABr{+UJ-4SoT_e zXjIN>hQ~tDZAkS#3wsj3IpK>z9a!x1-?DH=Tm7Lo`;H^O7J4=YOo){bxNHtt6l^DM znvf>+xALSTA$i=jFxlq6t7Oj=E=No?E?OmpeV>MML??!~6kyNjLHe_%$SH?#h)cwm&X3(b#ZkhkXJO4f#l+2p`>#G*_r|4>qO!Oi2s!}s zBz*FAr8MOUQ4^gv7{JQ%0?>fqS^FQ5^POo=e<5Xa|MP{?MH8Ot6}YG0kTCgheL9a8 zaBWLeS^gr|8l*NO#z%K=qoJr)bG8K1VVzdgQWb>OE|hy9Gw;*uze<{Aytdvzimse5 zOI337u+{7>Do)!J*53tafs3W8uz>)!5}}fW;PHL|3oX@|gZ_+NxUw9hDuvH;*SuU= zN4*lX|6Cy{<6D`yHh;OkvlKw+kFzt6OdQ9?x&EW2awwb7-YZ{zV4q;&V(qh%ld zIPs{E^O!u~7sscj`V-zyZ(^UvV+0Ex|Ab|z|m5bKb%Vl_s1N)TBIsu?5nShk@f^B#{dxxboftPdg zX-UVGwTBCo>Adi^(W^(2zMwr}^-FMvuP7*Y(9=9$@xxRSLch2Gpkl~;MgauDQ`A&z zB9=V;{^X$yw}1Z4Kd}M3dB!VF(>T3u#)Amg%3F7&5!7cg>7rIs0Pt*um9?-|UKmnV z;0z>OpBn$*2miRTvyFR$N~+bxO-O?ybSTEY&QC{Pe0yazg#O1p_8bhvW`LCQWXC8$ zELa{ML$}uJR{Za(^ZOpi!dM_HyZv6WnVw;4L16|lzi~B`Azgj+t#jQ}!9H)CrSg0J zVrT@60)g%wb?|1gEnM-pS@ErYkbpODT8S|R1HoPA=W*xGdjwa614{P@Fggv8mpCkm zh(2lqcyHiSjUsj#ioZvyGo3%&jNSKUqq;5{EoR9Nj$Sd_I59p;GOLpgCdUAQp%S6# z3jZa;-q*Si>X^V9l6(9$C7TPP@+?KQrZ!{VSc zyVY^JpRNcPv8xVS<-dUXSRhtH2gFLw9*r_I&;M)dmpt_Id+CAd?$_>Y;KFc3Qxhb2 zOMXTIQ@fo@$@~%~kW>Xz4r(gI0k*YA)&%xbP%<`TH498EnF%?Ao+Vhu)|xSAdN~qL zS`#eH)Sj}t3Gj{Llf80pYd;*5T%%B3?c+~7S;F1Pqa9Ir-)^?C+{3-NG@H~Z*kTnf zc`UvnxVaq@(ISoyWRp@6Hq06JzKuRUC3)uPCgv5PqxaFh32&IclAkUs-%0!z}E`4(I$0(oKNN(J%z>Kuie$m zM4nPK6a^+oh+HI`Uz3{NTo2AiK1Q6LUv$?P0PQo{T%3zfWm`J|6C95zalo(p@_j1% zl^QgR0KC%7s}cWC`hLEP=JQU+`f-(1MV%O$pBUd}K^H7DIF!7H^u`FX`{|Sa- zR3`GNt?|XYm$#a%p7vU`(76>riu;8$@e7HU5ku%oCipG66~P@c4p76%)uUCIOgod( z#7<+HzRFNoNFzH#lh>d^Q|>oIA9H^xV>U)w2;y_woLLp{dEltued)hixqhO5aO*1U z!8*NHuq4{yuz1!LtnwFsCm+`Qa>Q(xs7noNw zg^ZGK8owYbe&~bJd)GJkm%G3p?~`+b`Io-k8D6C?e+%PoURVD89^vPyc92Fi^J1(` zl(q96Vmja2tmX+OD2DA-BK(612JTDz!;tQEA!+nhXHNEy21NjzmU^nu_Tpi7D^OC{ z^^YosJxfE%kg4gJ*=2-#c$~M_Z=_P$-Re4}okHmRutB;ayDSv`_G15P_k3ZhY3JR$ zSvTJea9s9Y)1ew&yN=toijSm3ADFL9HKVoo#aF?R2(q38yXbWHu94}bzl*Q~vgu?c z=huiH3LgtdBz}2&gjPQT^Fl3eSJ{{q&0jR%^Jj5}f|@{ZZ9Z^g zOLq|BO*fF6y6I~*d=gX0#n|D`=wcTAQ`CU{Yk)yb1yqNO$4UmZ(jZVozo6&vWMShL z4kqb(`;|%KqiY5Oizr!$xT&1$&r}b_aFnQht#~b1HtH@IplbWb_ zxtc0+seR$v%CJuEBz*6uy;3q==U_j^P_D~Tli{h5xF457Ai^huxj6R7kBKZ7ijlz& zk#YO=HlFUpWGR)OY2vs`{THM+CnSX-8NVWkGpFp42wN3};G9sRjFjN9{B`W5XL_7N zQgpdIUqOR`F4Nt&e=gr@rO2m<+D}GDK*gH-{M4_grWmMk1%jZ5lT;XG+KMiUH@E<} zR#bK|t&4SlPydI{fYDPZ>FCE-PzMF0%^pWyqBGQoUBm=JXgK&5z8bvA@?OXD_tDJN zhYwm2e3WlAa**d>2TKQ@Tlmu%&a<7bj~a&`t$qZD4mZ)Vj-Mh`%j8sw`?j0QO3cg- zC%!icoTqXahJ)twZ*ilcR`vdrhTxpW$>D_RVwk1JmnJ~hr)zo86S)z3ulNG`YQWKh zD?ag-)m%IM&~<%)nbsNe4a0}NmF2rI_S7x$-PKI9B7CW#hKu*jXHtnK)3%Ao-yiAA z1d$V^jPq!%#EquS_zi;Jcys_>C#aGDR)}|FgF=c+)$Bzb>EYKRe!#{F^w4XSRFoO9 zbZwOs$x~1j@oL{k8#g0`4!wD0p(1%udzOiDpW=IFVT(eu8xM6DpFB%|Z&KGvY7w`c}49Pp1Sz8u%^2=1)sLf@CVM;N7tdWlRCWUY2>sGCJmE`vn zXf(>z{q3L&LyMlq+*3h|S%m*ip0*ThyMfKqRk-0c-kda7&&mjrsNyPe7>x3J=bEY{ zC7qlSv#zb(L&T&&STyryPLyy#rAYllYD$`<>4FgK%~5-G25dbBGVV7BuMh=@uIz0= zr1FI_Vq=p>QBPZR=9IUzBj@fRr?TIZ^hHhl+hVrqgj)u=C{Gpkgpz~xTJ3U7^MPdr zD}YX9f2l$6atLD2G2u_nOI1<8!`EfjHm}C=j=7$cz54|6aNLf-ZAIcN-*Z*f!1K+o zaFVCFr>Tau)!`EHj;8`+L(#)Y-zlp9A6e%ZSXcM0`?P87q)8g9 zv2CldZQHh*CXH>|Hk+ogZ5xeujJxu_=YP*R_vTB!?Y$?~oMVmgjNikRvtaOhbvJR( z=Q!1Va?vC(m4S<2Z{>jED3-#1e72-RtZc+t8cO}R(0Z;$ci%|Qh)nc=>_}`k_4;FA zt1~GjoNTvd?+L8bb*w@)n9|HZpM+au?^ifd^1NNX<>lkB?@#>RI(d_}6gQ}kNbIaW z^7gL@yRq<;RlN6GV19erGf8Q4b@yVvade5o+HYI?d>C@S?IzKfsBD!pIha7Gw*wK*L7nxa>tddP5x5g)U-FNBc*g~mVvY*|Vmeq(a-dvo4!y)6lt zKyhB1Tj%NIah%C3DFG7Q)Aco3ftnai(i4O1qFB709W(#q2*Ab#oV=A89nQ|>mqbZf zpF{G0ww)=FHp{)%lslFX)}0^ZMMl>dA!S-QD}K+o7B`}v0dINY*QCELbybv^CVTj zH40j?S&)tO@qBM_@@#+`1{)I%Z+uWrhD zn|Z7NrY3Kgr*}12V@zFvQ(s#Fd@IZ&=yxzUjptS(xo>_2an5!+g&)g$&!{Y5^wZ5#O{IG(S-{4aXOXtT%Yo8e${Ih|u z+wz|9_Y0Yvec?8l$!+7siHc9d_uFxymmUofU(2Gi7YD(cTB)TEXwIVLe|*s()wTK5 zt~2WozGLd(lpFTBez{dWl&f8y)}%PN$x}#nL&Q3@pjX~60HO%qWZyTx4??r>!_SNP zTggnN*I%!ydCGWxMkuXx4HcyQfXftiHbdY-7xkP+3MVKSZgBI2F{3}8Dk4p~fA)vz zxYjcHI$e-8>c^61l%}Z8=vxz_;jdLM&U{$m7^Zg+Cm_{al(aPGqCRy+SttV-~&N6Z-jyrz2Si$-2X>j)6ZV zzw@+$V@QARKe+(i-mywV{ye|VLy>CWyX(0gj^|J@Kl#f-18!f^O3^F zkw{i^eI7v0`GNY)^nw-{AesYkPH6yPZ$*w)fhE8m_E^~8AqFhPErQq=c+QVGjMkTb zR5o_0lWp6SQJ7F5ARgw*AF`^ebulH?wA4(q`M+qz>TThN-<_4F*wh3vIh~IVoit`P z6-@c*jNuGcygv2q z`jqwbFIRuFMm442szT1YtE>t*+#AYactV_skDs0Hx^$DDzNL4jA=YHRmCD%hS>lsq z%XPxjY*+O)z#g8}iHl8iCC3qD5<9o5E1(Ti{i;)Q!U7U{LCkb~Ci*knUAw0OlC$AVBh z8#~%MSHy;pyDkBOSWiNmk?1y9`3PNlu5mxTSvr*&2I^~r4@mK{3KCIF`;+ftime$JjeZf;xz*J1s zD-ml#ha!g?8b*qZ;Mk|(Dp#c1mK&xPu9_CM>&Go~VGzQ1l6egz(Mf{4Y<)Fq@w1zQ z&}#J~Ff39f)1!411UrlXdiTLt0{0jL0Xvg0JSQt!dcTyf8_(EwQ`v*iSrTC8?8m>Y zndkz6!lz`XHnq8JLhK^MHD2_?_b*Exz7E1DBcJj~mwW12HJ|b&y80-x2_YG??FT{QE2xQOs>xUilPSI8 zGp@pu#E=IX$4uS;y@6(>($IC!a~|IDCwd~%KaWFrBpV|uL^=?9AOaqGZA{%%3CPxq8*5ePSOru>Pa zc~yAXSkmEdPg*UK%vfsJ@2j${?~7nk1YC}aygX|6HIBI!)wV^RFCBxw>%@bXtxs~l zKGIFCr0a%&7?3VJ-Otl}_e};NKIDu90$wNj$UC(J=CJjN|9Fq~<5RD77icYt0U&D| z?8xJF7Zo5g_xk;DsI%y=)%V)fj}F+bT3eK$whiKTOTmC-B&v6zde8! zP(3I0a%mpLH5%5J#jt-Jbl0qIZINR!BDqM!7q!%McCke zJYtRZj=2VztCS`9l}<_~B9|udSOCIQw&Xp=kJor19#O{5L6kjU zV3CTZ->aY9H(orCJ9cgjtej1bKA7W4mw({(9+fh5MlbE4dD39SSqs_OaOxkqB0wBT zXH=srU-+~z=W}I9CmCn^jbL2`V@4w`b~5s0n-n%>@>?=BC17Pl%u|m?S&J$n_qk9!i;My$=l{q;r!e z`?OK@)IExH^L(2k^^n2ZsD6m$3)-Chcj|tUabQ~b-~1Z6l}fAREl3JLeaKaBjV?tY zWkoC(s{Doh*QT^Y^^zg-cps~c!s}l{2v@U<;FLRaAc5Wd*0^aL_}^@>Mv3wS_IWv9 zm$#Qk)s^n9^Ft;iSFPrdeQ5_G*&jxP)mmI9p3`6vNkS zuTHd3AgmOhj*=664~!;1i_x3~>X~GRB=Ad@HHramMDb?hS6>3az{cL$8U&?Lyt)~W z^u+0G3%p8;aMxJj5APaUB1mr`I52le>uQq$TY6!z2F}LqfE$BcsyD-V7#)NPzTcP~ zgq5$U_wC@f_eA17wims6YbCNlGfBEsGzRD;wi4KY$590M#fknf^^Jz+rr3K0+Sz zU`|~SfX;M|0(gCkz3%{Im|~($rXQ~uAXb&B8IgQ}SIz!#Lnyd$DIH7uUTJBA{$W8d z?G5r#& zX0^DZ!_Kq}5@gj9!%Sv47e4XHXn(L5y3+jdcwta8;7al98UhHjfGdU*VY{nYyo(fm zE@MLZg+u2RG}5t$0v*(~vFTAv86V3&QMOG2n4J6rt`Ql$0&ad`pr=Yheh|YyO3?T2 zZj4(_$`c`-k^YyaKMB*xRTcp~$@`(xK=nTRV}Twa_UremwPu3c|MpGN5g5W&M3Vc3fdR+b zTf1n#sJz~6B-!6@fVBZf3{bl;qXAY{AcXwqQitKop5d|T?RufoEwM3n%hd?`Xc>FV zfM2)an90@(4n{PF8p2az@yqj4UH#&TvjL@OWSL@Iiwgje+yV8}^>`FVi}n@p5X*Q$ zbue9z#Wu~67Jqend-ffO6|qcA3}2|3Bzzuh%O)k^Te}g<=ITdR!?j;$>V6I#EV)nc zo^DY4WpzIOlHF*#lP9&&(Y$A{dDZCDNrfLU$k*(7G!Yjg$KE+V;2$u2HR|`e=tFZR zE6ezkBaaV1e%;+D6n#yB@-P#7?M zzmO)G+B#ng+rDN;@TyvQXzZE-&;?`#l7iL=7~bM;<_SvQmHhzEgDjv4EWte1UUN&70`hdplph)<)Er#P8c$pLOxW z3|7?kQII6vxq9~Wh~J-3eWOxf&pP%y4m`?KHjmr!?m@9X{-O5AfsJ9sA%3rsoN zHEr3L!HOPLNxHWVuC=={kL+=D^nvHcwRr+I;wfSRB`hX^3?H0zx*cw%pBsu1V2E|h@PmGxC?ZH0M0A>oN;^1h}T~}#KU}?#B%8KSw)}M zrjgKJ80f5~OD&P?c1BQ#hG>iLfgT6Rj zVhhAXN_^_8#-H{49^0`@iI~C8$C+vg^+_n4{Rc}N1`7L{firX*lGhH#Rh#dHzf4j9 zPWr8(_xi7}b+gvyd|Pz5j*p$RKnWIX(pws+#nJYK_5q$H3e~=H)e6>rQi)geA9H4= z6NW-l^25%}(7PAJy8w%WEldD|4fwmfLv4cnQUIt;YI``W6H({KoRRHY*e|Pad)g7c zgbVA*U-hC!c3M6yEWp7Cr>qIwm0kY8r6xxyis6Zysa4n2JmrNb-Y7Zt2|gq7!8w;O z!6}icrdmeBoy8ex$MVsON(&y8=}6JGJ|@^z#LIT@dT39j!e1Xu$N>fr<~8;6q3s(z zd+_?YS4}zA#~e0dYPRPI<^`v8wxOT02G6PdQ3J2V%~o4|)w-!2_HvrceuL?ihgZWE z2InsxdMhl%E*rmLn7i8)jVG(>?g@%yeEqEIZa;^fUK9dIC64P|4LUjDtS)C(d`y2t zBmm6=rimIfwg(u@=v)VKvDf#*y0pFnAfibkt$C5bGDF%p>MijMr*A`d9# z2V%3Dy-f6ZS8N}2r=Cs~wQr%Vynwcdtt=rHhQ-PUVN5~HuOnUFhns7;<3UeaY%cz# zJnUQ5;8Q-HkoTa|G9x7iTQ@IpOlkkd3z&JAqn4&|SrI$BQOOTnQ16E^ZA4G=CM`^I zC6-|4^@3iN0+lh~sP$h2^}|Kr^4N(2?k{wCwEwFMSnr(Q9s1-2$`99ih+=>Dv-Lkf zqPv22-LdZUWxY;ZPilURq-O&?=#g~BEZ^I>SL)))4DAQ^ns(z`>lj5T2ZtR|OlJi& z-7Q`As_i%h_Ni+-w+ZnW=J;Iq(|)|rMuksnw?wvt<2i^?u|fgMgm^DBBn^DN-K9fJ zuEZ;(j6b4Z7=$&^`*Ee3X_6+y3qOgH$S<82UN<0~@Q=F=O3;uazQ|qk6_(V?GYN2i z!ee?swL<&0U?rj+Iuof7UE$qYcgEN&NufX$?(Pl8I+?(KP39RT#W|G_B^K4~`YvFK zUiyV-Xq*HkgzvMDbemtYwr-|IY+Q(F0TB%L0^swUD_)S(?6rYiOp-`>kN#xkm5s-3 z4@6_*f#q)U)tADag1&QmD6N#S-2_n)5)+DqRfA30ev`WrB-D^;7=2~gs_?~W87`gVlWRuaP1^WqU5H7oZ~&!wD54p8lY=6 zIjJnMW*e*VoEk#n$J0b^4`->5x~~^wv7L<}#M8b$fInUO9eX8g#XW6I-`mCQV?u8E zeD~Tcv6CpJpY?_d1yRW6@*|`ibmL5S(b5)mGTyHuJ1y2aZaTosdujN~N~+Q)HDbDA zQ|i$lv@2{I;R_d7=!N2ED^KF1LD~>Q=%Awme#fiX*Sa+qZLVaNwij@~N`3Ysd)kw4xM?`|~ems1a z*ZWtOLtEb^yvDiFkIUswE>47xa`+rUi8!!cj_Jw^@-~d>ZVKflzK);xBY}+vd%tO@ z?0J#y)2KCcE~?Wao`!gXG2`YCFbPk|q=Y{l0)6--A}ei-3yM;Wz&1O=I<b z%V|f)SX*p!d>PV?Juf+$$*8F~jNj5y!$h%CjlxKO>S()g>3e)x!ml@#t2x*Xoms>8 zLv1u!nol_ECF|fFT8;~kjmvo3Z392r`G3GahMLm5zvf)2zB-Njc5)8!QhXhiOTzWo zyg|ZgnLp{pJ(4bdO`65)ln;44I{l?_eyVOt+wNq&lC)O7dtW3fxTeKW61(wc^wO5i zEDAo*jrCQmlre-IkkS&L$h1iefq2DNg*LS7XJLjUK2&cpQ%OEJfZB|f@&fX8Gxxs| zIw@6=uijUiPv*4I=~S8)zP8#Py+W-)MR?GUBCfUK`g-2gX*<4@#jLkEkqW(P!0Y~KHUsl7hSvV^0FysaLZs#O|?`7c{=H%ql%H{*52lV4LITa zxG;Q+-&W3OZ!x>p<6?he&%Lp^%(6o?jo0LJT44 z%TFDCEOp=OF6wjD*5Pt8(=ghe{0sBxt>!W(VSpn7%RvXY&Kc}oKAx?|<0=5x@msza zSOcw$1&jko?!k>2fU0(TBm%H=CXP-+*tVTs(<=--0BqcXb`2K{1b^~KRoD7@j3Bof zIsJN1A{g(3DovhoG%0SND@>>OJQd#0PqzjEU11ABMH|CiM)x*}9?Q%51PLnd`uhiF zK3(+X;yjd9J|#ZQXHP#_RcqzhNbj>v_IVn76Fc(05m!)De|v?e z%}r@@S3?-+ZEwG87W9y?@StTU&EcUYDuk}bG1go9s=d2?zBtJYG2jqWU$X7mYbH|d znr2HHLsm_Up6C+&L=>t zH-YZbA05rFE$C@i`+BcZF}4v}9?v(VQJc*8QUH>A;W~-dQ7}G(VwSXNQukX%#H<0? z;}u)4@Qd1gKtFljARv;n37KJrT}CRMgOfmRxgYp-?A0~mc3R$=Y<^SBA33k5e{{J& zpx0H}NeftyAR$f{nu#Eo$!g9Geq>I zc$$m5Nrdq1GwF#)2aX`s4C(^qY5+G3ur>Vx>G zJF_A=jQ*+2;SBoVq9rtjdbiDKpybD)+fqUEEP)8TfYm?wm*WU>Iu%Tl866$Vyd(w) z)&BS-5-`n>M+rz2g50ohz!ub+n1ovn$TejfKdsE!NcI`b8vym9EH)I{Fs zx;--orBx*)kxhTIIQ>~2xESc}abU0+`6Z?FIaRz+8TI>WyQcgc&s77n!&1t~(@Xja z+)u)W;02e#=aj*6k8fYh&6W+SozE(r7(I$!nY9htk`6xE?tfwOvl;Z>mRJT+7n2}a z>m&!TRlsaI7ds+fZX%h@QQ-xr&aE&*pC7#~W4|rL@H{>C!NsC zlCgu4_lqPf$Limv5i zPyH6IB&MLJwRoO!%(q-y$ppY0Hs&Xf&`H^40Rzk6!ydt-(xJDEWj7+d&nz?Dt=CR_ zqHh;EpZ9T2%|;UWLw|C)@W~Jz^4q9~&_wAb_&mvO_iZ5c#W(GU@vi&{&q3>L=SD(o z6k#`NGF}~uhqyb!)DQpLp52$pS68J0Z%?;2A+Bq7+lzij@WMoY{y3+E^ctUE{E-GA z)i~RJhqq2H*-Dx%#AK0)%)1>9_})eV0z zeHHwt)y~=g&G8vXdWevD%`WUK$yUJXuTC6J60405O;Cusuk21D=gU?&tFV~a2lV2_1iqhCn+3wkLT}LGHvGtgLU&On?Zt*skFVzcxKX z9%^&GX!|V_jm&ZdR1(+UgS?XVL8dR{d7*jELW=W|W)WFVabDtVzFu8l44QhJ^sdca z$N!+LI65M|)q(^=)TW2;k_{%Vr#JB_XrU*+WTnY+u2_pEJK;0_H_Ac=uz%`$!8_nOH)_Axs9hOb>rE4It8K) zhw^poxFZS?&x-7yp^+_CKYN@Yt!-rU{l+&DIbC<=o7mi8@fMq1P8QF2dz^fvHJ?xM z)fTli9R_`R$Z0)~Xt3Cw-eLLnaX1hValgEA}5e$5D z2~byqnN!nbJZ%XAuGWp*eda7zHq0JUy&AGX$Y3&}R6|+{fa3V7-y*W%XFQCfG-EZU zvMtGjFA;oG?u>l^U0F)Yef_SoyM`tg(={vpOB11gAT@ApgR0lFwQGkO+Q2!fGGZbKtTDr2bf=QT#&{=eIbUV$1E3#&z2|6oTESoRD6_&x44ed z|Ir2XT;YX#MVquj5|Us5a^eLOEz)+~23uK)D?jNc{U#r|1RS-rj8SJ;)776;e)Daz zV|H^hqN}WOQTx6e#nNXZdR~(q8}&DER#WHtSRS_H`LyI%AG|6tJ|(|lJl=o9wNgx& zi}0Nzp+lhFM&$qdBM9ht0B<_}vlKqt9WSIwxF^+;DEo4F`BFDK&JPLiH$S|>GE0_wcWq>`A7soab z!|)LS{W4(6Uc?(Oz7md%e7t2(Qs`UZ$H(H)^M7f@2M@8RTF{Wti=&3?;h-yW^O~g( zN{a%bWJ;@4#4;XhrK7&Q+NIxEy(vVdkt>LHXUy6MK~zK5+Z*k!#s_QI4*2@W`CY?l`o^d zQASm(>=T;=MUM=+9I#XVBA!Nv8G_cR1BW{4_Ea(x#mEj(hf&gsZAJG!0X~4D95F5~ec)r`V8l`CvfgOViZf1uZ0o2d5 zh&KOCk4!2DPHt99OI~%n*lUgU{s!gp{=c9h7whC!`_(kB!~8nMMSLt&*UfmK6S}2lpv0=qgk@QTyko zGwb>=njcd>Ek_YyiSWg*Vv8n_KbAjhXv@ldFd5!G9uV$7#VWQw`jLp@C*#rZR2SRn z1#X0ol}8va@nz{E;yi_PWxXNS1O8{hwvLu^iCMGS0gjqW)Ap^QR-M#&0d;8t!f^{Y zur4|1zn+A_1loI`zFSION!`(x{tu=oa{kzUAk^PT8j|>NU@gdAb;|iP^GhmVuRQ~X zE7g61fc-W36`QzOJ1WM6AV19_fs0u(H9V%B`&QXZek|T8ZjC>woxbU!x;R?VYVMbz z6+xA{a8fSUJ{ApG+_|DzpjxCy1)MNLD8CVhWfBvUW&VvUj1Xy|UMv4|+5ECL({*lt z@y7Mts*LduzozX9&~xMYBOLs4?%{lCwl6Q96MMEuhJQ30&6SlqyT@`W_|y36xOYso zKv8d!UCOzN8^6gzB(D_v#_j9j4Es})s<`RH3w5z#jNBxl*C7=GFn*hV)=v%)YO}@- zn4qLvzT-6&dQ_WhBB7lvfQKBWc<}xk(-95vKXtQJ>}!}F=Xe;VNMx`He~}yGQR6C2 zKcY){-{+F1K(ESmX_K;hf@`+($$Ps~M(%=j3y@|pB!=*2Xe!VzZmVHhJ2az{VLW@K z{T;z{rPRVgqui5FU+BIm=+sO9Tf|}+aGRpjb2kNWs2>_)YiLeSL}o*~&6~74N1#2YDix~wEt2hd?U&$p3=CmNB+Z5k;n;7X z%PUG|H#A(cSdCOJQzTpsVbStyQB^8Xl?u{;qdmZp;ZEAy3<5L9|B-_kE*mT>iMGy$ zI14JFQp(cQ{(Ma!}Ee;?f@#7|EI2>w_nnOt>LHBtvG%K5mySnK$-K(7M^$a=qUaCBNj8nf8gr%j zV(XG{S}OJTSDzuronzwiQ)+CJxWKZx7NIsX$xN-)rIa|LPJ!0M<^E_fb1IVm&9Q3M z8fWmTeVakS@2%qxZ_nppPfZ}%IiUI#$KT?c@TxL_|{|~g8`G&ST#I07rlV06kfAqr8cFr>Y^cR5uY3fB5*@LnB zY|)1(k;^E+rdz^p4C@@)r{R5nef?*DZEp8Tl>|3z=BDMJWGcYrZBqe2;cdARf;9_6 zJ8F_7E3kc_bOsYJ^hxl9p)7p-CJBK(}@uGBeLw+JfX`~ zp4N~h`nsP>gp&Ga4&2JEqCP=3lamPypN0&d8v5SdhCM6`{$DuEp$bmPec!0wMx|aEl2rDJrGe<}gAPgbEw_$$$Lg?!22Msp4}ngz(CI=yT3AT^g7z z-vGW}>;5=;g1cho4SO7qCD4O<-f#ax(CfsKzX2@+^on!gSsIjQBJwbQsS0ljiwsdB6)7$W+h{uEj z`1gq8zu|VcJe~_I=C9L7D|te@%T-`k5w$1oYRZfetNR3dRQc_C%fC1-@tR~TXspF{T{fi zMk>eqvVfQLwqiGNlbhH78f98o-kizhTmL4V3^~y0SYcy>LLMGnD^Kr0ptYv&CM2j= ze!81K#rYfMdc!M~&xD^Ft?z)J;E8;OxxmbeRQ-Rnnav58*}XD7ETSHa1SQ_wrg&%V0V?G#OfS<1Xi!P2G@%Q9bY^1;UA6$PC|sQlNm7#jdEb zp=f_5Vf;8z9YDWe8tZd52iRjkqv<;f#+fQ^cd*2|=U1yOYd;vOBWC~yG2M@4SRGMa zLsko8yO%tr?;nXGl;&VFM*0*)A*hA&1X9qOsWg8(Sk}|PNGP&TF~?HxLhm@1;luAJ ziYHKDpL_;nIAAOv-nD07S+Q*l=@H>y(TJ5xlhb)pa~he?xDl`Mtc(u;#A6N`v7?>X zsys(9;8yT`gR~@8J%0sm zk}e}4Py)m5uYuo^t21uXYMQ<8asB18+tu)q*@^u)jS(`|hm#87E`@)wZT`U^Z{6_J z@M^xxSdmT%RzS79YYSPr1%fZejJau)Gljef(A0qcr;*vRh2*Xe!X{?3 z0ZHQcvL0#dQO?275>`-~$NqRQyqu)_Nt4AUgCu#>3)$yk%74k}BkdG({K5LzsM7A6 z8lkxH*~_eZgs_R&c+%|LSa@^uz5WC}7Y*rdwt zI!7YmId9=v#E+BLaOsg=hG&0$A1*cwCuDzFo!i0Jb8?1pc-^;tY2IrKf>s4GVkoLx}rc$h*)>~FL^=G zr?Xeh$A|&L%1QmF-U-eZO`eFwX4di{Ljq{e3E)7(Th{N~g>JScuuqDueffRxtmVWO3zSlz)lmEqF{NVBCzUS7u>V9U( z%;%AH1eS;TpIpGX2MR_y&?wD&^LNmQ{pa;Z_L(0{*j0N@aP#~NlR>U<>@Q+z{#Mt^ z&2JsI_lcmdYSE$vmpUIaAwX4PsfcH}jl4o?`c_Q^U6Hhr0GhYJ}ac9pM%wncz(to945)k8TQAQuM9sTTrb*}npW3>wY1;fITX zD*Oc!1Z=d7M=8$gJi;>%1rQu-4Qd@$M_bFT{87nfWJ5;L!}P%k#RKtV(~a0=U%qnO zL;S_}fdQg7>DNZI9!3pk-e|x?KzAntVyuj;&n4yNGD<>5ed~w?7+;PHu+RKmE?b>a$uv|jYon#^=`u?{XN zA?>SdfR|Pe@=m}+!;(=JQX*Nb91mA$6N?>cuPQPy^;13VTVfdS&)3#&86JV*~CM$Z$=$t_GSNwrg^tDWqFeA()%u4$E45iu8uELAY<# zEU4&843{1Vu!U->38dMHdOTF!xQdsXbQY+zv^rMo^ZNY2c5C{T0sM?#%tdvCirCmR z-I;`#8zqA##HegrsYju>8p1`+IGkDAY!*rbu*uyN)ZWvMWOmo{RSQ6k8%&$ z5DAogkm2lH8p10*<@0{P?+)1 z{_1vg)YpMtzRYM5XlZTZq_+0kdVi!l|R zUKhxXH)#@+>PQNJgR)A7r%^qAU)F_-r)Zg`vBcWyVd`M=-=?rP++>Y|DF9P(=&b(z zLR(}1rLk2y%O(HRQ(C2Tdc{k!y1N%Y&%!XT?>N_JFE^fAX|0mxTM0!)8d*mGmv?7b zpU6vNx++O-lw@0iyyt{^Xzzm81AS*Q3>feLxw|Ila3{P<8D@Hip<<0^F3r6L*9mC=3DVn}9}w6#pTc7F_xy41!j1V38+P55pw)qZT(DPN_2 z*ten-22?*qy?-^Cjjzk2t6NfR!o&B=!0%k>pvJS0m4QqqyLU@7B-5 z8-Q;i;IUoT!J3YqeZfKhrQ#)=M4m;WZoPchGk2SY_UAt3_HV_~vxrPN8^Lf^G!m z#8`>)Y-i(quJYsq9W@Qna4+OyJ_?!Hm(Eg9ftqClLe_HlghzAAh&rE!G5_~ZJ<2GW@>g%&SZpO(q`9Q^7`0#-QyMQP1W$f+RbzBiG`r!9 z1X5fzQ;)i+Qr_N9b&;+ZKuKFzog)c=sUNI6)J7uK|Yj zrCySD6ZsVfe$=aqdmVgiv z5UNi#rS)AN>FK(+^Y9kYP-`s(A5;p#Y<04yAA=1ACyk)W3g<6M12>dmm3t^^oe1x@ zGRLo(4K;n})Y>5Ljzqo02d;h7?-cpC9tOb;2^Vl9RcZ*AB*9*=8|zBKN0pLL zNi>PYZ7>vEe^XT?45!4)`#uSQNXaUf(E7{y)0ee7i*Q{=ei(mjKn32hCLldLeu}(w z$*!rF5$|+IG~e=hTeDgYW4DXGv@gSnikhT$aOkRj))EV+-s{`j>(kWKT;_t*vso~EdzXoQY#bfYC?NS?|NTv8T^ z_3G?x_Lu;@M220JR#$7*9?zjiATur3zGA+7ZJ2SU@~5?WSU7$esbOJa+;1_pB$dwz zs%#7biVBe7^{GirC^HaTq(zFOe}aY9i0F?bgHjfyvQl!Wt0?{VO7ayvDP~brTxO?< zPnR>Nhl?c3(}$09pZ*}1)dE`|8QA*2m<#eMMBNO$nIZ`*yVj&?-F(YVu68UQVhFj$53qpjps6n3WU5z6HC9Qrw&v*=crUnB?g%b>*}@*)*U3qQ516p9ZF!AdOI$r z11X~H9oh4zG&TR;+E$IB9dbR@X+NvqPN3){5WJ5@RhdezHP=SBxLD4^vZmt}&B$ed z;<3qj0Sd`3nF_KeQrqsZ(x6EZsWL5m^z%9$HI0cAXjtj?+k|Nhn{cB&3$ENk6~6*& z%Y|$3kU*8F4P$j!#$X6$suEX2Ph%-Q4YdjujfThRMi?hqR6l z9WE=a{~QJ0buQPg-X6v8&r7l07UdqS`Owryiu=lhwSnUhA25E^2S{naIfu4*e&)$l znz2{{TRYC0U!IRYm6J0hqf$g99bcg)(UB5;=*{C!-<>{@(JZeMB5rL?r|Ys_eEREp z6ju0VQBpN0pBEerbSHsbe4fO*p{tHl&T7geA6bnUDxiWhDyyCx&_7vL(;Jb~RZnv7 zppjzKF!1e;yCEMdD9-YVc-@qMlWxR>4VCoej})J2R#tpITX_u+<2USEH;5C`-mQ8Z4V{5? zl_zs)nZK^IaB@0yQy3m6b+1VyTP1mt;vX{}{LOL%ys&(_FK+u`J)$0^>5{KI znwzt;qk~g5CbD=_IW_L;Y|U0-5D_1`cgXSiDtHwfhZ>)?vaiSZW)0xJtxPw%lHe^? z@jl&)bvUdWQG|&C)0R^|T-Ea0d|I3R&~VDPK(O?jrHU|B>!me8rJh!Hx|iscVQNT7 zJbNZeRaYmGKDo_VH|BA5G7M?_LOt@RrU0eG(3M{F0b^=An$_J-r|%K0HBZYg>{-PB z`TKIO+@pGdjjj%)l;Bjhl-3fJ4jS=9nXcH(M|C-&R40sA-LCg2AaQ`YlbGMDkczba z*s@!_w?1c1FBUAvXqwH5twGIC7@hzAQr&^^Xq#$S64utQGD{cc6B#uhOpE*3C_B!4 zB-V>|x35nsL-<4eF@-UC!hdoB3(>7lf7$i=viPqhehrY3ku~DPnqeAS>$8ap$oLxc z*m2*{c-vi0#nenIdP4#S+@UoEvt_?q1|xl~CybR@-Kg`aslz(&jL#nT?96>({7FeS z&2RgD8Xyd*r5|Vpsfk>4;7O$CKDwpXM==r6<{G^q`q_l`MD$sUBiTe*egoGkPljT> zatbmz0o_MdmQw|(_)JJItV&NUWm$=7q^nGPuFKG*<(l30!uIw3aktOaxwXzkHcXyo zRB3Z2h;URLd8RhGWUc}5y>(PvP4^|7gakqe5F`*hxVw9TH#893 z-92dIkl^m_E{(gpySux4<1)?jyze*j`)1Agg9Y5~d+V+`Rdvp;eReszuvPuA4w(9p zCdl=zO5D~sW@n*H;o`EDjFwjfJUm`OU{>Rr8{l-D7bWWZrRG`Tqo`C6sYOm^W)Xv% zo67!hBBy4S=+oITJMKu7w4*hkzcC0yR3xP!A!~6s(CFD(O*7@}${lk;-Qw)@z;C!U zndfUxA{z@sJj2S(hqPVD)!gx7q2UiOi=UziQti$1Jb>a;&G%nOz9GT&v5!uN9O(>Urt`V>0$Iq z^sgXmKsnTpp_ceb@Z;N5QgyHTQ#BxiM-_#mW+)A$=0X|>O3<;{HV~dz@-RD*N?a>- zf~EsGIgt54X|iCC48xJmT{ zh!$V0*<+Wm%G9sc)Rx?o>a<*zhc5+12a+?Z3#Og-^ngkR>g!{(g2a*{nwYIe!E_n_ ze;b!Rt@Dwvt$0gZ1UD}W>}r4Mi7OL9UEPk7(LLW@YD_JbOV zaGVuw*es>$Js)`dlDUtz)2-S{#3=YBnPhj$cj9C<@s9rXuIO>KutQ*b+Yf+MX z>&_qa2KnJrKwDK}qVdC-Vu#Ib(rlPT%qhiLKv5CNFkTX6T%3jkQ1xsC4(;hFx0wT~ zO8IImug;M!QHZFRUCgK|DK%(z%Fqtomw&Y?!mHP}5L25?7tmCo;UQA zF7zM*;Mm*-^e4y!KaTcx<*r(ePzb;VwGt`xfmKfw#8>lY1Z((*&YZlc7~0438i zgaDD<`KbMIFXr?)`DNoEgkI%WHrA(+=Q2~dKc@|zf# zm)VR&OV!TFd5IauB+k4ovVCaOP!@JOrzK!70p!GhK#tTln~Te;F)MS%d-pEn!DroJ zjmu{W9b$=sG~fi#?mpCjS}=IW>J#p?7thRshMAI9g;u>`E^XWoBR}XXj(ylafD|O~ zp}ECYDcfCfR)^HGAi1z%VSfX^Y~kyy!(-Q<$W3md36pmz_)sWq?=OX$U*dlL%*V1RIR^37h#ztcY!giLBcY)tKwe&<+ilsBB6__z)MC7NZ<&kH$OQ_KO(GWUw*1cY3cp%b!onzdbko7azZ*8GI9k zc?U`24}_jME-Z@2??Nm(vH^j_S2(WBxet8U+iE^7;aPcT4*8R+z?&^j_w=~a&=v9L zaK+A10J^0*nZM$35+L4m%2=4eXl>`PQ2>k`<}lmr#W!uz0bbZMKK|~z>)OKEaNRjd zP(^k*kAc=XORVzN*vu0|atYFq0vZ)*5jI=u`PxcvHYFoSd@L627@ieFqkq3=vqW1d zo)03i+}yB2g{5Du8f!UlH54sXFXFl5yWkyE#P8ELeBuFJj1Ik6`_bJS)IVgw2^jT= zWRErHeMX--QnF#bI7{`))15^43b(z+W3%K9ung;txCZOl>32Vsm1Q{lz%H{f~p4h znfPc4nn3)(%r4H#>$Q#hDpGLbUU5;Wm(h+sovh_4E+GNKr=nu36tTM*BarlMcmY&x$Pf^T{M>Y2OHlLake*T@ z(g7-4yU8K;i+m+ADD1_-b?(s00`W`C5=Ya%-|-(*(@noD=c=;7FWt4r^yyi;k)K2$ z#@biv&mlpe0LZ%}*dAoI35NQulMvj(n|zhc_eFoB=koW)2xi5V&ECbw=hlML1jl-! zdZS!-xD~g5Q4&VfLhvvU!R*cVF2@ziwJx6_ZlKT)^(bx>t}Z`nTs6|QT6>Vw%ua4r z)b3UvT>}rFPH;iF0QA+z^KYT(OD*war|Sk5^3Bx`DEx4b@pF1tUF=%u@bxrKe{MsC zq?nDsC(czQWeyY(ckk_)mAt}M0p1YYGiZd!_0F@w4b?F~;Z-air6%)Y&`lT=?PQCU|J7)aRX z2A#O$p6!Eh*u!%l);hyZms?8{OGp~j+lAhRKp8{OY$15PZpg{3Bq`Ipo-xDf`DynY z_~&NefTn+P`Z~$r5XqO!X#}Kikh)^ek-)7#pD3Ia?Pe=duVNcl%o?Fu)did>Q@37iN>7M>n=kYeFZI^^Q zx9eU{IJ?>cz>oTfKJ%{?xxR!5{Nb{^RL7m2F6U&;`n?h&Td*19e&_bIM12_E?s1*z zc4$<<4X-HV`{SR@?hO9yQH1)_xE+N?ieQB%C&zK0Oi^X2hjtkrl)i z69ze%?uHn5=9-!eDC*Ma7UVO`|6D6eG~hS96} zWl*yD$+Qvk+=Ax_GD@D>Ol{2~&YMKWYIEsOfThyIO0eC=?@sxw*wSb%hag}*glA@| zYJKPCQxtxL3ojW=AW5-xcPGoKq}}DV^)~f5J@HS7JhC5Qb3cJnr0pI?7*kg8t$Jj9 z8hz=@`cZTx`<7eYB71T+%N&Ea%+u4z)Xk73?J82KwN)|9nuw(NS zS@)u7$aejlv_r-n0=XQJ2m;WpA=*{u_tykJG%oxe(X3C_+}K~*tNy?$u!|}7aW=nZ zgRfT9!PSi}D(m(f%!GjMR{cqKluW`W^`u4w z@tDQ3w=l(sRO+7qqw_QlprdZuU$^NEx^`$N* z_IVS8Q@=z$i$YIF=>&}+`|_DtZSCd^_uQwy2)!=fqcj+CT1oPK+jqdTaI6M>2jk@& zM^yL>N8dvHmg~=Lb|ylY962FcHbBrWWeq{_Vi|FpZXQVJvGiBU$wlJ_+}{ z^i*pL$=*!mz~-5Ucp{!mT0_n=Y?a0g-|_wou+o#tgvV4qX0r=If|!)^_F4_h{IuvW z>& z#Byv#BSUQ#Z*>5r-qHCXgn+f_hBzea)cdLFA})FbKP%T+`RdC>oe*xtc9{P5rKm)_ z2M>+gTR&6dw0%rzG~LV0)w5ndpzJ`NH_H+eGk9Drm>hiw)q3bIj7Y@~DRSguGQG?DqjGaHZ7E*MJ^Cm} zd(8IKpGzwJ6Nkskre4>PEX;^wW!5M<4-QSEe{cYYelpbKI8pF_P%KZx- zHf9MZ$-0>;wq#Msf(S*tG@U@n!YL=eXf#iI;7Y1LMwQ8U$4-QGVcKwM(XxRDL~jS? ze4MK)F`|OUh4iZAiHtT_72U$Vl8EL^#I~4D4--skckV&;FP3#VX*I8Alojl0xyT>V z?`34%mYU$8s{M{ay2zZB*^nee_4nc32D;z--5`9rksYq^woAAMSuwq(4qYs?CLQbG z8v3{?4L_5`p=nFz0Fq1Ht%C41{#lJ-mDXnkHAs}uZdWQW`$1aeVx^0(&|!B_&~!%W zgA?ZjEUE|wXpC9{R%5Bk?AUbVB8GmNc6Su_&1H1iErg$Z%n2%_ZIHgIP5yD`DK|oN z5#isQ(s)`fr*q?+hL3J&PMNT@A)XOUqM6OA{nspE=aO&3XMB=_?mZPv8S9s4w{i~Z z>xu14OkKJzb@^3)Hi8;<&o&ZZ&apk8VUa1T%>R2e3r-(C`y{Y zwJyZ2Wa91kn!6-fbwgV`AR8J)XEi7kv1P`;MNXPw3CExp7tkpL8bnV<7q~Ima z9Q$c~9q9C63%1Z90lenGl&O2oBp+@UC}LpD)R-$!jzJ z*5H3dBN{Wex_Dr>@&D~_;pSE(K0!w&_TC*Sv&Spi7kj3&r=_<=?!gNUPO3I@a^$&X zJB(UZmb{HfebKtpC5@*;U$bR18JjCd-s(nWQ>qwg<+|9%H4`yys~2w$U-~AEVQ^A@ z{;lNNhC%aYZ+h{1?y0lCxTW`F3olorp)p}M=Jn1Xv9ym6ye|S}pq1srp4blo_+krh zl4}v9;-)JS!VgBZuFd?5y3u}eR)i9%cpel2L#Rkjc8p5);gt+NcR)&<(%4|mi|MV<{<7%6G) z5*-KJeH;5vI9@e>pdvI4d}Qt$YVV^ll4JE>*P-A)toph>Z&n2Kw|@u>EbY~) zICTv-CHzRq$P0F|QXeHawEHONK?(yuRql&5>E|xh#?H%*^N^{y9_BvV5F8pj$$L;` z1N|It_+9sGEc}Q+#2QZp%h=tH{%PC>GT%E7sj-$C&Jlau9um-gUqo`fTz|{Sh`q*b zHWJR;XlYi(+<#ojP6Ex<4ypM_>+K0;h;TQE%voBYGQIama8{cvRQ35l|L2Eq&qt{ zRnxre7JC#YKH#7&aZ08z`mErS6sX2=>))#(khmaQXn~Z)>jW{ME^fb))bHpm3+0XW zuEuaIQq}aMs>aLq2lzRp4A6z!e%OKGT~U46_}zVSfW&;X`!W;xm;d^)?02NRO!lfv zPZQs|N7oE);&9jW;hkFT(W8S#zV5HWg8b9(?RqaZ=vXtR7sCV4K^5&@Rsuf!s0;d}%+Dgu!%=m~s}q(OP#4#yJmyfyX&b7o z_517>Q)Bb{0FYJM0;XNiY0E@smD?b%O!IPHgOBB>(3VYx8tF#1oGp>P4D(3)&@?&#{2=Ks?QO&mjpSCKITM-}sw(goz9g6VFOS8}&)Aiq8n% zAq9re_|hw1U7Xc`BUZnZY+l_>X4;^4pDr@DWnPjJwQ{ZyyJWe$D7}h4{bLOrdPy=- z=7o8@{9J1Q)wBqR@(V@?*z{*7#Udw;;7)j`7gNQ}CHZm4!RF4a-w(_`16}`q6N;^5 zKzGFd^M@zWYlv4x8C!z!7I94oiXcI4>b2OwV!$(K!UcQZvWw{7&!&4rAHEQsP=Nmy zQ8~f(m9`2s3h2E&*;sXoJUwBXM83Vw%~1RGN{+ICN)8KZ;K_rW-{IkH@Xdw)d(9K| zmCGcj9y(HW&ij$Y^!_$A*^=v@Y2wYB=-1$0Es4EZiT~2_J@(ja5sq!(e;y?-_YFMUYJ`#G{yly9u-Uwz`}KRTf1hf* zngOIrZ#HOY#+CyhQ2g@Uds347cSKGjLfXF{`l0a;6>e*5%f0e^k$K$6mVoE`Fw}qg zCO~VS@(#)zvzucE5lH9Zwn&wz2ZF?GY1Jm&ydTbx<7hBG0{_o;9&0ZvY592jM#bu6 z5~=9e85m%d-Uv*rh@ZRwFg3o@c6K@Wg~1LEZmpW%2L9*itgqM~5)tuexMD|%7*F%6 zPgU9mYQY~qqrEbr#Hvt59?pM1sUe6-vq2~I+0}h=iGEVT)F{T_XnB|0b+kqw>#b*K zdR?}6RixsQyo6md@xML2$I+neNy>EN5o&wddU#$xZ5wF&d}@q@W&G0g0ZMTEPYZS@ zKETj)RA1IIQcLkWvq`h2=aJmq_v3Jt&_kF7-JzN8F$MXRR|b-lLtrbkoH|MC^mT*q z&6>(2shRfv%_+aTcE?V764oe=T6xgMON&!$T=ycKFSLgm3Nvp{4TlyYa+BE-ZjamP zo8C_&8{j(+S_xNOupZfx9Z3)vJQ$FBI(Ngb)D#CLExh5$YA{3r)kGXhG(%oMPpvqihRc&-B7^t0$aWn{ zP;Q~4kj%uNuvIl%D)5PV1BcSkV@_{x@@IhZ%P2BmZmwXii@y{u1~tAUA6DXHgY7a& z4#nMXG1ciRk{x*7H%j~tz6&3Ua^*?-?$gGO_{e4hQXjleHUZzIZ4sFxfKaKkM3)n=-olWi~z(t z8skd@fo=62NA{N{dMZx2#~6@eFE%pP#gC zrX8RddyD};#dj!XgW|9oB?Ug*;W7uG!yMnFhSFy&J<ReXS18L_9q80#!rse?yUNqPnOosw)CzfhdVk4nwMi#3vtiPj>UX^FrS{-9w)XfH z)tkbvmS;Rs-bt!L-kS@eHCeq5h#zQ)XP$Ps6SC z^s)DbGUpl6FcubUt@p-RCX7*NG8iSAf=8eCfmoF(OPgRmsPbL|GVUf-WR6C#xMB|g zS<3xl|0b53&w)+;b2z%s2g;4Aswlrm%t9*HuQ|fW`2)8NEJ<+U;ffne!qB*;_iJ!#Ab3CpUEEHIDCPSxF2ME2Q+(bVb4!?K> zRPO~nT$2Nl9FG!RmwVkR^{Tb(%eu{-EQIV=8G3JC7A_i6bbXfk6fQB2q?LvwksdaO z4o6zZ-t`8o#QqGVMYG<@pyn(gj%OqBNi8~fA;sqHR2YAEUhnoIKU@*tYOv@hHSi>T zVf|#h;1Y-DuUJbf1Kqr~c8cc1OwzjfXTSBM2wh>pF_oxH4Fm^oe z{`5CgYlV|h(M4eI*^2p1H4*6x7CVK%2N^}{b1DHXzx^8LuN zjK0n25__QQV~!ToLPjFOnfOaF89Wyc#C3saPXFB?Mo8tpxV<-l@2_W4>g3A!vd#5U z%xKCfBXg0EU05R{g(iAa9M}kqHyM25js{G5-NsiDX`u-UCIQgko-=4bq@i56Y1|!2 z;z$8vO2yiT+%hW5g?raIIMef*_cbc1iw7o(K-rT+2k0@cP{B>;w1L&Iq406=ZhI(aaPf+RT}A8J~{7i<{~z@W{Qz9?EICE8>Q>``J(Z1}2^!~sgXu()78)Lydo(@iYqP&(-1Ra$kUFXRm>$n2z-kKPs8j7PS}8L;}UUuyGEvcxp0ejZ9VI+;_cn=a2)c!x~LKDM^ZYvGM|60)24IMQ!rgb_G1-GmzyA#LW%_ z>kDgNtD7&@6D>+OIBXyVP%yDmu{IpW%J>ZaOIuU$ppWeJt@};?M!w)W;SqBowode+ zkd=gkLK0#jj^upPMfZaq5T6@%plu>@*%Zx`X9bq3#>v2x-nIVVK};0LwQl2wddX3LqYQ~UpOiv<$Zgk2l)@~E zyn~!+wY!LUM3CTU;l8?cM8NPbrP&33QIvXnQaQu3uDYQyLvITOx&7Nta;vWj77+<0 zq00^2^{R>%eijFe;~LXGMYlsYhlPPgSdLOQrJ%Blg2Dl-qEXa0Rq#1gq9;9f0P@icd zrA-BM=R`)BbHksNUbKkui%L8d7fHX(Ye@Z~y-8Z9G>P&J^@DQoOKuoEj!u zSE&olzG|tn8{7w&$dQMYF&Z!3H{_qr+ny_ZTcHK{VmzuftEh_cjTHt>JsmGb>1fCn~svx*;H-nl7T%&dmFUE z?SEDurLk_9qAWt4q2kswsGzndUFb4sq0ovc&aL7>Tw&`cf1iRx0#KysMKV3i`H1kh zo5v*Ve8s3&i6Uk)6-FL8Z-qi(qv|=uv^xzic_@QFXMsb3fLBKKa)!rj==m7Y}f5EXgW%!1V?=LgY+vPMdWsL7!ulR3+d7X zoqg7JKyyjlpUy}`unh*RFO>it*YQxQz0q~}j8ZasgMpfm^>Tups!%wU1^PJgBQ%47 zb}5fugN>)~cs zmpgoI%j86Jp?w3p+-hVDhy`{u}Rgy@Vb#pT}+sV`G#7V&+lSeGqD^1 zT?H;6RdCsjK2cFwlm6rGdLnbY&f=esUCFtQ?_2I00@`M=mis)l!?;Hc-ucHkV}tjDn$ zH6W;39IT4>A1wAic=-Q@6#m6b1^?fypDBZ&y2&b@V}S8?`ZzFCn ze7%d1`_GJf+#Nb*v^ZjD+F~qS0SACriVVbYlwaaidM#Xg^9FwwnkEIv-;3Q)f3@p? zW?05XxqPju-C@wV6#5W-jWXDoagR>?efONq18?Eif4*9}+`j4CSRpE2Y}A>ttQTA) zLyYhLT!8)#j6;HNSsF|NBw&vQfR?S=2OJW5x?Uev8z?JiE+qN9-P_?hRM&H;Y4jB1Os z&odVFhG3Q)g%5G=h`0MaYsh?B4B>*RnG7e5FHf$M>&^C;xOLe%#iq>vg}%ptk(X)D z&*wVZm0KJaVR)81<`egUU9Ce4mF$v?CTuSEJ8zi1U@?Y1@iKUrIKwj=Xrp{#nV+qC zYN0S&+A$oztuoSlvJ*Fs-e_p&5@a7*T!Vt2{gb?!d#Ve}ZA@cUXO+a4?vs64kVvtd zmpqja2g7p8sHgg5G7lRI5%9vBfBkJ#3T#>e900OdZ92lT&ibedL&^6-5=k3&%ww|* zh9=Nsi!(WCENE%*YVa#j3L&bCu%p_`u~CoFlPI(Powmdlx7iA+3Ns;hTUp%)GsQV@ zvOP1?x@_6T&Mww3k|$hr;5oiy3BTqD>5^;gjAq5K~bXy*_Agj_$^wG#YM6jhO4t#WaO@HTuW)zu15=0L7o=Z{H;me|BQlI~0u z3S({r9u8#(X(`GZV{meH*Mg1cqJKgKlmV&1zsQzD=8v7r%r%&MH5H?S6PPNe z&5mpN@!ZrR++^{K;~BEUiGJ=*2y6PW6u9Svs?5E7NHhG$=b`P`qYB)VE_S>WASz}X z^qia&iW{LFpe7p)J;B$+;Suv|!?<^6T#Vx(b%xmiK06`3-HUDz^UQJj&#>83-K!Ez zqesw{)Z$yFAUiU?iUD$#wmxh*F^j53PL(x3{MEBG6z9Y!iNYKv&{JXF2%@V)uh83V zg~t*$@a|@=zDZ{Gs7U&CE@yP@u}b%Rm)n`iIw(4Yq%jz_*kFFlx&FxdM^Caskd0=Z zI;CZ&GLD8qVAs1?<67d+>_F}Dgg~fo@6wP?!slxD4XzbaF^66s4z|`@%ar`KxS|=*mQy#k2(zn z0i&^Q+M$NZB|O@JP-H@`SyWc$I^a0(uRtZUO%CZ$AbMk-0eu)Jv1SrUp;J(}L9WlS zGypg#1srH3j~y?38&DG~$M*Ru+}Bi_vDap6ky+H#VSIUPsQ&uBaG7y`*}sCLE55VyV+svQxZ<=#nqm ztkn-OpSX11zOiGpaZ4^OZD*c(iD=Z@;V)mfYO z)8oMQJBzeZ?ijO;HArq8ZM_>E2Z`hIr*jvxm2*^Cfd?I=WC^9L(%rCcmh#n30M#o9 zu2d`5H5nuiZKIEXokY38W!Q0$nxAh&dv8ZU_T;`6F9s}80&Ag+nMjJu^ehQtjzqni zy(zcZsmyZ9>Lm`n-Fc8NW#1|BNQ%O1c3G}Tn~BUZ)2!T#jLn?voN=$;#h!IoitdNG zoiH0;U*~sCrG28&F7ijMbDV#<)B|o!7lc|W%;UMSKdV=w zj5EC4dC~R_NBMPvpc+|^dzHc^3PFS0?8xpfDPs$^&>=45O8KaK)==7iFq*r_vo2N_ zi-y@mzIwk&xxwgh&4F_1KD?oJMoRfG7qR z?-54y)YHsm@h0y~pfA)OdTdxF3)j~`KPv)_hi$6>+4gX2+gt{}G#6sBbyeMx(X6+M z?bFSx#azic2TwoOpasxkrEk(F9lxOA#HzDeu(@1--p1N{g*@)RU4T|CE3qlwi)K+`oh*VTe#=CkRnW{R-`;m-R z!bJeQ{QVD^P}P-5_tS~ESP(}dEWnP#z|C2rfJlb@+UJa1&!a_Yo6r-ItWxc3YY& zb;aFwx6Wb)X5+tD0M)%RR!B-C!}g_pFUXHK@91(G_3HRc>t^*!u-enS`84E`Ck=E}dyI`vT2+cer= zu$rCAM=r%`=MVib>zhYr#8kvLpu#M6OdP3^3Vyw;u1$-gv}kSk2Nl&b<#8K?!#_w0 zqLO*tUAa;2rSw_{R)xvTx?8q^vcnL|^flA5XNrRPqtV!w?4`K8$~$Cgwqz2XVW|&p zH&ZPd0kU$d(lbiCzNw?U&c(8cBlQiJjaH#XD);VR&Jwdw-oDx6At^wapVs+P?q^j> zuJKgNV6!})cqP7?JNio^B{=>{URYf=wSn6%E2@l2Bx8=(!g-z>QVu#=ne`-|tpN18@2^Px8sQt|7mInCcY!tcj}MUkkbTza0zBttve01#aC z#VMPCri!|JFiQV`2M_GK(g4GrfhgvwijfUF>`PoME*E%!fNHXG{qGA%`nR~ZdXB>@ z+;)^Hd~K$h|6ddZTzXV5gdLM1izUJjFe(^cPf$G*)BV}vP39WucI-^H?i6f~E)VKu zthlj3OGc0e)=^tmtzY1!R&Htm{l$mQ(Lecb*f>VSA#k#tI3R{1ZO%I>D}iJ(IRcx> zcl5#6F^+VSmK%(PWXtK!$mcd3;dTiufzwu1W~B$s=XfIZAEkt$l4*BpEGyQB@W$2x zT^71r-IX2|u3e|6`aN9sPXf963~AK33^oj>6FDy>MvX|ARB`+XMC_33s;$VtNYPrd zeqddqAkeKD*A!`a${_iYKTfY?wJ!qlWi39iVR{7R_wpGtJvD(oqf% zni-vg--A793;+>%UAjT%*d!;2&7=FK?-dso)%20Pm{r$ObftN3*zxR|JIO|V8t4&& z_L$$=WNk=s4;SvuSnj}u4XHvDtgqp8M&yy=Qm2{gMxchGQt@y3GiUkta@+zS&qDA@|Q#)lb3J@eH(<-O+lO@fLhvM7sxj zf=$lM@>@d2Rz#Fkbemj=69&DQtx-%00oP+Ol`}x4t6Hj1en@)}LV*yY>}j7)`KWFs zSA_cUTw6zAMIRMIN$L>K)~KAz z6{@}Chf^;!JPjbqH5D5~x@|L%Gs1iES#;w{IQ0| zuvS`*W9GyO$xf_F5^95TuJg*3MX@UxmohaM-F(FUKu;g`LIDXYOR=dd0jY8ssE0;) zv|_?+^%-(~oy>(WP?6=ZOj*D+0a&H%Ws3yVjF7pktfc1M!jBa@|@q$h**EW0#*^jk_094kNNhDSa_&k(l}z0|_p*JO3JlnBGdRP9FbyRNRR?$w1R%RU-8=|G1x~szmCiEF z8c4J6-h;TjI;(*{Yoht%*K5JOqgV)J#%V;Hm>Y9TQqL=-c3x! z3nciBG{+xw=kqb|Ude#Fvdy%`Qr`)taViePi_FeN=1EsX=d~1ND|7ZL&PC}nTG^5s zNsWNQ*eV&>D!+2NC_4o+98Ukz(~q3WF2s$hsJ<+d4+)y8yr`~RNR29N(YJI^x0jDA zcUiq%4zmo9YK&4{%KR9Bz0TCFaOZ2?F?h>V_6CLSoh&o<9(_b!FI%@pw8c~KTW z#6j7NZ|8L!8;X?U zVz;>ZHxLDDHJd9{R*(evbRR2eZ^~R~waQtQj5>m`^%H8X0-w~U}m&c4lr?6#dX zWK;lUl*N2c>yRr3!>=U~k@8-&sHnN;9SqJf!@aAS{A+U5gbcTGD})l5@CoFizAHu7{;ll`9@ zgeNNAyTjX_hniPZ+|;yU(00;x%iD^m=w8OlYSWUO<10=~{9zjxs5*T2ELo$H3b*_= zmnXJ~A!OcBvX7|0pXU2Tj|VShUq4+s0Fq~603yWD>XP%3AEGTjVpM>XHJ#C2UKYUe z_x}FMTR#HuZ!`SimEv26k!qRgpEV9|@^|S{cj84M&Ub>4Znew5uX55h0a4LPGSRIu zyv#Y_LTy5($U6FWTVGr}zcBn=TI^Hu7yPJgoqOe;o^6D?Es;=;G>0P2pu7EgR{N$@ zJ1R$kw_#mDrx;^MOZQ=EOnCKsh`q;J&(FiNBx_OiUU%=`q%-Rz)qjE@QluR-EJ(m5i0tIRsST>&#YYNwh6k3XBa%A^yn->EI#PQWAW%AXu+~(oX3JU zK_Qp_I`B=9hq( zsGQD}v-%pB#y^;k_Gbq%W~)LMT4ve?p+5+>DD$}h!b3;Su% zkqD*#G!vrJoOyY~aAVLtf_ug=tw)rcL|3riThhcoF%1x69OH#BD2$5-Ug^w z{@h89I*-`3a=BLg8cbh@<<1x+k7?~OQJ{@F7m3Ja^VuXfFt6&ao3d)1K-bs>Y-Y`18ajbKcd}w$$c~L zbMPbQMbhZ@v-^J#3VI1i5ov70d2Hf?TMP#Q8&cQZQ`c6V*mtR8nM&6y^d4wIOG|!; zU&4>N=AvGN4I@sn;HGfO&*7vmD*TaGp0<6H3p8Okd@uz%N(*mfT_y^?aJ6!D^6l4- z(PQogUxI3xb3T;_r%9kS-Rdj{Dybu+Z4>k>6)(4*Nc{bb@P-Q$?eN1N7*tG6ISjaH zjaxe79uOBVtx~X{{nVRWPB62W$pCB|bSnyBX1zpoLr;j4n`z{{CZdNm%Wa=^AmA@d z^k>avKy+SbnQmwR@yty;RVvU&v!~Bp^(XRRSwo``6oJWdb2Akn=-L$jtB8f3lGWHn zwy_MNjervvv0#EmkQE?bEhistkT<9ATtF_x4*DKUraT~ zevcQYWaFLdWb!gl?e6f_D}M}mhJSD06sp6Omlr@s8l^rLBcnkRT&(rNU6~qFnlu0G z*o`K%Yo1bnDKM?@k>DXGg!U>?uN>r)Ij9NfD!-GHB^#>28F(MvjZB9dh{z>3WxIe* zpf&O1xd3d{QF3oUeSFX(J(!%ZlW;vL-dFoyEZ{uBuy{YmM70XEM>W=q1eN!cbgjKh zD@M0?y8F>ZuS6XG@b1C-t(7ELt>52l|AdGGKUL7tYw3a2>@gjc^vwAMZDypnG1JQ< zU{_@hIqAGbdZ7k!T|Ud}=zS-~$SH`E#Bp{M>qA%eOFsLGi@pBo0iCRumIij)Liv-t zzT>CT)~H<#}YvzMOf>D~@|Nvm@W@o)XVm1gHf z?b6>k01&9EDrD(N%^i`y?iklLjTD=Q`*+EU=M2D_0-;p6)X;+SOZ|h9;U*MS=1a1) zAO%YEdN>}o<5I52D=WJ+Uq60ZC4BS^W%}ggv{;v=!Nu`I z35`}YR{@tcRcl;v1TnZ}I zVbCR=cL83?&$JJNxY#rg7REK&opUZ$_(8Io2=pgs`*ocMNnnkm>eF7sAq83yV{65! zb((47o*IRqGFsXc;c_e}MS>CVlQD4G-%k(>ryPiqvXPA7m(V)baA|*($2rITt9QB0 zzN>Naq-d`6NJA8}n^M%XP)cdp<5NR3+1&@SR{+gV5nWOmSv3V;h$VY*vN{Z_U-tSS zGRL=@nydkH;X&0*-Jp`x#joTYl)wAN?rrpTHvP4<^8{||%khm|S~~&H{$|LEEX)&Z zZ=@~U&`N{V^=9MY@r}uGF2$cwqt-i?6@2lnJLtg~tNATzUm6t!>WGkP&jm0rI(KRD z_)1rLDIyll(i)l-xjQex8n&kRRjDl|H&+WYp-*-%bE36PXO-&+{ZSurMfsi9NTwI< zud!$6A$(VJm2Jrr^=dc67QfZ(RZEnYY>xgy^f2>>6L4sHt#Qy&C~$?{x`Kx zDb2WKz$&~crBc(>fcYeDO+0L8R-z3lT~~(#m<}4f(*>jaxRf}*vW8wX(66@O;aHTU znmm%g!4O&(PHDS@ec|g7#FiV+Dk2!QTGpS1r85)6eqdxCh|9749^{jQS4=k8jbMHNS-lYXO`NZdeHvzaQ#zrsQ39~XjH8E$lbEG-#qpm(K7THRU~wkL;fAVY>_TU4#rV3+`V z(c`<^6)z{iP7Tdc1_*6~(T(3SwNxK>gG1e+cE7uqW{ml286p;n&@PI_hu>$oXP>4r z)+DB_Re!+ibD!I~+DMfUR~P+(n*sIg?`|cJ(XX}oj0da672>vEt`sHORU3mR26VK7 z0rA^ku>ZQ)$^2q0ay!D`B)5~@RLRWoE?zm~kU_7;=k3fZ<7o5B#_iPB3sC@>|6WFI z8gyL&9<*SS11Q~U8B`ZIdpi@Bj2a&hl4a16d!02#804sGfpc&j=j)RGp*a}DGe19@ z@ZTo+2oN!yitH3Hgo$#x<>lXQe;idAY9?-ohW^{7t3z z+=xtvluZ^pzj>m#aQIV=cp9>YJ9-|gXa<0!gP@zz5RPdaXmDhO%)CWl^u6m9dmSS2 z2X}6_cs>09l3@C%^5-?+r_eKi2mUwyi>uEII95amQxGx6%2RqD*rE>=HHwn!+%L*{ z-{P@#ExQ1ZPJc=0)pxJSZ6U8UD1}6c|;9S2+V4y(vplefx z&ePU~FF#!A+1d~T_V50*EFpYdV8WORh8h_TKyDyqv0dq^1-3B!5I--vG^-tXb_WxY z?Zq4$o^@Mm=dWd^Y(B^RJ*ZXX%nR*T?r;0@sLx!-8pSg>9+$`v)mpUAs9mWBJYD5E zZ!NRpl*>IaY`RFjZmK$t?kC43rK}+H#wjI6l)=LNc00Ockk_RY2PngGFKQu5FSeqn@@cIc{W%7HLJ1Wu z#{HXkQPt!bakTu8RxD$Qy*Bb^rZ$_8Ap|RM)vd{rH217l?dP{{cH^1#WHs#uVdl$D z7@;#~yq8JE87Op4R)GJ9tEsNM-o?03`Xk2+@EIfO-kZeLrejFG+fQ~VP*6xUC34L@ zzf8*h(=K_HAFrVfBfY(E|IM`*01RsCFZly-V7f&u!u7{MgTCv=+PBnk9NgJ5AzFp0 zqEN~Ni@+)gHr&`gCpmk;Ei|vY!Bs-&+fRnIhGqIt(R(Ng>)S?`aOi{Kh_?q9-pFf?(%$H1kzF54QZ9VN=rn;lXL@oMj(?HPU;+(yP zFcC0en=7#)V_!76XiGNMU?u)Q)~AL;K#mbHtTET!P;u?_!}<$5(pD|Z z<7c(QGIH$LlkUCH z$$0mH-Cf7-St=g|k%1AZ3O6ApT1Di>E+o(TJ{$h|CB~FUP`cSP%`DYN?8&dR8blj! z6}6C(I43~I%K=VoN5|XF>YV=pv@1)hq;g0O(_ModlWW^y-A#>|R|m&21WKe4!qGEL zvdMMl=6c^Vv^brth7kM?hsjYQp<(BK&|mL!JA68qk>O`_na!~2pjv7K-srJ*s>|BJ zn6XU}I$Ol*oMd|9yXV?gEN*_9TbT(R_@M1q-Z}#^;f+iiO<5*qh2*&=`xB)w`32tH zQ;&j(`L^29>B}w^=iV7Xo2PWT4np)`yJg3 z4M~mHuBAoiMrm@v1TJSP0atjBG{YmgVEF+w&YbiC@7hAivc#!k?a-trJwQ#m{qzWi zO-xNU9crdvTC1DcMao<4?D5cG6W>TSlszOJ{4A^K($BJ+&O`WeYVhh_So0=NK7VJ8 zYt3SoqCghA^Mf|~?8ky2&M|<-0XIm1++cn?czB1s>B(syn+6oeOKCdf_|a&<>rui4R&R*81#DaCP-{)~@`T4pOJE z4_vIs)or}Kj@A!171_r4kZ%wC13xN61>y1il*f{gW=78mX>pFF?Xq8<4-JoKT#Kj* zieU85fh{7mg%XM;nsu5fl-m%k(DQ{VwQwk;b z7QD5>n~t%CJL3TK*G~Vdce0_|E;~Kujg;5esB6(wzM%5uXn4+=_0ReomRIFjN=`Kn ziX#?fieC6T8wH`orvate5f4N9%s2yBJIr^8!i-d(q_7kbwbW;X74OrJkIX6OT=AW_ z(~Kb+A*EpUyFKY|*6y2gB4rD$J7?=Q1zD$ukF*Qk5+xJ-^9_ZrdOa)&3gpwGf?8>+ zc*_6~q`sGci?y?fiNz-K2-rdnH_Jv0waos&(F#bBJh8U1WGtq`v-jMO*mOK315PVlIrYg&&eLHA2;b zW;OqZ79>F#n;V?3UBDfbB5c*Po+~H6UgLD!)ZAQw-Di80PYunq?jop35O)=vT7WX)JCeB*qRP~%wMPAtXBD#1)AbLD_6063q zMlr%tZNoqBd42S+9iLjP;F^%JCA{8LXfEg@;Vr!q0!}iCkUTF2R@c^z$bDjZI;gV9 zeEDeko?s{XwLZdRhkG?fL#;_b3L_LYYN0^T`A*!!rl_f5mB{!p1HxpcII%8i$Ng9G zJ6!X@%i(dGVS&5akLbzdzL_~Ay4cb;taHiooK;l5dvuxC7zD~dm~M3|oU3`~CO1Fz z>HQY_%}&_FMuHI1lQ*)_yWLBoOfR)*mV}tkA*!O%TOE;a^*Rz6^<8P@ARDwlqZH$# zszJ5$!!a{GVR!P-SU^3BYZz)Hx-8mV>~ryR*}>lNUfc8c--E}v%2Nef)f{M<^wrHZ zVr}D81w`kU4|4j*S3C0*x;|{xRTn>e*mxW@f3c?FI+gJqEoV;s8w3+<=t>gti_60v zU8{@>4(9I-*MJ*JQBhlY7sc>==N?)Er+gX|3Bd|xn||r|=cWd2+DJy;9^$ka!RZSf zls<+JgaG>q8Mq=-r(jNV@kHnCb3|)|ywhg>{h7ss4UWgFni^F$!b-~%ZImqa?{--b z_#%Lqc=ELc#|60;zYP00#`r`LGr$lAffyLq42LilxAct_`SXLFJaO}Q6YCFD8aDYCr^!2O zh$;;0+E=JpK-Sn-vm(ZT1)>mzijbK~rT`7_Xh+?%{cVpVdV1PzLsS%t!6j|UWoa{` zPpp;K9}&Tz8%GA+k$N-RaeTp6A62@%>T(bgFy_zUH>~YN+MzY@u{L^t+c9Wp&n23H!e|DBILoio{^3o}gElG9wyfeVQ(!V{EG6Cc~%e{M@*?9?&Hh zuOCqfw{ocP=R1mPvweL>h8G{oO6QByB&p|rv5Jf`BHc&v2k9@C)bFQHs}Ji-uX60! zSCdNVKC1m9|~>GHpkopE`7Lq6Ts0psS((ec~4M#eW*^eM#8-iJ8Yf3YI3vKy;RU1@BCov+ym zb@Fl-ozGVDRgXj(C@H!;O3-?ws`dRXlZX{r#J&DXX;*iFPZK>+Q!?~z%5q@;Cs5qO zHcRdFVs`svc=g`%=(nR*n)&qNMqg%+F*|RC1MryM(O0@rZZ1BymV7F`Dr{65S#JOe!0RnP*7bd1xVXZ7gtiYM^D_?cxa@?&>WL(nS*$JB2(zl&z9@>`l+_lr@9*&R)gCroQ+ZW-X_gAUvu%R>kHpM$IuV zq##-Ek&lY9VmF0_kD4{-ZP}q>&=k#GGB)3_U2Alkw&KBvZlj5}ub7|k{)CE#6X*F; zqrO!#M8HmLNj>ZQQg%9e@YFWmHuJ1LTRDW|BnuUCrm~YRk0Vz)$ywQ=6Dl%)lC?^S zS8C1E7I0d72`Du(iA2GsN>vv>!-aNS$Hr2GIXpuTpxoI+sR#WEgZ`;i7oSkTTj*TU zOM<3+L7%$)1IxHbZfBok$nxZUw@j=4TY4r?)}vB+4%*x&=!st}EW}pRc)ZJT=)2_j zop&-=HUu5QV>oOktx08+(g`wjl8j-ptg{rg{T-8G1*QqGR3qD?_N8SDPlt;3&jK2O zkWp^Jmx;hsaP08dh*r|%zk?{JE}Q9}wyt}qwqV+y2ejI7sCxTijeU6UM`m1qD*e%O zp}2Z`n+L$PPXFaZbM(` zQ3LS$UiO#Hj`PLj7KT>WooszwqLZnBhFdH@sXVUCcvcaEWh0o+;GgDz$f46yJtr#w;&n7TUbyp+EMJrAmdNaK>gt zvBNH(_tUm_$DPs}VbZC2X{D+b(%5j1!@V|i0!I$`e6VKfNUU}kR;MGPTY~-DtGIs5 zfmEfByrovJnK+3st9H5DDUIUgkB8r{_!}$rO_we>O@otLq3=r|Nv7H*gySw&hm{h7-?bYaDM`zE@cj9@R(#tbQ$misgiWr)W~wXGUqO`O_cp z`>orO1#be`L|2`>u=&dx&Nuw7+1o=c-T+5t=e7Nd=c6*%hsM;wsdUXZ0`YHh$UC$T zwJr3FKSHRdr(-$^h>omLrm&4IKjFoYwsXETAHnP#7Ku&DRZ*+mmXE+0i@T(tTlNqP z+#Q}{LzR*5v{rV9g;eOKJz#>#ISDu*S1RoCOm=P8!=OP7(!-*EjG0}qe8nGu6|h*) zmBcs|p(9T;H*ji>dt8tg%yMo!%E?h{eaKW(>ubp#d^i{3<_iEN|^hW9IFX&g9sOXB+aQVeMZUpJKvGOH0VHpIaa`=*Q9seb`w zQvQ`*{kOCZY7Kzs)los|xoxOx7sIqrY6Mo`^(>;ruaWK;Yu-OV1RUxv19;j!VO7D!*yvK z9N0!^{_TP*MMFaa(m`Ke@cRAzO0JcpJYP~(<~S!>2jFZ8Sg3Q0P7X@Q zv_-E$2)X?Q$Oix)DAvt{fZNQgSXMsQ)&0$qyV}SwgmZ{_+wkDEv{9hIB6V1 z_pw0|hV?K^px3f`^R3vUAu%i+Cv^g2>>FMEt}77)5qZ;%x&$MN z-p54GYLB591_C7D5cKp+|fDguj{s}U?&j^ zCNU(XysGlMHkqjtsgWxG8_ukhxr6ho#X#mTx&!csNLt8txx<;%F}3k zCKlc-v8+MZ&H30lz)B4%u{l&zH$g)Um5-LV*Gc0Oxy$&FcK9~B4Y?O<6-C4J!Ox9D z!R)b5@^T?ombi_S7|%OhzW<7m(Kh1kU@me$(Kbx7%>z=Ed z>zUzEy_=ko5nZzn$Li%<-6)>ErR|K-mYYq$f9&e52kVa0*A^t}ir zlH0R+%xn198}0C>nK_bsArAlJ#Z6Yy^)DJfFVbh4g3KA0eVD;uj%#ljATmNqqg87i z?Qi;(wk30Hu-GqZbaNS)ywK9nq64POa=$V>`lk#5Kn(!|kYq}*Q||Ap`*??wY6UlK zFs=#zd)a<8+1CFGDAoVOLfF4c>;J*({{Nzh^8jFEuC4|>%8r3w?p)9IP0+Jv8|8Q- Sb2Zn4rXZvGyj1FC(EkENCMV_q literal 0 HcmV?d00001 diff --git a/media/tether_demo.svg b/media/tether_demo.svg new file mode 100644 index 0000000..e3b7b71 --- /dev/null +++ b/media/tether_demo.svg @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + Tethered Object Transport (top view) + One winch, one UGV, one UAV, one tether. Slide the object across the floor, around the pillar, into the goal. + + + + + + + + x + y + + + + + GOAL + + + + + pillar + + + + + + + + straight pull jams + + + + 1 + + 2 + + + + + + + + + + then + fly here + + + + + + + + + + + + + W + Winch (fixed) + /winch/cmd_rate + + + + + + + + + + + G + UGV herover_1 + moving pulley on its deck + /herover_1/cmd_vel + + + + + + + + + + + A + UAV cf_1 + flying pulley + /cf_1/go_to + + + + + + O + Object + /tether/object_pose + + + d₁ = |W − G| + d₂ = |G − A| + l + + + + How the tether works + The object stays on the floor. + It can only be pulled, never lifted. + Tether paid out by the winch: L + l = L − |W−G| − |G−A| + free tether from the UAV to the object + • Slack: object stays put + • Taut: slides straight toward + the point under the UAV + • Pulled into the pillar: it jams. + Go around it in steps + + + + Your controls + 1. Drive the UGV + sets where the tether bends + 2. Fly the UAV + sets the pull direction + 3. Turn the winch + reel in (pull) / pay out (slack) + + Goal: object inside the + green circle + diff --git a/startup/_setup.sh b/startup/_setup.sh index 1635fec..414babd 100755 --- a/startup/_setup.sh +++ b/startup/_setup.sh @@ -12,7 +12,7 @@ # # Order: the argument, then an already-exported HERO_SIM_CONFIG, then # DEFAULT_CONFIG_NAME. Edit DEFAULT_CONFIG_NAME to change your day-to-day default. -DEFAULT_CONFIG_NAME="test_branch.yaml" +DEFAULT_CONFIG_NAME="test_perching_full2.yaml" _hero_sim_config_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../config" && pwd) From 1690a3aeebc4da9b28e071b1955da8cda13aeaeb Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:25:35 +0100 Subject: [PATCH 11/13] feat(obstacles): pillars make their own octomap; a scenario has a map or obstacles With obstacles:, octomap.launch.py writes the pillars as an octomap .bt (/hero_sim/_obstacles.bt, floor to each pillar's height, no ground plane) and serves it with octomap_server like a sim.map, so RViz shows the same occupied cells, projected map and topics. obstacles.write_bt is read_bt's inverse. Pillars take an optional height (default 2 m) for RViz and the octomap; collisions still ignore it. A scenario sets sim.map or obstacles:, not both: obstacles drop the default map, and setting both is an error. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++++---- config/example.yaml | 12 ++++--- hero_sim/config.py | 35 +++++++++++++++----- hero_sim/ground_robots.py | 12 +++---- hero_sim/obstacles.py | 69 ++++++++++++++++++++++++++++++++++++--- launch/octomap.launch.py | 29 ++++++++++++---- test/test_config.py | 16 +++++++-- test/test_nodes.py | 9 ++--- test/test_obstacles.py | 49 ++++++++++++++++++++++++++- 9 files changed, 204 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 8c5a78a..b5c1497 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,13 @@ ground_robots: crazyflies: # named cf_1, cf_2, ... in this order - pose: {x: 1.0, y: 0.0, z: 0.03} -obstacles: # optional pillars +obstacles: # optional pillars, on an empty floor - {x: 2.0, y: 1.0, radius: 0.3} - -sim: - map: null # no map: an empty floor + - {x: 3.0, y: -1.0, radius: 0.2, height: 0.5} ``` +A scenario's world is either a map (`sim: {map: ...}`, an octomap `.bt` file, `icuas26_1` by default) or `obstacles:`, not both. + ## Moving the robots Every robot's pose is on `//pose` (`geometry_msgs/PoseStamped`, in the `world` frame) and on TF. @@ -117,15 +117,17 @@ The low-level setpoint topics (`cmd_position`, `cmd_hover`, ...) work too. A robot stops when it would hit something, and moves again once you command it away. These block it: -- the map's trunks and posts, in scenarios with a `map:`; +- the map's trunks and posts; - pillars, from the scenario's `obstacles:` list; - other ground robots. -Drones are blocked at any height: they have to fly around a trunk or a pillar, not over it. Drones don't collide with ground robots or with each other. +Pillars show up in RViz the same way a map does, as occupied cells, and also as cylinders. `height:` sets how tall a pillar is drawn (2 m if left out). + +Drones are blocked at any height, even above a short pillar: they have to fly around a trunk or a pillar, not over it. Drones don't collide with ground robots or with each other. Each robot is a circle, and its size is its `radius:`, which you can set per robot (defaults: herover 0.17 m, scout 0.42 m, Crazyflie 0.1 m). To turn all collisions off, set `sim: {collision: false}`. -Your code can read the pillars from `/hero_sim/obstacles` (`visualization_msgs/MarkerArray`). There's one cylinder per pillar: `pose.position` is its center, and `scale.x` its diameter. +Your code can read the pillars from `/hero_sim/obstacles` (`visualization_msgs/MarkerArray`). There's one cylinder per pillar: `pose.position` is its center, `scale.x` its diameter and `scale.z` its height. ```bash ros2 topic echo --once /hero_sim/obstacles @@ -177,6 +179,7 @@ The `tether:` section of `config/tether/tether_demo.yaml` sets where the winch, | Problem | What to do | |---|---| | `config not found` | Check the filename: `ls ~/ros2_ws/src/hero_sim/config/*` | +| `sim.map and obstacles: are both set` | A scenario has a map or obstacles, not both: remove one | | `... is in more than one folder` | Two configs share a filename: name the folder (`mrs/my.yaml`) or rename one. After configs were moved, an old build can also cause it: `rm -rf ~/ros2_ws/build/hero_sim ~/ros2_ws/install/hero_sim`, then build again | | `Package 'hero_sim' not found` | Build ([Setup](#setup) step 2), then open a new terminal | | `ros2 launch` doesn't find a config you just added | Build again. `start.sh` finds new files without a rebuild | diff --git a/config/example.yaml b/config/example.yaml index 28ab6c7..0daef1d 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -48,7 +48,8 @@ sim: # only if hero_mrs_msgs is installed; true | false to force rviz: true # RViz with config/sim.rviz models: true # robot meshes in RViz (crazyflies, and ground robots with a model) - map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none + # map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none. + # Default icuas26_1, but not with obstacles: (below), as here collision: true # robots and the tether object stop where their footprint # would hit a map obstacle or a pillar (obstacles: below); # ground robots also stop at each other @@ -56,10 +57,13 @@ sim: # trunks and posts, not the floor, ceiling or tree canopies # Optional pillars (m): circles that block the robots (drones at any height) -# and the tether object while sim.collision is on, with or without a map. -# Drawn in RViz and published latched on /hero_sim/obstacles. +# and the tether object while sim.collision is on. They are the scenario's +# map: no sim.map with them (setting both is an error), and RViz shows them +# as an octomap like a .bt map's. Also drawn as cylinders and published +# latched on /hero_sim/obstacles. +# height: 2.0 how tall it is in RViz and the octomap (default 2.0); collisions ignore it obstacles: - - {x: 1.5, y: 0.5, radius: 0.35} + - {x: 1.5, y: 1.5, radius: 0.35, height: 2.0} # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", diff --git a/hero_sim/config.py b/hero_sim/config.py index b45bff7..6f3f601 100644 --- a/hero_sim/config.py +++ b/hero_sim/config.py @@ -5,7 +5,8 @@ ground_robots kinematic ground robots, one entry per robot crazyflies Crazyflies, named cf_1..cf_N in list order sim optional overrides of SIM_DEFAULTS - obstacles optional pillars (x, y, radius) that block robots and the tether object + obstacles optional pillars that block robots and the tether object; they make + their own octomap, so a scenario has obstacles or sim.map, not both tether optional winch, tether and object (hero_sim/tether.py) Unknown keys are rejected so typos fail loudly instead of being ignored. @@ -14,6 +15,7 @@ import math import os from pathlib import Path +from typing import NamedTuple import yaml @@ -48,6 +50,7 @@ 'scout': 0.42, } DEFAULT_GROUND_RADIUS = 0.3 +DEFAULT_PILLAR_HEIGHT = 2.0 # m # Height (m) a ground robot's pose reports, by model: on hardware the mocap # markers sit on top, so the pose is up there and not on the floor. Measured @@ -85,6 +88,14 @@ class Crazyflie: radius: float = FOOTPRINT_RADII['crazyflie'] # m, footprint for map collisions +class Pillar(NamedTuple): + """One entry of a scenario's obstacles: list, in m.""" + x: float + y: float + radius: float + height: float = DEFAULT_PILLAR_HEIGHT # in RViz and the octomap; collisions ignore it + + @dataclass(frozen=True) class Tether: """Winch -> pulley on a ground robot -> ring on a Crazyflie -> object on the floor.""" @@ -109,7 +120,7 @@ class Scenario: crazyflies: tuple sim: dict tether: Tether = None - obstacles: tuple = () # (x, y, radius) pillars (m), see hero_sim/obstacles.py + obstacles: tuple = () # Pillars, see hero_sim/obstacles.py def load(path): @@ -159,6 +170,11 @@ def parse(raw): sim['collision_z'] = (float(band[0]), float(band[1])) pillars = tuple(_pillar(i, o) for i, o in enumerate(_list(raw, 'obstacles'))) + if pillars: + if sim_raw.get('map'): + raise ValueError('sim.map and obstacles: are both set; use one or the other ' + '(the obstacles make their own map)') + sim['map'] = None # instead of the default map tether = (_tether(raw['tether'], ground, cfs, pillars) if raw.get('tether') is not None else None) return Scenario(ground, cfs, sim, tether, pillars) @@ -253,10 +269,12 @@ def _point(value, where, axes, **defaults): def _pillar(i, entry): where = f'obstacles[{i}]' - x, y, r = _point(entry, where, ('x', 'y', 'radius')) - if r <= 0.0: - raise ValueError(f'{where}.radius must be > 0, got {r}') - return x, y, r + pillar = Pillar(*_point(entry, where, ('x', 'y', 'radius', 'height'), + height=DEFAULT_PILLAR_HEIGHT)) + for key in ('radius', 'height'): + if getattr(pillar, key) <= 0.0: + raise ValueError(f'{where}.{key} must be > 0, got {getattr(pillar, key)}') + return pillar def _tether(entry, ground, cfs, pillars): @@ -289,8 +307,9 @@ def _tether(entry, ground, cfs, pillars): raise ValueError(f'{where}.slack must be >= 0, got {tether.slack}') if tether.goal[2] <= 0.0: raise ValueError(f'{where}.goal.radius must be > 0, got {tether.goal[2]}') - for i, (x, y, r) in enumerate(pillars): - if math.hypot(tether.object[0] - x, tether.object[1] - y) < r + tether.object_radius: + ox, oy = tether.object + for i, p in enumerate(pillars): + if math.hypot(ox - p.x, oy - p.y) < p.radius + tether.object_radius: raise ValueError(f'{where}.object starts inside obstacles[{i}]') return tether diff --git a/hero_sim/ground_robots.py b/hero_sim/ground_robots.py index 2663c40..396c75e 100644 --- a/hero_sim/ground_robots.py +++ b/hero_sim/ground_robots.py @@ -17,7 +17,8 @@ against the others' current positions, so they never end up overlapping. This node also draws the scenario's pillars (its obstacles: list), latched on -/hero_sim/obstacles: one CYLINDER per pillar, at its x, y, scale.x = diameter. +/hero_sim/obstacles: one CYLINDER per pillar, at its x, y, scale.x = diameter, +scale.z = height. (octomap.launch.py serves them as an octomap too.) """ import math @@ -34,7 +35,6 @@ FRAME = 'world' OBSTACLE_TOPIC = '/hero_sim/obstacles' -PILLAR_HEIGHT = 2.0 # m, drawn only: pillars block at any height class _Robot: @@ -108,17 +108,17 @@ def _publish_pillars(self, pillars): clear.header.frame_id = FRAME clear.action = Marker.DELETEALL markers = [clear] - for i, (x, y, radius) in enumerate(pillars): + for i, p in enumerate(pillars): # config.Pillar; the height is drawn only m = Marker() m.header.frame_id = FRAME m.ns = 'obstacles' m.id = i m.type = Marker.CYLINDER m.action = Marker.ADD - m.pose.position.x, m.pose.position.y, m.pose.position.z = x, y, PILLAR_HEIGHT / 2.0 + m.pose.position.x, m.pose.position.y, m.pose.position.z = p.x, p.y, p.height / 2.0 m.pose.orientation.w = 1.0 - m.scale.x = m.scale.y = 2.0 * radius - m.scale.z = PILLAR_HEIGHT + m.scale.x = m.scale.y = 2.0 * p.radius + m.scale.z = p.height m.color.r, m.color.g, m.color.b, m.color.a = 0.55, 0.45, 0.4, 1.0 markers.append(m) self._pillar_pub.publish(MarkerArray(markers=markers)) diff --git a/hero_sim/obstacles.py b/hero_sim/obstacles.py index 46da39b..6409288 100644 --- a/hero_sim/obstacles.py +++ b/hero_sim/obstacles.py @@ -8,7 +8,8 @@ slab, ceilings and the tree canopies the drones perch in, and keeps trunks, posts and walls. pillars the scenario's top-level obstacles: list, circles (x, y, radius) - kept exact rather than gridded. + kept exact rather than gridded. A scenario has these or a map, not + both. For RViz, write_pillar_map turns them into an octomap. Moving robots are circles too, passed to each move as `others` (hero_sim/ground_robots.py does that for the ground robots). @@ -23,6 +24,7 @@ nearest map obstacle, up to the largest radius it will be asked about. """ import math +from pathlib import Path import numpy as np @@ -87,11 +89,11 @@ class ObstacleMap: def __init__(self, occupied, x0, y0, res, max_radius, circles=()): """occupied: bool array [row = y, col = x] with cell (0, 0) at (x0, y0). - circles: pillars (x, y, radius), in m. + circles: pillars (x, y, radius[, height]), in m. Height is ignored. """ self.res = res self.max_radius = max_radius - self.circles = tuple(tuple(float(v) for v in c) for c in circles) + self.circles = tuple(tuple(float(v) for v in c[:3]) for c in circles) # drop any height # Pad by the farthest reach so robots just outside the obstacles' extent are covered. n = math.ceil(max_radius / res + 0.5) self.x0, self.y0 = x0 - n * res, y0 - n * res @@ -160,10 +162,69 @@ def move(self, x0, y0, x1, y1, radius, others=()): return x1, y1 +def pillar_voxels(pillars, res=PILLAR_RES): + """Octomap keys (kx, ky, kz) of the voxels that fill config.Pillars. + + A voxel is in a pillar when its center is inside the pillar's circle, from + the floor (z = 0, no ground plane) up to the pillar's height. + """ + keys = set() + for x, y, r, height in pillars: + layers = range(max(1, round(height / res))) + for kx in range(math.floor((x - r) / res), math.floor((x + r) / res) + 1): + for ky in range(math.floor((y - r) / res), math.floor((y + r) / res) + 1): + if math.hypot((kx + 0.5) * res - x, (ky + 0.5) * res - y) <= r: + keys.update((kx + _KEY_ORIGIN, ky + _KEY_ORIGIN, kz + _KEY_ORIGIN) + for kz in layers) + return keys + + +def write_bt(path, voxels, res): + """Write occupied voxels (octomap keys) as an octomap .bt file; read_bt reads it back. + + Every voxel is a leaf at full depth (nothing pruned), the rest is unknown. + """ + if not voxels: + raise ValueError('no voxels to write') + data = bytearray() + nodes = 1 # the root; the header's size must match what octomap reads + + def write_node(keys, kx, ky, kz, size): + # The read_node encoding: 2 bits per child, 10 occupied leaf, 11 inner node. + nonlocal nodes + half = size // 2 + children = [[] for _ in range(8)] + for key in keys: + i = (key[0] >= kx + half) | (key[1] >= ky + half) << 1 | (key[2] >= kz + half) << 2 + children[i].append(key) + kind = 2 if half == 1 else 3 + bits = sum(kind << 2 * i for i, child in enumerate(children) if child) + data.extend((bits & 0xFF, bits >> 8)) + nodes += sum(1 for child in children if child) + if kind == 3: + for i, child in enumerate(children): + if child: + write_node(child, kx + half * (i & 1), ky + half * (i >> 1 & 1), + kz + half * (i >> 2), half) + + write_node(sorted(voxels), 0, 0, 0, 1 << _TREE_DEPTH) + with open(path, 'wb') as f: + f.write(b'# Octomap OcTree binary file\n' + b'# written by hero_sim from a scenario\'s obstacles\n' + + f'id OcTree\nsize {nodes}\nres {res}\ndata\n'.encode() + bytes(data)) + + +def write_pillar_map(path, pillars, res=PILLAR_RES): + """Write config.Pillars as an octomap .bt at `path` (dirs made as needed). Returns path.""" + Path(path).parent.mkdir(parents=True, exist_ok=True) + write_bt(path, pillar_voxels(pillars, res), res) + return path + + def load(sim, radii, pillars=()): """The scenario's ObstacleMap for robots of these radii, or None when collisions are off. - pillars: the scenario's obstacles, (x, y, radius) each. None when + pillars: the scenario's obstacles (config.Pillar). None when sim.collision is off or there are no robots. Without a map or pillars the map is empty, but still checks the `others` passed to move(). """ diff --git a/launch/octomap.launch.py b/launch/octomap.launch.py index aeba38e..29e57cf 100644 --- a/launch/octomap.launch.py +++ b/launch/octomap.launch.py @@ -1,25 +1,40 @@ -"""octomap_server for the scenario's sim.map (maps/.bt or a path; null disables it). +"""octomap_server for the scenario's map: its obstacles, or else its sim.map. ros2 launch hero_sim octomap.launch.py [config:=] + +A scenario with obstacles: gets an octomap of its pillars, written to +/hero_sim/_obstacles.bt; otherwise sim.map is served +(maps/.bt or a path; null: no octomap). Either way RViz shows it the same. """ +from pathlib import Path +import tempfile + from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, LogInfo, OpaqueFunction from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node -from hero_sim import config +from hero_sim import config, obstacles def _actions(context): config_path = config.resolve_config(LaunchConfiguration('config').perform(context)) - bt_file = config.resolve_map(config.load(config_path).sim['map']) - if bt_file is None: - return [LogInfo(msg='[hero_sim] sim.map is null: no octomap')] + scenario = config.load(config_path) + if scenario.obstacles: + bt_file = obstacles.write_pillar_map( + Path(tempfile.gettempdir()) / 'hero_sim' / f'{Path(config_path).stem}_obstacles.bt', + scenario.obstacles) + source = f'{len(scenario.obstacles)} pillar(s) from obstacles:, written to {bt_file}' + else: + bt_file = config.resolve_map(scenario.sim['map']) + if bt_file is None: + return [LogInfo(msg='[hero_sim] no obstacles: and sim.map is null: no octomap')] + source = bt_file return [ - LogInfo(msg=f'[hero_sim] octomap: {bt_file}'), + LogInfo(msg=f'[hero_sim] octomap: {source}'), Node(package='octomap_server', executable='octomap_server_node', name='octomap_server_node', output='screen', - parameters=[{'octomap_path': bt_file, 'frame_id': 'world'}]), + parameters=[{'octomap_path': str(bt_file), 'frame_id': 'world'}]), ] diff --git a/test/test_config.py b/test/test_config.py index bd6f9eb..04fd85c 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -83,8 +83,15 @@ def test_crazyflie_gripper_is_optional(): def test_obstacles_are_optional_and_parsed(): assert parse('{}').obstacles == () - s = parse('obstacles: [{x: 1, y: 2, radius: 0.5}, {x: -1, y: 0, radius: 0.1}]') - assert s.obstacles == ((1.0, 2.0, 0.5), (-1.0, 0.0, 0.1)) + s = parse('obstacles: [{x: 1, y: 2, radius: 0.5}, {x: -1, y: 0, radius: 0.1, height: 0.4}]') + assert s.obstacles == ((1.0, 2.0, 0.5, config.DEFAULT_PILLAR_HEIGHT), (-1.0, 0.0, 0.1, 0.4)) + assert s.obstacles[1].height == 0.4 + + +def test_obstacles_replace_the_default_map(): + assert parse('{}').sim['map'] == config.SIM_DEFAULTS['map'] + assert parse('obstacles: [{x: 1, y: 2, radius: 0.5}]').sim['map'] is None + assert parse('obstacles: [{x: 1, y: 2, radius: 0.5}]\nsim: {map: null}').sim['map'] is None def test_drive_is_case_insensitive(): @@ -119,6 +126,9 @@ def test_drive_is_case_insensitive(): ('obstacles: [{x: 0, y: 0, radius: 1, z: 2}]', r"obstacles\[0\]: unknown key.*'z'"), ('obstacles: [{x: 0, y: 0, radius: 1}, {x: 0, y: 0, radius: 0}]', r'obstacles\[1\].radius must be > 0'), + ('obstacles: [{x: 0, y: 0, radius: 1, height: 0}]', r'obstacles\[0\].height must be > 0'), + ('obstacles: [{x: 0, y: 0, radius: 1}]\nsim: {map: icuas26_1}', + 'sim.map and obstacles: are both set'), ]) def test_rejects_bad_config(text, match): with pytest.raises(ValueError, match=match): @@ -260,7 +270,7 @@ def test_rejects_bad_tether(extra, match): def test_tether_object_must_start_clear_of_pillars(): clear = TETHER_ROBOTS + 'obstacles: [{x: 1.4, y: 2, radius: 0.2}]\n' - assert parse(clear + f'tether: {TETHER_MIN}}}').obstacles == ((1.4, 2.0, 0.2),) + assert parse(clear + f'tether: {TETHER_MIN}}}').obstacles == ((1.4, 2.0, 0.2, 2.0),) inside = TETHER_ROBOTS + 'obstacles: [{x: 0, y: 0, radius: 0.1}, {x: 1.3, y: 2, radius: 0.2}]' with pytest.raises(ValueError, match=r'tether.object starts inside obstacles\[1\]'): parse(inside + f'\ntether: {TETHER_MIN}}}') diff --git a/test/test_nodes.py b/test/test_nodes.py index bef0209..f9b59c6 100644 --- a/test/test_nodes.py +++ b/test/test_nodes.py @@ -145,7 +145,7 @@ def test_ground_robots_stop_at_map_obstacles(make, scenario_file): - pose: {x: 0.0, y: 1.0, z: 1.0} obstacles: - {x: 1.0, y: 0.0, radius: 0.2} - - {x: 1.0, y: 1.0, radius: 0.2} + - {x: 1.0, y: 1.0, radius: 0.2, height: 0.5} sim: {map: null, ground_rate: 10.0, cf_rate: 10.0} """ @@ -174,7 +174,8 @@ def test_crazyflies_stop_at_pillars_at_any_height(make, scenario_file): cf._go_to_cb(req, GoTo.Response()) for _ in range(20): server._update_all() - assert 0.7 - 0.05 <= cf.position[0] <= 0.7 # pillar edge at 0.8, minus 0.1 + # Pillar edge at 0.8, minus 0.1: the pillar is 0.5 m tall, but blocks at any height. + assert 0.7 - 0.05 <= cf.position[0] <= 0.7 assert cf.position[2] == pytest.approx(1.5) # height still goes through @@ -381,7 +382,7 @@ def _latched(make, topic, publisher_cls): def test_pillars_drawn_latched_even_with_collision_off(make, scenario_file): from visualization_msgs.msg import Marker - from hero_sim.ground_robots import OBSTACLE_TOPIC, PILLAR_HEIGHT, GroundRobotSim + from hero_sim.ground_robots import OBSTACLE_TOPIC, GroundRobotSim scenario_file(PILLAR_SCENARIO.replace('map: null', 'map: null, collision: false')) markers = _latched(make, OBSTACLE_TOPIC, GroundRobotSim).markers @@ -391,7 +392,7 @@ def test_pillars_drawn_latched_even_with_collision_off(make, scenario_file): ('obstacles', 0, Marker.CYLINDER, 'world'), ('obstacles', 1, Marker.CYLINDER, 'world')] assert [(m.pose.position.x, m.pose.position.y) for m in pillars] == [(1.0, 0.0), (1.0, 1.0)] assert all(m.scale.x == m.scale.y == pytest.approx(0.4) for m in pillars) - assert all(m.scale.z == PILLAR_HEIGHT for m in pillars) + assert [(m.scale.z, m.pose.position.z) for m in pillars] == [(2.0, 1.0), (0.5, 0.25)] def test_robot_models_latched_and_frame_locked(make): diff --git a/test/test_obstacles.py b/test/test_obstacles.py index c4199f6..ab2cbee 100644 --- a/test/test_obstacles.py +++ b/test/test_obstacles.py @@ -5,7 +5,11 @@ from conftest import CONFIG_DIR, wall_map from hero_sim import config -from hero_sim.obstacles import PILLAR_RES, ObstacleMap, load, read_bt +from hero_sim.config import Pillar +from hero_sim.obstacles import ( + _KEY_ORIGIN, PILLAR_RES, ObstacleMap, load, pillar_voxels, read_bt, write_bt, + write_pillar_map, +) MAPS = sorted((CONFIG_DIR.parent / 'maps').glob('*.bt')) ICUAS26_1 = CONFIG_DIR.parent / 'maps' / 'icuas26_1.bt' @@ -162,3 +166,46 @@ def test_load_pillars_with_and_without_map(): both = load(dict(sim, map=str(ICUAS26_1)), [0.1], [(0.0, 0.0, 0.2)]) assert both.clearance(0.0, 0.0) == 0.0 assert both.clearance(*TRUNK) == 0.0 + + +def bt_header(path): + head = path.read_bytes().split(b'data\n')[0].decode() + return dict(line.split(None, 1) for line in head.splitlines() if not line.startswith('#')) + + +def test_write_bt_one_voxel(tmp_path): + path = tmp_path / 'one.bt' + write_bt(path, {(_KEY_ORIGIN, _KEY_ORIGIN + 1, _KEY_ORIGIN + 2)}, 0.05) + # octomap checks the node count: the root plus one node on each of 16 levels. + assert bt_header(path) == {'id': 'OcTree', 'size': '17', 'res': '0.05'} + res, boxes = read_bt(path) + assert res == 0.05 + assert boxes.tolist() == [[_KEY_ORIGIN, _KEY_ORIGIN + 1, _KEY_ORIGIN + 2, 1]] + with pytest.raises(ValueError, match='no voxels'): + write_bt(path, set(), 0.05) + + +def test_pillar_voxels_fill_the_circle_from_the_floor_up(): + voxels = pillar_voxels([Pillar(1.0, -0.5, 0.2, height=0.5)], res=0.05) + assert all(math.hypot((kx - _KEY_ORIGIN + 0.5) * 0.05 - 1.0, + (ky - _KEY_ORIGIN + 0.5) * 0.05 + 0.5) <= 0.2 for kx, ky, _ in voxels) + assert sorted({kz - _KEY_ORIGIN for _, _, kz in voxels}) == list(range(10)) # 0 to 0.5 m + columns = len(voxels) // 10 + assert columns == pytest.approx(math.pi * 0.2 ** 2 / 0.05 ** 2, rel=0.1) + + +def test_pillar_map_reads_back_as_the_same_obstacle(tmp_path): + pillars = [Pillar(1.5, 0.5, 0.35), Pillar(-1.0, 2.0, 0.2, height=1.0)] + path = write_pillar_map(tmp_path / 'new' / 'p.bt', pillars) + res, boxes = read_bt(path) + assert {tuple(b[:3]) for b in boxes.tolist()} == pillar_voxels(pillars) + flat = ObstacleMap.from_bt(path, 0.2, 2.0, max_radius=0.5) + exact = ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, 0.5, pillars) + for x, y in ((1.5, 0.5), (-1.0, 2.0), (1.5 + 0.45, 0.5), (-1.0, 2.0 - 0.3), (0.0, 0.0)): + assert flat.clearance(x, y) == pytest.approx(exact.clearance(x, y), abs=res) + + +def test_circles_ignore_a_pillar_height(): + a = ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, 0.5, [(1.0, 0.0, 0.2)]) + b = ObstacleMap(np.zeros((0, 0), bool), 0.0, 0.0, PILLAR_RES, 0.5, [Pillar(1.0, 0.0, 0.2, 0.1)]) + assert a.circles == b.circles == ((1.0, 0.0, 0.2),) From b59aff3620b3e2d3ce6e20a0c11995a2cfd58912 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:39:10 +0100 Subject: [PATCH 12/13] docs(readme): Maps section; RViz gets the octomap whenever it starts README: one Maps section for the two kinds of world, a .bt map or obstacles:, what each looks like in RViz and where the pillar map file goes, instead of the scattered mentions. sim.rviz: the occupied-cells displays subscribe Transient Local, as octomap_server publishes the map once, latched. RViz started after the octomap now still draws it, so session.yml needs no sleep before it. example.yaml: the pillar commented out again, so the example uses the default map. Co-Authored-By: Claude Opus 5.5 --- README.md | 26 +++++++++++++++++++++----- config/example.yaml | 6 +++--- config/sim.rviz | 4 ++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b5c1497..591ad66 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,30 @@ ground_robots: crazyflies: # named cf_1, cf_2, ... in this order - pose: {x: 1.0, y: 0.0, z: 0.03} -obstacles: # optional pillars, on an empty floor +obstacles: # pillars instead of a map, see Maps below - {x: 2.0, y: 1.0, radius: 0.3} - - {x: 3.0, y: -1.0, radius: 0.2, height: 0.5} ``` -A scenario's world is either a map (`sim: {map: ...}`, an octomap `.bt` file, `icuas26_1` by default) or `obstacles:`, not both. +## Maps + +What's in the world comes from one of two places: + +| | Set it with | In RViz | +|---|---|---| +| A map | `sim: {map: icuas26_1}`: a map from `maps/`, or the path to any octomap `.bt` file. `icuas26_1` is the default | occupied cells | +| Pillars | an `obstacles:` list, on an empty floor | occupied cells, plus a cylinder for each pillar | + +A scenario uses one or the other: with `obstacles:` there's no map, and setting both is an error. For an empty floor with nothing on it, set `sim: {map: null}` and leave out `obstacles:`. + +```yaml +obstacles: # m + - {x: 2.0, y: 1.0, radius: 0.3} + - {x: 3.0, y: -1.0, radius: 0.2, height: 0.5} # height: how tall it is (2 m if left out) +``` + +The pillars are turned into an octomap, written to `/tmp/hero_sim/_obstacles.bt`, and served just like a `.bt` map, with the same topics (`/octomap_binary`, `/projected_map`, ...). So RViz, and any code that reads the map, can't tell them apart. To keep a pillar map, copy that file and use it as `sim: {map: path/to/file.bt}` in another scenario. + +The map is drawn when you start with `start.sh`. Both kinds block the robots, see [Obstacles](#obstacles). ## Moving the robots @@ -121,8 +139,6 @@ A robot stops when it would hit something, and moves again once you command it a - pillars, from the scenario's `obstacles:` list; - other ground robots. -Pillars show up in RViz the same way a map does, as occupied cells, and also as cylinders. `height:` sets how tall a pillar is drawn (2 m if left out). - Drones are blocked at any height, even above a short pillar: they have to fly around a trunk or a pillar, not over it. Drones don't collide with ground robots or with each other. Each robot is a circle, and its size is its `radius:`, which you can set per robot (defaults: herover 0.17 m, scout 0.42 m, Crazyflie 0.1 m). To turn all collisions off, set `sim: {collision: false}`. diff --git a/config/example.yaml b/config/example.yaml index 0daef1d..b2a58a6 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -49,7 +49,7 @@ sim: rviz: true # RViz with config/sim.rviz models: true # robot meshes in RViz (crazyflies, and ground robots with a model) # map: icuas26_1 # octomap: maps/.bt, a path to a .bt, or null for none. - # Default icuas26_1, but not with obstacles: (below), as here + # Default icuas26_1; not together with obstacles: (below) collision: true # robots and the tether object stop where their footprint # would hit a map obstacle or a pillar (obstacles: below); # ground robots also stop at each other @@ -62,8 +62,8 @@ sim: # as an octomap like a .bt map's. Also drawn as cylinders and published # latched on /hero_sim/obstacles. # height: 2.0 how tall it is in RViz and the octomap (default 2.0); collisions ignore it -obstacles: - - {x: 1.5, y: 1.5, radius: 0.35, height: 2.0} +# obstacles: +# - {x: 1.5, y: 1.5, radius: 0.35, height: 2.0} # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", diff --git a/config/sim.rviz b/config/sim.rviz index 09d4400..c3250a4 100644 --- a/config/sim.rviz +++ b/config/sim.rviz @@ -215,7 +215,7 @@ Visualization Manager: {} Topic: Depth: 5 - Durability Policy: Volatile + Durability Policy: Transient Local History Policy: Keep Last Reliability Policy: Reliable Value: /occupied_cells_vis_array @@ -520,7 +520,7 @@ Visualization Manager: {} Topic: Depth: 5 - Durability Policy: Volatile + Durability Policy: Transient Local History Policy: Keep Last Reliability Policy: Reliable Value: /occupied_cells_vis_array From 121a126ffdf2c4a43bdfca02089ad3eb15735355 Mon Sep 17 00:00:00 2001 From: KhAlamdar11 <75181406+KhAlamdar11@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:41:20 +0100 Subject: [PATCH 13/13] config: example with its pillar; RViz shows pillars as octomap cells only The example scenario uses its pillar (no map) again. sim.rviz hides the pillar cylinders by default, which showed through the octomap cells; the Obstacles display can still be ticked on. Co-Authored-By: Claude Opus 5.5 --- config/example.yaml | 4 +-- config/sim.rviz | 60 ++++++--------------------------------------- 2 files changed, 10 insertions(+), 54 deletions(-) diff --git a/config/example.yaml b/config/example.yaml index b2a58a6..24638e8 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -62,8 +62,8 @@ sim: # as an octomap like a .bt map's. Also drawn as cylinders and published # latched on /hero_sim/obstacles. # height: 2.0 how tall it is in RViz and the octomap (default 2.0); collisions ignore it -# obstacles: -# - {x: 1.5, y: 1.5, radius: 0.35, height: 2.0} +obstacles: + - {x: 1.5, y: 1.5, radius: 0.35, height: 2.0} # Optional: a winch drags an object across the floor with a tether through a # pulley on a ground robot and a ring on a Crazyflie (README "Tether task", diff --git a/config/sim.rviz b/config/sim.rviz index c3250a4..66480f8 100644 --- a/config/sim.rviz +++ b/config/sim.rviz @@ -8,6 +8,7 @@ Panels: - /Grid1 - /Axes1 - /TF1 + - /Obstacles1 - /MarkerArray2 Splitter Ratio: 0.5 Tree Height: 1285 @@ -64,32 +65,10 @@ Visualization Manager: All Enabled: true cf_1: Value: true - cf_10: - Value: true - cf_11: - Value: true - cf_12: - Value: true cf_2: Value: true - cf_3: - Value: true - cf_4: - Value: true - cf_5: - Value: true - cf_6: - Value: true - cf_7: - Value: true - cf_8: - Value: true - cf_9: - Value: true herover_1: Value: true - herover_2: - Value: true scout_1: Value: true world: @@ -103,32 +82,10 @@ Visualization Manager: world: cf_1: {} - cf_10: - {} - cf_11: - {} - cf_12: - {} cf_2: {} - cf_3: - {} - cf_4: - {} - cf_5: - {} - cf_6: - {} - cf_7: - {} - cf_8: - {} - cf_9: - {} herover_1: {} - herover_2: - {} scout_1: {} Update Interval: 0 @@ -152,7 +109,6 @@ Visualization Manager: Name: Grippers Namespaces: gripper: true - gripper_left: true Topic: Depth: 1 Durability Policy: Transient Local @@ -173,22 +129,22 @@ Visualization Manager: Value: /hero_sim/tether Value: true - Class: rviz_default_plugins/MarkerArray - Enabled: true + Enabled: false Name: Obstacles Namespaces: - obstacles: true + {} Topic: Depth: 1 Durability Policy: Transient Local History Policy: Keep Last Reliability Policy: Reliable Value: /hero_sim/obstacles - Value: true + Value: false - Class: rviz_default_plugins/MarkerArray Enabled: true Name: MarkerArray Namespaces: - perching_positions: true + {} Topic: Depth: 5 Durability Policy: Volatile @@ -200,7 +156,7 @@ Visualization Manager: Enabled: true Name: Docks Namespaces: - charging_docks: true + {} Topic: Depth: 5 Durability Policy: Volatile @@ -212,7 +168,7 @@ Visualization Manager: Enabled: true Name: MarkerArray Namespaces: - {} + map: true Topic: Depth: 5 Durability Policy: Transient Local @@ -224,7 +180,7 @@ Visualization Manager: Enabled: true Name: Marker Namespaces: - comm_edges: true + {} Topic: Depth: 5 Durability Policy: Volatile