From 65e32435567655ca1644fe500303d6088f45fd53 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Wed, 11 Feb 2026 14:00:20 +0000 Subject: [PATCH 01/13] dotbot/examples: added minimum naming game --- .../minimum_naming_game/controller.py | 212 ++++++++++++ .../minimum_naming_game/gen_init_pos.py | 48 +++ .../minimum_naming_game/init_state.toml | 149 +++++++++ .../minimum_naming_game.py | 151 +++++++++ .../minimum_naming_game/models/E1.xml | 12 + .../minimum_naming_game/models/E2.xml | 11 + .../minimum_naming_game/models/G1.xml | 8 + .../minimum_naming_game/models/G2.xml | 8 + .../minimum_naming_game/models/G3.xml | 8 + .../minimum_naming_game/models/G4.xml | 11 + .../minimum_naming_game/models/Sloc1.xml | 12 + .../minimum_naming_game/models/Sloc2.xml | 14 + .../minimum_naming_game/models/script.txt | 8 + .../models/supervisor.yaml | 11 + .../controller.py | 231 +++++++++++++ .../init_state.toml | 53 +++ .../minimum_naming_game_with_motion.py | 187 +++++++++++ .../walk_avoid.py | 76 +++++ dotbot/examples/sct.py | 314 ++++++++++++++++++ 19 files changed, 1524 insertions(+) create mode 100644 dotbot/examples/minimum_naming_game/controller.py create mode 100644 dotbot/examples/minimum_naming_game/gen_init_pos.py create mode 100644 dotbot/examples/minimum_naming_game/init_state.toml create mode 100644 dotbot/examples/minimum_naming_game/minimum_naming_game.py create mode 100644 dotbot/examples/minimum_naming_game/models/E1.xml create mode 100644 dotbot/examples/minimum_naming_game/models/E2.xml create mode 100644 dotbot/examples/minimum_naming_game/models/G1.xml create mode 100644 dotbot/examples/minimum_naming_game/models/G2.xml create mode 100644 dotbot/examples/minimum_naming_game/models/G3.xml create mode 100644 dotbot/examples/minimum_naming_game/models/G4.xml create mode 100644 dotbot/examples/minimum_naming_game/models/Sloc1.xml create mode 100644 dotbot/examples/minimum_naming_game/models/Sloc2.xml create mode 100644 dotbot/examples/minimum_naming_game/models/script.txt create mode 100644 dotbot/examples/minimum_naming_game/models/supervisor.yaml create mode 100644 dotbot/examples/minimum_naming_game_with_motion/controller.py create mode 100644 dotbot/examples/minimum_naming_game_with_motion/init_state.toml create mode 100644 dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py create mode 100644 dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py create mode 100644 dotbot/examples/sct.py diff --git a/dotbot/examples/minimum_naming_game/controller.py b/dotbot/examples/minimum_naming_game/controller.py new file mode 100644 index 0000000..cb5e467 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/controller.py @@ -0,0 +1,212 @@ +import random +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, +) +from dotbot.examples.sct import SCT + +DISTINCT_COLORS = [ + (255, 0, 0), # Red + (0, 255, 0), # Lime + (0, 0, 255), # Blue + (255, 255, 0), # Yellow + (255, 0, 255), # Magenta + (0, 255, 255), # Cyan + (255, 165, 0), # Orange + (128, 0, 255), # Violet +] + + +class Controller: + def __init__(self, address: str, path: str): + self.address = address + + self.position = DotBotLH2Position(x=0.0, y=0.0, z=0.0) # initial position + self.direction = 0.0 # initial orientation + + self.neighbors: list[DotBotModel] = [] # initial empty neighbor list + self.vector = [0.0, 0.0] # initial movement vector + + # SCT initialization + self.sct = SCT(path) + self.add_callbacks() + + self.led = (0, 0, 0) # initial LED color + + # --- Naming Game Variables --- + self.counter = 0 # FOR DEBUGGING + self.last_broadcast_ticks = 0 # Tracks timing + self.max_broadcast_ticks = 5 + + # Pre-defined words (e.g., num_words = 128) + self.num_words = 8 + self.words = list(range(self.num_words)) + + # Word reception state + self.received_word = None + # self.received_word_checked = True + self.new_word_received = False + + # Global variable for the word chosen for transmission + self.w_index = 0 + + # Inventory of known words + self.inventory = set() + + + def control_step(self): + + self.counter += 1 # Increment step counter + + # Run SCT control step + self.sct.run_step() + + self.color_code() # Update LED color based on inventory state + + + # Register callback functions to the generator player + def add_callbacks(self): + + # Automatic addition of callbacks + # 1. Get list of events and list specifying whether an event is controllable or not. + # 2. For each event, check controllable or not and add callback. + + events, controllability_list = self.sct.get_events() + + for event, index in events.items(): + is_controllable = controllability_list[index] + stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + + if is_controllable: # Add controllable event + func_name = '_callback_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, func, None, None) + else: # Add uncontrollable event + func_name = '_check_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, None, func, None) + + + # Callback functions (controllable events) + def _callback_startTimer(self, data: any): + """ + Saves the current tick count to mark the start of the broadcast interval. + """ + # print(f'DotBot {self.address}. ACTION: startTimer') + self.last_broadcast_ticks = self.counter + + + def _callback_selectAndBroadcast(self, data: any): + """ + Selects a random word from the inventory, or invents a new one + if the inventory is empty. Sets the flag for transmission. + """ + # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") + + # Select or Invent a word + if not self.inventory: + # Inventory is empty: invent a new word from the pool + self.w_index = random.randrange(self.num_words) + # Store the word (equivalent to inventory[0] = words[w_index].data[0]) + self.inventory.add(self.words[self.w_index]) + else: + # Inventory is not empty: pick a random word from current known words + self.w_index = random.choice(list(self.inventory)) + + # Set broadcast flag for transmission + self.broadcast_word = True + + # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') + + + def _callback_updateInventory(self, data: any): + """ + Updates the inventory based on the last received word. + If the word is known, the agent reaches a local consensus (inventory collapses). + If unknown, the word is added to the agent's vocabulary. + """ + # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") + + # Ensure we have a word to process + if self.received_word is None: + return + + # Check if the received word is within the inventory + if self.received_word in self.inventory: + # SUCCESS: word is known. + # Remove all other words (collapse inventory to just this one) + self.inventory = {self.received_word} + # print(f' removed all other words, inventory now: {self.inventory}') + else: + # FAILURE: word is unknown. + # Insert it into the inventory + self.inventory.add(self.received_word) + # print(f' added word {self.received_word}, inventory now: {self.inventory}') + + # Mark as checked + self.received_word_checked = True + + + # Callback functions (uncontrollable events) + def _check__selectAndBroadcast(self, data: any) -> bool: + """ + Checks if a new word has been received. + Returns True (1) if a word is waiting to be processed, otherwise False (0). + """ + if self.new_word_received: + # Reset the flag + self.new_word_received = False + return True + + return False + + + def _check_timeout(self, data: any) -> bool: + """ + Checks if the broadcast timer has expired. + Returns True if the current counter exceeds the last broadcast time + plus the defined interval. + """ + if self.counter > (self.last_broadcast_ticks + self.max_broadcast_ticks): + return True + + return False + + + def color_code(self): + """ + Updates the LED color based on the inventory state. + - If the robot has not reached consensus (inventory size != 1), the LED is OFF. + - If consensus is reached, the word is mapped to a specific RGB color. + """ + # 1. Check if the inventory has reached consensus (size exactly 1) + if len(self.inventory) != 1: + self.led = (0, 0, 0) # Turn LED off + return + + # 2. Extract the single word known to the agent + word = list(self.inventory)[0] + + # ------ ORIGINAL ------ + # # 3. Calculate RGB components using the original base-4 logic + # # Mapping word index (0-127) to a color space (1-64) + # color = (word % 63) + 1 + + # r = color // 16 + # rem1 = color % 16 + # g = rem1 // 4 + # b = rem1 % 4 + + # # 4. Update the LED state + # # Note: Original Kilobot RGB values are 0-3. + # # convert to range 0-255. + # self.led = (r * 85, g * 85, b * 85) + # ------------------------ + + # ------ NEW SIMPLIFIED COLOR CODING ------ + # Map the word to an index (0-7) + color_idx = word % len(DISTINCT_COLORS) + + # Assign the high-contrast color + self.led = DISTINCT_COLORS[color_idx] + # ----------------------------------------- \ No newline at end of file diff --git a/dotbot/examples/minimum_naming_game/gen_init_pos.py b/dotbot/examples/minimum_naming_game/gen_init_pos.py new file mode 100644 index 0000000..617cf36 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/gen_init_pos.py @@ -0,0 +1,48 @@ +import random +import math +from pathlib import Path + +def format_with_underscores(value): + """Formats an integer with underscores every three digits.""" + return f"{value:_}" + +def generate_lattice_toml(width_count, height_count, sep_x, sep_y, start_x=120, start_y=120): + output_lines = [] + + for row in range(height_count): + for col in range(width_count): + bot_id = row * width_count + col + 1 + address = f"AAAAAAAA{bot_id:08X}" + + # Calculate positions + pos_x = start_x + (col * sep_x) + pos_y = start_y + (row * sep_y) + + # Randomize theta between 0 and 2*pi + random_theta = round(random.uniform(0, 2 * math.pi), 2) + + # Manually build the TOML entry string to preserve underscores + output_lines.append("[[dotbots]]") + output_lines.append(f'address = "{address}"') + output_lines.append(f'pos_x = {pos_x:_}') + output_lines.append(f'pos_y = {pos_y:_}') + output_lines.append(f"theta = {random_theta}") + output_lines.append("") # Empty line for readability + + return "\n".join(output_lines) + +# --- Configuration --- +WIDTH_NODES = 5 # Robots per row +HEIGHT_NODES = 5 # Number of rows +SEP_X = 240 # Separation between columns +SEP_Y = 240 # Separation between rows + +# Generate +toml_string = generate_lattice_toml(WIDTH_NODES, HEIGHT_NODES, SEP_X, SEP_Y) + +# Save to file +output_path = Path(__file__).resolve().parent / "init_state.toml" +with open(output_path, "w") as f: + f.write(toml_string) + +print(f"Generated TOML file at {output_path}") \ No newline at end of file diff --git a/dotbot/examples/minimum_naming_game/init_state.toml b/dotbot/examples/minimum_naming_game/init_state.toml new file mode 100644 index 0000000..c2913fb --- /dev/null +++ b/dotbot/examples/minimum_naming_game/init_state.toml @@ -0,0 +1,149 @@ +[[dotbots]] +address = "AAAAAAAA00000001" +pos_x = 120 +pos_y = 120 +theta = 4.38 + +[[dotbots]] +address = "AAAAAAAA00000002" +pos_x = 360 +pos_y = 120 +theta = 0.56 + +[[dotbots]] +address = "AAAAAAAA00000003" +pos_x = 600 +pos_y = 120 +theta = 2.12 + +[[dotbots]] +address = "AAAAAAAA00000004" +pos_x = 840 +pos_y = 120 +theta = 2.37 + +[[dotbots]] +address = "AAAAAAAA00000005" +pos_x = 1_080 +pos_y = 120 +theta = 0.69 + +[[dotbots]] +address = "AAAAAAAA00000006" +pos_x = 120 +pos_y = 360 +theta = 5.21 + +[[dotbots]] +address = "AAAAAAAA00000007" +pos_x = 360 +pos_y = 360 +theta = 5.09 + +[[dotbots]] +address = "AAAAAAAA00000008" +pos_x = 600 +pos_y = 360 +theta = 3.86 + +[[dotbots]] +address = "AAAAAAAA00000009" +pos_x = 840 +pos_y = 360 +theta = 2.66 + +[[dotbots]] +address = "AAAAAAAA0000000A" +pos_x = 1_080 +pos_y = 360 +theta = 3.44 + +[[dotbots]] +address = "AAAAAAAA0000000B" +pos_x = 120 +pos_y = 600 +theta = 2.64 + +[[dotbots]] +address = "AAAAAAAA0000000C" +pos_x = 360 +pos_y = 600 +theta = 2.15 + +[[dotbots]] +address = "AAAAAAAA0000000D" +pos_x = 600 +pos_y = 600 +theta = 5.72 + +[[dotbots]] +address = "AAAAAAAA0000000E" +pos_x = 840 +pos_y = 600 +theta = 0.24 + +[[dotbots]] +address = "AAAAAAAA0000000F" +pos_x = 1_080 +pos_y = 600 +theta = 0.07 + +[[dotbots]] +address = "AAAAAAAA00000010" +pos_x = 120 +pos_y = 840 +theta = 4.61 + +[[dotbots]] +address = "AAAAAAAA00000011" +pos_x = 360 +pos_y = 840 +theta = 2.62 + +[[dotbots]] +address = "AAAAAAAA00000012" +pos_x = 600 +pos_y = 840 +theta = 4.47 + +[[dotbots]] +address = "AAAAAAAA00000013" +pos_x = 840 +pos_y = 840 +theta = 4.52 + +[[dotbots]] +address = "AAAAAAAA00000014" +pos_x = 1_080 +pos_y = 840 +theta = 6.11 + +[[dotbots]] +address = "AAAAAAAA00000015" +pos_x = 120 +pos_y = 1_080 +theta = 2.42 + +[[dotbots]] +address = "AAAAAAAA00000016" +pos_x = 360 +pos_y = 1_080 +theta = 1.86 + +[[dotbots]] +address = "AAAAAAAA00000017" +pos_x = 600 +pos_y = 1_080 +theta = 1.56 + +[[dotbots]] +address = "AAAAAAAA00000018" +pos_x = 840 +pos_y = 1_080 +theta = 0.86 + +[[dotbots]] +address = "AAAAAAAA00000019" +pos_x = 1_080 +pos_y = 1_080 +theta = 3.28 diff --git a/dotbot/examples/minimum_naming_game/minimum_naming_game.py b/dotbot/examples/minimum_naming_game/minimum_naming_game.py new file mode 100644 index 0000000..553d5e2 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/minimum_naming_game.py @@ -0,0 +1,151 @@ +import asyncio +import os +from typing import List + +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, + DotBotQueryModel, + DotBotRgbLedCommandModel, + DotBotStatus, + DotBotWaypoints, + WSRgbLed, +) +from dotbot.protocol import ApplicationType +from dotbot.rest import RestClient, rest_client +from dotbot.websocket import DotBotWsClient + +from dotbot.examples.minimum_naming_game.controller import Controller + +import numpy as np +import random +from scipy.spatial import cKDTree + +COMM_RANGE=250 +THRESHOLD=50 + +# TODO: Measure these values for real dotbots +BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance + +dotbot_controllers = dict() + + +async def fetch_active_dotbots(client: RestClient) -> List[DotBotModel]: + return await client.fetch_dotbots( + query=DotBotQueryModel(status=DotBotStatus.ACTIVE) + ) + + +async def main() -> None: + url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") + port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") + use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) + sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml") + + async with rest_client(url, port, use_https) as client: + dotbots = await fetch_active_dotbots(client) + + # print(len(dotbots), "dotbots connected.") + + # Initialization + for dotbot in dotbots: + + # Init controller + controller = Controller(dotbot.address, sct_path) + dotbot_controllers[dotbot.address] = controller + # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') + + # 1. Extract positions into a list of [x, y] coordinates + coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] + + # 2. Convert the list to a NumPy array + positions = np.array(coords) + + # 3. Build the KD-Tree + # This tree can now be used for fast spatial queries (like finding neighbors) + tree = cKDTree(positions) + + ws = DotBotWsClient(url, port) + await ws.connect() + try: + + counter = 0 + + while True: + print("Step", counter) + + for dotbot in dotbots: + + controller = dotbot_controllers[dotbot.address] + controller.position = dotbot.lh2_position + controller.direction = dotbot.direction + + # print(f'Controller position: {controller.position}, direction: {controller.direction}') + + # 1. Query the tree for indices of neighbors + neighbor_indices = tree.query_ball_point([dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE) + + # 2. Convert indices back into actual DotBot objects + neighbors = [ + dotbots[idx] for idx in neighbor_indices + if dotbots[idx].address != dotbot.address + ] + + # print(f'neighbour of {dotbot.address}: {[n.address for n in neighbors]}') + + # 3. If there are neighbors broadcasting, pick ONE randomly to listen to + if neighbors: + selected_neighbor = dotbot_controllers[random.choice(neighbors).address] + + # Share the word: take the neighbor's chosen word index + if selected_neighbor.w_index != 0: + controller.received_word = selected_neighbor.w_index + + # Set the flags so the robot knows it has a new message to process + controller.new_word_received = True + controller.received_word_checked = False + + # Update controller's neighbor list + controller.neighbors = neighbors + + # Run controller + controller.control_step() # run SCT step + + # Force update + waypoints = DotBotWaypoints( + threshold=THRESHOLD, + waypoints=[ + DotBotLH2Position( + x=dotbots[0].lh2_position.x, y=dotbots[0].lh2_position.y, z=0 + ) + ], + ) + await client.send_waypoint_command( + address=dotbots[0].address, + application=ApplicationType.DotBot, + command=waypoints, + ) + + await ws.send( + WSRgbLed( + cmd="rgb_led", + address=dotbot.address, + application=ApplicationType.DotBot, + data=DotBotRgbLedCommandModel( + red=controller.led[0], + green=controller.led[1], + blue=controller.led[2], + ), + ) + ) + + # await asyncio.sleep(0.1) + counter += 1 + finally: + await ws.close() + + return None + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dotbot/examples/minimum_naming_game/models/E1.xml b/dotbot/examples/minimum_naming_game/models/E1.xml new file mode 100644 index 0000000..4ca4b55 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/E1.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/E2.xml b/dotbot/examples/minimum_naming_game/models/E2.xml new file mode 100644 index 0000000..dc3f468 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/E2.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/G1.xml b/dotbot/examples/minimum_naming_game/models/G1.xml new file mode 100644 index 0000000..8f2d4f5 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/G1.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/G2.xml b/dotbot/examples/minimum_naming_game/models/G2.xml new file mode 100644 index 0000000..ec83205 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/G2.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/G3.xml b/dotbot/examples/minimum_naming_game/models/G3.xml new file mode 100644 index 0000000..a88ab9b --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/G3.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/G4.xml b/dotbot/examples/minimum_naming_game/models/G4.xml new file mode 100644 index 0000000..4663d81 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/G4.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/Sloc1.xml b/dotbot/examples/minimum_naming_game/models/Sloc1.xml new file mode 100644 index 0000000..5f136a9 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/Sloc1.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/Sloc2.xml b/dotbot/examples/minimum_naming_game/models/Sloc2.xml new file mode 100644 index 0000000..9b0c480 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/Sloc2.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/dotbot/examples/minimum_naming_game/models/script.txt b/dotbot/examples/minimum_naming_game/models/script.txt new file mode 100644 index 0000000..f36a4db --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/script.txt @@ -0,0 +1,8 @@ +Gloc1 = Sync(G1,G3) +Gloc2 = Sync(G2,G4) + +Kloc1 = Sync(Gloc1,E1) +Kloc2 = Sync(Gloc2,E2) + +Sloc1 = SupC(Gloc1,Kloc1) +Sloc2 = SupC(Gloc2,Kloc2) \ No newline at end of file diff --git a/dotbot/examples/minimum_naming_game/models/supervisor.yaml b/dotbot/examples/minimum_naming_game/models/supervisor.yaml new file mode 100644 index 0000000..3abeea8 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/models/supervisor.yaml @@ -0,0 +1,11 @@ + +num_events: 5 +num_supervisors: 2 +events: [ EV_startTimer, EV_selectAndBroadcast, EV_updateInventory, EV__selectAndBroadcast, EV_timeout ] +ev_controllable: [ 1, 1, 1, 0, 0 ] +ev_public: [ 0, 1, 0, 1, 0 ] +sup_events: [ [0, 0, 1, 1, 0], [1, 1, 0, 0, 1] ] +sup_init_state: [ 1, 0 ] +sup_current_state: [ 1, 0 ] +sup_data_pos: [ 0, 11 ] +sup_data: [ 2, EV_updateInventory, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV_startTimer, 0, 1, 1, EV_timeout, 0, 2, 1, EV_selectAndBroadcast, 0, 0 ] \ No newline at end of file diff --git a/dotbot/examples/minimum_naming_game_with_motion/controller.py b/dotbot/examples/minimum_naming_game_with_motion/controller.py new file mode 100644 index 0000000..a62034a --- /dev/null +++ b/dotbot/examples/minimum_naming_game_with_motion/controller.py @@ -0,0 +1,231 @@ +import math +import random +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, +) +from dotbot.examples.sct import SCT +from dotbot.examples.minimum_naming_game_with_motion.walk_avoid import walk_avoid + +DISTINCT_COLORS = [ + (255, 0, 0), # Red + (0, 255, 0), # Lime + (0, 0, 255), # Blue + (255, 255, 0), # Yellow + (255, 0, 255), # Magenta + (0, 255, 255), # Cyan + (255, 165, 0), # Orange + (128, 0, 255), # Violet +] + + +class Controller: + def __init__(self, address: str, path: str, max_speed: float, arena_limits: tuple[float, float]): + self.address = address + self.max_speed = max_speed + self.arena_limits = arena_limits + + self.position = DotBotLH2Position(x=0.0, y=0.0, z=0.0) # initial position + self.direction = 0.0 # initial orientation + self.prev_position: DotBotLH2Position | None = None + + self.neighbors: list[DotBotModel] = [] # initial empty neighbor list + self.vector = [0.0, 0.0] # initial movement vector + + # SCT initialization + self.sct = SCT(path) + self.add_callbacks() + + self.led = (0, 0, 0) # initial LED color + + # --- Naming Game Variables --- + self.counter = 0 # FOR DEBUGGING + self.last_broadcast_ticks = 0 # Tracks timing + self.max_broadcast_ticks = 5 + + # Pre-defined words (e.g., num_words = 128) + self.num_words = 8 + self.words = list(range(self.num_words)) + + # Word reception state + self.received_word = None + # self.received_word_checked = True + self.new_word_received = False + + # Global variable for the word chosen for transmission + self.w_index = 0 + + # Inventory of known words + self.inventory = set() + + + def control_step(self): + + self.counter += 1 # Increment step counter + + # Run SCT control step + self.sct.run_step() + + self.color_code() # Update LED color based on inventory state + + self.vector = walk_avoid(self.position.x, self.position.y, self.direction, self.neighbors, self.max_speed, self.arena_limits) + # print(f'DotBot {self.address} Walk Vector: {self.vector}') + + def update_pose(self, position: DotBotLH2Position) -> None: + if self.prev_position is not None: + dx = position.x - self.prev_position.x + dy = position.y - self.prev_position.y + if (dx * dx + dy * dy) > 1e-6: + heading_rad = math.atan2(dy, -dx) + self.direction = (math.degrees(heading_rad) + 360.0) % 360.0 + + self.prev_position = position + self.position = position + + + # Register callback functions to the generator player + def add_callbacks(self): + + # Automatic addition of callbacks + # 1. Get list of events and list specifying whether an event is controllable or not. + # 2. For each event, check controllable or not and add callback. + + events, controllability_list = self.sct.get_events() + + for event, index in events.items(): + is_controllable = controllability_list[index] + stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + + if is_controllable: # Add controllable event + func_name = '_callback_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, func, None, None) + else: # Add uncontrollable event + func_name = '_check_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, None, func, None) + + + # Callback functions (controllable events) + def _callback_startTimer(self, data: any): + """ + Saves the current tick count to mark the start of the broadcast interval. + """ + # print(f'DotBot {self.address}. ACTION: startTimer') + self.last_broadcast_ticks = self.counter + + + def _callback_selectAndBroadcast(self, data: any): + """ + Selects a random word from the inventory, or invents a new one + if the inventory is empty. Sets the flag for transmission. + """ + # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") + + # Select or Invent a word + if not self.inventory: + # Inventory is empty: invent a new word from the pool + self.w_index = random.randrange(self.num_words) + # Store the word (equivalent to inventory[0] = words[w_index].data[0]) + self.inventory.add(self.words[self.w_index]) + else: + # Inventory is not empty: pick a random word from current known words + self.w_index = random.choice(list(self.inventory)) + + # Set broadcast flag for transmission + self.broadcast_word = True + + # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') + + + def _callback_updateInventory(self, data: any): + """ + Updates the inventory based on the last received word. + If the word is known, the agent reaches a local consensus (inventory collapses). + If unknown, the word is added to the agent's vocabulary. + """ + # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") + + # Ensure we have a word to process + if self.received_word is None: + return + + # Check if the received word is within the inventory + if self.received_word in self.inventory: + # SUCCESS: word is known. + # Remove all other words (collapse inventory to just this one) + self.inventory = {self.received_word} + # print(f' removed all other words, inventory now: {self.inventory}') + else: + # FAILURE: word is unknown. + # Insert it into the inventory + self.inventory.add(self.received_word) + # print(f' added word {self.received_word}, inventory now: {self.inventory}') + + # Mark as checked + self.received_word_checked = True + + + # Callback functions (uncontrollable events) + def _check__selectAndBroadcast(self, data: any) -> bool: + """ + Checks if a new word has been received. + Returns True (1) if a word is waiting to be processed, otherwise False (0). + """ + if self.new_word_received: + # Reset the flag + self.new_word_received = False + return True + + return False + + + def _check_timeout(self, data: any) -> bool: + """ + Checks if the broadcast timer has expired. + Returns True if the current counter exceeds the last broadcast time + plus the defined interval. + """ + if self.counter > (self.last_broadcast_ticks + self.max_broadcast_ticks): + return True + + return False + + + def color_code(self): + """ + Updates the LED color based on the inventory state. + - If the robot has not reached consensus (inventory size != 1), the LED is OFF. + - If consensus is reached, the word is mapped to a specific RGB color. + """ + # 1. Check if the inventory has reached consensus (size exactly 1) + if len(self.inventory) != 1: + self.led = (0, 0, 0) # Turn LED off + return + + # 2. Extract the single word known to the agent + word = list(self.inventory)[0] + + # ------ ORIGINAL ------ + # # 3. Calculate RGB components using the original base-4 logic + # # Mapping word index (0-127) to a color space (1-64) + # color = (word % 63) + 1 + + # r = color // 16 + # rem1 = color % 16 + # g = rem1 // 4 + # b = rem1 % 4 + + # # 4. Update the LED state + # # Note: Original Kilobot RGB values are 0-3. + # # convert to range 0-255. + # self.led = (r * 85, g * 85, b * 85) + # ------------------------ + + # ------ NEW SIMPLIFIED COLOR CODING ------ + # Map the word to an index (0-7) + color_idx = word % len(DISTINCT_COLORS) + + # Assign the high-contrast color + self.led = DISTINCT_COLORS[color_idx] + # ----------------------------------------- \ No newline at end of file diff --git a/dotbot/examples/minimum_naming_game_with_motion/init_state.toml b/dotbot/examples/minimum_naming_game_with_motion/init_state.toml new file mode 100644 index 0000000..c211177 --- /dev/null +++ b/dotbot/examples/minimum_naming_game_with_motion/init_state.toml @@ -0,0 +1,53 @@ +[[dotbots]] +address = "AAAAAAAA00000001" +pos_x = 240 +pos_y = 240 +theta = 3.5 + +[[dotbots]] +address = "AAAAAAAA00000002" +pos_x = 720 +pos_y = 240 +theta = 6.0 + +[[dotbots]] +address = "AAAAAAAA00000003" +pos_x = 1_200 +pos_y = 240 +theta = 1.64 + +[[dotbots]] +address = "AAAAAAAA00000004" +pos_x = 240 +pos_y = 720 +theta = 1.23 + +[[dotbots]] +address = "AAAAAAAA00000005" +pos_x = 720 +pos_y = 720 +theta = 4.16 + +[[dotbots]] +address = "AAAAAAAA00000006" +pos_x = 1_200 +pos_y = 720 +theta = 3.81 + +[[dotbots]] +address = "AAAAAAAA00000007" +pos_x = 240 +pos_y = 1_200 +theta = 2.8 + +[[dotbots]] +address = "AAAAAAAA00000008" +pos_x = 720 +pos_y = 1_200 +theta = 1.54 + +[[dotbots]] +address = "AAAAAAAA00000009" +pos_x = 1_200 +pos_y = 1_200 +theta = 6.03 diff --git a/dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py b/dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py new file mode 100644 index 0000000..0a707bb --- /dev/null +++ b/dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py @@ -0,0 +1,187 @@ +import asyncio +import math +import os +from time import time +from typing import Dict, List + +from dotbot.examples.vec2 import Vec2 +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, + DotBotMoveRawCommandModel, + DotBotQueryModel, + DotBotRgbLedCommandModel, + DotBotStatus, + DotBotWaypoints, + WSRgbLed, + WSMoveRaw, + WSWaypoints, +) +from dotbot.protocol import ApplicationType +from dotbot.rest import RestClient, rest_client +from dotbot.websocket import DotBotWsClient + +from dotbot.examples.minimum_naming_game_with_motion.controller import Controller + +import numpy as np +import random +from scipy.spatial import cKDTree + +COMM_RANGE=250 +THRESHOLD=0 + +# TODO: Measure these values for real dotbots +BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance +MAX_SPEED = 300 # Maximum allowed linear speed of a bot (mm/s) + +ARENA_SIZE_X = 2000 # Width of the arena in mm +ARENA_SIZE_Y = 2000 # Height of the arena in mm + +dotbot_controllers = dict() + + +async def fetch_active_dotbots(client: RestClient) -> List[DotBotModel]: + return await client.fetch_dotbots( + query=DotBotQueryModel(status=DotBotStatus.ACTIVE) + ) + + +async def main() -> None: + url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") + port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") + use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) + sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml") + + async with rest_client(url, port, use_https) as client: + dotbots = await fetch_active_dotbots(client) + + # print(len(dotbots), "dotbots connected.") + + # Initialization + for dotbot in dotbots: + + # Init controller + controller = Controller(dotbot.address, sct_path, 0.9 * MAX_SPEED, arena_limits=(ARENA_SIZE_X, ARENA_SIZE_Y)) + dotbot_controllers[dotbot.address] = controller + # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') + + # 1. Extract positions into a list of [x, y] coordinates + coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] + + # 2. Convert the list to a NumPy array + positions = np.array(coords) + + # 3. Build the KD-Tree + # This tree can now be used for fast spatial queries (like finding neighbors) + tree = cKDTree(positions) + + ws = DotBotWsClient(url, port) + await ws.connect() + try: + + counter = 0 + + while True: + print("Step", counter) + + dotbots = await fetch_active_dotbots(client) + + # 1. Extract positions into a list of [x, y] coordinates + # This loop iterates through your dotbot list and grabs the lh2_position attributes + coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] + + # 2. Convert the list to a NumPy array + # The structure will be (N, 2), where N is the number of dotbots + positions = np.array(coords) + + # 3. Build the KD-Tree + # This tree can now be used for fast spatial queries (like finding neighbors) + tree = cKDTree(positions) + + for dotbot in dotbots: + + controller = dotbot_controllers[dotbot.address] + controller.update_pose(dotbot.lh2_position) + + # print(f'Controller position: {controller.position}, direction: {controller.direction}') + + # 1. Query the tree for indices of neighbors + neighbor_indices = tree.query_ball_point([dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE) + + # 2. Convert indices back into actual DotBot objects + neighbors = [ + dotbots[idx] for idx in neighbor_indices + if dotbots[idx].address != dotbot.address + ] + + # print(f'neighbour of {dotbot.address}: {[n.address for n in neighbors]}') + + # 3. If there are neighbors broadcasting, pick ONE randomly to listen to + if neighbors: + selected_neighbor = dotbot_controllers[random.choice(neighbors).address] + + # Share the word: take the neighbor's chosen word index + if selected_neighbor.w_index != 0: + controller.received_word = selected_neighbor.w_index + + # Set the flags so the robot knows it has a new message to process + controller.new_word_received = True + controller.received_word_checked = False + + # Update controller's neighbor list + controller.neighbors = neighbors + + # Run controller + controller.control_step() # run SCT step + + ### TEMPORARY: The simulator does not accept negative coordinates, + # so we set it to zero and scale the positive value proportionally. + point = DotBotLH2Position(x=controller.vector[0], y=controller.vector[1], z=0.0) + if dotbot.lh2_position.x + controller.vector[0] < 0: + point.y = controller.vector[1] * (controller.vector[0] / MAX_SPEED) + point.x = 0.0 + if dotbot.lh2_position.y + controller.vector[1] < 0: + point.x = controller.vector[0] * (controller.vector[1] / MAX_SPEED) + point.y = 0.0 + ### + + waypoints = DotBotWaypoints( + threshold=THRESHOLD, + waypoints=[ + DotBotLH2Position( + x=dotbot.lh2_position.x + round(point.x, 2), + y=dotbot.lh2_position.y + round(point.y, 2), + z=0 + ) + ], + ) + + await client.send_waypoint_command( + address=dotbot.address, + application=ApplicationType.DotBot, + command=waypoints, + ) + + await ws.send( + WSRgbLed( + cmd="rgb_led", + address=dotbot.address, + application=ApplicationType.DotBot, + data=DotBotRgbLedCommandModel( + red=controller.led[0], + green=controller.led[1], + blue=controller.led[2], + ), + ) + ) + + # await asyncio.sleep(0.1) + counter += 1 + finally: + await ws.close() + + return None + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py b/dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py new file mode 100644 index 0000000..a5490c1 --- /dev/null +++ b/dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py @@ -0,0 +1,76 @@ +import math + +from dotbot.models import DotBotModel + + +def walk_avoid(position_x: float, + position_y: float, + direction: float, + neighbors: list[DotBotModel], + max_speed: float, + arena_limits: tuple[float, float]) -> list[float]: + """ + Walk straight while avoiding collisions and arena boundary. + Arena limits: x, y in [0.0, 1.0] + """ + UNIT_SPEED = max_speed + MARGIN = 0.1 # Trigger turn when within 10% of any edge + + # 1. Identify if any neighbor is too close + neighbor_collision = False + if neighbors: + neighbor_collision = True + + # 2. Identify if any arena boundary is violated + curr_x = position_x + curr_y = position_y + + wall_collision = (curr_x < MARGIN*arena_limits[0] or curr_x > (arena_limits[0] - MARGIN*arena_limits[0]) or + curr_y < MARGIN*arena_limits[1] or curr_y > (arena_limits[1] - MARGIN*arena_limits[1])) + + # 3. Determine "Local" movement + local_v = [0.0, 0.0] + + if neighbor_collision or wall_collision: + + if wall_collision: + # Decide direction of repulsion (Left or Right) + if (curr_x < MARGIN*arena_limits[0]): + local_v[0] += UNIT_SPEED + if (curr_x > (arena_limits[0] - MARGIN*arena_limits[0])): + local_v[0] += -UNIT_SPEED + if (curr_y < MARGIN*arena_limits[1]): + local_v[1] += UNIT_SPEED + if (curr_y > (arena_limits[1] - MARGIN*arena_limits[1])): + local_v[1] += -UNIT_SPEED + + if neighbor_collision: + avg_dx = sum(n.lh2_position.x - curr_x for n in neighbors) + avg_dy = sum(n.lh2_position.y - curr_y for n in neighbors) + + mag = math.sqrt(avg_dx**2 + avg_dy**2) + if mag > 0: + # Add "Away" vector to the existing global movement + local_v[0] -= (avg_dx / mag) * UNIT_SPEED + local_v[1] -= (avg_dy / mag) * UNIT_SPEED + # print(f"Neighbor avoidance vector: {local_v} from neighbors {[n.address for n in neighbors]}") + + # Normalize so we don't go double speed in corners + total_mag = math.sqrt(local_v[0]**2 + local_v[1]**2) + if total_mag > 0: + return ( + (local_v[0] / total_mag) * UNIT_SPEED, + (local_v[1] / total_mag) * UNIT_SPEED, + ) + return (0.0, 0.0) + + else: + local_v = [UNIT_SPEED, 0.0] # Normal forward motion + + # 4. Rotate Local Vector to Global Vector + theta_rad = math.radians(direction) + + global_vx = (local_v[0] * -math.cos(theta_rad)) - (local_v[1] * math.sin(theta_rad)) + global_vy = (local_v[0] * math.sin(theta_rad)) + (local_v[1] * -math.cos(theta_rad)) + + return (global_vx, global_vy) diff --git a/dotbot/examples/sct.py b/dotbot/examples/sct.py new file mode 100644 index 0000000..7f1432f --- /dev/null +++ b/dotbot/examples/sct.py @@ -0,0 +1,314 @@ +import random +import yaml + +class SCT: + + def __init__(self, filename): + + self.read_supervisor(filename) + + self.callback = {} + self.input_buffer = None # Clear content after timestep + self.last_events = [0] * len(self.EV) + + + def read_supervisor(self, filename): + try: + with open(filename, 'r') as stream: + self.f = yaml.safe_load(stream) + except yaml.YAMLError as e: + print(e) + + self.num_events = self.f['num_events'] + self.num_supervisors = self.f['num_supervisors'] + self.EV = {} + for i, ev in enumerate(self.f['events']): + self.EV[ev] = i + + self.ev_controllable = self.f['ev_controllable'] + self.sup_events = self.f['sup_events'] + self.sup_init_state = self.f['sup_init_state'] + self.sup_current_state = self.f['sup_current_state'] + self.sup_data_pos = self.f['sup_data_pos'] + self.sup_data = self.f['sup_data'] + + + def add_callback(self, event, clbk, ci, sup_data): + func = {} + func['callback'] = clbk + func['check_input'] = ci + func['sup_data'] = sup_data + self.callback[event] = func + + + def run_step(self): + self.input_buffer = [] # clear buffer + self.update_input() + + # Get all uncontrollable events + uce = self.input_buffer + + # Apply all the uncontrollable events + while uce: + event = uce.pop(0) + self.make_transition(event) + self.exec_callback(event) + + ce_exists, ce = self.get_next_controllable() + + # Apply the chosen controllable event + if ce_exists: + self.make_transition(ce) + self.exec_callback(ce) + + + def input_read(self, ev): + event_name = self.get_event_name(ev) + if ev < self.num_events and self.callback[event_name]: + return self.callback[event_name]['check_input'](self.callback[event_name]['sup_data']) + return False + + + def update_input(self): + for i in range(0,self.num_events): + if not self.ev_controllable[i]: # Check the UCEs only + if self.input_read(i): + self.input_buffer.append(i) + self.last_events[i] = 1 + + + def get_state_position(self, supervisor, state): + position = self.sup_data_pos[supervisor] # Jump to the start position of the supervisor + for s in range(0, state): # Keep iterating until the state is reached + en = self.sup_data[position] # The number of transitions in the state + position += en * 3 + 1 # Next state position (Number transitions * 3 + 1) + return position + + + def make_transition(self, ev): + num_transitions = None + + # Apply transition to each local supervisor + for i in range(0, self.num_supervisors): + if self.sup_events[i][ev]: # Check if the given event is part of this supervisor + + # Current state info of supervisor + position = self.get_state_position(i, self.sup_current_state[i]) + num_transitions = self.sup_data[position] + position += 1 # Point to first transition + + # Find the transition for the given event + while num_transitions: + num_transitions -= 1 + value = self.get_value(self.sup_data[position]) + if value == ev: + self.sup_current_state[i] = (self.sup_data[position + 1] * 256) + (self.sup_data[position + 2]) + break + position += 3 + + + def exec_callback(self, ev): + event_name = self.get_event_name(ev) + if ev < self.num_events and self.callback[event_name]['callback']: + self.callback[event_name]['callback'](self.callback[event_name]['sup_data']) + + + def get_next_controllable(self): + + # Get controllable events that are enabled -> events + actives = self.get_active_controllable_events() + + if not all(v == 0 for v in actives): + randomPos = random.randint(0,1000000000) % actives.count(1) + for i in range(0, self.num_events): + if not randomPos and actives[i]: + return True, i + elif actives[i]: + randomPos -= 1 + + return False, None + + + def get_active_controllable_events(self): + + events = [] + + # Disable all non controllable events + for i in range(0, self.num_events): + if not self.ev_controllable[i]: + events.append(0) + else: + events.append(1) + + # Check disabled events for all supervisors + for i in range(0, self.num_supervisors): + + # Init an array where all events are disabled + ev_disable = [1] * self.num_events + + # Enable all events that are not part of this supervisor + for j in range(0, self.num_events): + if not self.sup_events[i][j]: + ev_disable[j] = 0 + + # Get current state + position = self.get_state_position(i, self.sup_current_state[i]) + num_transitions = self.sup_data[position] + position += 1 + + # Enable all events that have a transition from the current state + while num_transitions: + num_transitions -= 1 + value = self.get_value(self.sup_data[position]) + ev_disable[value] = 0 + position += 3 + + # Remove the controllable events to disable, leaving an array of enabled controllable events + for j in range(0, self.num_events): + if ev_disable[j] == 1 and events[j]: + events[j] = 0 + + return events + + + def get_value(self, index): + if isinstance(index, str): + return self.EV[index] + return index + + + def get_event_name(self, index): + if isinstance(index, int): + return list(self.EV.keys())[list(self.EV.values()).index(index)] + return index + + + # Get function that returns event information (event names and controllability) + def get_events(self): + return self.EV, self.ev_controllable + + +class SCTPub(SCT): + + def __init__(self, filename): + super().__init__(filename) + self.ev_public = self.f['ev_public'] + + + def run_step(self): + self.input_buffer = [] # clear buffer + self.input_buffer_pub = [] + self.update_input() + + # Apply all public uncontrollable events + public_uce = self.input_buffer_pub + while public_uce: + event = public_uce.pop(0) + self.make_transition(event) + self.exec_callback(event) + + # Apply all private uncontrollable events + uce = self.input_buffer + while uce: + event = uce.pop(0) + self.make_transition(event) + self.exec_callback(event) + + ce_exists, ce = self.get_next_controllable() + + # Apply the chosen controllable event + if ce_exists: + self.make_transition(ce) + self.exec_callback(ce) + + + def update_input(self): + for i in range(0,self.num_events): + if not self.ev_controllable[i]: # Check the UCEs only + if self.input_read(i): + if self.ev_public[i]: + self.input_buffer_pub.append(i) + else: + self.input_buffer.append(i) + self.last_events[i] = 1 + +class SCTProb(SCT): + + def __init__(self, filename): + super().__init__(filename) + self.sup_data_prob_pos = self.f['sup_data_prob_pos'] + self.sup_data_prob = self.f['sup_data_prob'] + + + def get_state_position_prob(self, supervisor, state): + prob_position = self.sup_data_prob_pos[supervisor] # Jump to the start position of the supervisor + for s in range(0, state): # Keep iterating until the state is reached + en = self.sup_data_prob[prob_position] # The number of transitions in the state + prob_position += en + 1 # Next state position (Number transitions * 3 + 1) + return prob_position + + + def get_active_controllable_events_prob(self): + + events = [] + + # Disable all non controllable events + for i in range(0, self.num_events): + if not self.ev_controllable[i]: + events.append(0) + else: + events.append(1) + + # Check disabled events for all supervisors + for i in range(0, self.num_supervisors): + + # Init an array where all events are disabled + ev_disable = [1] * self.num_events + + # Enable all events that are not part of this supervisor + for j in range(0, self.num_events): + if not self.sup_events[i][j]: + ev_disable[j] = 0 + + # Get current state + position = self.get_state_position(i, self.sup_current_state[i]) + position_prob = self.get_state_position_prob(i, self.sup_current_state[i]) + num_transitions = self.sup_data[position] + position += 1 + position_prob += 1 + + # Enable all events that have a transition from the current state + while num_transitions: + num_transitions -= 1 + value = self.get_value(self.sup_data[position]) + + if self.ev_controllable[value] and self.sup_events[i][value]: + ev_disable[value] = 0 # Transition with this event, do not disable it, just calculate its probability contribution + events[value] = events[value] * self.sup_data_prob[position_prob] + position_prob += 1 + + position += 3 + + for j in range(self.num_events): + if ev_disable[j] == 1: + events[j] = 0 + + return events + + + def get_next_controllable(self): + + # Get controllable events that are enabled -> events + events = self.get_active_controllable_events_prob() + prob_sum = sum(events) + + if prob_sum > 0.0001: # If at least one event is enabled do + random_value = random.uniform(0, prob_sum) + random_sum = 0.0 + for i in range(self.num_events): + random_sum += events[i] + if (random_value < random_sum) and self.ev_controllable[i]: + return True, i + + return False, None + \ No newline at end of file From cae228fc49d898bebd1be23ca9d18e7cd9391525 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Wed, 11 Feb 2026 14:01:09 +0000 Subject: [PATCH 02/13] pyproject.toml: add pip dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5bef8e5..6c5fd3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "marilib-pkg >= 0.8.0", "pydotbot-utils >= 0.3.0", "toml >= 0.10.2", + "pyyaml >= 6.0.3", + "scipy >= 1.17.0", ] description = "Package to easily control your DotBots and SailBots." readme = "README.md" From d147a3a911c3ebe00faeb264a8268640f7aad1c4 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Wed, 11 Feb 2026 14:40:51 +0000 Subject: [PATCH 03/13] dotbot/examples: changed file structure for minimum naming game --- .../controller_with_motion.py} | 3 ++- .../init_state_with_motion.toml} | 0 .../minimum_naming_game_with_motion.py | 2 +- .../walk_avoid.py | 0 4 files changed, 3 insertions(+), 2 deletions(-) rename dotbot/examples/{minimum_naming_game_with_motion/controller.py => minimum_naming_game/controller_with_motion.py} (99%) rename dotbot/examples/{minimum_naming_game_with_motion/init_state.toml => minimum_naming_game/init_state_with_motion.toml} (100%) rename dotbot/examples/{minimum_naming_game_with_motion => minimum_naming_game}/minimum_naming_game_with_motion.py (99%) rename dotbot/examples/{minimum_naming_game_with_motion => minimum_naming_game}/walk_avoid.py (100%) diff --git a/dotbot/examples/minimum_naming_game_with_motion/controller.py b/dotbot/examples/minimum_naming_game/controller_with_motion.py similarity index 99% rename from dotbot/examples/minimum_naming_game_with_motion/controller.py rename to dotbot/examples/minimum_naming_game/controller_with_motion.py index a62034a..ecb2199 100644 --- a/dotbot/examples/minimum_naming_game_with_motion/controller.py +++ b/dotbot/examples/minimum_naming_game/controller_with_motion.py @@ -5,7 +5,7 @@ DotBotModel, ) from dotbot.examples.sct import SCT -from dotbot.examples.minimum_naming_game_with_motion.walk_avoid import walk_avoid +from dotbot.examples.minimum_naming_game.walk_avoid import walk_avoid DISTINCT_COLORS = [ (255, 0, 0), # Red @@ -71,6 +71,7 @@ def control_step(self): self.vector = walk_avoid(self.position.x, self.position.y, self.direction, self.neighbors, self.max_speed, self.arena_limits) # print(f'DotBot {self.address} Walk Vector: {self.vector}') + def update_pose(self, position: DotBotLH2Position) -> None: if self.prev_position is not None: dx = position.x - self.prev_position.x diff --git a/dotbot/examples/minimum_naming_game_with_motion/init_state.toml b/dotbot/examples/minimum_naming_game/init_state_with_motion.toml similarity index 100% rename from dotbot/examples/minimum_naming_game_with_motion/init_state.toml rename to dotbot/examples/minimum_naming_game/init_state_with_motion.toml diff --git a/dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py similarity index 99% rename from dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py rename to dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py index 0a707bb..51be125 100644 --- a/dotbot/examples/minimum_naming_game_with_motion/minimum_naming_game_with_motion.py +++ b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py @@ -21,7 +21,7 @@ from dotbot.rest import RestClient, rest_client from dotbot.websocket import DotBotWsClient -from dotbot.examples.minimum_naming_game_with_motion.controller import Controller +from dotbot.examples.minimum_naming_game.controller_with_motion import Controller import numpy as np import random diff --git a/dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py b/dotbot/examples/minimum_naming_game/walk_avoid.py similarity index 100% rename from dotbot/examples/minimum_naming_game_with_motion/walk_avoid.py rename to dotbot/examples/minimum_naming_game/walk_avoid.py From cec66c2968bb5d7d1acbc9154de30ba2772d8fc3 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Wed, 11 Feb 2026 15:25:48 +0000 Subject: [PATCH 04/13] dotbot/examples: added work-and-charge --- dotbot/examples/work_and_charge/controller.py | 136 ++++++++ .../examples/work_and_charge/gen_init_pose.py | 48 +++ .../examples/work_and_charge/init_state.toml | 47 +++ dotbot/examples/work_and_charge/models/E1.xml | 15 + dotbot/examples/work_and_charge/models/E2.xml | 15 + dotbot/examples/work_and_charge/models/E3.xml | 15 + dotbot/examples/work_and_charge/models/E4.xml | 15 + dotbot/examples/work_and_charge/models/G1.xml | 17 + dotbot/examples/work_and_charge/models/G2.xml | 14 + dotbot/examples/work_and_charge/models/G3.xml | 10 + .../examples/work_and_charge/models/Sloc1.xml | 42 +++ .../examples/work_and_charge/models/Sloc2.xml | 42 +++ .../examples/work_and_charge/models/Sloc3.xml | 60 ++++ .../examples/work_and_charge/models/Sloc4.xml | 60 ++++ .../work_and_charge/models/script.txt | 14 + .../work_and_charge/models/supervisor.yaml | 10 + .../work_and_charge/work_and_charge.py | 326 ++++++++++++++++++ 17 files changed, 886 insertions(+) create mode 100644 dotbot/examples/work_and_charge/controller.py create mode 100644 dotbot/examples/work_and_charge/gen_init_pose.py create mode 100644 dotbot/examples/work_and_charge/init_state.toml create mode 100644 dotbot/examples/work_and_charge/models/E1.xml create mode 100644 dotbot/examples/work_and_charge/models/E2.xml create mode 100644 dotbot/examples/work_and_charge/models/E3.xml create mode 100644 dotbot/examples/work_and_charge/models/E4.xml create mode 100644 dotbot/examples/work_and_charge/models/G1.xml create mode 100644 dotbot/examples/work_and_charge/models/G2.xml create mode 100644 dotbot/examples/work_and_charge/models/G3.xml create mode 100644 dotbot/examples/work_and_charge/models/Sloc1.xml create mode 100644 dotbot/examples/work_and_charge/models/Sloc2.xml create mode 100644 dotbot/examples/work_and_charge/models/Sloc3.xml create mode 100644 dotbot/examples/work_and_charge/models/Sloc4.xml create mode 100644 dotbot/examples/work_and_charge/models/script.txt create mode 100644 dotbot/examples/work_and_charge/models/supervisor.yaml create mode 100644 dotbot/examples/work_and_charge/work_and_charge.py diff --git a/dotbot/examples/work_and_charge/controller.py b/dotbot/examples/work_and_charge/controller.py new file mode 100644 index 0000000..8be9819 --- /dev/null +++ b/dotbot/examples/work_and_charge/controller.py @@ -0,0 +1,136 @@ +import math +from dotbot.models import ( + DotBotLH2Position, +) +from dotbot.examples.sct import SCT + +class Controller: + def __init__(self, address: str, path: str): + self.address = address + + # SCT initialization + self.sct = SCT(path) + self.add_callbacks() + + self.waypoint_current = None + self.waypoint_threshold = 50 # default threshold + + self.led = (0, 0, 0) # initial LED color + self.energy = 'high' # initial energy level + + + def set_work_waypoint(self, waypoint: DotBotLH2Position): + self.waypoint_work = waypoint + + + def set_charge_waypoint(self, waypoint: DotBotLH2Position): + self.waypoint_charge = waypoint + + + def set_current_position(self, position: DotBotLH2Position): + self.position_current = position + + + def control_step(self): + + # Calculate distance to work waypoint + dx = self.waypoint_work.x - self.position_current.x + dy = self.waypoint_work.y - self.position_current.y + self.dist_work = math.sqrt(dx * dx + dy * dy) + + # Calculate distance to charge waypoint + dx = self.waypoint_charge.x - self.position_current.x + dy = self.waypoint_charge.y - self.position_current.y + self.dist_charge = math.sqrt(dx * dx + dy * dy) + + # Run SCT control step + self.sct.run_step() + + + # Register callback functions to the generator player + def add_callbacks(self): + + # Automatic addition of callbacks + # 1. Get list of events and list specifying whether an event is controllable or not. + # 2. For each event, check controllable or not and add callback. + + events, controllability_list = self.sct.get_events() + + for event, index in events.items(): + is_controllable = controllability_list[index] + stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + + if is_controllable: # Add controllable event + func_name = '_callback_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, func, None, None) + else: # Add uncontrollable event + func_name = '_check_{0}'.format(stripped_name) + func = getattr(self, func_name) + self.sct.add_callback(event, None, func, None) + + + # Callback functions (controllable events) + def _callback_moveToWork(self, data: any): + # print(f'DotBot {self.address}. ACTION: moveToWork') + self.waypoint_current = self.waypoint_work + self.led = (0, 255, 0) # Green LED when moving to work + + + def _callback_moveToCharge(self, data: any): + # print(f'DotBot {self.address}. ACTION: moveToCharge') + self.waypoint_current = self.waypoint_charge + self.led = (255, 0, 0) # Red LED when moving to charge + + + def _callback_work(self, data: any): + # print(f'DotBot {self.address}. ACTION: work') + self.energy = 'low' # After working, energy level goes low + + + def _callback_charge(self, data: any): + # print(f'DotBot {self.address}. ACTION: charge') + self.energy = 'high' # After charging, energy level goes high + + + # Callback functions (uncontrollable events) + def _check_atWork(self, data: any): + if self.dist_work < self.waypoint_threshold: + # print(f'DotBot {self.address}. EVENT: atWork') + return True + return False + + + def _check_notAtWork(self, data: any): + if self.dist_work >= self.waypoint_threshold: + # print(f'DotBot {self.address}. EVENT: notAtWork') + return True + return False + + + def _check_atCharger(self, data: any): + if self.dist_charge < self.waypoint_threshold: + # print(f'DotBot {self.address}. EVENT: atCharger') + return True + return False + + + def _check_notAtCharger(self, data: any): + if self.dist_charge >= self.waypoint_threshold: + # print(f'DotBot {self.address}. EVENT: notAtCharger') + return True + return False + + + def _check_lowEnergy(self, data: any): + if self.energy == 'low': + # print(f'DotBot {self.address}. EVENT: lowEnergy') + return True + return False + + + def _check_highEnergy(self, data: any): + if self.energy == 'high': + # print(f'DotBot {self.address}. EVENT: highEnergy') + return True + return False \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/gen_init_pose.py b/dotbot/examples/work_and_charge/gen_init_pose.py new file mode 100644 index 0000000..2dbe876 --- /dev/null +++ b/dotbot/examples/work_and_charge/gen_init_pose.py @@ -0,0 +1,48 @@ +from pathlib import Path + +def generate_dotbot_script(): + # Configuration Constants + NUM_ROBOTS = 8 # Total robots to generate + START_ID = 1 # Start at AAAAAAAA00000001 + X_RIGHT = 800 + X_LEFT = 100 + START_Y = 200 + Y_STEP = 200 + THETA = 3.14 + FILENAME = "dotbots_config.toml" + + lines = [] + + for i in range(NUM_ROBOTS): + # 1. Address: Increments by 1 every robot + address_hex = f"AAAAAAAA{START_ID + i:08X}" + + # 2. X Position: Alternates 800, 100, 800, 100... + pos_x_val = X_RIGHT if i % 2 == 0 else X_LEFT + + # 3. Y Position: Increases by 200 for EVERY robot + pos_y_val = START_Y + (i * Y_STEP) + + # 4. Format numbers with underscores (e.g., 800_000) + pos_x = f"{pos_x_val:,}".replace(",", "_") + pos_y = f"{pos_y_val:,}".replace(",", "_") + + # Build the TOML block + block = ( + f"[[dotbots]]\n" + f"address = \"{address_hex}\"\n" + f"pos_x = {pos_x}\n" + f"pos_y = {pos_y}\n" + f"theta = {THETA}\n" + ) + lines.append(block) + + # Save to file + output_path = Path(__file__).resolve().parent / "init_state.toml" + with open(output_path, "w") as f: + f.write("\n".join(lines)) + + print(f"Generated TOML file at {output_path}") + +if __name__ == "__main__": + generate_dotbot_script() \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/init_state.toml b/dotbot/examples/work_and_charge/init_state.toml new file mode 100644 index 0000000..75622c9 --- /dev/null +++ b/dotbot/examples/work_and_charge/init_state.toml @@ -0,0 +1,47 @@ +[[dotbots]] +address = "AAAAAAAA00000001" +pos_x = 800 +pos_y = 200 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000002" +pos_x = 100 +pos_y = 400 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000003" +pos_x = 800 +pos_y = 600 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000004" +pos_x = 100 +pos_y = 800 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000005" +pos_x = 800 +pos_y = 1_000 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000006" +pos_x = 100 +pos_y = 1_200 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000007" +pos_x = 800 +pos_y = 1_400 +theta = 3.14 + +[[dotbots]] +address = "AAAAAAAA00000008" +pos_x = 100 +pos_y = 1_600 +theta = 3.14 diff --git a/dotbot/examples/work_and_charge/models/E1.xml b/dotbot/examples/work_and_charge/models/E1.xml new file mode 100644 index 0000000..6e53df5 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/E1.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/E2.xml b/dotbot/examples/work_and_charge/models/E2.xml new file mode 100644 index 0000000..493542d --- /dev/null +++ b/dotbot/examples/work_and_charge/models/E2.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/E3.xml b/dotbot/examples/work_and_charge/models/E3.xml new file mode 100644 index 0000000..c6edfc0 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/E3.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/E4.xml b/dotbot/examples/work_and_charge/models/E4.xml new file mode 100644 index 0000000..64df924 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/E4.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/G1.xml b/dotbot/examples/work_and_charge/models/G1.xml new file mode 100644 index 0000000..0a1337f --- /dev/null +++ b/dotbot/examples/work_and_charge/models/G1.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/G2.xml b/dotbot/examples/work_and_charge/models/G2.xml new file mode 100644 index 0000000..2d7a2fa --- /dev/null +++ b/dotbot/examples/work_and_charge/models/G2.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/G3.xml b/dotbot/examples/work_and_charge/models/G3.xml new file mode 100644 index 0000000..53b1111 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/G3.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/Sloc1.xml b/dotbot/examples/work_and_charge/models/Sloc1.xml new file mode 100644 index 0000000..325fd60 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/Sloc1.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/Sloc2.xml b/dotbot/examples/work_and_charge/models/Sloc2.xml new file mode 100644 index 0000000..a833722 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/Sloc2.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/Sloc3.xml b/dotbot/examples/work_and_charge/models/Sloc3.xml new file mode 100644 index 0000000..c7aa189 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/Sloc3.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/Sloc4.xml b/dotbot/examples/work_and_charge/models/Sloc4.xml new file mode 100644 index 0000000..4b08ce8 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/Sloc4.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotbot/examples/work_and_charge/models/script.txt b/dotbot/examples/work_and_charge/models/script.txt new file mode 100644 index 0000000..57d4413 --- /dev/null +++ b/dotbot/examples/work_and_charge/models/script.txt @@ -0,0 +1,14 @@ +Gloc1 = Sync(G1,G3) +Gloc2 = Sync(G1,G3) +Gloc3 = Sync(G1,G2) +Gloc4 = Sync(G1,G2) + +Kloc1 = Sync(Gloc1,E1) +Kloc2 = Sync(Gloc2,E2) +Kloc3 = Sync(Gloc3,E3) +Kloc4 = Sync(Gloc4,E4) + +Sloc1 = SupC(Gloc1,Kloc1) +Sloc2 = SupC(Gloc2,Kloc2) +Sloc3 = SupC(Gloc3,Kloc3) +Sloc4 = SupC(Gloc4,Kloc4) \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/models/supervisor.yaml b/dotbot/examples/work_and_charge/models/supervisor.yaml new file mode 100644 index 0000000..1299fef --- /dev/null +++ b/dotbot/examples/work_and_charge/models/supervisor.yaml @@ -0,0 +1,10 @@ + +num_events: 10 +num_supervisors: 4 +events: [ EV_lowEnergy, EV_highEnergy, EV_atWork, EV_atCharger, EV_charge, EV_notAtWork, EV_work, EV_moveToCharge, EV_moveToWork, EV_notAtCharger ] +ev_controllable: [ 0, 0, 0, 0, 1, 0, 1, 1, 1, 0 ] +sup_events: [ [1, 1, 0, 0, 1, 0, 1, 1, 1, 0], [1, 1, 0, 0, 1, 0, 1, 1, 1, 0], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1], [0, 0, 1, 1, 1, 1, 1, 1, 1, 1] ] +sup_init_state: [ 5, 6, 3, 7 ] +sup_current_state: [ 5, 6, 3, 7 ] +sup_data_pos: [ 0, 77, 154, 279 ] +sup_data: [ 3, EV_highEnergy, 0, 0, EV_lowEnergy, 0, 3, EV_charge, 0, 1, 3, EV_moveToWork, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_moveToCharge, 0, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, 3, EV_charge, 0, 5, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 6, 2, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_highEnergy, 0, 6, EV_work, 0, 7, EV_lowEnergy, 0, 4, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, EV_moveToCharge, 0, 0, 3, EV_moveToWork, 0, 4, EV_lowEnergy, 0, 0, EV_highEnergy, 0, 6, 3, EV_charge, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 3, 2, EV_lowEnergy, 0, 5, EV_highEnergy, 0, 2, 3, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 1, EV_charge, 0, 0, 3, EV_highEnergy, 0, 7, EV_work, 0, 5, EV_lowEnergy, 0, 4, 3, EV_highEnergy, 0, 2, EV_lowEnergy, 0, 5, EV_moveToCharge, 0, 3, 3, EV_moveToWork, 0, 7, EV_highEnergy, 0, 6, EV_lowEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 7, 5, EV_notAtWork, 0, 7, EV_atCharger, 0, 0, EV_atWork, 0, 0, EV_charge, 0, 6, EV_notAtCharger, 0, 0, 5, EV_atCharger, 0, 1, EV_atWork, 0, 1, EV_notAtWork, 0, 2, EV_moveToCharge, 0, 0, EV_notAtCharger, 0, 1, 5, EV_notAtWork, 0, 2, EV_atWork, 0, 1, EV_atCharger, 0, 2, EV_moveToCharge, 0, 7, EV_notAtCharger, 0, 2, 5, EV_notAtCharger, 0, 3, EV_notAtWork, 0, 3, EV_moveToWork, 0, 5, EV_atWork, 0, 6, EV_atCharger, 0, 3, 5, EV_notAtWork, 0, 5, EV_atCharger, 0, 4, EV_atWork, 0, 4, EV_notAtCharger, 0, 4, EV_work, 0, 1, 4, EV_atCharger, 0, 5, EV_atWork, 0, 4, EV_notAtWork, 0, 5, EV_notAtCharger, 0, 5, 5, EV_atWork, 0, 6, EV_atCharger, 0, 6, EV_notAtCharger, 0, 6, EV_notAtWork, 0, 3, EV_moveToWork, 0, 4, 5, EV_notAtWork, 0, 7, EV_notAtCharger, 0, 7, EV_atCharger, 0, 7, EV_atWork, 0, 0, EV_charge, 0, 3, 4, EV_notAtWork, 0, 0, EV_notAtCharger, 0, 0, EV_atWork, 0, 0, EV_atCharger, 0, 5, 5, EV_atCharger, 0, 4, EV_atWork, 0, 1, EV_notAtCharger, 0, 1, EV_moveToCharge, 0, 0, EV_notAtWork, 0, 1, 5, EV_notAtCharger, 0, 2, EV_work, 0, 1, EV_atCharger, 0, 3, EV_atWork, 0, 2, EV_notAtWork, 0, 2, 5, EV_notAtWork, 0, 3, EV_atCharger, 0, 3, EV_work, 0, 4, EV_atWork, 0, 3, EV_notAtCharger, 0, 2, 5, EV_atWork, 0, 4, EV_atCharger, 0, 4, EV_moveToCharge, 0, 5, EV_notAtCharger, 0, 1, EV_notAtWork, 0, 4, 5, EV_atWork, 0, 5, EV_notAtCharger, 0, 0, EV_notAtWork, 0, 5, EV_atCharger, 0, 5, EV_charge, 0, 6, 5, EV_notAtWork, 0, 6, EV_moveToWork, 0, 3, EV_atCharger, 0, 6, EV_atWork, 0, 6, EV_notAtCharger, 0, 7, 5, EV_notAtCharger, 0, 7, EV_moveToWork, 0, 2, EV_notAtWork, 0, 7, EV_atWork, 0, 7, EV_atCharger, 0, 6 ] \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/work_and_charge.py b/dotbot/examples/work_and_charge/work_and_charge.py new file mode 100644 index 0000000..ba7af8e --- /dev/null +++ b/dotbot/examples/work_and_charge/work_and_charge.py @@ -0,0 +1,326 @@ +import asyncio +import math +import os +import time +from typing import Dict, List + +from dotbot.examples.orca import ( + Agent, + OrcaParams, + compute_orca_velocity_for_agent, +) +from dotbot.examples.vec2 import Vec2 +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, + DotBotMoveRawCommandModel, + DotBotQueryModel, + DotBotRgbLedCommandModel, + DotBotStatus, + DotBotWaypoints, + WSRgbLed, + WSWaypoints, +) +from dotbot.protocol import ApplicationType +from dotbot.rest import RestClient, rest_client +from dotbot.websocket import DotBotWsClient + +from dotbot.examples.sct import SCT +from dotbot.examples.work_and_charge.controller import Controller + +import numpy as np +from scipy.spatial import cKDTree + +ORCA_RANGE = 200 + +THRESHOLD = 50 # Acceptable distance error to consider a waypoint reached +DT = 0.05 # Control loop period (seconds) + +# TODO: Measure these values for real dotbots +BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance +MAX_SPEED = 300 # Maximum allowed linear speed of a bot (mm/s) + +(QUEUE_HEAD_X, QUEUE_HEAD_Y) = ( + 200, + 200, +) # World-frame (X, Y) position of the charging queue head +QUEUE_SPACING = ( + 200 # Spacing between consecutive bots in the charging queue (along X axis) +) + +CHARGE_REGION_X = QUEUE_HEAD_X +WORK_REGION_X = 1800 + +dotbot_controllers = dict() + + +async def fetch_active_dotbots(client: RestClient) -> List[DotBotModel]: + return await client.fetch_dotbots( + query=DotBotQueryModel(status=DotBotStatus.ACTIVE) + ) + + +def order_bots( + dotbots: List[DotBotModel], base_x: int, base_y: int +) -> List[DotBotModel]: + def key(bot: DotBotModel): + dx = bot.lh2_position.x - base_x + dy = bot.lh2_position.y - base_y + return (dx * dx + dy * dy, bot.address) + + return sorted(dotbots, key=key) + + +def assign_goals( + ordered: List[DotBotModel], + head_x: int, + head_y: int, + spacing: int, +) -> Dict[str, dict]: + goals = {} + for i, bot in enumerate(ordered): + + # TODO: depending on the robot's current state (moving to base or work regions), assign a different goal + + goals[bot.address] = { + "x": head_x, + "y": head_y + i * spacing, + } + return goals + + +def preferred_vel(dotbot: DotBotModel, goal: Vec2 | None) -> Vec2: + if goal is None: + return Vec2(x=0, y=0) + + dx = goal["x"] - dotbot.lh2_position.x + dy = goal["y"] - dotbot.lh2_position.y + dist = math.sqrt(dx * dx + dy * dy) + + dist1000 = dist * 1000 + # If close to goal, stop + if dist1000 < THRESHOLD: + return Vec2(x=0, y=0) + + # Right-hand rule bias + bias_angle = 0.0 + # Bot can only walk on a cone [-60, 60] in front of himself + max_deviation = math.radians(60) + + # Convert bot direction into radians + direction = direction_to_rad(dotbot.direction) + + # Angle to goal + angle_to_goal = math.atan2(dy, dx) + bias_angle + + delta = angle_to_goal - direction + # Wrap to [-π, +π] + delta = math.atan2(math.sin(delta), math.cos(delta)) + + # Clamp delta to [-MAX, +MAX] + if delta > max_deviation: + delta = max_deviation + if delta < -max_deviation: + delta = -max_deviation + + # Final allowed direction + final_angle = direction + delta + result = Vec2( + x=math.cos(final_angle) * MAX_SPEED, y=math.sin(final_angle) * MAX_SPEED + ) + return result + + +def direction_to_rad(direction: float) -> float: + rad = (direction + 90) * math.pi / 180.0 + return math.atan2(math.sin(rad), math.cos(rad)) # normalize to [-π, π] + + +async def compute_orca_velocity( + agent: Agent, + neighbors: List[Agent], + params: OrcaParams, +) -> Vec2: + return compute_orca_velocity_for_agent(agent, neighbors, params) + + +async def main() -> None: + params = OrcaParams(time_horizon=5 * DT, time_step=DT) + url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") + port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") + use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) + sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/work_and_charge/models/supervisor.yaml") + async with rest_client(url, port, use_https) as client: + dotbots = await fetch_active_dotbots(client) + + # Initialization + for dotbot in dotbots: + + # Init controller + controller = Controller(dotbot.address, sct_path) + dotbot_controllers[dotbot.address] = controller + + # Cosmetic: all bots are red + await client.send_rgb_led_command( + address=dotbot.address, + command=DotBotRgbLedCommandModel(red=255, green=0, blue=0), + ) + + # Set work and charge goals for each robot + # sorted_bots = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) + sorted_bots = sorted(dotbots, key=lambda bot: bot.address) + base_goals = assign_goals(sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING) + work_goals = assign_goals(sorted_bots, WORK_REGION_X, QUEUE_HEAD_Y, QUEUE_SPACING) + + for address, controller in dotbot_controllers.items(): + goal = base_goals[address] + waypoint_charge = DotBotLH2Position(x=goal['x'], y=goal['y'], z=0) + controller.set_charge_waypoint(waypoint_charge) + + goal = work_goals[address] + waypoint_work = DotBotLH2Position(x=goal['x'], y=goal['y'], z=0) + controller.set_work_waypoint(waypoint_work) + + while True: + try: + ws = DotBotWsClient(url, port) + await ws.connect() + + while True: + dotbots = await fetch_active_dotbots(client) + + goals = dict() + agents: Dict[str, Agent] = {} + + for bot in dotbots: + + # print(f'waypoint_current for DotBot {bot.address}: {dotbot_controllers.get(bot.address).waypoint_current}') + + # Get current goals + if dotbot_controllers.get(bot.address).waypoint_current is not None: + goals[bot.address] = { + "x": dotbot_controllers[bot.address].waypoint_current.x, + "y": dotbot_controllers[bot.address].waypoint_current.y, + } + + agents[bot.address] = Agent( + id=bot.address, + position=Vec2(x=bot.lh2_position.x, y=bot.lh2_position.y), + velocity=Vec2(x=0, y=0), + radius=BOT_RADIUS, + max_speed=MAX_SPEED, + preferred_velocity=preferred_vel( + dotbot=bot, goal=goals.get(bot.address) + ), + ) + + # Prepare coordinates for all agents + # Extract [x, y] for every agent in the same order + agent_list = list(agents.values()) + positions = np.array([[a.position.x, a.position.y] for a in agent_list]) + + # Build the KD-Tree + tree = cKDTree(positions) + + # Run controller for each robot + for dotbot in dotbots: + agent = agents[dotbot.address] + pos = dotbot.lh2_position + # print(f"DotBot {dotbot.address}: Position ({pos.x:.2f}, {pos.y:.2f}), Direction {dotbot.direction:.2f}°") + + # Run controller + controller = dotbot_controllers[dotbot.address] + controller.set_current_position(pos) # update position + controller.control_step() # run SCT step + + # Get current goal + goal = controller.waypoint_current + goals[dotbot.address] = { + "x": goal.x, + "y": goal.y, + } + + ### Send goal + + # Without KD-Tree + # local_neighbors = [neighbor for neighbor in agents.values() if neighbor.id != agent.id] + + # Using KD-Tree: Prepare neighbor list using KD-Tree + neighbor_indices = tree.query_ball_point([agent.position.x, agent.position.y], r=ORCA_RANGE) + local_neighbors = [agent_list[idx] for idx in neighbor_indices if agent_list[idx].id != agent.id] + + if not local_neighbors: + orca_vel = agent.preferred_velocity + else: + orca_vel = await compute_orca_velocity( + agent, neighbors=local_neighbors, params=params + ) + step = Vec2(x=orca_vel.x, y=orca_vel.y) + + # print(f'goal for DotBot {dotbot.address}: ({goal.x:.2f}, {goal.y:.2f}), step: ({step.x:.4f}, {step.y:.4f})') + + # ---- CLAMP STEP TO GOAL DISTANCE ---- + goal = goals.get(agent.id) + if goal is not None: + dx = goal["x"] - agent.position.x + dy = goal["y"] - agent.position.y + dist_to_goal = math.hypot(dx, dy) + + step_len = math.hypot(step.x, step.y) + if step_len > dist_to_goal and step_len > 0: + scale = dist_to_goal / step_len + step = Vec2(x=step.x * scale, y=step.y * scale) + # ------------------------------------ + + ### TEMPORARY: The simulator does not accept negative coordinates, + # so we clamp the step to keep the next position non-negative. + if agent.position.x + step.x < 0: + step.y = step.y * (abs(step.x) / MAX_SPEED) + step.x = -agent.position.x + if agent.position.y + step.y < 0: + step.x = step.x * (abs(step.y) / MAX_SPEED) + step.y = -agent.position.y + ### + + waypoints = DotBotWaypoints( + threshold=THRESHOLD, + waypoints=[ + DotBotLH2Position( + x=agent.position.x + step.x, y=agent.position.y + step.y, z=0 + ) + ], + ) + await ws.send( + WSWaypoints( + cmd="waypoints", + address=agent.id, + application=ApplicationType.DotBot, + data=waypoints, + ) + ) + await ws.send( + WSRgbLed( + cmd="rgb_led", + address=agent.id, + application=ApplicationType.DotBot, + data=DotBotRgbLedCommandModel( + red=controller.led[0], + green=controller.led[1], + blue=controller.led[2], + ), + ) + ) + + await asyncio.sleep(DT) + except Exception as e: + print(f"Connection lost: {e}") + print("Retrying in 1 seconds...") + await asyncio.sleep(1) # Wait before trying to reconnect + finally: + await ws.close() + + return None + + +if __name__ == "__main__": + asyncio.run(main()) From 5d5446c4159635a556a18fff844c872d7f9f30e2 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Thu, 12 Feb 2026 14:33:58 +0000 Subject: [PATCH 05/13] pyproject.toml: remove example specific packages --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c5fd3b..5bef8e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,6 @@ dependencies = [ "marilib-pkg >= 0.8.0", "pydotbot-utils >= 0.3.0", "toml >= 0.10.2", - "pyyaml >= 6.0.3", - "scipy >= 1.17.0", ] description = "Package to easily control your DotBots and SailBots." readme = "README.md" From dbde5d04c978f64b8ae7639eef368bd0dc0c4311 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Thu, 12 Feb 2026 14:34:25 +0000 Subject: [PATCH 06/13] dotbot/exmaples: add readme --- dotbot/examples/minimum_naming_game/README.md | 51 +++++++++++++++++++ dotbot/examples/work_and_charge/README.md | 33 ++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 dotbot/examples/minimum_naming_game/README.md create mode 100644 dotbot/examples/work_and_charge/README.md diff --git a/dotbot/examples/minimum_naming_game/README.md b/dotbot/examples/minimum_naming_game/README.md new file mode 100644 index 0000000..5882c92 --- /dev/null +++ b/dotbot/examples/minimum_naming_game/README.md @@ -0,0 +1,51 @@ +# Minimum Naming Game + +This demo runs the minimum naming game in the DotBot simulator, where the robots use local communication to converge on a single word. + +This demo includes two variants: a static setup without motion and a dynamic setup with motion. + +## Install Python packages (pip) + +Install the Python packages required to run this demo. + +```bash +pip install pyyaml scipy +``` + +## How to run + +1. Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](config_sample.toml). + +**Static setup** (without motion) using init_state.toml: + +```toml +simulator_init_state_path = "dotbot/examples/minimum_naming_game/init_state.toml" +``` + +**Dynamic setup** (with motion) using init_state_with_motion.toml: + +```toml +simulator_init_state_path = "dotbot/examples/minimum_naming_game/init_state_with_motion.toml" +``` + +2. Start the controller in simulator mode: + +```bash +python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error +``` + +3. Run the minimum naming game scenario: + +Open a new terminal and run the minimum naming game scenario. + +**Static setup** (without motion): + +```bash +python -m dotbot.examples.minimum_naming_game.minimum_naming_game +``` + +**Dynamic setup** (with motion) : + +```bash +python -m dotbot.examples.minimum_naming_game.minimum_naming_game_with_motion +``` \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/README.md b/dotbot/examples/work_and_charge/README.md new file mode 100644 index 0000000..ce69be2 --- /dev/null +++ b/dotbot/examples/work_and_charge/README.md @@ -0,0 +1,33 @@ +# Work and Charge + +This demo shows a work-and-charge scenario in the DotBot simulator, where agents alternate moving between two regions to perform some work and return to charge. + +## Install Python packages (pip) + +Install the Python packages required to run this demo. + +```bash +pip install pyyaml +``` + +## How to run + +1. Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](https://github.com/DotBots/PyDotBot/blob/main/config_sample.toml). + +```toml +simulator_init_state_path = "dotbot/examples/work_and_charge/init_state.toml" +``` + +2. Start the controller in simulator mode: + +```bash +python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error +``` + +3. Run the work-and-charge scenario: + +Open a new terminal and run the work-and-charge scenario. + +```bash +python -m dotbot.examples.work_and_charge.work_and_charge +``` From 0c73f79936c04662434b627813890f47e21d32fa Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Thu, 12 Feb 2026 14:41:00 +0000 Subject: [PATCH 07/13] dotbot/examples: update readme --- dotbot/examples/minimum_naming_game/README.md | 8 +++++--- dotbot/examples/work_and_charge/README.md | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dotbot/examples/minimum_naming_game/README.md b/dotbot/examples/minimum_naming_game/README.md index 5882c92..acf6714 100644 --- a/dotbot/examples/minimum_naming_game/README.md +++ b/dotbot/examples/minimum_naming_game/README.md @@ -14,7 +14,9 @@ pip install pyyaml scipy ## How to run -1. Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](config_sample.toml). +### Specify the initial state + +Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](config_sample.toml). **Static setup** (without motion) using init_state.toml: @@ -28,13 +30,13 @@ simulator_init_state_path = "dotbot/examples/minimum_naming_game/init_state.toml simulator_init_state_path = "dotbot/examples/minimum_naming_game/init_state_with_motion.toml" ``` -2. Start the controller in simulator mode: +### Start the controller in simulator mode ```bash python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error ``` -3. Run the minimum naming game scenario: +### Run the minimum naming game scenario Open a new terminal and run the minimum naming game scenario. diff --git a/dotbot/examples/work_and_charge/README.md b/dotbot/examples/work_and_charge/README.md index ce69be2..292611c 100644 --- a/dotbot/examples/work_and_charge/README.md +++ b/dotbot/examples/work_and_charge/README.md @@ -12,19 +12,21 @@ pip install pyyaml ## How to run -1. Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](https://github.com/DotBots/PyDotBot/blob/main/config_sample.toml). +### Specify the initial state + +Specify the initial state of the DotBots by replacing the file path for ```simulator_init_state_path``` in [config_sample.toml](https://github.com/DotBots/PyDotBot/blob/main/config_sample.toml). ```toml simulator_init_state_path = "dotbot/examples/work_and_charge/init_state.toml" ``` -2. Start the controller in simulator mode: +### Start the controller in simulator mode ```bash python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error ``` -3. Run the work-and-charge scenario: +### Run the work-and-charge scenario Open a new terminal and run the work-and-charge scenario. From be32a6ea25da4b97193ec85eca149cab8696dfdb Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Tue, 24 Feb 2026 13:21:22 +0000 Subject: [PATCH 08/13] dotbot/examples: update README --- dotbot/examples/minimum_naming_game/README.md | 2 +- dotbot/examples/work_and_charge/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotbot/examples/minimum_naming_game/README.md b/dotbot/examples/minimum_naming_game/README.md index acf6714..ce42302 100644 --- a/dotbot/examples/minimum_naming_game/README.md +++ b/dotbot/examples/minimum_naming_game/README.md @@ -33,7 +33,7 @@ simulator_init_state_path = "dotbot/examples/minimum_naming_game/init_state_with ### Start the controller in simulator mode ```bash -python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error +python -m dotbot.controller_app --config-path config_sample.toml -a dotbot-simulator --log-level error ``` ### Run the minimum naming game scenario diff --git a/dotbot/examples/work_and_charge/README.md b/dotbot/examples/work_and_charge/README.md index 292611c..415b38b 100644 --- a/dotbot/examples/work_and_charge/README.md +++ b/dotbot/examples/work_and_charge/README.md @@ -23,7 +23,7 @@ simulator_init_state_path = "dotbot/examples/work_and_charge/init_state.toml" ### Start the controller in simulator mode ```bash -python -m dotbot.controller_app --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator --log-level error +python -m dotbot.controller_app --config-path config_sample.toml -a dotbot-simulator --log-level error ``` ### Run the work-and-charge scenario From ebd56b25f2815f0ac622a453429004264230158d Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Tue, 24 Feb 2026 13:22:43 +0000 Subject: [PATCH 09/13] dotbot/examples: add calibrated = 0xff to init_state configs --- .../{gen_init_pos.py => gen_init_pose.py} | 26 ++++++++++++------- .../minimum_naming_game/init_state.toml | 25 ++++++++++++++++++ .../init_state_with_motion.toml | 9 +++++++ .../examples/work_and_charge/gen_init_pose.py | 13 ++++++---- .../examples/work_and_charge/init_state.toml | 8 ++++++ 5 files changed, 66 insertions(+), 15 deletions(-) rename dotbot/examples/minimum_naming_game/{gen_init_pos.py => gen_init_pose.py} (75%) diff --git a/dotbot/examples/minimum_naming_game/gen_init_pos.py b/dotbot/examples/minimum_naming_game/gen_init_pose.py similarity index 75% rename from dotbot/examples/minimum_naming_game/gen_init_pos.py rename to dotbot/examples/minimum_naming_game/gen_init_pose.py index 617cf36..e6c26d7 100644 --- a/dotbot/examples/minimum_naming_game/gen_init_pos.py +++ b/dotbot/examples/minimum_naming_game/gen_init_pose.py @@ -2,38 +2,44 @@ import math from pathlib import Path + def format_with_underscores(value): """Formats an integer with underscores every three digits.""" return f"{value:_}" -def generate_lattice_toml(width_count, height_count, sep_x, sep_y, start_x=120, start_y=120): + +def generate_lattice_toml( + width_count, height_count, sep_x, sep_y, start_x=120, start_y=120 +): output_lines = [] - + for row in range(height_count): for col in range(width_count): bot_id = row * width_count + col + 1 address = f"AAAAAAAA{bot_id:08X}" - + # Calculate positions pos_x = start_x + (col * sep_x) pos_y = start_y + (row * sep_y) - + # Randomize theta between 0 and 2*pi random_theta = round(random.uniform(0, 2 * math.pi), 2) # Manually build the TOML entry string to preserve underscores output_lines.append("[[dotbots]]") output_lines.append(f'address = "{address}"') - output_lines.append(f'pos_x = {pos_x:_}') - output_lines.append(f'pos_y = {pos_y:_}') + output_lines.append("calibrated = 0xff") + output_lines.append(f"pos_x = {pos_x:_}") + output_lines.append(f"pos_y = {pos_y:_}") output_lines.append(f"theta = {random_theta}") - output_lines.append("") # Empty line for readability - + output_lines.append("") # Empty line for readability + return "\n".join(output_lines) + # --- Configuration --- WIDTH_NODES = 5 # Robots per row -HEIGHT_NODES = 5 # Number of rows +HEIGHT_NODES = 5 # Number of rows SEP_X = 240 # Separation between columns SEP_Y = 240 # Separation between rows @@ -45,4 +51,4 @@ def generate_lattice_toml(width_count, height_count, sep_x, sep_y, start_x=120, with open(output_path, "w") as f: f.write(toml_string) -print(f"Generated TOML file at {output_path}") \ No newline at end of file +print(f"Generated TOML file at {output_path}") diff --git a/dotbot/examples/minimum_naming_game/init_state.toml b/dotbot/examples/minimum_naming_game/init_state.toml index c2913fb..55577c3 100644 --- a/dotbot/examples/minimum_naming_game/init_state.toml +++ b/dotbot/examples/minimum_naming_game/init_state.toml @@ -1,149 +1,174 @@ [[dotbots]] address = "AAAAAAAA00000001" +calibrated = 0xff pos_x = 120 pos_y = 120 theta = 4.38 [[dotbots]] address = "AAAAAAAA00000002" +calibrated = 0xff pos_x = 360 pos_y = 120 theta = 0.56 [[dotbots]] address = "AAAAAAAA00000003" +calibrated = 0xff pos_x = 600 pos_y = 120 theta = 2.12 [[dotbots]] address = "AAAAAAAA00000004" +calibrated = 0xff pos_x = 840 pos_y = 120 theta = 2.37 [[dotbots]] address = "AAAAAAAA00000005" +calibrated = 0xff pos_x = 1_080 pos_y = 120 theta = 0.69 [[dotbots]] address = "AAAAAAAA00000006" +calibrated = 0xff pos_x = 120 pos_y = 360 theta = 5.21 [[dotbots]] address = "AAAAAAAA00000007" +calibrated = 0xff pos_x = 360 pos_y = 360 theta = 5.09 [[dotbots]] address = "AAAAAAAA00000008" +calibrated = 0xff pos_x = 600 pos_y = 360 theta = 3.86 [[dotbots]] address = "AAAAAAAA00000009" +calibrated = 0xff pos_x = 840 pos_y = 360 theta = 2.66 [[dotbots]] address = "AAAAAAAA0000000A" +calibrated = 0xff pos_x = 1_080 pos_y = 360 theta = 3.44 [[dotbots]] address = "AAAAAAAA0000000B" +calibrated = 0xff pos_x = 120 pos_y = 600 theta = 2.64 [[dotbots]] address = "AAAAAAAA0000000C" +calibrated = 0xff pos_x = 360 pos_y = 600 theta = 2.15 [[dotbots]] address = "AAAAAAAA0000000D" +calibrated = 0xff pos_x = 600 pos_y = 600 theta = 5.72 [[dotbots]] address = "AAAAAAAA0000000E" +calibrated = 0xff pos_x = 840 pos_y = 600 theta = 0.24 [[dotbots]] address = "AAAAAAAA0000000F" +calibrated = 0xff pos_x = 1_080 pos_y = 600 theta = 0.07 [[dotbots]] address = "AAAAAAAA00000010" +calibrated = 0xff pos_x = 120 pos_y = 840 theta = 4.61 [[dotbots]] address = "AAAAAAAA00000011" +calibrated = 0xff pos_x = 360 pos_y = 840 theta = 2.62 [[dotbots]] address = "AAAAAAAA00000012" +calibrated = 0xff pos_x = 600 pos_y = 840 theta = 4.47 [[dotbots]] address = "AAAAAAAA00000013" +calibrated = 0xff pos_x = 840 pos_y = 840 theta = 4.52 [[dotbots]] address = "AAAAAAAA00000014" +calibrated = 0xff pos_x = 1_080 pos_y = 840 theta = 6.11 [[dotbots]] address = "AAAAAAAA00000015" +calibrated = 0xff pos_x = 120 pos_y = 1_080 theta = 2.42 [[dotbots]] address = "AAAAAAAA00000016" +calibrated = 0xff pos_x = 360 pos_y = 1_080 theta = 1.86 [[dotbots]] address = "AAAAAAAA00000017" +calibrated = 0xff pos_x = 600 pos_y = 1_080 theta = 1.56 [[dotbots]] address = "AAAAAAAA00000018" +calibrated = 0xff pos_x = 840 pos_y = 1_080 theta = 0.86 [[dotbots]] address = "AAAAAAAA00000019" +calibrated = 0xff pos_x = 1_080 pos_y = 1_080 theta = 3.28 diff --git a/dotbot/examples/minimum_naming_game/init_state_with_motion.toml b/dotbot/examples/minimum_naming_game/init_state_with_motion.toml index c211177..5837d93 100644 --- a/dotbot/examples/minimum_naming_game/init_state_with_motion.toml +++ b/dotbot/examples/minimum_naming_game/init_state_with_motion.toml @@ -1,53 +1,62 @@ [[dotbots]] address = "AAAAAAAA00000001" +calibrated = 0xff pos_x = 240 pos_y = 240 theta = 3.5 [[dotbots]] address = "AAAAAAAA00000002" +calibrated = 0xff pos_x = 720 pos_y = 240 theta = 6.0 [[dotbots]] address = "AAAAAAAA00000003" +calibrated = 0xff pos_x = 1_200 pos_y = 240 theta = 1.64 [[dotbots]] address = "AAAAAAAA00000004" +calibrated = 0xff pos_x = 240 pos_y = 720 theta = 1.23 [[dotbots]] address = "AAAAAAAA00000005" +calibrated = 0xff pos_x = 720 pos_y = 720 theta = 4.16 [[dotbots]] address = "AAAAAAAA00000006" +calibrated = 0xff pos_x = 1_200 pos_y = 720 theta = 3.81 [[dotbots]] address = "AAAAAAAA00000007" +calibrated = 0xff pos_x = 240 pos_y = 1_200 theta = 2.8 [[dotbots]] address = "AAAAAAAA00000008" +calibrated = 0xff pos_x = 720 pos_y = 1_200 theta = 1.54 [[dotbots]] address = "AAAAAAAA00000009" +calibrated = 0xff pos_x = 1_200 pos_y = 1_200 theta = 6.03 diff --git a/dotbot/examples/work_and_charge/gen_init_pose.py b/dotbot/examples/work_and_charge/gen_init_pose.py index 2dbe876..9880d22 100644 --- a/dotbot/examples/work_and_charge/gen_init_pose.py +++ b/dotbot/examples/work_and_charge/gen_init_pose.py @@ -1,9 +1,10 @@ from pathlib import Path + def generate_dotbot_script(): # Configuration Constants - NUM_ROBOTS = 8 # Total robots to generate - START_ID = 1 # Start at AAAAAAAA00000001 + NUM_ROBOTS = 8 # Total robots to generate + START_ID = 1 # Start at AAAAAAAA00000001 X_RIGHT = 800 X_LEFT = 100 START_Y = 200 @@ -30,7 +31,8 @@ def generate_dotbot_script(): # Build the TOML block block = ( f"[[dotbots]]\n" - f"address = \"{address_hex}\"\n" + f'address = "{address_hex}"\n' + f"calibrated = 0xff\n" f"pos_x = {pos_x}\n" f"pos_y = {pos_y}\n" f"theta = {THETA}\n" @@ -43,6 +45,7 @@ def generate_dotbot_script(): f.write("\n".join(lines)) print(f"Generated TOML file at {output_path}") - + + if __name__ == "__main__": - generate_dotbot_script() \ No newline at end of file + generate_dotbot_script() diff --git a/dotbot/examples/work_and_charge/init_state.toml b/dotbot/examples/work_and_charge/init_state.toml index 75622c9..0e70945 100644 --- a/dotbot/examples/work_and_charge/init_state.toml +++ b/dotbot/examples/work_and_charge/init_state.toml @@ -1,47 +1,55 @@ [[dotbots]] address = "AAAAAAAA00000001" +calibrated = 0xff pos_x = 800 pos_y = 200 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000002" +calibrated = 0xff pos_x = 100 pos_y = 400 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000003" +calibrated = 0xff pos_x = 800 pos_y = 600 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000004" +calibrated = 0xff pos_x = 100 pos_y = 800 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000005" +calibrated = 0xff pos_x = 800 pos_y = 1_000 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000006" +calibrated = 0xff pos_x = 100 pos_y = 1_200 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000007" +calibrated = 0xff pos_x = 800 pos_y = 1_400 theta = 3.14 [[dotbots]] address = "AAAAAAAA00000008" +calibrated = 0xff pos_x = 100 pos_y = 1_600 theta = 3.14 From 43d72181d0adf5b1c585bfaddd14beee4cc2f74a Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Tue, 24 Feb 2026 13:23:06 +0000 Subject: [PATCH 10/13] dotbot/examples: apply formatting --- .../minimum_naming_game/controller.py | 154 ++++++++-------- .../controller_with_motion.py | 172 +++++++++--------- .../minimum_naming_game.py | 49 +++-- .../minimum_naming_game_with_motion.py | 64 ++++--- .../minimum_naming_game/walk_avoid.py | 54 +++--- dotbot/examples/work_and_charge/controller.py | 40 ++-- .../work_and_charge/work_and_charge.py | 71 +++++--- 7 files changed, 322 insertions(+), 282 deletions(-) diff --git a/dotbot/examples/minimum_naming_game/controller.py b/dotbot/examples/minimum_naming_game/controller.py index cb5e467..e1a2270 100644 --- a/dotbot/examples/minimum_naming_game/controller.py +++ b/dotbot/examples/minimum_naming_game/controller.py @@ -6,9 +6,9 @@ from dotbot.examples.sct import SCT DISTINCT_COLORS = [ - (255, 0, 0), # Red - (0, 255, 0), # Lime - (0, 0, 255), # Blue + (255, 0, 0), # Red + (0, 255, 0), # Lime + (0, 0, 255), # Blue (255, 255, 0), # Yellow (255, 0, 255), # Magenta (0, 255, 255), # Cyan @@ -34,26 +34,25 @@ def __init__(self, address: str, path: str): self.led = (0, 0, 0) # initial LED color # --- Naming Game Variables --- - self.counter = 0 # FOR DEBUGGING - self.last_broadcast_ticks = 0 # Tracks timing - self.max_broadcast_ticks = 5 - + self.counter = 0 # FOR DEBUGGING + self.last_broadcast_ticks = 0 # Tracks timing + self.max_broadcast_ticks = 5 + # Pre-defined words (e.g., num_words = 128) self.num_words = 8 - self.words = list(range(self.num_words)) - + self.words = list(range(self.num_words)) + # Word reception state self.received_word = None # self.received_word_checked = True self.new_word_received = False - + # Global variable for the word chosen for transmission self.w_index = 0 - + # Inventory of known words self.inventory = set() - def control_step(self): self.counter += 1 # Increment step counter @@ -63,7 +62,6 @@ def control_step(self): self.color_code() # Update LED color based on inventory state - # Register callback functions to the generator player def add_callbacks(self): @@ -75,103 +73,97 @@ def add_callbacks(self): for event, index in events.items(): is_controllable = controllability_list[index] - stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + stripped_name = event.split("EV_", 1)[1] # Strip preceding string 'EV_' - if is_controllable: # Add controllable event - func_name = '_callback_{0}'.format(stripped_name) + if is_controllable: # Add controllable event + func_name = "_callback_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, func, None, None) - else: # Add uncontrollable event - func_name = '_check_{0}'.format(stripped_name) + else: # Add uncontrollable event + func_name = "_check_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, None, func, None) - # Callback functions (controllable events) def _callback_startTimer(self, data: any): - """ - Saves the current tick count to mark the start of the broadcast interval. - """ - # print(f'DotBot {self.address}. ACTION: startTimer') - self.last_broadcast_ticks = self.counter - + """ + Saves the current tick count to mark the start of the broadcast interval. + """ + # print(f'DotBot {self.address}. ACTION: startTimer') + self.last_broadcast_ticks = self.counter def _callback_selectAndBroadcast(self, data: any): - """ - Selects a random word from the inventory, or invents a new one - if the inventory is empty. Sets the flag for transmission. - """ - # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") - - # Select or Invent a word - if not self.inventory: - # Inventory is empty: invent a new word from the pool - self.w_index = random.randrange(self.num_words) - # Store the word (equivalent to inventory[0] = words[w_index].data[0]) - self.inventory.add(self.words[self.w_index]) - else: - # Inventory is not empty: pick a random word from current known words - self.w_index = random.choice(list(self.inventory)) - - # Set broadcast flag for transmission - self.broadcast_word = True - - # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') - + """ + Selects a random word from the inventory, or invents a new one + if the inventory is empty. Sets the flag for transmission. + """ + # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") + + # Select or Invent a word + if not self.inventory: + # Inventory is empty: invent a new word from the pool + self.w_index = random.randrange(self.num_words) + # Store the word (equivalent to inventory[0] = words[w_index].data[0]) + self.inventory.add(self.words[self.w_index]) + else: + # Inventory is not empty: pick a random word from current known words + self.w_index = random.choice(list(self.inventory)) + + # Set broadcast flag for transmission + self.broadcast_word = True + + # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') def _callback_updateInventory(self, data: any): - """ - Updates the inventory based on the last received word. - If the word is known, the agent reaches a local consensus (inventory collapses). - If unknown, the word is added to the agent's vocabulary. - """ - # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") - - # Ensure we have a word to process - if self.received_word is None: - return - - # Check if the received word is within the inventory - if self.received_word in self.inventory: - # SUCCESS: word is known. - # Remove all other words (collapse inventory to just this one) - self.inventory = {self.received_word} - # print(f' removed all other words, inventory now: {self.inventory}') - else: - # FAILURE: word is unknown. - # Insert it into the inventory - self.inventory.add(self.received_word) - # print(f' added word {self.received_word}, inventory now: {self.inventory}') - - # Mark as checked - self.received_word_checked = True + """ + Updates the inventory based on the last received word. + If the word is known, the agent reaches a local consensus (inventory collapses). + If unknown, the word is added to the agent's vocabulary. + """ + # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") + + # Ensure we have a word to process + if self.received_word is None: + return + # Check if the received word is within the inventory + if self.received_word in self.inventory: + # SUCCESS: word is known. + # Remove all other words (collapse inventory to just this one) + self.inventory = {self.received_word} + # print(f' removed all other words, inventory now: {self.inventory}') + else: + # FAILURE: word is unknown. + # Insert it into the inventory + self.inventory.add(self.received_word) + # print(f' added word {self.received_word}, inventory now: {self.inventory}') + + # Mark as checked + self.received_word_checked = True # Callback functions (uncontrollable events) def _check__selectAndBroadcast(self, data: any) -> bool: """ - Checks if a new word has been received. + Checks if a new word has been received. Returns True (1) if a word is waiting to be processed, otherwise False (0). """ if self.new_word_received: # Reset the flag self.new_word_received = False return True - + return False - def _check_timeout(self, data: any) -> bool: """ Checks if the broadcast timer has expired. - Returns True if the current counter exceeds the last broadcast time + Returns True if the current counter exceeds the last broadcast time plus the defined interval. - """ + """ if self.counter > (self.last_broadcast_ticks + self.max_broadcast_ticks): return True - - return False + return False def color_code(self): """ @@ -191,14 +183,14 @@ def color_code(self): # # 3. Calculate RGB components using the original base-4 logic # # Mapping word index (0-127) to a color space (1-64) # color = (word % 63) + 1 - + # r = color // 16 # rem1 = color % 16 # g = rem1 // 4 # b = rem1 % 4 # # 4. Update the LED state - # # Note: Original Kilobot RGB values are 0-3. + # # Note: Original Kilobot RGB values are 0-3. # # convert to range 0-255. # self.led = (r * 85, g * 85, b * 85) # ------------------------ @@ -209,4 +201,4 @@ def color_code(self): # Assign the high-contrast color self.led = DISTINCT_COLORS[color_idx] - # ----------------------------------------- \ No newline at end of file + # ----------------------------------------- diff --git a/dotbot/examples/minimum_naming_game/controller_with_motion.py b/dotbot/examples/minimum_naming_game/controller_with_motion.py index ecb2199..f9808d2 100644 --- a/dotbot/examples/minimum_naming_game/controller_with_motion.py +++ b/dotbot/examples/minimum_naming_game/controller_with_motion.py @@ -8,9 +8,9 @@ from dotbot.examples.minimum_naming_game.walk_avoid import walk_avoid DISTINCT_COLORS = [ - (255, 0, 0), # Red - (0, 255, 0), # Lime - (0, 0, 255), # Blue + (255, 0, 0), # Red + (0, 255, 0), # Lime + (0, 0, 255), # Blue (255, 255, 0), # Yellow (255, 0, 255), # Magenta (0, 255, 255), # Cyan @@ -20,7 +20,13 @@ class Controller: - def __init__(self, address: str, path: str, max_speed: float, arena_limits: tuple[float, float]): + def __init__( + self, + address: str, + path: str, + max_speed: float, + arena_limits: tuple[float, float], + ): self.address = address self.max_speed = max_speed self.arena_limits = arena_limits @@ -39,26 +45,25 @@ def __init__(self, address: str, path: str, max_speed: float, arena_limits: tupl self.led = (0, 0, 0) # initial LED color # --- Naming Game Variables --- - self.counter = 0 # FOR DEBUGGING - self.last_broadcast_ticks = 0 # Tracks timing - self.max_broadcast_ticks = 5 - + self.counter = 0 # FOR DEBUGGING + self.last_broadcast_ticks = 0 # Tracks timing + self.max_broadcast_ticks = 5 + # Pre-defined words (e.g., num_words = 128) self.num_words = 8 - self.words = list(range(self.num_words)) - + self.words = list(range(self.num_words)) + # Word reception state self.received_word = None # self.received_word_checked = True self.new_word_received = False - + # Global variable for the word chosen for transmission self.w_index = 0 - + # Inventory of known words self.inventory = set() - def control_step(self): self.counter += 1 # Increment step counter @@ -68,10 +73,16 @@ def control_step(self): self.color_code() # Update LED color based on inventory state - self.vector = walk_avoid(self.position.x, self.position.y, self.direction, self.neighbors, self.max_speed, self.arena_limits) + self.vector = walk_avoid( + self.position.x, + self.position.y, + self.direction, + self.neighbors, + self.max_speed, + self.arena_limits, + ) # print(f'DotBot {self.address} Walk Vector: {self.vector}') - def update_pose(self, position: DotBotLH2Position) -> None: if self.prev_position is not None: dx = position.x - self.prev_position.x @@ -83,7 +94,6 @@ def update_pose(self, position: DotBotLH2Position) -> None: self.prev_position = position self.position = position - # Register callback functions to the generator player def add_callbacks(self): @@ -95,103 +105,97 @@ def add_callbacks(self): for event, index in events.items(): is_controllable = controllability_list[index] - stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + stripped_name = event.split("EV_", 1)[1] # Strip preceding string 'EV_' - if is_controllable: # Add controllable event - func_name = '_callback_{0}'.format(stripped_name) + if is_controllable: # Add controllable event + func_name = "_callback_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, func, None, None) - else: # Add uncontrollable event - func_name = '_check_{0}'.format(stripped_name) + else: # Add uncontrollable event + func_name = "_check_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, None, func, None) - # Callback functions (controllable events) def _callback_startTimer(self, data: any): - """ - Saves the current tick count to mark the start of the broadcast interval. - """ - # print(f'DotBot {self.address}. ACTION: startTimer') - self.last_broadcast_ticks = self.counter - + """ + Saves the current tick count to mark the start of the broadcast interval. + """ + # print(f'DotBot {self.address}. ACTION: startTimer') + self.last_broadcast_ticks = self.counter def _callback_selectAndBroadcast(self, data: any): - """ - Selects a random word from the inventory, or invents a new one - if the inventory is empty. Sets the flag for transmission. - """ - # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") - - # Select or Invent a word - if not self.inventory: - # Inventory is empty: invent a new word from the pool - self.w_index = random.randrange(self.num_words) - # Store the word (equivalent to inventory[0] = words[w_index].data[0]) - self.inventory.add(self.words[self.w_index]) - else: - # Inventory is not empty: pick a random word from current known words - self.w_index = random.choice(list(self.inventory)) - - # Set broadcast flag for transmission - self.broadcast_word = True - - # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') - + """ + Selects a random word from the inventory, or invents a new one + if the inventory is empty. Sets the flag for transmission. + """ + # print(f'DotBot {self.address}. ACTION: selectAndBroadcast', end=". ") + + # Select or Invent a word + if not self.inventory: + # Inventory is empty: invent a new word from the pool + self.w_index = random.randrange(self.num_words) + # Store the word (equivalent to inventory[0] = words[w_index].data[0]) + self.inventory.add(self.words[self.w_index]) + else: + # Inventory is not empty: pick a random word from current known words + self.w_index = random.choice(list(self.inventory)) + + # Set broadcast flag for transmission + self.broadcast_word = True + + # print(f'\tinventory: {self.inventory},\tselected word: {self.w_index}') def _callback_updateInventory(self, data: any): - """ - Updates the inventory based on the last received word. - If the word is known, the agent reaches a local consensus (inventory collapses). - If unknown, the word is added to the agent's vocabulary. - """ - # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") - - # Ensure we have a word to process - if self.received_word is None: - return - - # Check if the received word is within the inventory - if self.received_word in self.inventory: - # SUCCESS: word is known. - # Remove all other words (collapse inventory to just this one) - self.inventory = {self.received_word} - # print(f' removed all other words, inventory now: {self.inventory}') - else: - # FAILURE: word is unknown. - # Insert it into the inventory - self.inventory.add(self.received_word) - # print(f' added word {self.received_word}, inventory now: {self.inventory}') - - # Mark as checked - self.received_word_checked = True + """ + Updates the inventory based on the last received word. + If the word is known, the agent reaches a local consensus (inventory collapses). + If unknown, the word is added to the agent's vocabulary. + """ + # print(f'DotBot {self.address}. ACTION: updateInventory', end=". ") + + # Ensure we have a word to process + if self.received_word is None: + return + # Check if the received word is within the inventory + if self.received_word in self.inventory: + # SUCCESS: word is known. + # Remove all other words (collapse inventory to just this one) + self.inventory = {self.received_word} + # print(f' removed all other words, inventory now: {self.inventory}') + else: + # FAILURE: word is unknown. + # Insert it into the inventory + self.inventory.add(self.received_word) + # print(f' added word {self.received_word}, inventory now: {self.inventory}') + + # Mark as checked + self.received_word_checked = True # Callback functions (uncontrollable events) def _check__selectAndBroadcast(self, data: any) -> bool: """ - Checks if a new word has been received. + Checks if a new word has been received. Returns True (1) if a word is waiting to be processed, otherwise False (0). """ if self.new_word_received: # Reset the flag self.new_word_received = False return True - + return False - def _check_timeout(self, data: any) -> bool: """ Checks if the broadcast timer has expired. - Returns True if the current counter exceeds the last broadcast time + Returns True if the current counter exceeds the last broadcast time plus the defined interval. - """ + """ if self.counter > (self.last_broadcast_ticks + self.max_broadcast_ticks): return True - - return False + return False def color_code(self): """ @@ -211,14 +215,14 @@ def color_code(self): # # 3. Calculate RGB components using the original base-4 logic # # Mapping word index (0-127) to a color space (1-64) # color = (word % 63) + 1 - + # r = color // 16 # rem1 = color % 16 # g = rem1 // 4 # b = rem1 % 4 # # 4. Update the LED state - # # Note: Original Kilobot RGB values are 0-3. + # # Note: Original Kilobot RGB values are 0-3. # # convert to range 0-255. # self.led = (r * 85, g * 85, b * 85) # ------------------------ @@ -229,4 +233,4 @@ def color_code(self): # Assign the high-contrast color self.led = DISTINCT_COLORS[color_idx] - # ----------------------------------------- \ No newline at end of file + # ----------------------------------------- diff --git a/dotbot/examples/minimum_naming_game/minimum_naming_game.py b/dotbot/examples/minimum_naming_game/minimum_naming_game.py index 553d5e2..00b9b21 100644 --- a/dotbot/examples/minimum_naming_game/minimum_naming_game.py +++ b/dotbot/examples/minimum_naming_game/minimum_naming_game.py @@ -21,8 +21,8 @@ import random from scipy.spatial import cKDTree -COMM_RANGE=250 -THRESHOLD=50 +COMM_RANGE = 250 +THRESHOLD = 50 # TODO: Measure these values for real dotbots BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance @@ -40,7 +40,9 @@ async def main() -> None: url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) - sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml") + sct_path = os.getenv( + "DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml" + ) async with rest_client(url, port, use_https) as client: dotbots = await fetch_active_dotbots(client) @@ -52,8 +54,8 @@ async def main() -> None: # Init controller controller = Controller(dotbot.address, sct_path) - dotbot_controllers[dotbot.address] = controller - # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') + dotbot_controllers[dotbot.address] = controller + # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') # 1. Extract positions into a list of [x, y] coordinates coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] @@ -83,11 +85,14 @@ async def main() -> None: # print(f'Controller position: {controller.position}, direction: {controller.direction}') # 1. Query the tree for indices of neighbors - neighbor_indices = tree.query_ball_point([dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE) - + neighbor_indices = tree.query_ball_point( + [dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE + ) + # 2. Convert indices back into actual DotBot objects neighbors = [ - dotbots[idx] for idx in neighbor_indices + dotbots[idx] + for idx in neighbor_indices if dotbots[idx].address != dotbot.address ] @@ -95,31 +100,35 @@ async def main() -> None: # 3. If there are neighbors broadcasting, pick ONE randomly to listen to if neighbors: - selected_neighbor = dotbot_controllers[random.choice(neighbors).address] + selected_neighbor = dotbot_controllers[ + random.choice(neighbors).address + ] # Share the word: take the neighbor's chosen word index if selected_neighbor.w_index != 0: controller.received_word = selected_neighbor.w_index - + # Set the flags so the robot knows it has a new message to process controller.new_word_received = True controller.received_word_checked = False - + # Update controller's neighbor list controller.neighbors = neighbors - + # Run controller - controller.control_step() # run SCT step + controller.control_step() # run SCT step # Force update waypoints = DotBotWaypoints( - threshold=THRESHOLD, - waypoints=[ - DotBotLH2Position( - x=dotbots[0].lh2_position.x, y=dotbots[0].lh2_position.y, z=0 - ) - ], - ) + threshold=THRESHOLD, + waypoints=[ + DotBotLH2Position( + x=dotbots[0].lh2_position.x, + y=dotbots[0].lh2_position.y, + z=0, + ) + ], + ) await client.send_waypoint_command( address=dotbots[0].address, application=ApplicationType.DotBot, diff --git a/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py index 51be125..958838a 100644 --- a/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py +++ b/dotbot/examples/minimum_naming_game/minimum_naming_game_with_motion.py @@ -27,8 +27,8 @@ import random from scipy.spatial import cKDTree -COMM_RANGE=250 -THRESHOLD=0 +COMM_RANGE = 250 +THRESHOLD = 0 # TODO: Measure these values for real dotbots BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance @@ -50,7 +50,9 @@ async def main() -> None: url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) - sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml") + sct_path = os.getenv( + "DOTBOT_SCT_PATH", "dotbot/examples/minimum_naming_game/models/supervisor.yaml" + ) async with rest_client(url, port, use_https) as client: dotbots = await fetch_active_dotbots(client) @@ -61,9 +63,14 @@ async def main() -> None: for dotbot in dotbots: # Init controller - controller = Controller(dotbot.address, sct_path, 0.9 * MAX_SPEED, arena_limits=(ARENA_SIZE_X, ARENA_SIZE_Y)) - dotbot_controllers[dotbot.address] = controller - # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') + controller = Controller( + dotbot.address, + sct_path, + 0.9 * MAX_SPEED, + arena_limits=(ARENA_SIZE_X, ARENA_SIZE_Y), + ) + dotbot_controllers[dotbot.address] = controller + # print(f'type of controller: {type(controller)} for DotBot {dotbot.address}') # 1. Extract positions into a list of [x, y] coordinates coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] @@ -88,7 +95,9 @@ async def main() -> None: # 1. Extract positions into a list of [x, y] coordinates # This loop iterates through your dotbot list and grabs the lh2_position attributes - coords = [[dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots] + coords = [ + [dotbot.lh2_position.x, dotbot.lh2_position.y] for dotbot in dotbots + ] # 2. Convert the list to a NumPy array # The structure will be (N, 2), where N is the number of dotbots @@ -106,11 +115,14 @@ async def main() -> None: # print(f'Controller position: {controller.position}, direction: {controller.direction}') # 1. Query the tree for indices of neighbors - neighbor_indices = tree.query_ball_point([dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE) - + neighbor_indices = tree.query_ball_point( + [dotbot.lh2_position.x, dotbot.lh2_position.y], r=COMM_RANGE + ) + # 2. Convert indices back into actual DotBot objects neighbors = [ - dotbots[idx] for idx in neighbor_indices + dotbots[idx] + for idx in neighbor_indices if dotbots[idx].address != dotbot.address ] @@ -118,30 +130,38 @@ async def main() -> None: # 3. If there are neighbors broadcasting, pick ONE randomly to listen to if neighbors: - selected_neighbor = dotbot_controllers[random.choice(neighbors).address] + selected_neighbor = dotbot_controllers[ + random.choice(neighbors).address + ] # Share the word: take the neighbor's chosen word index if selected_neighbor.w_index != 0: controller.received_word = selected_neighbor.w_index - + # Set the flags so the robot knows it has a new message to process controller.new_word_received = True controller.received_word_checked = False - + # Update controller's neighbor list controller.neighbors = neighbors - + # Run controller - controller.control_step() # run SCT step + controller.control_step() # run SCT step - ### TEMPORARY: The simulator does not accept negative coordinates, + ### TEMPORARY: The simulator does not accept negative coordinates, # so we set it to zero and scale the positive value proportionally. - point = DotBotLH2Position(x=controller.vector[0], y=controller.vector[1], z=0.0) + point = DotBotLH2Position( + x=controller.vector[0], y=controller.vector[1], z=0.0 + ) if dotbot.lh2_position.x + controller.vector[0] < 0: - point.y = controller.vector[1] * (controller.vector[0] / MAX_SPEED) + point.y = controller.vector[1] * ( + controller.vector[0] / MAX_SPEED + ) point.x = 0.0 if dotbot.lh2_position.y + controller.vector[1] < 0: - point.x = controller.vector[0] * (controller.vector[1] / MAX_SPEED) + point.x = controller.vector[0] * ( + controller.vector[1] / MAX_SPEED + ) point.y = 0.0 ### @@ -149,9 +169,9 @@ async def main() -> None: threshold=THRESHOLD, waypoints=[ DotBotLH2Position( - x=dotbot.lh2_position.x + round(point.x, 2), - y=dotbot.lh2_position.y + round(point.y, 2), - z=0 + x=dotbot.lh2_position.x + round(point.x, 2), + y=dotbot.lh2_position.y + round(point.y, 2), + z=0, ) ], ) diff --git a/dotbot/examples/minimum_naming_game/walk_avoid.py b/dotbot/examples/minimum_naming_game/walk_avoid.py index a5490c1..32454a3 100644 --- a/dotbot/examples/minimum_naming_game/walk_avoid.py +++ b/dotbot/examples/minimum_naming_game/walk_avoid.py @@ -3,19 +3,21 @@ from dotbot.models import DotBotModel -def walk_avoid(position_x: float, - position_y: float, - direction: float, - neighbors: list[DotBotModel], - max_speed: float, - arena_limits: tuple[float, float]) -> list[float]: +def walk_avoid( + position_x: float, + position_y: float, + direction: float, + neighbors: list[DotBotModel], + max_speed: float, + arena_limits: tuple[float, float], +) -> list[float]: """ Walk straight while avoiding collisions and arena boundary. Arena limits: x, y in [0.0, 1.0] """ UNIT_SPEED = max_speed - MARGIN = 0.1 # Trigger turn when within 10% of any edge - + MARGIN = 0.1 # Trigger turn when within 10% of any edge + # 1. Identify if any neighbor is too close neighbor_collision = False if neighbors: @@ -24,9 +26,13 @@ def walk_avoid(position_x: float, # 2. Identify if any arena boundary is violated curr_x = position_x curr_y = position_y - - wall_collision = (curr_x < MARGIN*arena_limits[0] or curr_x > (arena_limits[0] - MARGIN*arena_limits[0]) or - curr_y < MARGIN*arena_limits[1] or curr_y > (arena_limits[1] - MARGIN*arena_limits[1])) + + wall_collision = ( + curr_x < MARGIN * arena_limits[0] + or curr_x > (arena_limits[0] - MARGIN * arena_limits[0]) + or curr_y < MARGIN * arena_limits[1] + or curr_y > (arena_limits[1] - MARGIN * arena_limits[1]) + ) # 3. Determine "Local" movement local_v = [0.0, 0.0] @@ -35,15 +41,15 @@ def walk_avoid(position_x: float, if wall_collision: # Decide direction of repulsion (Left or Right) - if (curr_x < MARGIN*arena_limits[0]): + if curr_x < MARGIN * arena_limits[0]: local_v[0] += UNIT_SPEED - if (curr_x > (arena_limits[0] - MARGIN*arena_limits[0])): + if curr_x > (arena_limits[0] - MARGIN * arena_limits[0]): local_v[0] += -UNIT_SPEED - if (curr_y < MARGIN*arena_limits[1]): + if curr_y < MARGIN * arena_limits[1]: local_v[1] += UNIT_SPEED - if (curr_y > (arena_limits[1] - MARGIN*arena_limits[1])): + if curr_y > (arena_limits[1] - MARGIN * arena_limits[1]): local_v[1] += -UNIT_SPEED - + if neighbor_collision: avg_dx = sum(n.lh2_position.x - curr_x for n in neighbors) avg_dy = sum(n.lh2_position.y - curr_y for n in neighbors) @@ -56,21 +62,25 @@ def walk_avoid(position_x: float, # print(f"Neighbor avoidance vector: {local_v} from neighbors {[n.address for n in neighbors]}") # Normalize so we don't go double speed in corners - total_mag = math.sqrt(local_v[0]**2 + local_v[1]**2) + total_mag = math.sqrt(local_v[0] ** 2 + local_v[1] ** 2) if total_mag > 0: return ( (local_v[0] / total_mag) * UNIT_SPEED, (local_v[1] / total_mag) * UNIT_SPEED, ) return (0.0, 0.0) - + else: - local_v = [UNIT_SPEED, 0.0] # Normal forward motion + local_v = [UNIT_SPEED, 0.0] # Normal forward motion # 4. Rotate Local Vector to Global Vector theta_rad = math.radians(direction) - - global_vx = (local_v[0] * -math.cos(theta_rad)) - (local_v[1] * math.sin(theta_rad)) - global_vy = (local_v[0] * math.sin(theta_rad)) + (local_v[1] * -math.cos(theta_rad)) + + global_vx = (local_v[0] * -math.cos(theta_rad)) - ( + local_v[1] * math.sin(theta_rad) + ) + global_vy = (local_v[0] * math.sin(theta_rad)) + ( + local_v[1] * -math.cos(theta_rad) + ) return (global_vx, global_vy) diff --git a/dotbot/examples/work_and_charge/controller.py b/dotbot/examples/work_and_charge/controller.py index 8be9819..0cd0163 100644 --- a/dotbot/examples/work_and_charge/controller.py +++ b/dotbot/examples/work_and_charge/controller.py @@ -4,6 +4,7 @@ ) from dotbot.examples.sct import SCT + class Controller: def __init__(self, address: str, path: str): self.address = address @@ -13,24 +14,20 @@ def __init__(self, address: str, path: str): self.add_callbacks() self.waypoint_current = None - self.waypoint_threshold = 50 # default threshold + self.waypoint_threshold = 50 # default threshold self.led = (0, 0, 0) # initial LED color - self.energy = 'high' # initial energy level - + self.energy = "high" # initial energy level def set_work_waypoint(self, waypoint: DotBotLH2Position): self.waypoint_work = waypoint - def set_charge_waypoint(self, waypoint: DotBotLH2Position): self.waypoint_charge = waypoint - def set_current_position(self, position: DotBotLH2Position): self.position_current = position - def control_step(self): # Calculate distance to work waypoint @@ -46,7 +43,6 @@ def control_step(self): # Run SCT control step self.sct.run_step() - # Register callback functions to the generator player def add_callbacks(self): @@ -58,40 +54,35 @@ def add_callbacks(self): for event, index in events.items(): is_controllable = controllability_list[index] - stripped_name = event.split('EV_', 1)[1] # Strip preceding string 'EV_' + stripped_name = event.split("EV_", 1)[1] # Strip preceding string 'EV_' - if is_controllable: # Add controllable event - func_name = '_callback_{0}'.format(stripped_name) + if is_controllable: # Add controllable event + func_name = "_callback_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, func, None, None) - else: # Add uncontrollable event - func_name = '_check_{0}'.format(stripped_name) + else: # Add uncontrollable event + func_name = "_check_{0}".format(stripped_name) func = getattr(self, func_name) self.sct.add_callback(event, None, func, None) - # Callback functions (controllable events) def _callback_moveToWork(self, data: any): # print(f'DotBot {self.address}. ACTION: moveToWork') self.waypoint_current = self.waypoint_work self.led = (0, 255, 0) # Green LED when moving to work - def _callback_moveToCharge(self, data: any): # print(f'DotBot {self.address}. ACTION: moveToCharge') self.waypoint_current = self.waypoint_charge self.led = (255, 0, 0) # Red LED when moving to charge - def _callback_work(self, data: any): # print(f'DotBot {self.address}. ACTION: work') - self.energy = 'low' # After working, energy level goes low - + self.energy = "low" # After working, energy level goes low def _callback_charge(self, data: any): # print(f'DotBot {self.address}. ACTION: charge') - self.energy = 'high' # After charging, energy level goes high - + self.energy = "high" # After charging, energy level goes high # Callback functions (uncontrollable events) def _check_atWork(self, data: any): @@ -100,37 +91,32 @@ def _check_atWork(self, data: any): return True return False - def _check_notAtWork(self, data: any): if self.dist_work >= self.waypoint_threshold: # print(f'DotBot {self.address}. EVENT: notAtWork') return True return False - def _check_atCharger(self, data: any): if self.dist_charge < self.waypoint_threshold: # print(f'DotBot {self.address}. EVENT: atCharger') return True return False - def _check_notAtCharger(self, data: any): if self.dist_charge >= self.waypoint_threshold: # print(f'DotBot {self.address}. EVENT: notAtCharger') return True return False - def _check_lowEnergy(self, data: any): - if self.energy == 'low': + if self.energy == "low": # print(f'DotBot {self.address}. EVENT: lowEnergy') return True return False - def _check_highEnergy(self, data: any): - if self.energy == 'high': + if self.energy == "high": # print(f'DotBot {self.address}. EVENT: highEnergy') return True - return False \ No newline at end of file + return False diff --git a/dotbot/examples/work_and_charge/work_and_charge.py b/dotbot/examples/work_and_charge/work_and_charge.py index ba7af8e..f877784 100644 --- a/dotbot/examples/work_and_charge/work_and_charge.py +++ b/dotbot/examples/work_and_charge/work_and_charge.py @@ -40,7 +40,7 @@ BOT_RADIUS = 40 # Physical radius of a DotBot (unit), used for collision avoidance MAX_SPEED = 300 # Maximum allowed linear speed of a bot (mm/s) -(QUEUE_HEAD_X, QUEUE_HEAD_Y) = ( +QUEUE_HEAD_X, QUEUE_HEAD_Y = ( 200, 200, ) # World-frame (X, Y) position of the charging queue head @@ -79,9 +79,9 @@ def assign_goals( ) -> Dict[str, dict]: goals = {} for i, bot in enumerate(ordered): - + # TODO: depending on the robot's current state (moving to base or work regions), assign a different goal - + goals[bot.address] = { "x": head_x, "y": head_y + i * spacing, @@ -149,7 +149,9 @@ async def main() -> None: url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) - sct_path = os.getenv("DOTBOT_SCT_PATH", "dotbot/examples/work_and_charge/models/supervisor.yaml") + sct_path = os.getenv( + "DOTBOT_SCT_PATH", "dotbot/examples/work_and_charge/models/supervisor.yaml" + ) async with rest_client(url, port, use_https) as client: dotbots = await fetch_active_dotbots(client) @@ -158,7 +160,7 @@ async def main() -> None: # Init controller controller = Controller(dotbot.address, sct_path) - dotbot_controllers[dotbot.address] = controller + dotbot_controllers[dotbot.address] = controller # Cosmetic: all bots are red await client.send_rgb_led_command( @@ -169,16 +171,20 @@ async def main() -> None: # Set work and charge goals for each robot # sorted_bots = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) sorted_bots = sorted(dotbots, key=lambda bot: bot.address) - base_goals = assign_goals(sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING) - work_goals = assign_goals(sorted_bots, WORK_REGION_X, QUEUE_HEAD_Y, QUEUE_SPACING) + base_goals = assign_goals( + sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING + ) + work_goals = assign_goals( + sorted_bots, WORK_REGION_X, QUEUE_HEAD_Y, QUEUE_SPACING + ) for address, controller in dotbot_controllers.items(): goal = base_goals[address] - waypoint_charge = DotBotLH2Position(x=goal['x'], y=goal['y'], z=0) + waypoint_charge = DotBotLH2Position(x=goal["x"], y=goal["y"], z=0) controller.set_charge_waypoint(waypoint_charge) goal = work_goals[address] - waypoint_work = DotBotLH2Position(x=goal['x'], y=goal['y'], z=0) + waypoint_work = DotBotLH2Position(x=goal["x"], y=goal["y"], z=0) controller.set_work_waypoint(waypoint_work) while True: @@ -197,27 +203,32 @@ async def main() -> None: # print(f'waypoint_current for DotBot {bot.address}: {dotbot_controllers.get(bot.address).waypoint_current}') # Get current goals - if dotbot_controllers.get(bot.address).waypoint_current is not None: + if ( + dotbot_controllers.get(bot.address).waypoint_current + is not None + ): goals[bot.address] = { "x": dotbot_controllers[bot.address].waypoint_current.x, "y": dotbot_controllers[bot.address].waypoint_current.y, } - + agents[bot.address] = Agent( id=bot.address, - position=Vec2(x=bot.lh2_position.x, y=bot.lh2_position.y), - velocity=Vec2(x=0, y=0), - radius=BOT_RADIUS, - max_speed=MAX_SPEED, - preferred_velocity=preferred_vel( - dotbot=bot, goal=goals.get(bot.address) - ), - ) - + position=Vec2(x=bot.lh2_position.x, y=bot.lh2_position.y), + velocity=Vec2(x=0, y=0), + radius=BOT_RADIUS, + max_speed=MAX_SPEED, + preferred_velocity=preferred_vel( + dotbot=bot, goal=goals.get(bot.address) + ), + ) + # Prepare coordinates for all agents # Extract [x, y] for every agent in the same order agent_list = list(agents.values()) - positions = np.array([[a.position.x, a.position.y] for a in agent_list]) + positions = np.array( + [[a.position.x, a.position.y] for a in agent_list] + ) # Build the KD-Tree tree = cKDTree(positions) @@ -230,8 +241,8 @@ async def main() -> None: # Run controller controller = dotbot_controllers[dotbot.address] - controller.set_current_position(pos) # update position - controller.control_step() # run SCT step + controller.set_current_position(pos) # update position + controller.control_step() # run SCT step # Get current goal goal = controller.waypoint_current @@ -246,8 +257,14 @@ async def main() -> None: # local_neighbors = [neighbor for neighbor in agents.values() if neighbor.id != agent.id] # Using KD-Tree: Prepare neighbor list using KD-Tree - neighbor_indices = tree.query_ball_point([agent.position.x, agent.position.y], r=ORCA_RANGE) - local_neighbors = [agent_list[idx] for idx in neighbor_indices if agent_list[idx].id != agent.id] + neighbor_indices = tree.query_ball_point( + [agent.position.x, agent.position.y], r=ORCA_RANGE + ) + local_neighbors = [ + agent_list[idx] + for idx in neighbor_indices + if agent_list[idx].id != agent.id + ] if not local_neighbors: orca_vel = agent.preferred_velocity @@ -286,7 +303,9 @@ async def main() -> None: threshold=THRESHOLD, waypoints=[ DotBotLH2Position( - x=agent.position.x + step.x, y=agent.position.y + step.y, z=0 + x=agent.position.x + step.x, + y=agent.position.y + step.y, + z=0, ) ], ) From 83a6f139315121d7efdbe505a736377edbde07cd Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Tue, 24 Feb 2026 13:23:46 +0000 Subject: [PATCH 11/13] dotbot/examples: add example-specific pip dependencies to tests_requirements.txt --- tests_requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests_requirements.txt b/tests_requirements.txt index 6a4d438..1c760b2 100644 --- a/tests_requirements.txt +++ b/tests_requirements.txt @@ -2,3 +2,5 @@ build hatchling tox twine +scipy +pyyaml From 82efa885c6d669fa0796df3f6fa9192e298b1591 Mon Sep 17 00:00:00 2001 From: Genki Miyauchi Date: Tue, 24 Feb 2026 14:12:28 +0000 Subject: [PATCH 12/13] dotbot/examples: add screenshots to README --- dotbot/examples/minimum_naming_game/README.md | 12 ++++++++++-- .../screenshots/minimum_naming_game.png | Bin 0 -> 20288 bytes .../minimum_naming_game_with_motion.png | Bin 0 -> 29886 bytes dotbot/examples/work_and_charge/README.md | 6 +++++- .../screenshots/work_and_charge.png | Bin 0 -> 32628 bytes 5 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game.png create mode 100644 dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game_with_motion.png create mode 100644 dotbot/examples/work_and_charge/screenshots/work_and_charge.png diff --git a/dotbot/examples/minimum_naming_game/README.md b/dotbot/examples/minimum_naming_game/README.md index ce42302..1262469 100644 --- a/dotbot/examples/minimum_naming_game/README.md +++ b/dotbot/examples/minimum_naming_game/README.md @@ -4,6 +4,14 @@ This demo runs the minimum naming game in the DotBot simulator, where the robots This demo includes two variants: a static setup without motion and a dynamic setup with motion. +**Minimum naming game without motion** + +![Minimum naming game](screenshots/minimum_naming_game.png) + +**Minimum naming game with motion** + +![Minimum naming game with motion](screenshots/minimum_naming_game_with_motion.png) + ## Install Python packages (pip) Install the Python packages required to run this demo. @@ -38,7 +46,7 @@ python -m dotbot.controller_app --config-path config_sample.toml -a dotbot-simul ### Run the minimum naming game scenario -Open a new terminal and run the minimum naming game scenario. +Open a new terminal and run the minimum naming game scenario in the top-level directory ```PyDotBot/```. **Static setup** (without motion): @@ -50,4 +58,4 @@ python -m dotbot.examples.minimum_naming_game.minimum_naming_game ```bash python -m dotbot.examples.minimum_naming_game.minimum_naming_game_with_motion -``` \ No newline at end of file +``` diff --git a/dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game.png b/dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game.png new file mode 100644 index 0000000000000000000000000000000000000000..4689686d9362c027e9c59157f8c4f8f6159a6513 GIT binary patch literal 20288 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 literal 0 HcmV?d00001 diff --git a/dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game_with_motion.png b/dotbot/examples/minimum_naming_game/screenshots/minimum_naming_game_with_motion.png new file mode 100644 index 0000000000000000000000000000000000000000..6926ed843b8b72a1967e97b6aaa3ca16e4eeb760 GIT binary patch literal 29886 zcmdqJgkWvED0@BhYAuSTp9S%K+Gz>~AH6UF&q%=r(cMMVz(wzfHw^9Pa zd3b-{ch3KCye?zr*|GMjXRmwR7_OzEMEHQ_0SE*lRDLC|0|G%Pf!{Q^*uXcZ?JaA- zUzqRZl=X0dUp}~R!hp}zZVEFL|e>|5>^_nNdsZZ~yWsZ2#3h%sgT|@rj3MU7c-N-J^qO1N{=7!+KB3 z#k{iSA}v*I)z9ye>N#Ic(tmmQg6UfNYw4fs_4ny3U&FcIvuzEzC(isiX{4WdUA`e? ziToqIQdZpF-cGL^MJpO(>bWF*Aefu;>nOC=`PT2%>nS_RC(1~cB1tM<7U1uV;8i&! zi?WW{|DQhQv`z+m&xP{OK+Q;bO-a$K*L@Yi`wL)Ho1Pn8UqTDo98( zzLO2w8FqYbEbGstm6qJYYj&xjXj`5HvTQns=a6S9F>%Cm>xAF$KS{+q z5FXSFs5PDm*nWq&6%wV|@(`GzfB%^pqJ}e!Ns((3d4G_Y)todn$ULYHRT}RAXiTR^ zz$8A752FmBi(vfE61fppjZfK41IiE%|BIJ|A8>6AHVorD(@*y+BmjGJCo&IfSU#D7U3VHmDG@D{X=fc)=} zz`pfc$bYv*2KT-P4v8E;mmDRWmH1C!mM(>z6r9$uG74ZNP8z^{&U~spTklO5@o6gWG- z%kH!F6#)<#{h#oS?^C$m8z2ZayZ(<&BBJg`vc8Sn{hveWSOMbyVdQ^uzyyrV9RUuW zLhSm#vmk*GRKqf`{O>5*z!TDf>;&LcBtj`DbAamxx9#H|K`(VzER~qao=sqpwyO(7Pd2vhY)f{l)N3*T`39;Esdr2$6rS}o7 zh|Y>GSGzxfb{;M7@)O#p%Vz6sL#QpAFyg~t57A0yI9zDBEqoiQkAJFMUgvbH5(mf# zRoVFzDNfU%F$5i{`U-c|&5pE3v!&4Unt?M|^E-VraVbm#_$(^#-K?MU5H675nC!(E z`X>bREg<%R_N2H^{NkTq?yYw;bFu5p4= zbsq0S9v{Sk>Ov8~TKlg1_qxK|1&FY(3bc{{@h34N_)I`PUyQ)leW=Li84^@f3q#k_ z;6Y@cuxtxkno#EUrXFD}7BAb|SQ?Bp^`Wb0xnIxj^D}F%SQnOjQg&)s&#v>xYAYkE zBgM7D8#RtSlJs!myNCIDqkg}ud%qujB~78`G6U!)#I_930+RF9Nl0YWpZPPuk%L8LN)lLV20PbOjuz0n-F$nqM%dsR*WT(`&9H# zLh%D2VH{BZAsfRLvx$c(6mm4htj#Z`AaRHUd0V3wwR*MK#Yii0l8N4W<>8;7A`|3s zze1uQBCNfJt#OY<2`(Qd6Yi_1=fR&p!)eUqPlSMbX_^_OrL;GjCi#S^?) z{U1n6oHAQ7teV;T%6^>8e+6l)ehN#FZzwqL#+h+OX<3jl$-1czr>>wE733ZI9-y_+ zXTOp=;t1s{*pFWLRQf$@>b?2p`c{!+#AW_3AK6_xpa8u`#k+fZd!jx@U%bJWcmgGQ zFIz~~>CTxOh||Af{S_xQbus`OKU~T(AX&+2(4eni!|1cG?!>6(p`;7AM*22j&m1cI zgbkN=o2E)0&1Ho+_j^wuq0Jv|Ka6x_+S(PfL3tIcwR@v!`^sAmn_Vft48(UZj5q(i zX0%RFY$y~yfGjFtM%mD`W_(R3VsGNfngJWhy1C2?dY2{I25+mJYZmEOzwpU&nmvr$ zVVjEUU3&jP#A}gfPVi!X6x}Ah=e7TLPx_AQKDxr<-!=wZe)fhOcc3cbX0X|>U;Lur z6({8(`HEPgPLI2IC-Agy{XVVMwp}}~Z!I`Zln%x_H~zSMFgKSg$U`ZCF|y&6KHS}~ zIvBU*)L*aj3)c5GjT!Ldyq%^k3#cIZ){o5#VZLrGef7|{3Fau+Jt6o$Dxt2Mx z(}G-!$7s_ZMbrAz7KO4wJd^{+qAuXooCfahBuvtmpxeKi+jC?d%hb*tQhZ5#D{$AR z$2Jx|_)9Kc?=ZsCvI0g=*4wBV}WK_^O@U-+Ph zB+c*7U>_r31*0hZ78AJIm93fS)fOCxrKi#ufD48oGQpY7zv@IFyb$S*Dh1#i%Cwd} z4=yjQsKg#_j)czDte^RgzxFA6_Z>@~Y8K~`hd*N9eo=jjLX7%Se2nz|;+VJ7iL?6e zVl{Q;Qx|b45uFZCK{YV?=_U5DKH3%Sf*Pb1v++dBqTyM(tN-Ol5!>&Wmwhs52lF7Q zizx8?!9sy4xmNfp(>F3Wthx-e49SN?A}=H$sbpNv4a(n8q!%nzy+3d*lGrl-aPrg4 z5CCBh)itG4OT(2o#4R!GD%KyvvPYw4Bjh(iB17gu3v8((%Y7&Xd94;|vBK~zDg06l zaf}hLFTM^CaXLFj&Cj5$%f6KKAD-sNEqN@=MtokC`|HVmjHiX=_VZ*B$`o&cX+rS$ z^U65jOe(#TMevIy_>e&5gvMw&onwvqyna~{Fr(&SW1_9Q7{M%&Sj1wB0uQDc)7xL- zpi3Hl?v)-OUy~P6ng!hc%l35HUZ$I`kDiH?GztLb{pLJ|pqZHn(iWl|_T@yvxEbG` zT``jw+%h*g$3~ay0sbV$WBP#0@cV>jpE$V^J>Hhc)=T{lKoXL*M!$f4Se`@0+L=`v z=_;W~(b8zu{@bT$|37f2d%v!RVcqN+9$&7HHVLJ@Q^AZRebYGF+eC7SH-@!K#Dx26 z;t5mbO-gdp-VDlB{sO2|a&^vs#Q{zy@ap3weAX(~%eSO#yv%`_ig!|SB8(#9I)FLc zLZC6=Vu7}i?vl5Xppix_Gx^W}5L1uaoJcpnd%fxXYMkcUiQn~$4!@#=1iqLit;5?F zQ5DImZl>aOSVz9kNZ$G>MfH2(Q7K3D&E{b30lD8k=K3&F)^#TjemYiEQJC%$f-k&O z*A0Cn1l*b`0(^A}ztM=%iUs0Q`-ACODWvxC(v(Vzd4ZHkuBd&VcW$35%7XH@&N zS+0(wBO!&5(mtgxVC7+r*cp)jof6-^kYzxBlvnNb3`b97;L61283Av(@$tOl<&RZ4 z=K~4AAB#==Z(hwOkjJmessDLcbt{j9mLDgkAUq&2fwU5FKgsX;M_%E^MmJlSOWfynEz6 z_3^p83&g;NsHjAFn`P5K@}3@LdRP|;m~7YNlXR}Ruf6-D-5*8Ma{rKnS`WdO2w!{hn<-mtHTnYxTC`M?3v4+_JaTErT`gsAw zB^L@rE+^JtVrM8r@A}0`=!|)69}XR+#QtY9Q-uL|_OT!dHwEcLBYnK{bF49}MxsX2 zWKbGV^VtLU?xNXf8uAi*$YX!WG*`vuvq#Ung3;bgMzz?^Z0Y(9#^bIiDHI%?gtG4b z{t-|+$fXtRxkwUwF^5AW^^D$g`!iHI?>k(3Cj*-o>jPL6+>&do5lKz3h@FNfiL+0% zK-XgWi4gFvugkuo=lGQ0)1LGDOFPy?FJ-<%Gkr+dCqajzWYHY~;39*#m_LZaDwmpO z^_%BD=|OVf|tF!=@LdnY@Z6CYPoufcp9Cf_GbKA$|YXxb_5Sstv+;5lI zZ*f^YG7TZ1OxRB9Zj4|>C4~`Bw&|8VH8saR44S$#Q7=g-L(2fD22Ozq;ISL_r?y__ zuc;&`>>Xx&7b`6*?{4{Fn5PE|mu(lzkv+Qx6(Ltw`3EyT2j61R75>2`<$dZF2_G3JTt+sk)AfHY4qigFLB*anfCIZDli7gT>{@OZ1IJ^TwmH7?dZ1 zHsC`J`MS7;R9XE!Ad&ImfrgA!i*$FdQJXl%fcs3(p9u|zD{rBIt6VB|f`T~6IG?O-|DMc-)VeTqVxR}jhufxJs>2!! zG4X*^nM(D;snWJtjjMOr@x~}B7STb*xFKAFWz7`pbM?r1X zE8E}<-z|#n>e-#De8*brL~EK^T+C$RwnKX4(k5);ZN0i%3=fG^fn)KLTuN_r$eLw4 z0*fPmrS%0|yldRz>OZHnz=VrgBxxz@a2Qo%`kj9JzOr?DZKYU5ruj+cABS10glMn+ z=^@=xX7LOa7JMTi(^uVlJ=3x2n)jGgfNPS3V@78;MTo-6=iQKhoN=Y?HW}Izb%weV z{zHRgsmo1u4&0eA-ZbmDvF3IYwb76~n&enesd^jMc=L|3n~UF=cVKP=!H$>+n9wcH zOM9$VpQ@KnZpB|oSoPGr_Ydj_(eT@Cr3!V8GC?;=n(88ReNvxsKp#QBS6f$IE(?vI zVkEv#776t1&qq;#+0!Ql-Szpq6q>Fe{1MwK?_CG#;=d8S>4TZb)jq9-B(jg(Vc%dU z$8TK^V>qkQ)AF+@_iT4kq{O@%%bERf{>gP6-F}TO{03{D6F%G95{smOH59%(#f1wA zw6D#_J8*d7Kx{}lb7E|Z8X=d=e~Brm-v_LaIwZbA>NzUL@-EgT@dSMpvO;8n>K1~5R7lP=^BqsZ8tK}|jYD_9-Vx{ zum>M8D(jS+`3U6u|C|C$>&7uT{XK#_dZnZymLfvGgz1%@eviY{)EHJV=hudRnRij} zv3&LQKYXnZJ>E~D69M3X-Gx+l_?&IJnm?-{?GM5n!-i#Ki)@{lg8Vpx4;)K*dmEoT;k9?j)-Tz{u6rBsW3lDqMRJPBdR7pc%iL~x zBVBah<55bD`}gOLDp_L{DxetG0bU1i!BS%B9|xBkgUqjL z?Z`~iM3$a4OYpqLVDO{59{QLrQE>+qdMl6n{Gir$Yse$BO2d(axM4fv zc&OFo44g3?SNw9df$h#y%X9UT<|x=f{P{K=q3=cxe*2gzBU7xVOBZ7*)HmNBjbr7j zN0w`+I+Ct4Zw~+T@P^J1XgQ=v(P9%t1yn70j3KirtN|G1npt3J|6NYU-7O&dSa11B zYf7SBk5?0DCZ46}c_Po?!DRy1H)~jLlx?R`6;rd))THe7dDVai@9r+lfB1RjieFQD zNo(asPQ28MA&pv$ZLGA<&d2@S?N0B?p~&1D z=z>w$SrKp(P}A41(l=D1EF8)@G%*|>KD!-|03eksRyZX;W@9)m~iIwdp#Hs(F;XRb7 zPX(#>^xt*G-9NQLqb9-cFDuMQcq#Z|PDrVp78KDx6lTgpBAF~2pR(RP3G8`{rbQX* zmMIec99f;U&yuL*o|9HKJHkC>4jj;VM|&g17iz_-%o%?$EztF2UQVIwc(mL!Dygs{ zwp{(^>tpBZgH*$s-ve-MPG=lvun23j{6VVob74{)BtT7&Y5fTw7NhebuZ~ima5NQCHFSb&;T%%mo^tXo={wN3{=G{M=VgYb&fsE~Q zJ$wSJ7;H24<`=%8G$NCDsQdh9-skk@agO+L?;Dr`dwsHEwkf7KLie0f*8j084-~Lx zemYtIW<2qJq-V04hny3n7!w6vRRQo~s+$Lm@1tjKQ>hjg5V%y#(B`KbxzD+BJej|D zf5)zb7ua5z&v_h$?2=lEHugv{LpswlC$n{5m%_~mNl-_5G;9;(m( zohG&bOXEiXU$8MGLiSuR0j?@`Vx;#pZG(fTSPZMsxaP!`icY6<2 z{1nMb+o)k<@q|B)D)v1Wzi1zT)(zj7Y>IEC z86_Aibi%*X7lgeIEN5))N`tgQd}5MeA~S~~DoPR-TW>RrU)E}sJ$KFt**=^M$EEpW z+sdZctATpLWW>$bJiRnO1ne^ZS*=iPpZ~IlFDGFwf(u965Jf)mrz?8aRc zSmzjuWG{r*U*rfoCP_{+DHl7QosDt2UM%rDP&n2Qns`}$imH3QnQS|DxpeeEccju1 zzT_UjdlB zFuHpjo^KhTMX?GMkb1A*5t0+8e{^E1HFaf%dk4)BL&{&}F*!vAAw&@^58VE7=Ja6X zNK6i*#9)iV^NT8KxS#jLA0u-I5-PODM#D%d7o5iePma5Ow)6r=pMDKTBYsxA#4B4cAO?~aO?e4sn1m`cB-xX|U#ocY%C&=Jc`Wg&cnNY0n-elof5^oTkb4)WEq z%l-X7MA83b`=&JU;nrvO>cZwQjD&mEbES2;WHU~4`|9S8oA(au{E(UP#7Bs}J~xeY z8rJpUV9t`~hyxvGRPoi-hpu4E|J$mRq5O#7h((o}IVdcMDVAwl*U0YeBt1r6BI;ni z^zo5^^h^8vR-lb(o&U>Urv0B&<5q@|`o~C~oV>MoU6Jy$Ks+*7S=8S@mS^_c zqu>u0A)P^%Tj|+O3xwuM3SG|HI*g7=m;uATJOeH?P(B2k;I8U<~Xn16oZ*<5ItI7u_k< zzG6VIQB#Ip+Ddt*2?JrPa`t$cn)Q@nu2k#c39~cS_!ge9;<>i4Lw!&MxWNT&_Ems# z5TP%R26-`DN(pRKu@qHHp2*kgsiizx=PYj}jgjCz0Gdv~p;YJD=MupT>V=7hoP;?6b0J~pM6V?mQOxvoK9-67UB@|;hvl$#NGsVipu@rep{d23;YyG1^ zfNZ~cC$^JYTcSfPWb(?ckP)LB{di>Iqtp>kuIsFAV;iy0E6iwGL}umrF_SVU@rX2S z_FA-P7I63eMJjeS2~5P|H+i*@qgw4$mtpnx;p&~VktT``N$JROS$(#?JMb?Jb;a*`H9y$ zBzd?O^8>0Me*+a^!x$k8>M=dlL#j-S_+d9JVyGs`U7y2$3XkcGHT?-0NdOwUjQYi8 zhzbly*z3Z4Q0RSgI3cjgYW1+ia)`>Png#lFSdwg5M&@8ZZxzrVEA8j~E$|VTD}2Yn zNg*kBEgp{?DCMT6;LF~X2lZNpI7*Mdc+_lwzx-b=fID(kjuif+Dnn5M-OydU7FzK7 zGfUAI;g>x-1zj8kC^n!g_H`04vW~4%X~P)d_LtUk#!;EN2)%&}R*FbWDx$~nFdiKa zA-%$Gz)7s9K#RkF&G}oWlRYtC&g8j! zr`hhiq_qoFb)k{GX|g|M6J>7AjkM0$X|% z;QEHr$%pVR^9v1)ga}0kPy|dDZqJh|#){|_{-Wk(5jG_};W@RpV1yIHap%qibIHOG z3o97X7*}kuE9}@GR1?ZqlOqhu(8pcb#Sb*Z`z^AoEgC<^rw*;qOoaDb*oXC87(kpR zI8_wZF=5AdPV!j*njI}3x3Bg_hZQQMH~UZ78qimf_Z1}Rp=o{w<}&7R4|pZ;Pgos~ z238CvFIN4lIwlUOkO%ryUBS7V4oUmhkjO9#0jzDpTFoLh^3Zx&L)eLAP%!70Y&r6K z)vpt(*5{#~=4AfcDKf~d{i%As1hn+*y@4gffF*A1h#v!&VFQpq*12j%Ni`L(o*e-> z>Alo0UC4jND5IRg$@-myZ}CIw=TbUla-5~+x1`QZ!Io1~qgCIs<-P;rYSlx;Nhc;_ zm?;b;xPJyt<~TNKHPK2?_}y3;H6c9!;8?T+FUiclP^$;97-t%|-;b9cEJjtxF7eQ0 z!+~A^wXzeg?@Z(2uL9+!IBaBI0*b_1uDE-QM`zWSLLi&_3<)o zPNooiWgae}Ll_2}4=!2h`(dL{b`$b%oL6ohm-YcUavD$?2c0qKBX|Ln3BvCmewH_G z5X(c!X&Umt?i=9rdEbQKw!bf5TX4cT%J)r)(l}J|erPe?(^_<{VsPG)cCL+}v`lEf z#an+5b}%VS_Pyc&5-Ct#r9BUMz@Z6LUtfNM9KvGF^-y-1 z-#u`5AFW_W^UTPW8-M^7S{ow)*Z`8mjj>dqc58cb4nzEa5};{z6ks*MqjVLul^Oc+ z5Yh3E5CF*zm#hR@tzwj01dF%1nupF7^*>$6h!1{%|PH`o{@_ii{FwV&g|DZM~ud0bnIudVR$<^-4gs2uzDBULFK`9SxsJ=F zum(m$>ow2su+3PmE{T;Z9+&l)Sp2Dt)SCPFTN%jb#l>jzyFj}Mn93YidBA$CW{Y?c z42bW9{Pb{_zH2hZb!POdEViFHnDUMAbW-y1K88N3h^F`Je;Gsn&%4k#i3P9?>9ow; z2)p#?G{{|SRzlsqdzQ*@%YbomrfbQw>!6`4idsPIBU<)yB6GZ4P3JyTPU%#93dU9sZKf9x+MnuF!}kZ`({34k>ndFS3Q$N@+AaD zV_cW!-M%tA*}_^+6=T$xz{I`nv_(>&o<;#J(LO8vj*xL`gqu}!e;t=AI_KSIkF_n|kiIbbpFPZfA z(RUAZ@_8jK&Er$j=qAy*X>gT z<8AB{eHx%Y%OSG~7M9Hfg5kv?{3T{20DpdAwmf&dJa1TS-peu;1zs@iHygO*#$p#W z_rw*Tj{m`(wdJV2VY8{`b=ZVU)JKp1O{p(AqG2UuW6*MtXZ7|vJM``(PvxExveCEr z=181!!^ZhHgXd`0EHOR7L_rr8B`R5<>TPhR9H|ySoIu9c;GvjNR2BeM^^|Mk9l)NF z@xAW#TWp*KKs?E_M*yyM)P@7W&NWEMU4sn<=Z^oDLWg6II0U#rl=5_AXey2M{_alV z#mLapdx4rgV_#=&4k1?Vq?cuSy+c{CfLIuJl_KHRz#sx|f;w>ga2@do%rA=U!S4~g z-B3zS8AB6rU7f)68cfbc9&vvM=AU47)cm5pqB^^j>+8K%~h{R6@E8c zM`SI9wP@DI%^@Q(x2hn?yx+*biZ}n>$lw7~l4Ppcwr9~flRr!VE$gigBkyStI0FjAt z$J7gu@FZVhM!iJ5Mtz3C)%37@8kk zN$d11NX%sb5Jfqp{YBK=r6&4jl9P)#E+7+>NoqRIYks$i%>AujZN^-`Pp*>MAEmOC&FG`t(B2jTa6tiCp9Wx}B@S2unA&zv zl!`A#;!!gA6R0Ml(mlWHYIbT!^k8@!J0*BnBULmE(Zjz^$N&_&N&ccxXo9o&lRFkY zjt1u2dmbEzKI<6IZa@2Kr1__|^T6w)*h8(3yS7d_9s0+LZVB^1#d*ml;33;Y3-$rE z#sfsW+}e2gMk*QUw}H?q4C#yKmz$y!<*#&J@8BDjRKB@;Q>_0A zsiQ}_3M)uwGCJMES-Di4pL)eRK(G!G>al!`Y7GNH)v!l>wBElBA=LM1%Z+9v9O0`}x?Kj!XC%h^aV+VOcN&*bSurBFU zYqCfMFucfDVHDxebpn1QAtqrM_KySY3t8gmt5O#(-Ktj|}<=lk} zIMJx3*(Nx;$91%<@80Y0iAphp9_vn>8 zrTYkiz%3eB^grj^%(h#A)8H7bchapV_g2t>JsBl#QQUxG^5YqQWGRw+WZjr7jGxGj zm@ZqL(3~h&MAFRSd*LU?c`&jvO$ZO%??ngFsAl;kgWA)uQ*9xhEGQ(paAx!j*1|p<>*xnuCh)xxo@M`sn+%w zGn9{o!+iwY7Qls|1^O%Tj;ceIXYbLp_rb@RoI_0cu#B>zeHXB%0(wL~sStmhH@92u z$!$*&_!g9gzX(xNt$C`f)1IK#2x?JOR+*DWXQIYr`+!b}@Jqycm_cSx0C7MjSP8=h z?1`bPTcUxCX9t|-@hnf#Q_1-KSoOMyad+lpqT{E>kKp)lCVt2z`N`G?aRDvAcPaok zO+-Z*pxI1Eeid51UeRnJRVKG%9AmZjw|E6~ZX%@uT)?pa3s3`#;tL@iJEQ@8l&_z~ z!eLkj(5i|=<}mZsgUs(}ic3~;WL~u}DU)wl!h9pYP4MQb9UvW(KrSGlu-|e~t$%?6 z%QqBX1gxU6i}W~^p|L>v5PA#r&#GV-be^c?k6mFTw36z;En);^AU_!Vz#;TaO8^z_ ztx5GiaB}?P37~&=vSO8*$fu30^jSiwT|1=M6yt2RCGhYqFhpC0IkPl6u z`cMsk1Q>+s_3r^FrVliM_=~DQ7$b;K=l%7j)hjH~Fji$?4N!R#+j)sW=TFftAc?&n z>729aV^hXD;XhITD_}>dvx*J5!;fNviqwOaF_%@&`Gm#l8KS#-J$Rr+Ec&I$2b9zR zV@QFrld>HIU=|HaiB3IY=z!XiI6V=Hhe1(5s|#n8ZOR^Vu8Nwvi&ip2alP{7(Xsq* zGh>?2=mW%ZFs}iUQxo7wM>#bF1D(21`iRf2bJp-#%X^gK@1~uh;p=68ndUO}G%Cmu zB~QeRglCAfuf<-O<&5dU_k7Yn&yI=Lf_84=9mYb=l6s6huwfoseoJgJ>FD7+|g)|H8?8BIj7{2}UbDv|=Lbk*F%vVK8U$>E%6F&}mTN<=r2d6T-j8 ztNe~s0M@whtG~+sY^t~KVkeI9@mHtOCq|W^3||V5Sw^!GmKhV>Mhmd(fkQch)`fdf zD%V}m=&4~kqAl)#0`|7p39%J-fR#gTB{qgpEpu|Xv&?K-jg7OxWCs`BxbC%&jf|E37^RIZrQdzwaAvj z_UQo4{G)+8gUgMXZvK6)=FBnHdw;e|z94ds44Hp z;j2XzZgH_3)WsLQNdii&>0*5!vBSBVlRX@ID&1y!H?62uqdHExzFNB~YkL3Rc&+C& zk{63M6>a@&KzIAGt}bLNol?jOP}{{`-Itnp$vdBQS9Tuj+ndIV+v~oOiuJ!I7VontE`5X6Jy_%v*&N-Rd)u5@u+u5$2~p17 zrLU2k$W=_N|6sC40wFaaq%Wny zI^W;)y+~=2`S8E3;)+;kB2VNNz=^7JDK||u^LzfiXXwq7Uov;p3qZ+8ZS%`43A1}& z%9D&?nfJRz_L?i%F<~{NO@^u0{zhU>(6sQ$+r0U&j_BD@s=ANiXI#+lupF%Q79KYl zWraStHWQFu${J$IvUN53J?O;Oqb~WS-@o-)UxD8Za5*sCfdz2eY({JuzY4KKb;{c) zZqFNV@yd)vohVADO4e`4xvIIG`JkBY?qdTwrc0-p7wMib83%*)8JG6wPyyan{(OqW zcfV;!xWzHdUo}zKsT10Ko)?^pQOnR|2h;_RQV|I#>ko-GKpDXvEWV7pIO4RO$~@zT zKhV(T(tp~e5We7h(QCysO~61nHB~0%^~}da9}B3L&t|$Niz?q)rx}~lA+ps|XGML| z=J?DWcjkP&*!M0o?lX!D#?Llx$iFjT@O^YG4BU=9t7}F=bTy;Pb(%g==vVrK-Xo;K zzB&@E+k1 z_OLnymzFNPa*Q+sxWEJI2)^wQw&~iPv^!wLfPp@AjaI$0uGP{rRC@Bdjx(mDB+`|# zu5QfQenOp@pWB?i)2b&zEZ?dRm%hY6LH2dqhl4T-hZJ#g-VVv>wZ`M`Zqt2Qc#YuX z#C@RUq_h+;LS_}A2nh-x>K2Rni^}=cNRvf2S6L-87=9SU6>jkPygxB=N1kio^P3g? zZRENzO9FtYO&7hyF{*xZa8zf1Hs?yEuTLTfzzw_xXJ;f&D7eG2#*1_xC+)zn1oExM zavEnM$Vp^@=K?ehEA-j9FGFV=oC%#4dZmg>Xgx!(2F%edMlW)&03&jr zA0I|O>tXMgmAlV7)-1*V^djhE3hp1}itV=AtEb=5`d=%@o8DuF zsfz|{%KWTP<2Feq6bA@<`*SB^6HL_~+*>_165uC2RRNZpRQi<{;fGo;5raUzX>Z3M z>b3Xtd{*P0=hhO-Jyb+UIF2Iw!ZaQPltdK zTEO_+uWPd0ROA`3ReE~y#|y_wqf|96#l2|~ULZ?YcEPMqW0Y%rE6fB+^f#ry8hoBA z`PRccLFaILlf1FpxDFe=n5}YM{CCln>e9O|x)Mb`t`Bx*Y4-2|emW`lQK`caloob1 z-;NTImC3?6Y0*hd6Ga#e9YLy>3U=SM`sZW4@u@bKR|BViUn@mIW0rDiHGrZ?Qz~KM z`N2ZpG`zlWb6G;j>d~`9jH&FG=dN9c26W;mpq#ci^}Pu8zmt&bk02j=FkVLM*x}3l zYPO1*facLsQEzPMkv~Jenq6NXLl zJe}XaROtG6w??ThQ4hQ&emw*V8mf7Vj7u=2I+m}M|N8O=v(@M4vMcR#<%X(B20~6W zkA3(7(rf4XuQ;pXB>>z|_(vrE)a+;i$76v9JDtz8`H;FX9$AHQLHt7iY%>KF)lfo) z@mI7=VzoenXOjeiDhbc3jM(Q~i44!GYVX*Scxak4z1bj-Y83CIVI`LWBEYWZCprMK zmU^4$h61}D5&on&3+9v0`r%G9&(km@O)o9kLbXz9S(lblI=fgv>~mJ4-M2gCI<%SE z_i>`YW`sdXycuTdrd{fMJ8cXfe(pMSd2&*7x((LNm%peiH+^5C|FbeGnY9VUQXlD5NLbwL#*J1?k(`f%W@A`Zu_=b?_^b9 zI%5{;%AD!I~;@&-<8owu0-Kd42FZz`u%}36lGYM|`_O^b>I*f_Orj%xzY2{e*&mR=W{&E+GWX~yuS`eO zEWnXrC8z5O7TI2lT<Eq7E_i*J6ImU5 zu}B7R%=(<>e}KPWzW$3ii;w2q)6y^dOb)PQb^j7CJfE{p1X| z%3L_u!^qEo_x^gbTffHG^|tMkLIN;y$PQ13#S1Y^LE?HG! zFFJR3@9_{!qA*#34{mBL*Q=pCw^kc{OQ<5$1nT%r+@mWDW>GIaKSBr*lZ37wd32Wo zd$rK(pX@F4y@G4_fh%Ac+b=4t=cj4>)_=CLK#s!-Hlnv2o?PTd&4p z0zMu=|7H&=PkqskcB3*mzpgK!`PdB!n%@mByCF5`swNQeIzS911~70|teNU&T>ch5$4qMpL| z#Zam8d%b4GH43}`mkV%Nq`(QjP2scw_#G8~Senv!Kz*{{vgL7GztFqp7Al^qxqEfS zwTcCjVZ!$Jz&XUJ2d*|nh3S}T81%eoDGx?JcFTi3V@D;CSNpO)9F_S)L%Qq`d}QAd z_z2?~Tmf<+DWla`;2~%23QpmvS?(^W=ygayYU%Xlu{hgU-y?3_?X2A%9Y@ zIY>l^ezrTWu897Q)AJ0$Kbt?@NkEN`leXycy(Tp$W}xqKzG`+0_ z0WmES8E3!g2qV1!{(FV?NwQ34al)0nagyyKvT(vlU0#!AkPu%y5S< zDL-Y`aE>806_jL=tfe6Ahaa`0)ODd1(HI{6^u){|DozC4nB^>N>6)13j1(cWoG+qv z;_h39^)_L6i(TQ$-@Y~bwd9)7bY8{Pq$)2&i^zbYFcZNw2tWD8M#L=bn}udWcH#T} z(;8Hv6!(lt;O``>4eN&Wph#?o{HG{k`B5W)rD*;z_1}T<#cpoMeRJ5vDJgA9$dw0~ zV!YRVK0+c9{E`6QI9=*iO5hWqw{4>u56@gPK|qWQ88V0IkzQcMsg>h>pEs#n*bmbQ z0~`hIOi`boU`io7Tox&G#BP&nA%6H`}NnYP4e*;9)Hm6qzmCNp>7cX zL6Z-f13mI&qY^V@#HP9vy=JiW2ybLx1Gs_gWl17wS3$kwon=we{m8x=RFEEWqW*n2IQP^~vy{l4zbdGlML6ySifC>vOOb=!0|40F`HtP!M12YX=SS{lyT?Y^b^kgl3)kqifE64k06!^@Q+>fx?upPD znQQtSKg@(ygZ<$Igb<`bruRYE-^5aax&qwiW>#1?ZClfEAFD3-EbE`?0rVW@9Ut;! z{4^}bghmQw&WI{h)!h33)7^UoG?hh-!YT9)5eP_;E=7t|sVWEtX$BOeh9DwE1nEQ} z859x`QF=!al%O;bL5d!wStx^2RWP7}(iJI6@$M66=KCK1|32QQaSl0quf6u_?I>9I zMOm+|XprwvtGf2{h^k0{tI&x4G?7j;kfDNq`;(mS53jH$dm`oZkmXV&L>`0ZA|V4fj3P_^k&5%WB6_cN|Y> zfgT?wmHpa>iRM_BUHS!hEUonYJ~eCnn(+~qggaDUA!khB{Z#Zrc|EH5n-_l7GE*+b zi1Qo<_l7{fUFi;xb2MxaXw1kVZ$;~mKhdr+n@vjcBd1fcsh@@IdmdSW?j9@vMsh=D zNt<9RT>MDMNPXAP>3IDmou$3CvvWiHhIfPV;to*Mb72kT#a{HKLwCw=xKS)xDhJWR zyB=~j8MJvHIt2(kR5--->hOk14Qichi6&943f_A$SR#L^C86UyQFQcZdrJ!Yxt52Z z;|VTfz;b2!)wmq)uEVkh@u(pmUu=E}>fb&Ah>39)o>>41i3rR)nYuey%RY#fl}l-lB`RUukh8#%>M3 zb_wL?U(Vqx-gT9zE>kma>aiw9e0!Wkc^B4n2Y|Ap?UK`};m_fPKS3@=LbGY-V$^BS zX=eN4f_mxwSdr)!_F^}*8oMjiqrtg$`=!HJs1^SLe6&6Q&Y-!;diUNTFS|(HB^Awr z73aja*RsieWSmJkX!aM*YAXS2RJ>fPmien=e~GzP!56|0xvavE(=8V^3)q!J=y};^4@b==)pEr_(@)!2(TD^}w z%#sXxK+E;i!lQgapW}cgDCj$JW;CJihfG${h5DYbgfHXcBJ*eZ6Z!z59Rzh#zV+t9 zi)UmK52W0=-*9NwRMbRlTu00ZOv%udnFfE>WgB_4b1_h6wakTuXV>;!tsJc4;F}l> zX=N!4oZxS;LGJvAl(io+u>~=9(6~ldAn31r1_~Q|BeAuUj(%AJg~_#_MAcVWJoDew zgRa7xSoPAqvG;pE`WVIZi4%7Zq!lb~-7KgE&HG$!0O$R!kSMr}54se z3JQx^OgT1r*9|obI521`;d=Hwx$ay`(H|Oh(hH>a9&+*EUmHbr?ZlF-mluYOlM1g+G zC2FsLlpiZr4J;3Eh#!5h=zdQ&k|BlWL3%1SiAu};)j4k=`tj=Ag~D8$xt3Px1s~8c zgA)U!=>Opnc0W&2fLYfh36fyws2mwIMR&7`Anw-no#6?(UZ{Y>19@M%_XMYLmPxS8 z!Dpw5s3@x{RM9R}rw#!h3A)iEpnc#v9?ZMmdvUdX=X&=oNExcHR{@Q=y4(Pq+y}i4 zE;t_lw->A8J2a8e{2}r`_t*kj&&vY*m2O4;3>{R#G#cGk5LDr-cJu%^P=+rn28-pn za^oYR)Gp7J zugF|8xuaR_s?uiPUvK?a7y0S>o7>k^e)Z*O$wlzuy>(XB+7$MCVGW`QzT54d5Q*?* z!Cb7X@nQ#E5d1A^;7DBj;iuk5@nW0lIqhSHl#Tgz`T0V;2rG&ajni?ks`%IuC>;uh0)^m_L)JaLmj=EB%PNLy z*umpaRE)r#$hmP!91O~t4gpd)BL|3!qaJ8?#66^*Jv{kFC{n$&Q&$SG9u9s357;VY zN{k?*X<`IAU%5c-mu}u&kXz@)<^Uy@Rti^j-*VyQL5$m94^AY@FiodJadX%&8rU)oW!4x56}NG$q1p9{+smQ}V>=ru%xz*98ASa7dvDlVeZ zm3RIG!G|$20FN(P0kdNY*=Y`;CstQhp>NPl$(AT~M#AVv61ll>*QHJ`GdGX%||=d z4FnB~*z9&SNiCxIItzJ@oRM7GIjPZZ2dr6n+)NohDLIKUUDtJ%MP2@{1LJ&1V92g1 z7>Z@1z0i-dTjMlq4)HEj{^e#Jl(lBO^p<%MD|mWahYG=9Nsr++py=`-z^HcP(_G_a z4so--tGO0`=bUIWblMe0d10X%7{a{ya}IAqLZxd9=3YR~LW4_J=c2E1h-D)ige0#- z`F1_GsHb;&$_RZ9{2E=409VN7^mbxf^whNMy36=eKR>{ed`2c^<*YJ0BF&VU$)s)z~a26PKC)L zCFmLjtsmvr=d8n3J$FTJ1$*?aS`v1uV4$5cnwhV|AX`YBSOt$e!KfT-T-W;6_A{5x z{q^RcT7yCLUE8Wv5}-5%l%Dz?kr8l ziy!HQ8V6ne;XxVDe&YQrb&EjEb7l}-28L-xA>Xc(jhL9?`?hit|$QtU8Fn_dg9{D zYit+PU|N8!2E&k?P-R$=2q~?nF^ZZhp?j3)Q0IjksT|IGp!9Y=#s{{kQq{_Rl?YFB z8!9|Rs8mS#&@^#Jt;|>gof|i|0;Q5kbW}CFN9cF&<-1eL}$CVX@;B4*+7- z)HO28dT@C%*K+R11ry-^aDLz?U!YVAc<@EMNdaYixchsrJ^@BRnR8yqx4gN{=+`Qj z135zEmsE+`j~md=C?!OfQ2Nthg}YVAZbikA4N)-BDAUD1@sO=doa)(WV%zHTtEqUg zy(_v5QhNx!K_DH9LOjGd`P@`fiu98(zsCxCx1I21oT38ew?Krw4->X`+isJwX+g254{@w7DexXVaN82^-zG6s@enW)ps=4UWyQ)bv7^GNWl?#Uv zYsimGv}AbtPr2{+9q;gg&Vy!1t~$!+P-1X-63* zHuI9oldN2jQH=i$S~xK@JIi;$ox?Pbr*5sB>k>7%l)%bdKWapZvx9#En0}J!PJh<1 zm*Gv+ZrwxU9FJ+do^vadujZ)TaFD!_U)LSlwNQor9s~Yp?S6VR$4F|<+NS){?A*`f z8L!|)6IL4~#R(oeShe9P838T>kD!V%Rs!~lhbj$TE;Um41a>qPu^5iqxeY5 zOa4Az{VbQGvv1nd>NWiOo@5j*Z`M`Q9*xFGUAMEBRlEX}%hwMXC1IhwA*9vlU9}l= zCy8BO@6gKpm&1d5hXYTN5D8>yjO3LDHhy7|4$4$uK#%9jbc z+#k5lImi-z$e=pYBo^0p0Nv8Fp3<^+BtBiJqvLd-x#G(J*tk+e)i1 zC@8u`o#gnY?GrCcQ0x8n*!fd*rRAFXr08#U0qbY=D#!1Apxk z)j!Kvc?wOnKVwiplCZuC#cGj;D?=p?Zn<;3=( zRB0b-s(X7AXwcC!`F?u9eFXPz<;b5LfRvh_BC!i0eGs*mo(d612KEyp=-D(*#$fcl zmuv7upOkxaBhkL29=ovngIF38Id;raKRb3IxM`49$>rIeyOXUmg%88r6@`o_u-bf_ z*A#Yip35N+1X8s&ehCvx*7nL?YJESISAEiI_JZr$8JFuOp6P{sa>uQIU07Won!6DH zoR55BH1Oo234gSfk*k?~`uVuyDW<>Oh*;OC#HRpOx?Y~4`wu5CVn;vn(=z*mizB(e ziubw0;HgxY&JI=HjZ`_%7Mx4gSVTZ^1Ss=bC40x67geS!QA4~|X52zA6J z7@w)@-}W6VqDxv0I@KMrKXX9viCJmy&n=l_Ix7XqwzR>b3Y@(hbyw{*7ljH1k}-K^ z%|1}lGXfVGCuUH{fqglAfX)r*6(7c*Ji~2s_q(<^xz=LFl@Is@%i~lImBAeAP0OuI zdVO079Pso?e4YPH2-0Mylr|P?6>h4Cxb<~ZdVoj8Bl+PrOTmYPS#Utyp`jlWlRdPB z<~{wj(KphE`&g^0y!OFw_vl_|%;5_vW;|x^4;?P4d_&%tb%19mI`IP{LR_s_-cm#u zp#fc7n5B|yYlc|i^&(*XVYbw1IKF71_rCu0ijE){FpySi=?uj9k}|O*vQiQ;XS)VU z1tlT<0LQEFg8D#8x*_}q)}@RDUUhpOj35OfHu=_l7lh#@R~+*~kTv}E?|g_PhFkkK zb})R;TSN9ng@()U%D;fZCA)NdeL`(j{r>$p(SJ0B{EGauwQZ2>ZNkjnvD$rg>w{7kEK9-dhhQN(O^RzZq+CuN!G9ZN!Rc1qbQW?5mNi zQSJZvrF-2~1o%Vp!Y*Ii4&CL1;!h^QYtLi`A8dgC@fI%F2ffJuOq=~P*D?)MzJnW1 z%isp((BMbzaAN_OQUI)T@E>JSCja0bg(Sh_Ff(73ftq;-f(Bvw!Dk%|^Np9@rc3^e z2|xl825jptX_xb^wgD$A&57>==vfz~7?D-UTQqX0$Frt9Ds$ju&sozD?j>WlbA=i-kmd&CXnDfF0T+taL(?ouPK(|f90>ke4kz^N0Y(Z3zEDy_ng_6 z(#}L>eS^l5+zsg^K}H_H9TyY$f$JWCjD?rtDPCoJ?g%nbU)mdF%W_2w%7u1Pw9}3| z!TkV0DyaHU!YCAI%VMN)um7P-AZ82TY5J%MJR^>1`OrPA|2_$4bV2!KFZ3E%kqY8f z0C`c8Jm-_JvMa|3xVPsncQU~_=A#lKzN8^b_!1BIex#KK%=1fdzdvlVDgyVO;L>8J zc7SmCPiTb}sLb&PTE{zJ$XDB~A{+N3$Um?aiQ~GTxLnN#!)KZA_!%y-G7eaqJ$=XA zWMyaq5^UcVwL%P+?okqEu9A_|hdKi0K-wewz%vl0276^>(;!!Z@65s1e@Hau=Uz>b zXnjrP&^?@gHfph9S46zQi>86tE=0w55Q3+5HDu1Gdm5$c^lS8O9_2T)NWR-nw=Cq$}66 zeawrrH|U%EFv5Jl;y=ytkWzzlW@xv^aFE|Ph<}mkExzk09+2M>zCTl@j7Ud%<-X6TngO#3Z zIbhTISeq2*%-F?{q|F4tO@|<|b0?duMA_H=y;c;tOc}KBn@q4M&&ex)tay^i z_C9Xb7n4@I{)$}O$-#r{M2aHLmiweAFsUuT;t0*7#1EZ{S|)dl2Fj6XkPd;Z_To$p zi|*8Cabn3zT)Wx3^n22>MQ7HwY0xS(G=Fuwee?3Sc8A@cdtsap9G*gmVlda;3}gE4 zj(*b&QT{s{yS?m5$Tu9w$*zY!LtRu~?d)tzJP{U@wG0pKK?maLOo}MA z{3gi|HCcD(4mlAi!Fyh|ujJgSP6+$b@LWp;?&6 zEPf1WP;WZjaV${p`~c9oZ#1lpqK_sCcqA)PfP=(2fn$T}%wtKyY0T zjw8-jO_yJ!JrSBf5(jVdBbq-JP}!K=uTrt^leC&^Yo5o3d%*D19Q1qs7G2VN!-22Y z=T@Ft_z~CUjB70;%7gy^62Vr_DN^a#vk-cew`z-@VFD#X2twW5&r0$ws%EVf@50^E zKfnAT`90UCR)*hiZDwA6v5QQidAqL(y=v=KSk3&xU0{8Y_M){Vm8%EFsgq9Fm(dCb zlND|vZ73ko%H*)Z;Z4bC1yY=*R_aGHau+bNu_32DJ1U<4Sv)MXgcSEEdHDC!Uxl=? z(d9Oprk06OVB=+=%rfs0r>qerj zu6HRNPEFhiWWjb7TgemVb9-uyjjEp)J>eN)%{kb8(?G(Ynti4G0TWPV58I$qQRb^F zw~ocd=`IP?Ty@Fil-0S%(kY1a5zf@?|K)o30F*OVXN1Z=*=L3da`WAz0*Y6_hAK~3 zdS6JHAzJuyoImS|V4e~sGEKBBQOmS4!muE+bWnhBYtH!MO1${_vL2s=OR=EK5lF>n z@o2L%%PSrhjV|t=BaREnOdKL0wc<;p|4!l%xpm(MxTH#kj`OW$kxjp5fzeUwYLLE? zz-rO><*JD6{{7+NS@kh-l7cFhh@F_RD=$VrdatJ!2lV#BAhq6OG3D=`VbnKM9|_8p z^PCN+FW_KHp#BuTKx6zZip^<#tW`^qw74@_rsowkOjY@yQSTE6CKFdAO_MQS4M3N+ zdV^^=5=Uda0g3?TP=CLJ~SSTdW+Po(5eNd^*mE%Rn#YuH4uG~ zOK-)ASKD0~K?Lo|;U}-|FB3{I{Z04gmLiL#_lIkVIC9D*e2rCp+Y1cNExZv(q~X#6 zzUr0u^oy$v33yN=EU-EDJlQi=;S!$3t!It(C|w&<32A>dVlaDP1iz{h#{Vf^dbu1d59jAM)6fx)y_*~49c$j6$S$t z>%HWd;1k#8#L#!rE7`#G=el(LO0T&&TMR9r#<|_I;wwF3&lWD;xqWgP4%E6jBkDd* zERho1Qbw0an6TYaO2yzhAGH8-8}pu1bHO(Q4rGdkiFZPuk#k{l%$yrGqs%yG0AnA@c%fc-8@Wx z=sy~Icy;BiUiCan0e8WHIP=nna)u&o+@HHS#{kY^OgP>bm^{vM>ER^Eely>&CT8BT zN2lK()>CXM zKU};k@vMiK(np)`#%tCb zi!!7B=MRyOdCp(Pj_c;<)M=%z`z7TZ*?S!upqnrf^N#ns6FV%82C!MEc(f<_3R0bA zoMVo=MPy_*$?M%s^IJ0~2!*_I&)y*E+}BlL*+P%b5;8M3GoK6JvLhzVy3vX~^^_9X zZM(;R_wrTA&=;5{&1*Ni?o>phsKL%?8us-AYL2~D)`%DJ)aql8@{8QKOCU532E1w| zjnHDf7&_VONrB1X zqI_hMpo}@*oM|e3Uz4c)G&=>wqva?*)Cv29`)e((TXk!a@29leW>4HGjfG8xQr_nJo{yRBx5JakM?fj?u24Cx(OB^iAh#4w z$lCY0Zl(mnpeh{}1`41Ap^nbm~Ar9c;8Ui=>qX3MXysIZBRE-=+wnBT-g zuK`&c!|Amor~dfPD`ND(Yg}!M?DQ~@tSMaHFy{i95Agc`2Y1N!vmwKkU|Pc(vb!ei zbmNUlhbl__56F|>*300)rlOwM!cnmvU{uk+wzB6~hie>{2lc#+F27nAIT_+O3$6AG2V8+>c zo`WY&SJ{7guN?&H4eYOy`3)3?=6@jocsAkXdPCdO9$2A^|3CnIEqeGblq$s()b7do zzq&3@_lJM=`eu7PA{!|O=d$p1@H%B@QPW5_lo@blZspi+=GKEb&Gm5WziSc-_$!D8 z#j3KS-LLpS&I#`74^TqhswMS%RHORJ)DNaPxd0X~(-Sffi;ZG zJZd%y?#Kkbb0J9a;X7Z9?R+Q~;s9r|V0W+dN_w+?5SQIlmV5+kyq=NZo3jbmD6(}w zBItoExDFCmcyC}b(hD6!&PDm%7MM7aR(chjonBZ7KPUhm(9C79lVOOECRelQe9tpb z{_tlnc1x5rOBMTVRJ@I=ZRoutz#W2<<7or5iP4|F3}f0Dc>MrKKv8#f7g%T60j{hrwqy=rv$br?PoU6nYpH1ulQQ3eo%D9|q7}Xtc7| z8j-6e?7th4!dkIhA(2&q!)E^MF@ zT~6KmUqO6A%MjI5Q6#=SDT#9Pxz<=Cj%zDsM&YJs&j^Wt@XcoU?0w2fZ^@3*uHCgnsvL?pX9 zqR7tuh}5Zm;{!lYn*r1ej&Ood4;s>?1%9oJ_>ZRof~<%il8wLzC;VP#y;0Q;1wkw( z?KrVxsAc}yx7ACt7j8W|{sd@?R{*kzB}v9Sge^(vFE2u!y7MoaS0sSZGL%B_hBJt% zbn!id6%3O{B`;v3uXlQc{)gR)TMcLu(u{RG_hE)q=)X9xs2dv0>a}-0)Hj!M_Yj~y zm?{n5Ng1HP$YhXm8k|XI5(VI1WeSEe0X?FF30ajo3+Dxa%w#@2+P+khME(HCQrt-B zKax;5w?i}Y3yaRnsaQv{*njLPWQdHu)F~Da&bk0IeF5NDp;+xD5io4o$N$Jz5um1+ z=v1!d_nWqK$s76?zsj|AXoH+j&VQ42|3Ka80vUG@>x6|7vH-!uYKr5zu2S&_y{;g- zc{q)LtzGO0VmLx9gyK@+h@ev-9zOtUK_>x65Ukou2%U5_43n-U0(B+{Q`&*^MIctK zwFFYShRZwpJ%D(Y14OAp1<^_j@}m$#+W*zpzYjF_0cwTI&{T3Tb<2-%cfWw*0vz5M z=1|RaDmhdsBxcg}ZP>pER=L5CPNyw&%(T}(sMoIzs`_`kLXMXGwR)Aa|KXQ@V->xM zibEOaHf9}fz~8Hp17rLF1qr*%ep--Ls4V&40s{4p&w#5@un~lz5dRy$Tnq99o(`A1 zRj&6ymcvB|^1|;Oh|vQQjA>dRY(FsttvJ+78ODTZOex)K`xnFX0|gxSWapR4Pc(uc zCG(5{0?kNvbT%?uzQWt1WWzd5926;}i9e@7PpG)@V%w14xsB!x|NM3rH$`SY_fi+j z+LKflt$#R`P6^^}$;KAxkaH4_+S?=RX3yK>@E?Je!zcVyTnZ%8-Y(CcUrY!o21sPe zPVy@2DofnK?x*FV{}KcJK)v0)s_EA?|4B05nR-YNih$-Lnx@D92V8;@qAkS72#42# zIg*m1i0VW`i!&i+QD1;+f(1D5!Uf1_GHKga_wn%6_RC;u29xn^8Ex%sq``N~q3TP{X;$tK5(_v<$fu4)qRa%j-Vgh6Tgt*~1JQ z#$7NKLz)y_s6>@H9)*gs&3`!lTT{KS*OC0Sy?5;F%)|v?%>ZizXM1`N_-c#l5=YG$ z;o?^Ab%mvDy}rtsciG#}IfAg(4=x6Mu5m}sVV3;0m5OsXyj++|hP>|0bn;Eeu!Phv zsYzDo2q<>7QY2r2-R7l3H~| z)^LYMF8ch%kudW#Cor-L7eEA|SD`S?smSj|PcUPbm6}6ak3si?$2XcG{-&RvAVUM+ zHfASx0h^kvVswMP=(<`>WB#xkUkkotOXK7gU*}nh7sk9CoC}mH;%tMR8BQhUp~stt z!H24xLMtbr&ST@ho1LZzK?l^XlQfrPWY2fgzsIJPPM!lQSA`-+EA#!02r9gAK_=vE z;X*uLf9*&~PuQAY7ytb{P}L&TOg@q(o(@RYdNR4a<#HV*6#8k>*eOJ;FXy$L2|mkL z+cLjFUFb@StTwwYX{Inm?UsMmxnNR$G%W*88PF~4(3YrT`6+v+Kn~5Oq_u6^$Z;Zi zk)%#q{Eb{i_U_VKc6-wX7-R-Mfx=#~^}SlxHy%lykK0unZ)x9+4)$)FD5-R&s8Vs$ z+LS(I{LI9b0uDCnO4G!w!lgw}Yslrh>vJeZuIGD_$l#^qhsTlCtmC7}8%NRtUI89; z+8eJlWrBDhAbIJSg%xqPW@AF%y@fYP*;EttAyl8=Fn0VYsrj5njWd5hk#+bU&kqTG z|H7l4#39f|tNB~Qs9_$oblRr^L0`P&XH?`Cl;0XRp} z!GtVE#^~&!ix-FNI=Ey{K9H*P_L1EGB<_k_qC+K8(YJcf&0h;01lb!8f*2R%K#Vr- zn5P#&7>wsr{`p-A?@Xj@=4N;^YrdUZjW(^x`x4Y*(LLspYodSS@`i@el!x(X2kDg1 z*k#UR?#^i$MevKaz4Si}i|c_BmEn8dBM`+n|3ciZ%wF?imBkO)Rc#$s6n>`|+kZIx zq~)=n+fSi1(+gktgK#{_;s?WZ*zjAw4_hJ~4Wr{!`YT81FK5ALxnO=#sEf&5lx9;4 z_V?E6um5s{r*h^G95|}Runx%eAgw`#mxs?`tDgB`WJq8wvncdMUW}k-N zxz#OeG9-9J(guQH9dR&8zH!22_BOQX-x0CTV?!tLj|{!2Qx;qm}^&Ttx?z6_7hba;Q$ zE$zpX@oSR9$ZS+p8>5`_Tta1(qu01kR@oqXKD?54+Is z1k8&g4<9R+r#pZnmfEGT^Reh%m|#6+lb6vXfx|!D2fP0`{Qs}C9uOLEHs${&Bl!Q; dtI5ClvqN*9nWV)Vfp3clU+gKUVI~dv<8Cg4++BhCQ zZxsM$go_zfp z5+Ya5SQ_Gclm4bZx847!ZuuYh5Ha*GA!=qY845N3R5(jV8JpFJFA5$=QEZDwB@ap& zx1>3wSjZT&T-KW;emPC@7}(z1+xz%>Adb;wuF5RGBq%!e&Qt8~=BBVjue1~FJMqYO zMsOX0;Fv9j%K-hSNV_d5I8eV1LIel#pG;a};1Ci35dsH=cStzk5cyWW?guzvzWSe! z{?9c1w@l(Xzr^>QxLI*C(cE0S6&+kk3glsaz6==RhYxiz*>syeugJyc6f9^h7mBtIZ${2&-VBq6ujUkBshy4xzfX zBUhSK>tjYe_g+p8lcn000J*sOZ(z;+)=8YgR#padBGHM7={c|k1(sbuqOtC1L?WY` z@$vOmyW3u2+m+*5&Y<}Py))thMorB-IUQUg*QcO~`2BkyZ`!*YU$6n9z3>Ty>%?jr z8n(Kf#1!yZz}~2kw6Lh_m5upM(DBS(y#GD02L)$wqcUHn7P5qe@^{qVlIbNPqxtdi z+s*&xPt6m;xyHrjHc&*una{Ggt|H@dzcwC{aI#VuhK>{^B_$uvq?{iaISA2=O0yZC zp&2vPlf`c7>bT4lT`DcL!96E_A@iO1Y(}}AZC>(z;l)K+cTjc)H8I_=Ac=J=|6P+A z2Ax*?%|RUX=K<)AWUumINK4BpMDA#OuKi8`nKgk-s_Nh0j2acEKPxEIkKos9z1N%8 zR@9cvNirIE-L_Ix+3poZ8QhwL$l&X77&tB~YlfxR1s$HJmDM)4u%aZQqvQJ$qF0dz z!@Q7(TMrkr*;KEKFRS7HZl`)zM{T7^V@m`!0e^*212TzUum?>w)DUD{V2HgjvdsXy};y`w(OF^v0F=UAed>$-u* zb-VWkHZ)bG*w113%k4pEE+4XVD0U#j_tr=LrW1r@H{&g0r#|ws&D8u7?fw~i=&Y2h zv8{LBs`u>I{QW%;R$x6>gGIZ1nueBfm?1kf(JJWd(^23iI)1+C7Pg{!B=h86)4_ZGKC$uz*?$w(q#vfFbGxOEx3s*Vy8%s-PX!dUkyd=4Lc6VO-t*7Ob$aS~) zLuvbrrVY`V1C?~K8oG6!l(-fD*jMN#n6mL$g zJ3o)a*h<(wG*?Y(TBJ?TBGP(3_3`nMz((ptWH7NG?*V+zbLdBq_jW56@}PIUc-fu#oyx+!I4EaP7ZyqtU&1G+(*BuL(`AWBhUb-N7`*=WvcLYsuOklv&h|2W z$W_`SQt4?~M?2^B;~k~v{Vu8Fypt+C7Zz3q!E{D&kUQTERTwdH6h+{+hGF@c#!vV4 zel!YVPutA~uI~gmLh7E)Up|}JpdRj03jCZurEyC2V5p*-BQL;#I(U-y`#zB)gLT>TJ!jp(MV8jP485>i$o3Owe1L@+VJ z2g9*!TRSZ|G@;(+%l4CX(&VNqgE~FI>$Rh+ll8L07Ko7d_Sh&7i!oA>F3FCD8durn z+7PF3yjl}i)$&?$H}6ruYVJ+in|r)Dk~zQA3XY! zZxPkAcEMdX$)gSS+bSZXlvMC-8!AQnu#N}0hSJK1>@ub>0=Fy3=$yPZvd@j;Rw}!T z^;V{`JbHu8{ljIMHIe6zVg?o@C$2p@rMOsM+V`^QW&eP%Th=Ga9f#wtqqx?yM}AyR z2IhxMMjoE_B{4eehCeaU0V02HR&wHmb|rYK?@W)1)X+6Vka3!!`A`C*cUy|Jy}g2N z?a4{!@zwFS)%#0XLfM4KI$s(_)M7llW4kwK8mcSWRsH?1Gd>UDyPouo!dfoy)NK-9 z&GfX3T%AvM?9DQn$Z!eVm~!3OUUhS}cX>BelVc;|+z}D!|M1jVtkIRF!`6_y$;=^l zWZa!~DrpgDDj6GVi8-Hl05eu@AabR(#v`a8`@f6~5$BkmKEFiVhMYXVr0w@PA=AU+ zB}I4yKZl*?*&qF2rmMT6N})mt6O%fh!{!b{Wo6RnWLqJ%vZFESdj zKJvog;Z%Ah8QizFPMMYUpm+;Ggsj#{cFNVF#`R7ab z4462Va2}S@D?&5 zDs#1vnK}BsD3@Ze1uIR*)nsDgY}e`!+Q~-JQ4V9E&+aD@X(y|(@O>`m02C@#+s6A; zvW^siH;_ zC1HMBC15#++3g!fOr*i3q2YcgExj=QfbHZMz%CRLBE*>x6vPy&@&QK6!%8jgqwvxY zXK>66D))^v&U95pM1v6X6?AXk6$amGR z+AgK86NaKatwW&VmBvo{BD6(DF774BN7yK$4 zcoh)J=*>LnJ2^A68CQteaMF)P7cMC9Lc_lc<@x|*ZnFCNb-wduq;8vo1BjdRi#JF+ zjXUObdles5^X*pK?>`a0YP}L!8X(w8Bnu{d-M*S&66D1DrikdImrS4h47f(fD!~$b zMC+j!spE_YgBQI9X2Y`ARmag}X>J3Du#k)i_$irztsAOjEMbie?nSXIyyGCojmS$exCIs4lmDgsgLxF z?=>|?oXjRN1}-0J&PErB8!GA5U$?Jp3a0JgBjY`&%7Anh0a5rerlFzip-qp9T3nm6 z$nrHRHkt~K;0nC!Uin&z&)F8yOGNlKNW5YD;#?v;UMNn-bJT#=n9F9yfUwkEAFBGD z9icMd#h8pY_9_*%vq+yZKkMTIiSOUPIiY0!NF%wKH)*Gt7ybi{HMW1I5l7qGM}r@) zRyzh2kJIgzy!1&p=}{LDLozSn_)T75d!trpQW8 z13wqt%6!L1HUI9eST#fCgvb#s*_-F?@Db%EA0LJ9$5@1C_9JX`Y>>!5f>&)!hLgGv zuN!0+JRNXfo~)8|oQ`xYnL%qCZ@ZwDI&cr?Pg?iBPbT3M=`sUw#0YD3q_?QtYz=S{_3LE%)%nH#nPAL z0Zrg`77c#|j7G_9Y5bLZwd=*SXssQQTJnqX}bgtFa}dA zDm-4huE#8XSq;Q-XM~@b!d5{Wfu>1F&%1+>C9B5H`4;Unep|UcTrYm1Orh7tZ|_`< z2)=wJv^xw54yF!66`VgXLazi4@SRaj0ZrY5d)-E&X}8{B3L=i~yhl)h(ws$U*`=1; z(Ra?*A6Da913*GsY~%C5%6ETuF%TmAv8;HR*-}kS>Y%u`-BL^U!QQla``Wc2ckFxZ zAfcliZZC;V##ctQI*2KZE9Lt-G05zwc5zv^IeF?5+VjI zofd9;v_dy9+Q^E(zvT6&m%oj)VEd$@@-c;>tJk7X$PlW{HXagdWJO~?J?Bfb^!Jz1 zW@Vd-wL*(VQilJv5X7|sEfcAi#;9nRW_;(EGous@li`8sB@N~~>7`8j2Z1W{s}O>s z*6P{Q?I@_2=cQ02&I}qGbk^K@q&SW(REZeqcc5?m=-nP)3K z)g9k#&jpX!fX&g$C|dplY<)P_Q+aq^sKQI!L^|8NRk>JHh@8ZcvF(P~6hCQ?jU<^vJ=i;=Y>LFlF)VYBmFLZs-=2zOczOQe zRi@m(vcge#6tur*GeawA)TwrpTXz z1}X)GS#%ZAIa))LO@jmX?hi{1HEx5eLJZmQD_<>UeCVsP8xa0Z3WY*1Dq_K2SRXCv z7D?H7NHF4xUH)}jN>+a;8rgc&+50!@Tk2iwgAKe?NRnrixKv2?;1}~O`bp)P67W2( z1TCFpzYBdCddi*lw?0L(K3dw+fE01uK*!stWVc?RLJ$2Cq4Wq(pkthm$gKW8X1J0i z$6+DU06bQUi&upiEwruB4I7Zt&IWs3y-Qn@C|RShUU?_8<4~_^hb5`j{^<(d#8$L* zS5=lj&Vifplx5SNp~v2`b)X^T?=^j^5q4$W55!w-l++|DXWtMuSQ(<2TEyvr=(c64GB_B_12imeS_qv2v1 zb%(3W#uc=Ysp+q;zYc59cdcuI6Agc+aG-RthgE%678!$N!exl}H;*OBY(rTmC*B~@ z%$OzWR=ZWh+%4FJD&5!fB;j{}cU*O3%!1Qg6#wl4qhN}z?kjROXSNF618=F%ZyPIU z8y&5VSG8wj6eMoFQmN>SsMFRf{k>EDx#QE0qCNRiwEo_`wdK_i23O0O>*9u$N}1m$ zH^;)I;#~Kn3^H%e=bX=Mi8s$11$gb*RZ%7CHQht<>H;;nk}6@YNFIW(2lE0Vvy**H z*hYWTHnpejiN7Se3tD1|N{}+5aj~botzhhN;A5Z2UU$dahStv%UJn^yui}lcYYDSY zCimwTeI~!OMO9X^L&qtn^th;>SLBY_bQ6gDPz?;e$dCvlfh*lUvq+JrwWT}ed$Yzx zqUj3#;)>PcLCbd%M*Th&3nuU`rwRFw=mYA**tn0&`O0 zesX%G2ae+mfyl)UC*qWgzDiD-Jqx#stD8Axdr%KW^s>>L(&5Cn0pYwS?+O!` zGp9Y7;CkM(wV%^OEZxi{OGcdIgXSu%A@b4k-6U7fS8;pTa_xww|H$y>+2LP02tIBs zc_uJchA4xrJ!pV^y*4hkn*K7ys*lp={bQ`~7kc0f$Y&jMPkFt2n&$_0n})?iS>o#N z&C5qeSu-t?`jwsyZ&;RWy1((%YzZ^sYr~lTTMg3`e96>ok<=EJa$tN0AMuV~?b&>f zSMosp+GX8D82aj__TS)ZrqhJhj@IkyqIG4RbvcxE5uG)igY_)WXoHaY7)|b<7zuq+ zI%{1Am#bpopNV##xiTIaaa6+0G+Km8PHJFWtJ`j;T$jp4X0s`Yljk|O7Zhf&{c$Mj zY9k!vPVO+~IDH{oR%CH2G7V=7AM99bPB_^1m)_--!+luKME~fM~=z8A|E`Md*c~JVl(OLM_HF|L7NqZH>md( zC#JOPXVHp_rI-0Xgid-T8`;Uh-|8+!pP^a#JLC_?b}x=Yk7?`t z2p-wL%JV%avQt+UQfiQh&LxO09^Uw;NRPMtngJq*0=tYG4slD}bd<^S3N8L#qtheJhE66*nNl?_L+n;$ zM4#dqDain}C284HX9TeH zn`V7qnE5_rNs;Esv-L)_NENYbIJK1#X96Ss_dnRSuPOJ>9GtWL5E4!2jBCq2_mc)A z&?smG|GN@K>C9mn&``6uy@_Oq%RW(-dX6dgo^@!!zj}abcl_GLur>t&#}ID-oC-hh zJpaxmN|GT)$+}w2s6ts`wzNcFI0hQ*sfDGD?bC9z=(B(P49os0I%i6j=Gjku=uqZ6 z+HCe|A+z9@0SqFSKkJRw#V}1yG{>C66n{ZjMG>9_x(PIu?3ZIrnBA;qIO#i7j4Rkr zXhTw|urIs~r8Wj1i(-wcI1na}>HK=04@-&Ah56}30EK;aT$BtXh(;Ssl;oQ!eNx>_ z#)Yw=uyo52cRwAtDDx5fl@+C#yTo~QnY)^?Ukp;zK#kAnSMzKV8Fyssacy?03-PIM zCq6dGdRB5@Li1FESdDo>ewOuUqgaf5Yz#fN-P3#{UDyevPS`RBQWN&ry@$tXU3J-O zBNpd<3|CT^Z8rWiQ7N|m0}M}{v)e$@ITWOE{d%nNe-g=&^ZE`17HmbgoH90mj|HPVT-+x;@qf6`-tq6R( z$%11g?@HqvaJP^U>Ww5%2RR%E-Y*!yFToYxwf&G4=)mm;9x3vtlH3R8uC=Hoi?-i{ z_8{>#2+2?P`x*8bEN*vMk-D3Q$+b0f625>qZCbD{nJxY*r)aW?+|eUx*fj% z&;tAqSpVM@5u7M$!+?0IV?tR~D@=zS4rA}ok(!3e&7EHiAnlVL;qTZy5LZj2@}d%AA1kc;CD=9`j>&*taAa#-Td>!D9i!R<~5YXZ;k^cgb? z|HQ=3s*1kRKj^~=VN7J=)c+P=hlfY8XcF4qG6@0dMgtSe}AtZ6+ z9Na~+eI-<<5bq}*8XBF5-q8p!GY<{zX!nQ=sxcZ^{?4hlCKk_5J6SEx4IQ_~#B^Q* z+$&^cIqj4k4Y8fl$wJ7OB&3fL--RYRD`URugg9$drN-SCTLewYab% zpIWkyz`VD|mD@OlS{;*P>!1SEg;ogjI0nxtC`M^#jfr@9#WU2^;~^jj6-Lra&ZYhr zkeu-U1&@@VP&qn56FlVLW>t;dO4-&oy5-(H|}-k zDpx}`i_PqXB@6)(J;eTnC4Nydeyk$_{FhP^$}!!d;YhWN&6paEEw|cr>)2}b6_=~( zPy^}r+~%vvsGhX_NeAeK;MIr6+m0m?_}xtp9H&l|`AHA;cAx1mnijTHwl^=gAN3(wSJT>2=GF?)K?P@+X7^bIGD z_oD}>#ElkI8^CJWi>uUHLo>w$Mr=tZ2fJo<;NNYSnjaeSvEc()VZYkmGQlG#y zp9hDl)3lD`ll7xsGKlN>j_vX6A~BrR^b|#&_pJH;T-nrb)t1P*L)BJ4P$il_6*$K< zw5d0;9mzPWHV~;xVIi+R7bAnZ?;Gh)1$$%E-)QnUcoyuI+EAT-C2(3cMyw6nX zkgcpJ7lE8$EZWR%;)Dq>FlfkZOL_j|n?FEI56yL>pDP=g2B+$^bA4gN` zn-99Gv$JR1Z5XRnBcSloA&EYAkO+kiRGDRjPLT%x?xXCG^?u#5|9F-MA$f$yIW|eb zYH(^k61uw2eCv9D? zR#S0AD@`cFHwVQ%5q+*88CW?LuO%dwqZQj-Bd4ZD6VXZLTg((!&iU>gx5C*yIQUMb zwsOzA7=wNsSEjW+UJGWhZ{W1TXBf?~0qH}T_3z4&pdaIZ{#+uSM@Ei{TdvILyW;kk;7Nq?D9fDsqdzxW;=-nqM|UZ{e{P|;@N zjX|LoFDN+4F3t;GL1%!TT5aVu5_j;CQvLio2c^kG-@z3}5HEPZu8#N!*xl8S1Rhub z`)=Er)+Nzk1uNRBYi(^x^wnh{q)TYDcL*7hS&sg2Gkr7@gbX*-+v`s|h~-q9;bnSJ+pWFnf#-oCwd97O8|iVtpX1r|(fgl?Z_e9p3%et1gq_k7%Iw83RVY1SkF z;sO6DNlAUZvAzyjVV+#bT3JP{Ivo}iL_%r3m}4?^*A)FOQAA(C3eu&e zkA{Y(lXHd zPsH5Zk&C<>ng$&Yk5CydhvnZ93yWP8f_-FUOd->Cweo*bUEd;!jtn7N1bIRbNpa7O z&_#3NEJ~4mTq=zN^?n|bpIyv2QvyC57?|H&S6Xn={yBY^55xOblWXVK;$;LrtjFQP zrAo%w7!$3MnHjuKU|>e$#DyPh0GfgdTu1OYp#ShF7?eTP0xiei<#Yh}Kd-ylDf7Dl zK-czcCDhAjNFs%{6sc_mFwEARv?ZdGg`e+?(kd#Bh8(@T>_-T6DTSVM>i^?;=m9Z! zn6v2M!8)GJN8x8i=*q;$AdgAT0Tf zq$0m0<|;ciKcdsX#CEtl3hp@Tbyt1bECs*dmlP;1d|i}dR_Wk6sPo=+8vt;Bki?pI z2ziUpLm0(%Fw4LDL%96`I1VQ+VlTEm!CUp2p6`+`zIUKVdN)^BUpND_F7$@)bZ}9T z!Xg-yoL5gP#b>RqPu8W9IOr!Qe~c-J`Ur5?pxp1j-1P}|b0byDKBa*qY&|Ctn@a=& zK`3wz3Y@%{ci2q3+9~{&ehd>E7Huq;V+lCDz2R?V4g{JM>wb>8@y81Ma9wCLYTh?;sDU=;a>L2!!3rP09*k`_QFOyDek1L7YVyf zL6{Hx=W>Z8S8BW2G#zdu!DQKx1n~<4w*PL4f90@*Q*qEcXq7vJmI1{B=s43YtWj|A zdC3F?`D(L2Z>rc&EjMmuj_P{Q>`+t_-!tDAXx!!Bxe!BYg2tkxm;4BJDP!8JwR179r z^x%(*l97<+XZ`wE9SxG&eSZ=p|4bkZTy!&8USHn`Vn?l+EciilyN7Y%cG^B7E&VO_ zyQU4Yw)fm$NXu;vQILdWhltC_Z}isv`Xx6L+_bW0l^CUOP(X~vccGRUm|4y|-wFsZ z5}?vhLR^yir1XtsS<(+~EA@sIypfM*sc8)tzz1BtWdte45=ep_jAvxIU|R0?NBvIo zWdpZ=a#{y~r!T9KRn8@(T1|7aS4$5#IKf?rHg%+7haiT9afXmw-nnVEVuZG_PY0X~ zb5mBecQ%rA+x%BFT#N?vDAmkQY#7B;_ARuw0S!8~$6fl>>r3}24`+Eyyfee`yf`xa z>}Mk+=H21QI453zX6v1fTu-IzRzk{-)`&d<&N){wNWeppas6Bqcln{|8!sy>QxO^} zsPLw!Zc*1_D!`}5&yQ)?7}AbLldMKc4lQC*H%cYr3Uznk1b=BB;lqHtEz!hjNnxad zU-CXI|AdR{wUt2{|2Zl-`J?Ku)NmdeB==NSN-5VevSamW_XDu6e6H6=6UXKddWdkk z{W>W{zb;+O*LoIh?%$jrH6I4@F#b!4ofxti)LA21Cs6+_di;?Eeqglz##OW&ss|A7 zQ&qn{C?>(Tw|{y_2MgZ@qAwWVfAkXA4uXk^tk9t?t{N30umZK!Fx->?w+Q=Q(g?nj zzC7Q5LZ~u-v|~BCHYSE4t)w7Mo%1t> z7xUk%VD0=OkhACt2Zdq!>Z(7YEl@#Jcl4(Ls0hbe#3R`3_mVjY^0k2^cfO@tN`#PRaK+KBOq__adaFmwQ>IWeGMrd(uzY+;)DP0Jntr{ge_*HQ7kol2?>dD*L6!4!$0(x;?q z_%DE{m;n`jP=zDoJ1JTC+RYqp$Q7i>705r!D$c&wIAkf~gjG|KFa+Wen9JN?xA>&` zgqWiF?aihR-aDIVjtDo;L|MR${M80pEOKIS%fXpxnGLGOBuE4EOZwC8p({&W5oR>s zrA?t3QXYFKXfP zy9r;YTBl=fUYT*fBRi5fsKm2=^#++ zz#C^zoix)agq_>fbk{F+K5+`$f~H=!wGE)JsC8J={Dau0dcs(}wLH{dv)^fSR~+cs zVVjgk#_V+nct*Hllk5N_FgV)AtMMi$k*4)P}en!B?Z{ zDTXE=*%u9%Lp|>K;_9C>uoCt7eWcH;tYkee3>7oe`%ketGA&{Y%%s-vI^fIz=>yiO zY76^}R=v+Qh&8s{i>5NcSZ7^1ggQ6Dn1Zz)3@<0v#WnaJ1W@XICAHHVLg!+%d{N-4L^dm;0TGzpz3=HgDq^|v&mn4dJHRwAzm7tqS zB2#_PfN!-v$l_OSBH+O>MQg+d86h+bE1BM2yB;)oCwwEU-^8DntoB@U?W%Du!;b%N z5aRa5J^u8Db4P+&5tXT7*Z5#JKT5!)PI^}nN|sfujl8F0dA|yKCz<8OY83oh;Z9uz zsPnm}FQqFWxmTuYT7 zy6{wXMlUv^GbBaK TdkFyom@a!mT+1}kWd`{pV7~WVH3g98O2DnZ6FayHX_+dw5 zCjV_I6Sk~d{q~RV;8Gu7V3W& z3XWgM+A2<~h~lxvw|%5IL4EjurA+sk6ks?6m9y<+W&_L5WW`m^OVjV4uSc_n>GLX! z5?BFHYRK2^KIKxn4{+&vw4wwx!K@f{4yHmc8DIPB*dv5eKxWHGZecVd+^(2N4g?nE zr!(J%jB#lFw@~qOzTZl@6@FN2QL@Bt3$@3qS0nr{@uuZJ(H1MoivVee7#T8~-1`qM zE=9(PFn}M6;+y5ANYKo7QsA5F(HXkMsDhV(B5%#DEM$waR5a3#!;3z%HJNsPOBpF? z4NS052%(RRaepehm#kEm{=M*bXe>2@pKG*88u5HqvW#H8COT(k0)!ObC=$&mfW)ju z&_`A$qBsycgbErH)4%YY%=q)0<^rMwrXGk~q%Ge@@Bx2WY~epTr|=$zdku}O`{nhU z(qsHlkK~SUhKj@8xeX>DIoU)***^8aAgM}`adu7~l=~M;K8v$`6>LxStj87!_||3$ zu_6nLJg>&u;~%4o)lSY<$ZUYVfwhRp_?4d7xK1kIL_4}51fxF`DnWC5uHmeYW6V;i z+xAa-^jH-lPQH#9v-;C|MG6vTS@cE= zS^ZQ4iRIXKvlv;>BykX(@K5O`mfU;loM^eRQayPY?qItajz*CoLt-F*!Lf&=!-dvK z(0F_o6psa)kw4vwiuGTxyUN8tzLcEy`_;4N1`(o>V-OE2zKZO1TZbMw?{WDvD0;6mfZc3;vc4;-?X}vsZp`*Vw4m_luMQSvR;#1r4F_I^Y3gH z`ysUa#)-5q0S?u3`0$~=bR0qV1G2j5(;lWE#QGnab%8+3SLcl1|L+V3cC)5F_B^gec}5BzQxdPs1E}BFsw+Va zwvbCGrI{g@H-z{xT-JuaP8yE?Gg5&{rX5+az4JZn8 zN2hPB($Y}PZb2vdmnWsbf3xZQXW9;eSY-6Y++2Dykpc78mhZ4kZ{L@Bt=^Gfzkfh0 zj}tvP^*VIRSxG8luyG1YO2T6tQZS;1gl1&FKtO!i>Kz#!1pWUmCubqy=YRrZhqQd{ zYCnax^-|Fyeh`kVfVMckgQv$z;yCA5vINb&Gf9bhGi9Sz5|I_YP`Ec+T#3P99WXa% z>L(&gPhV!OZ94QLn+_DK&L00Zo?g}M2pz7b?P-o@Z@2i;W11EkS-`-2(Rh!b!6AQ> zt}xL<8#nNr0m}njy+n1Iv^?fNv;fODZ!)#o3ixQKG}QaCUi=hE|3^rZO5yL@sz9L( z*hFKqi~Z}>xW0Fe8R9Y^=})_KOPNlVGWOo&H2Y`M!#gQ^aB>dcPegLycWPb(PjX?A zrQQgcxcF1IZSIuHOapsZj95TJ!>ELUPHOxq&`QogS+CpN-;4?tySBF!93{e$V^5^n zt)M_?K!n9|iC8G;T@ESOnbegNlY!^7k<=V;;7w1pZSJ`_g)B{lPcJ8?C>pUNF}Ytl zN=CBuTGUjpO0k>p4JTp4HEH&k=jYwP#?`A?BTIf%!#f!q&c=+ z>Bg$%&CPeIbyxoiW4U5LZ)0$6lC}>lQ}*EK=#M@8yOU`%oyAZB6 z1tCM6s|%A7lKS&pGd@1?%+D`oDeKRbcD})ef(}ozaNjuHAOdUOKtxDMBa5>(G$>KC zW*gM`GSxL@zniV@Y0~CcQP4&h37`G_(fQ;(jXf>JD9GEN2hBLx^a$D-?uGz{E;$o;UJsC(QaLm~ zxV!SLz^gUmgGORhFL;5IpGSAy-}?$RBSC}4JL>x!^D^Iw5=|a6t3Vqcf|7=U%eKtM zp(J6vrH7jFS0)wcWbLb7sQ4aq9$4Q;_QnE0a|7B= za+8ZSJr3l*6?o6d3qJ<{=nq_IJdSQasdvm?Ztm8A%1+}ROYd;d<8>Mn^Y8l2AeAsl z#z66?NbSaK=;L$r!`$C2YS6bd_|Nz#+JORt7VgyWGhCK;*XIL{yHjl)s2u-unNmgi3g`6L`vDja118 zbGJ%zG~L?XdWWc_((aGiPM!DrmpsJv#xb6YdHxTf0AY7{U;zykjh~;N>2A!oMQ`30 zA;Ba;pi!I8<#GXXeDN3Z=hMq3gq(r6V22ckVc#@KYU9;zCgBOyR(zo#XlUwxrmp@c zwsuJM3X`ZP04#J+gHJ zbpYu2*YArQT`$OgMeKp3<3Zna09ri*tJ+5{7Cj&U9sg*2xN$A>J+i5=BAEAdSlMwr z9fBnH(-o=&_Mm!E)t`%zLS+@hESbfsw_I5O?sZ!Jh`nfrwI&kNxo^JwvnvE}mDm$P-+EolqOfO=U3zu2b zUg6Xhs)EvJ@U|7F6@rU$Fv|ChQ_8z=bEJ{>J^=~Kh3~S zS`C+UrdJ4oqP2&UlSplU?SZ~orvV9d^*gR9WJGrqVu6d9GGWu>csA-PrK;+{_bk?0 zSBpjJ8N4_F)$9hvRE#a~K=je_IA@_BGKt8Dt(@CU>`)I@_`Hh2u6Kgspj|6Xn=j8nz`>aBcX0m5eZy$4luu?~?2!T#$> z|9=N|8U+Rx+F)HC8`RWuQf;?(eNX`Y>hv{YR;%Z?VF`ce1qnohXJ~*gDC|hNV;zP!y4&E?x4i}JUW2R|SQ`Mwq__zE z_~u0Kv6Els46vlv?VVvydxcT6kM5^W@Sn=fj1Uppk@T90N-HfBAa51977g+UN9lq0 zP*>0AWmTk^EG(iMw1m|!>Ml|zHO0hkciRkP(0ZnOErGiV0}Nk&fsQE*+23>3kgJT~WxxfFS0g&o9tkz11Rjo=%9`uBwXUY8x>Z?itb<}> zQ+$HT4Oa`_9p+zLFdenja1hqk2Cup-?ccMtHS^cPl`0ns4P-$wUqbmyLdGvDZ&jX{ z&Te@mm8?%a5tPy)BgcaFpGUAGLm5IydK$E0JlDuFVqUbnelr^v;^MxXA00BizhP2T zv9j6{h)pBW(Un7MFt%F2Z!iMAwmPS4Q5hb9o#I9)XyCUS85+9E%E+jd^HWkT(#R`f zwD*_kA5YFZICMO_PQ<5TTslqb*)6?1*vxCQm@Gh}%S>c?D}6N%+F*k+GbbA1sTgFM zdB}z2Umj0G>A5{X6ui@qx4&pQb$1>BC@+IlMa2@PV{dQN9S9AC zgus??GqphcKt5%Ea`89gFLr=!g_S0}8|cZ7Z@MvuXX55QzTC_I9Cj|ZvS9a;@75AN zdx!M-V<=E|iK(mWyH6=anxBg+>s})Obl46Wt3zi7wY$2G+qOXyaZT>U#nF77o&KJ< zwe{8_79wR%&hhGurPl0hhEZWN|BI;pekS6z{aWGOzFg_!623C;-R;BgoXZxv zh^=I@d_VW*vI(E$GCe;?A=VcqQ(|J-k&zRrT{M$~-duMVwMSVbT{v$%OG84?KY3VyJFoq=M4=~(l-GP{x zXUPzY{W+#l?d^3)FrefN2iJ}7o|yGJb#;irn^|pDd;2D;?ZQw71lr8U<(K-fX9ob2 zL>PAeZgBF@*#KiRC~x3d9Li}C2nY-bxvGob*cfg2<5gPlwzQf5fmc}r0f=afo82$9 zuhuAqFBx)zq38v)G|%v_d`%5CMu7S=7HK;>&oY~e=oq67wa{PCZ+bw!ZGGgW!^Fiv z#EGK3WYEII4w94CB(O;VGK3K)65F&il8b}3vMm4H9rKWi3ZWTXR*EXMraOChO2J3B zs}$bt?9_1>i=+B=?7HiXGTimY`&NF<{E4o|3cw0v-P}%ceTjG+k4Dg9Xn=zb>-y@% zA5uKL;N=y;udIY=oV?T0(`~dTqk>IzeGL(En4kh*60rQ|6GT!M!6KZ{%Q4>CrR|@1 zR?4cnBAtcX8|r%RvncShc0dZGAsEsEX(rcBS`n?J? zI)Q6(|1M%EKA?6mG79tsd(#+vKNm0^#I97wa{0$Fd@MnM+r6?Cm$&DjnkA8nCEp;xgW)En|k?imbU%>e?@ua82=V3#dC@w~(W&cFZ zp=@MH4@(Y+VQ}c#OA@KUYrt`nD>(rX+e5_Z7Vci@(qHm<938Sa-C#ny^FxHo{(ss# z>!_-twcUe=NDI;_f;0#M(jXuzh;*lPBi*Tlk`fZqN{Dnx=N6C<5Rj6P+)9U(Al!H3 zIrpCX#Tegzcib_Ke=&w^*4k^$HRrqD=Xri}a`MH>$`ww4X`D>8Y=xMOSt+Alc`q8j zbTc+imVIAR62S2G9cz*47e9N<8#7>5;~T&`96{K_#HsJ6p?)zxlWu>^Faj&e=;V5z z?%pTHGAG6~#|t21M$%9kERi0Z>@tjt?<&-ODSM3GmzuPui!Va(BT*!GDNuv$I_1&9 zmk|;&OXLLPhfn- zi_?n6*YSsH?sQ=(p{b$Cal>Xikbg!y=MXX+vA;h;#XNM}-TxtC`0;HI2Z}T24$^D^ zcASt9hBBG}F(7R`O4-!Vykwb1bmqOkRwh#`6pb16ibLk|i>RAy9B|#95fVH@(0eiY zZXPaR<&`{Z3&r31HO!y@#+_{>p(tmSF8FhyWFhNLw-zC8*&nE0YE)4%&qc~>I~JN%Zbf7_L0rEte)#w@T|-V%xE!U0%0a$ zx4tW=K1hPjav%0F1mI0$LL44JS+jBK0r!zx-m>rVa2vsgh#@w$cK<0Sl*6fS@CX?u zhh<}nFjO1f^*r9+q7a=aW1txu3*$DYz8%bMd@zl*I=b%J9~YM~$Mk{S?*J+PQ08*o zPiD_OjlFafdGL|!_7`Mv=V|bSbo_-bmIkr{ z4i=W8Yp3yj+t5U7FKuEfdaJp9DQ zSUn^!zQ;d!huXfu*8q#Q%H1J~U-coE@S^fvq7IVy)f;Z)0mLW~nsvQO?ID-j$X;FK z?aA?(x>w55Y`JEl^QhGmUXoRm#6b^n^9q-Sq@o%mg-FzBbn2ePq8b@;l-aLW{HrzB z6tQTbJCDni5L>tEG42k5nVc#uxurnNhz(Gp<9g5~mEQk7uVM{VImRe#Q6t7EN@^a| z=$4b$`>M{uul*6kbAhkel20oB>gI_n1xgRJ-Wj~W|JBcQ-k8fxcb`;yvy}DP26St8 z0pjyuc@CVtea`UbeGM$y5!KW+8zxl$0AO&ruSRp+!@NsHR;+RbCcZwnJO&e!9PwxV z%D7h$yVM((7T+_AhqkA};|prZ?Pkm@4X=HeNV|kb)CQD8nH#7uP?^uTgf?g>ZXo^X zklP3A_xKabX$H2_I;|B$BiSI%h1@l^~gUf_iHs zQ~DH4J|}Fj7c4^D8I?rc`B0huWY>P?JAU)Wr0Z;zExJ~6?xgN)uU5%O&-1rs+GP+y zu8aCso7P*99U8)^@WSipRfbK$k_mS-r?zX&bJ>&N%)5pTMmal03I+N#?@(SW)eBsf zURq_Sz#@VQQi9E&$U*Sb{&;=)R?XOwVV4_3f-RlMG8bI@N{qOweUmnKKo*ifx?EuS zRaf+_HcGTE9xMWwoIhNIU)E=C(rp<3o*CBZo0mw6XH9a_X{ncL%FNt#0g;eT$h5V; zoaGJGlkF>ndxyX8Dz15?6bO+q*`Vr@ZX<)c^5`6zMUOipDZ7C`8FzC0ydP93gXUiQ zsZRAgXF(6Rniz0LAe&@LFJs8V`HB*sd^*=Odt0J)N+&wQ(Vz#O&Cy)ot{i}G!mmw?lc?uPUjeSA$DJV7=H_$0EA}3AV9Hm`i^FJj*g9NMr zdG}va0VvKa?hZ|E;$sj-r0FgHQ(xJV@_Fi0)QCz~T7Pk=!;)sk9AzF)Na^mLL=r~9 zpCjhmie`%2G&75jKnrB?GzlBiR4yMLBob{-Nky*>#Qm+GX)w_m8hjBq(0%fVS8`dT z1mpWX2WWPKK6BOibb6vk6V!L_6;#mE;t5E|H7AMBgr2ALElO;d!f(A&E6BN(OEymp zerXt?gS<3|i_DG~g33~umd?z!+6ajEE+x2NC;j?avYC?Btvh-vgzZnsg5@FJ=9Xd$ zxk5yRV!_GL%IL_R)dUzE9@))5npyLXq`hhWlfae-tRL!XkI*rT9%I5KIIeiovNAed zJVm9W<1^1o2O^|UBm#uc!n)T+I}exL940V?7HeYfy?^&6%~`YZHNM^V1>b*2yAi&z z>vqV26VNqMtQAZz%Y0(M3N#f?bnZbHYIKQECDVybObUmFj9bOGuqxYAzq_`=y*i(y z-@jYgKFQU8P3+SZ)AbZENevj@mKt^#+gxvMb81sx0D4Ju9e)Ie~Je|gP5i1KD<*0n;6JhQ1hwJHQGiK1B4xDdNPkg$3 z&+$4--mmbk@cym-^*aO?zvKD_rPg8xLbz~=(~`2cw#EfxJo8V=L>+CXF^NjmeWZ8r z{Y%R-jWJ=K&wFc&3T3b5#mPI&=h1OdlB&DOsa8;qk!WDuE^E#zn*US~LRdH3llqCxx*|*mC zY3XT^L_VxH(W7*T-LjaZU`pkZj1XHc55562^I3mOWt4r9g+OJ>XE3Cs|0GfQs2u8y zPVQQb26h;OB}H@g>U2H!9>$v!8WfJGr6Gqz{Vq|Ly93&jU3aZQxc=quTsX3W5kip= z34C%A<=g|e8ze`KqLr`0->gijzggwgt=6sESzeZ*=ou7ee;YyuPk>y;qQT6%?zikX z`B0u{wd8I=-0dxRvpH;L4)o>tfml(Kt+-WyUY*o-P*{Hd3SRc5PUP?o) zJJwfV04V;PLK`hFDKv-60rmp+0;UMg)4ojMml!%7?5JpWT1nTq)7t9Bv&g~%Fxm95 zu{5|8zKveDV~J@j+0c&Szo7hp!ENM=JO_YCWbnUD^n=|h2!BLb2J+Bu4&@H2j1*tK zzOoiVpw~9Ih=!aK*;an><#0Y(R@?ak$$xqMTOG)nXj(1;l5;Fo zLp`3Ia5tj(0aZe~=6#xx=Ad`=5$O74AIj3#A+3#dh!(|P5yA!W=iCHU8GI-i9x!mF zQwNVG0%qlU#O=w$i<#dw3^BcxQUl*PN&mI=29=D8ytP)ZjNOW6 zR$<76_SfX*341>)BTZ?-Ts$+MV&38HU?b;_YkFz&=G?qC^l_VJ)@{m9_Lp|=#|>RP zQ0)0q+1@;NpY%$tVao~xD}=#JZe&GC!={fjd2`@X!+~gZDNLlZLko-OryEJc@~Rd@ zjl}Y|O!8RIDKELX<2GA9?5S%dxI4u1<4JztE1AA|i2_Pk^=-8e`%2ggN1!Z}`;S03I@5Hz2^_<2L9 zmqbm0NY)RBOopO8>}X4|K{mbrC#Nwst~O6r+jTfL-GUNv?#p%wLM!^dzbC(9>dVRI z(XX^@7DzY!2K)-MJ9^v;xAsj+gGck9h6i7Yl*TE@x^9eoc;h^fDzA35xC`}3>7j=7 z0JYvD(J&fV2;uxhhOg>|9f1CC^B$`)LLK`C<%9{5DQe|Yr4o(VCH5{qhL4#sWALPO zL#$Vhkc<}wx}sMMO53JB%;^4ywryRUpvt{GADm{(X|GvH~yY)z~*; zU+G9=gH-MGtj6Pfs3$BdE~!g&3%s|R}k=5+`g9NM2V|?1;lL( z;g@=KZi863vR&uJrSURvOha92UElifG32o!R(`g9mfW83+%~_h&p`!MVq0CfTKkma z@Ve0=IJdAf9^>1(AN_`*_>?jE^ID1QPR_x3Wo=%Dt?>O}x*k{cKa1PNmok**P~5`W zT2c63>b#z3x>oc8>_#jNR8${PRJ?z&&z?U7|3-2DPeRuJ&w|x=9V$2=pU;GkV>{OV z@TQa#FO3%y9%bAbRW1$c%{cCtKX(hwMvLUB8{ik6vD4gjcUBUfoO=~_By8pME=AL5k0t9m@46j$lgeyyuCeeOKgr_PCtS-hPf7`R$2} z#@FaMnW67$DnxW+rGzmaQ|K{b#Jc=O0kn`cGMrX@S);)uXM+kjH)Dny6qFJk4`%nX zGU8hREfaXSoBmkf|Bp`M4gq{I#N0gC+)!tEOApdX=2lPWlXq-wvg#;9@o^XUneZiM zPb{or8-K4j^3A`}lIB1HPpv%WMuR)sIi65|Z;wG6uL@4{V0y6WI*aA^0Two0IFNl! zoA^O+H8-6v&q>WfJ8rx|Wx@h@MpP~2)ytkkM`KuvKppo*PLRj%pV`&(t44%^I1sRz z5Rey!vPE^-+D6_Ee$PI{{u^0(;-aoD{Q7I>oomkZ^=@o3UZ86mq&Mz9Txpq~ zzrsW3#pLRve$L&k1jHM9O1HWe67vQB_~#7TQ$3SaSrqzEb=k@W$COJ|%HF=h_F93Y z#XzVg@5>@IG`b?x9swu5hz=jrBAW8HgnL5ZxvjNadKP2B!OqV2!-cu>u11|fROcdr zQdd{Clp}8Lx?2^Be!eyI(0H~A2o}FifgHMJwJJ-FdZjiGW!0LEmUnTvEbiq6x}Jsi z>5=1@S4a32RuK^pmN{?4m&g5SA4k-l$vE*&IX^t~tK050 z8)N>7Qd;E$fXJ&P`{+@3iPSQ*lxlxm2h!#NA(?2Ux0~mtEO|%;)8)C!EVNT#WT(x+ z1CjGn?dT zgequ$-qg_Jy5^euVCi@6Gr*TW@#mW?af*t{?(^A<0&!dsiykkY1)!Hq4a{t)u7G@i z3fJozV+@d9d}G2ZiSg4|G6D-rsA_jg>%$oa#04FfIQEKlG5IW|KO}uUzZg$hZ_dEL zg|2qAX#3ccCQJxsjE3bgzk4t5@aNV9h^*})$~u(I{>S!*F=rH8!_mh_1Bki=uNgmb z(6$ZUUPRs8m8KONPh`4dqcO%*Kmdj4_HwH6X!71F;;A15QUGS|ISmd4qcf`Lv67Ib zUWC&l{KkFVc}bai?aE6K#_RiD^%!03R7hPtc=OLzm>;fBQ-Ycvb$@POrHzJXnuIt? zVpf#pIG6ptyxI#Q3>LST$}7csSS@GbX+GSLkM)?L9My&ZdWJ7?Pw(6_`d}_S>Esk1hWY`4WOa>@pJovG zJ?t>(`xHC34UqXZtgBl2aIIHqX#73r`wJE${+v!YfB9>o5ElNDz-O&G-oSB_P&8PX z*W~Vr5B;(B$zhtyJm9D)+b?}Y$Vo}DTK;yW5aH)6(=wo?LkCXy#VA65i1^+Nj@TU_ zBp(krrq=f|x4Pl=>x*N_iLd9OvC#Z;b`L-Q_~H=E7!#Fh*DWnEXpfABHoQMxCvS%oaTxBbnqpAH22Qc?+0ISl&{TZt z?@w{R1J`>x(qe#M2_Wj4@90Et7p*Gm;vm8ZTM5ksM@#U7=G(9q48FKqE;DM7r@_>K2iC~M81rKJ$jKLtV@YX1Hv{d`U)Cx_1&=ewpi>*+ink26#i z>H9kU5P6kJud8k{XSOopsa08Df0*^tZB_bO3NIs`!`WUbB>77RX;)iLaihg=SA~OR z!gFJRo=~mhwjctVCD_qzCAFQ1boASws&DRL)E@3Avvg?ynH@3OY#WW zJjcckMbt!)lAU!;|4!a(ZCq*g@M^0&0~3xW(|Olizk~~K)bfPpRPhJb?e^mu1dE8E zoCvM7(hpu#TQu%d*?l8x?*Ok$)^_S_YxP)YB82D=A^vu051gDX^T9d`B>9W6adBlC zay~v*;yB1gpoEZS4=#EYpK0D_cO$?d*JM zHf!bJDz&nKD)D}@P9Z!%iKHO%@7K_n+mn?o^YdXXE#pbhFre+>=7=cO;|bA~#6-Q8`zvS{E_UrI6}7&9R6v~JYW z)b!nQGc=@d%t)t=4#UTt?fs0;MO3a+-5L49qnDincL00?fwAedgiPOR!v0i`wJYeB zHeM)gq0<$7Unq0<95az((Ojj%B4Gme5A3Q`w ziP7gE7D&O`%x7-Sa1ElNqhSU=>Ckp|+BX4Ta+$Fl4I~+EI-rFgM(5>~3EmEE{qji4 z!NJo=O3F$D5Y~N6zouLqce=A1l7t!85B&M3hRp-1+1ja`O5>k|5nV}seoYqECAg26g(cXnlJ3J*TqtNPH1xTkiN z7SeQ%P%${sXCB`Dp{b!wK{ncOFq2+WZ?#o>=70FLv^~-aHfiu(pq@KeSd2%NGVu*2 z9pPqxDZ-`}bWm_7%}IFW5I{eHkH6T6+;Ev!%y?w$B}`k|{5ownAB=HkKvisgC%W;& z+eUQjcQ|PzH&^bP8dLi4i0+b7=x~&I`WVu!cXVXunpm1tJ|HC<(pSIh?=PXl#bqaX z`1m!m!sA~+qINfBEHY9WP7~%|tDzU*vsCpNH?I09{ZiHNXN5Ul%cey5XlcIrq2q@P zH?nVvV!WqMVV9EeoO&}i*T$O4tb~;Rs8c`sOfaoSY;w5uE0uvgWwaRAAQx5{MNxcI zSd;M7*65)&>B{OVTeMq#{ps()ik*DP5C;_~A4g9PdyU#THLr}Kr-)NS(ZfS_w*cAA zBJ;2V@D>j4$P8-trkjY65jL)y89C2$)@Sd#x&7#jV^^3Y-2fn%< zhUVH?v{fAPrKPPcM1%iag*k(n*l#QC8jSX*q2*aBDzPtr+Xw;)B743dzkPP44%vQk zG;`$#W*4e#)zZ=Vp=6{dT82c;q9IqSsXUl}FS7XbPq_k*I>LzQ1A%2)`SB*byY~3a zx{De9B6R#2Ud%gq312rxW&WJZL)VN+`2 zv!gg!Z3*SQ@?xv>Z1Mo}YqT))nwCD%g?mM^3sChGX+g?9wI2``#NmsNQwiAiXL{lHZ=S0PPnM8j0)!?QzOVF?^ohAg zum%&YhbjD4i@%UfPU0B-VLv%N$lfy_FFRCkfa@jQ876UYI3JNC_a#F#y=hm`s^D<_ z31ZA;Ihb4dcr$a#JC_Khl&o-r4Cuq-YURuIY~ULk1Q-E- z{dAmmt0G){ePg;ySK;+V7i1`Wd(Xk=A<-?T0;iJ)9~{KCTBDcDpMr@+)oHbO&T@Z_ zFihF$a1M0{RKuVuAG{_HLoOx3!fh#uTTD?Nv34qQw8(Ssx*&O6`85+Kr{vCkSY`+8 z$IEs{qdaD?F{XBR9(NN9ogNN;wiz$u`D1^u0Q*)1BnIsOM_*Y*2OD%*=?>m5rUp&x z0a%s5-siyWixi!U-g_gPU8rspNr?eS?nX?8R|>C3D&64qBRCQSeaSOn*Y$6;L>rAR zW9DX-VSy?tM1<7SF=yK`WqF?@j$02}2p78YXGnTIrikkAG#y#|(A;&&qsf<-{`u3-^l{t;q=|(CX<$_=7WG3DOo_D{Btu|C~Ap z#y;5F$Zfm0M*6UBM;=@*zjv>y)1&Hb)n#EYl9U?JYnq<|uDiq4PO4)_74`#(G)U%U z)Gs2ifO`eVij;hlN~NaE&Q8zowM&VEJ1W|jGmjKJi;D3v@@#Dp-n{U--0a{ml`vY3F~ZEaw~}N*PlxJ3F5r9&pl6HT$MSfsqA0z@mfK>2+H;7AFuHpK11B$;0!J| zMV$WG3=Pmjn_0sF|c3B?G=K&VB^{U|vVlJ$&>cfgY6@{@7VL=@zUDt*&-Qhzi zfX5Rj&9oSeQR7%n9>VL))uJOlUr~v-?vi`b=aFpHP|$iof`BwBsi84nYC(R6T?Jcy zTGMz0#ASF07PO-uxx3A3u}8ZA$2A-8k-NZOIf6|_ytk6mf;VROP_bq99<`om4g3vI z`kZ@&pYaKWxqz;4am;goAFS}~Ygs{|k(bH;vL$(dRSQj2{Z`BdJhBE=S<;s8Ug`|F zQ|}TaF}GYnB4oni3_=X#m+ECZP#w-2;}*9e_Pk3o;3h5@gstCc-e+b(Y@6rmar3~L zR7+NO`K?$F&~-8VTOC91>$)3N4aUK_s=|9aJIX4S@VNZ369c(3b&Zp7l9~)Yu1+%X z?hc2zcspY(Rz&jGB4B%}n%v7#e}^c^asCIr8@noP$)Igstlt#hc0A?}s=G>6VoVf~ z+&g8M1Fo{*c?q2n7puvh0_J+cFGG7zGV(gv{829XuOpx#(ijGm()E6W;Ky0hZn z>{A=|(?`jYH{w`d@p@xVp?(`FhIcf%S1iQ5H@DK!#RraG*fe&?h>a|)UKK8%HP4&Z ze)-Pa&DE7Dz>SCvvq-!ADJASO)j*{xzq83G0_<>)Bkmd}YPFP}>8a0O_3>M&MtLHQ zA;?wQW0w<)ZMm7ctc*@_KL6I=$R zcGqXDoFLF|B1x8We)9wcp~Vp9iE~8fiFS%jh<}f=d9elEFNgl4-`&vBJ!zYFy$=h;koPu~ z0?{p2nS!^`0b7Kij1$Wr;wkuv2PZ0K0 zE55pN8|c+kqgz;~cZ8Sp+i!rGqqJ8XVvD`Hc|5tZZYOw|bF?$Y07lnPOmLH58qliA zE4klmD86TBa`nlM#hB@uC)-1hWSyV1Guf|gcDj*3n78$fdM5PF$ZeJT%W#K-1z&_t z-$dFY?z*D2DL6X-*kAQEY3*+c!iVue(~1sKDWpTeC2@1>0^zQs7LVWSd_e7j0#6kA zgX+0I|9!q-1~i(!ySDzF1KJHZ93-6901!N_wR6)92u&vN#DL5K9Sf&W|1zABWxs(6Om*m^6` zt?7x#aKqD1DVnPL&>Ptv5A+Q^s7$PesA`>P-H7iit-3_phWdw}O%2CZ0VB<22Z-e* z$ht#z+_xVIgmrfZOS;|hFh9>|BqW3j6wGNBD0;Qg6qn7w>wy-%GIh=M;p*wG{Jk@)In7F9~X&Zip#2{WKvgUJh}!1FvM z5~bxElmZ7*>_cuyZ{EFnBVmhQY4`S%VFf;ruedi=?@``J7<^!=r9_`KynKUY#Lgg zn1OZ`-3(z<{ao%RGhH_CL0Y@j^X^T!*bbnCKa@um+nKYAoJpfM*QSe0Io} z$h@FSHU*$gmQ19eL3$W;+=$gLksH4iauY6OcbGXL$yT`qg0P!TDVO4v2!z>c@&FLN z7@xUjgNNE-Q*P**hCCbn&nfOjX|l3F$({QWon6W8B+Xq1KsUKPSx-l8l?4{MAHw-c zV2&G-ClIzXh9GOK3;}G9KnglHkfmQ+tx!pV*vM7oN=vS$Kp>EBO zb^cwOKyFVSI4MbtoPn)J*6lX;jxVViU0Ni(hg#%w_?-;EJVAUDJYj1))zcWTr1K9? zebog|-Fj8LEZKzFu%f6ei5u4?3cXUytTGpj`cCbSzs%8Gxa$C3-)HGxdO^;kgCt+S z_QAe%_=|JIi@Z$v9+f))=vPb9-O&K|%wYuviQ6AQJ}TjPIH~kPjK2fGq zbVkaV-6?&R5(|{Gg)VD~Y`G(9vxNhaS1LZOP-(?spBF3Etf_Vc-@(}C9y1F=OvkJ| zeH|Q!WI7~jeafNx-flntOq}^4I@r?MeU~e59`C1pftc+GUyvE4jD?>f34}S=&1)#N$8Nh5( zsFOap+o}0E8vlnxf((fIN0;g4ti(;&-(|)11Y`bHxfzi10x84j3h*(Ebc8Ur_$)=I z<84a}<_pdW)_gI>25GnR@V~qZDu<0Xb?KRBMCG+pm#k*Yd0Dpt`L--AGYMwjETZ;CVhBx=IjK#YDkGg%RhQghrb-A1b z8k{JZQjIxHXX%BABZH)`&W6>wI7A3_=jqVPnKkNMwH_6$lL zq+q?4#D^h_$D33K(d~dO`dBV*!PE&zqLZkQ$a!_goy*%OOJ&csE@w`_~MD*M{e>g8dF5 zTJzb?g6$#jL0~!qFq8KR%%09Y6A?|xJ?aDtJkd+zW?Fh|=PxV?b71IEa&u4(_`5C8 tzju%R@7}}yt?d7ML-GIb_FbPp!(c&pJEzZGvqOV_a Date: Tue, 24 Feb 2026 17:03:50 +0000 Subject: [PATCH 13/13] dotbot/examples: fix missing lines at the end of files --- dotbot/examples/minimum_naming_game/models/supervisor.yaml | 2 +- dotbot/examples/sct.py | 1 - dotbot/examples/work_and_charge/models/supervisor.yaml | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dotbot/examples/minimum_naming_game/models/supervisor.yaml b/dotbot/examples/minimum_naming_game/models/supervisor.yaml index 3abeea8..c43c686 100644 --- a/dotbot/examples/minimum_naming_game/models/supervisor.yaml +++ b/dotbot/examples/minimum_naming_game/models/supervisor.yaml @@ -8,4 +8,4 @@ sup_events: [ [0, 0, 1, 1, 0], [1, 1, 0, 0, 1] ] sup_init_state: [ 1, 0 ] sup_current_state: [ 1, 0 ] sup_data_pos: [ 0, 11 ] -sup_data: [ 2, EV_updateInventory, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV_startTimer, 0, 1, 1, EV_timeout, 0, 2, 1, EV_selectAndBroadcast, 0, 0 ] \ No newline at end of file +sup_data: [ 2, EV_updateInventory, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV__selectAndBroadcast, 0, 0, 1, EV_startTimer, 0, 1, 1, EV_timeout, 0, 2, 1, EV_selectAndBroadcast, 0, 0 ] diff --git a/dotbot/examples/sct.py b/dotbot/examples/sct.py index 7f1432f..d1ea7bd 100644 --- a/dotbot/examples/sct.py +++ b/dotbot/examples/sct.py @@ -311,4 +311,3 @@ def get_next_controllable(self): return True, i return False, None - \ No newline at end of file diff --git a/dotbot/examples/work_and_charge/models/supervisor.yaml b/dotbot/examples/work_and_charge/models/supervisor.yaml index 1299fef..4a7552a 100644 --- a/dotbot/examples/work_and_charge/models/supervisor.yaml +++ b/dotbot/examples/work_and_charge/models/supervisor.yaml @@ -7,4 +7,4 @@ sup_events: [ [1, 1, 0, 0, 1, 0, 1, 1, 1, 0], [1, 1, 0, 0, 1, 0, 1, 1, 1, 0], [0 sup_init_state: [ 5, 6, 3, 7 ] sup_current_state: [ 5, 6, 3, 7 ] sup_data_pos: [ 0, 77, 154, 279 ] -sup_data: [ 3, EV_highEnergy, 0, 0, EV_lowEnergy, 0, 3, EV_charge, 0, 1, 3, EV_moveToWork, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_moveToCharge, 0, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, 3, EV_charge, 0, 5, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 6, 2, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_highEnergy, 0, 6, EV_work, 0, 7, EV_lowEnergy, 0, 4, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, EV_moveToCharge, 0, 0, 3, EV_moveToWork, 0, 4, EV_lowEnergy, 0, 0, EV_highEnergy, 0, 6, 3, EV_charge, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 3, 2, EV_lowEnergy, 0, 5, EV_highEnergy, 0, 2, 3, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 1, EV_charge, 0, 0, 3, EV_highEnergy, 0, 7, EV_work, 0, 5, EV_lowEnergy, 0, 4, 3, EV_highEnergy, 0, 2, EV_lowEnergy, 0, 5, EV_moveToCharge, 0, 3, 3, EV_moveToWork, 0, 7, EV_highEnergy, 0, 6, EV_lowEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 7, 5, EV_notAtWork, 0, 7, EV_atCharger, 0, 0, EV_atWork, 0, 0, EV_charge, 0, 6, EV_notAtCharger, 0, 0, 5, EV_atCharger, 0, 1, EV_atWork, 0, 1, EV_notAtWork, 0, 2, EV_moveToCharge, 0, 0, EV_notAtCharger, 0, 1, 5, EV_notAtWork, 0, 2, EV_atWork, 0, 1, EV_atCharger, 0, 2, EV_moveToCharge, 0, 7, EV_notAtCharger, 0, 2, 5, EV_notAtCharger, 0, 3, EV_notAtWork, 0, 3, EV_moveToWork, 0, 5, EV_atWork, 0, 6, EV_atCharger, 0, 3, 5, EV_notAtWork, 0, 5, EV_atCharger, 0, 4, EV_atWork, 0, 4, EV_notAtCharger, 0, 4, EV_work, 0, 1, 4, EV_atCharger, 0, 5, EV_atWork, 0, 4, EV_notAtWork, 0, 5, EV_notAtCharger, 0, 5, 5, EV_atWork, 0, 6, EV_atCharger, 0, 6, EV_notAtCharger, 0, 6, EV_notAtWork, 0, 3, EV_moveToWork, 0, 4, 5, EV_notAtWork, 0, 7, EV_notAtCharger, 0, 7, EV_atCharger, 0, 7, EV_atWork, 0, 0, EV_charge, 0, 3, 4, EV_notAtWork, 0, 0, EV_notAtCharger, 0, 0, EV_atWork, 0, 0, EV_atCharger, 0, 5, 5, EV_atCharger, 0, 4, EV_atWork, 0, 1, EV_notAtCharger, 0, 1, EV_moveToCharge, 0, 0, EV_notAtWork, 0, 1, 5, EV_notAtCharger, 0, 2, EV_work, 0, 1, EV_atCharger, 0, 3, EV_atWork, 0, 2, EV_notAtWork, 0, 2, 5, EV_notAtWork, 0, 3, EV_atCharger, 0, 3, EV_work, 0, 4, EV_atWork, 0, 3, EV_notAtCharger, 0, 2, 5, EV_atWork, 0, 4, EV_atCharger, 0, 4, EV_moveToCharge, 0, 5, EV_notAtCharger, 0, 1, EV_notAtWork, 0, 4, 5, EV_atWork, 0, 5, EV_notAtCharger, 0, 0, EV_notAtWork, 0, 5, EV_atCharger, 0, 5, EV_charge, 0, 6, 5, EV_notAtWork, 0, 6, EV_moveToWork, 0, 3, EV_atCharger, 0, 6, EV_atWork, 0, 6, EV_notAtCharger, 0, 7, 5, EV_notAtCharger, 0, 7, EV_moveToWork, 0, 2, EV_notAtWork, 0, 7, EV_atWork, 0, 7, EV_atCharger, 0, 6 ] \ No newline at end of file +sup_data: [ 3, EV_highEnergy, 0, 0, EV_lowEnergy, 0, 3, EV_charge, 0, 1, 3, EV_moveToWork, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_moveToCharge, 0, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, 3, EV_charge, 0, 5, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 6, 2, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 5, 3, EV_highEnergy, 0, 6, EV_work, 0, 7, EV_lowEnergy, 0, 4, 3, EV_lowEnergy, 0, 2, EV_highEnergy, 0, 7, EV_moveToCharge, 0, 0, 3, EV_moveToWork, 0, 4, EV_lowEnergy, 0, 0, EV_highEnergy, 0, 6, 3, EV_charge, 0, 6, EV_highEnergy, 0, 1, EV_lowEnergy, 0, 3, 2, EV_lowEnergy, 0, 5, EV_highEnergy, 0, 2, 3, EV_lowEnergy, 0, 3, EV_highEnergy, 0, 1, EV_charge, 0, 0, 3, EV_highEnergy, 0, 7, EV_work, 0, 5, EV_lowEnergy, 0, 4, 3, EV_highEnergy, 0, 2, EV_lowEnergy, 0, 5, EV_moveToCharge, 0, 3, 3, EV_moveToWork, 0, 7, EV_highEnergy, 0, 6, EV_lowEnergy, 0, 0, 3, EV_lowEnergy, 0, 4, EV_work, 0, 2, EV_highEnergy, 0, 7, 5, EV_notAtWork, 0, 7, EV_atCharger, 0, 0, EV_atWork, 0, 0, EV_charge, 0, 6, EV_notAtCharger, 0, 0, 5, EV_atCharger, 0, 1, EV_atWork, 0, 1, EV_notAtWork, 0, 2, EV_moveToCharge, 0, 0, EV_notAtCharger, 0, 1, 5, EV_notAtWork, 0, 2, EV_atWork, 0, 1, EV_atCharger, 0, 2, EV_moveToCharge, 0, 7, EV_notAtCharger, 0, 2, 5, EV_notAtCharger, 0, 3, EV_notAtWork, 0, 3, EV_moveToWork, 0, 5, EV_atWork, 0, 6, EV_atCharger, 0, 3, 5, EV_notAtWork, 0, 5, EV_atCharger, 0, 4, EV_atWork, 0, 4, EV_notAtCharger, 0, 4, EV_work, 0, 1, 4, EV_atCharger, 0, 5, EV_atWork, 0, 4, EV_notAtWork, 0, 5, EV_notAtCharger, 0, 5, 5, EV_atWork, 0, 6, EV_atCharger, 0, 6, EV_notAtCharger, 0, 6, EV_notAtWork, 0, 3, EV_moveToWork, 0, 4, 5, EV_notAtWork, 0, 7, EV_notAtCharger, 0, 7, EV_atCharger, 0, 7, EV_atWork, 0, 0, EV_charge, 0, 3, 4, EV_notAtWork, 0, 0, EV_notAtCharger, 0, 0, EV_atWork, 0, 0, EV_atCharger, 0, 5, 5, EV_atCharger, 0, 4, EV_atWork, 0, 1, EV_notAtCharger, 0, 1, EV_moveToCharge, 0, 0, EV_notAtWork, 0, 1, 5, EV_notAtCharger, 0, 2, EV_work, 0, 1, EV_atCharger, 0, 3, EV_atWork, 0, 2, EV_notAtWork, 0, 2, 5, EV_notAtWork, 0, 3, EV_atCharger, 0, 3, EV_work, 0, 4, EV_atWork, 0, 3, EV_notAtCharger, 0, 2, 5, EV_atWork, 0, 4, EV_atCharger, 0, 4, EV_moveToCharge, 0, 5, EV_notAtCharger, 0, 1, EV_notAtWork, 0, 4, 5, EV_atWork, 0, 5, EV_notAtCharger, 0, 0, EV_notAtWork, 0, 5, EV_atCharger, 0, 5, EV_charge, 0, 6, 5, EV_notAtWork, 0, 6, EV_moveToWork, 0, 3, EV_atCharger, 0, 6, EV_atWork, 0, 6, EV_notAtCharger, 0, 7, 5, EV_notAtCharger, 0, 7, EV_moveToWork, 0, 2, EV_notAtWork, 0, 7, EV_atWork, 0, 7, EV_atCharger, 0, 6 ]