From 5933d87fc5c876cbc89293a12782d983af2dbd6b Mon Sep 17 00:00:00 2001 From: WilliamTakeshi Date: Thu, 8 Jan 2026 12:51:18 +0100 Subject: [PATCH 1/5] feat: add examples folder and add an example for a charging station poc --- dotbot/examples/README.md | 36 ++ .../examples/charging_station_init_state.toml | 64 ++++ dotbot/examples/charging_station_poc.py | 326 ++++++++++++++++++ dotbot/examples/dotbot_api.py | 80 +++++ dotbot/examples/orca.py | 232 +++++++++++++ dotbot/examples/vec2.py | 58 ++++ dotbot/rest.py | 11 +- dotbot/tests/test_rest.py | 16 +- 8 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 dotbot/examples/README.md create mode 100644 dotbot/examples/charging_station_init_state.toml create mode 100644 dotbot/examples/charging_station_poc.py create mode 100644 dotbot/examples/dotbot_api.py create mode 100644 dotbot/examples/orca.py create mode 100644 dotbot/examples/vec2.py diff --git a/dotbot/examples/README.md b/dotbot/examples/README.md new file mode 100644 index 0000000..b333247 --- /dev/null +++ b/dotbot/examples/README.md @@ -0,0 +1,36 @@ +# DotBot Simulator Experiments + +This directory contains **experimental control scripts** for the DotBot simulator. +The goal is to prototype, test, and iterate on the testbed without needing to deploy anything, +with the same API that will run on a real testbed. **without touching the controller internals**. + +All interaction with the simulator is done **via HTTP**, exactly like a real deployment. + +--- + +## 1. Start the simulator + +First, start the DotBot controller in **simulator mode** with the correct configuration: + +```bash +dotbot-controller \ + --config-path config_sample.toml \ + -p dotbot-simulator \ + -a dotbot-simulator +``` + +## 2. Run the experiments + +For example, if you want to run the charging station proof-of-concept + +```bash +python3 dotbot/examples/charging_station_poc.py +``` + +## 3. API boundary + +All communication with the simulator/controller happens through: + +``` +dotbot/examples/api.py +``` diff --git a/dotbot/examples/charging_station_init_state.toml b/dotbot/examples/charging_station_init_state.toml new file mode 100644 index 0000000..ebf3d19 --- /dev/null +++ b/dotbot/examples/charging_station_init_state.toml @@ -0,0 +1,64 @@ +[[dotbots]] +address = "BADCAFE111111111" # DotBot unique address +calibrated = true # optional, defaults to true +pos_x = 400_000 # [0, 1_000_000] +pos_y = 200_000 # [0, 1_000_000] +theta = 0.0 # [0.0, 2pi] + +[[dotbots]] +address = "DEADBEEF22222222" +pos_x = 300_000 +pos_y = 100_000 +theta = 1.57 + +[[dotbots]] +address = "B0B0F00D33333333" +pos_x = 1_000_000 +pos_y = 1_000_000 +theta = 1.57 + +[[dotbots]] +address = "BADC0DE444444444" +pos_x = 500_000 +pos_y = 500_000 +theta = 3.14 + +[[dotbots]] +address = "5555555555555555" +pos_x = 10_000 +pos_y = 870_000 +theta = 3.14 + + +[[dotbots]] +address = "6666666666666666" +pos_x = 280_000 +pos_y = 880_000 +theta = 3.14 + + +[[dotbots]] +address = "7777777777777777" +pos_x = 120_000 +pos_y = 90_000 +theta = 3.14 + + +[[dotbots]] +address = "8888888888888888" +pos_x = 800_000 +pos_y = 560_000 +theta = 3.14 + +[[dotbots]] +address = "9999999999999999" +pos_x = 990_000 +pos_y = 180_000 +theta = 1.14 + + +[[dotbots]] +address = "A000000000000000" +pos_x = 880_000 +pos_y = 80_000 +theta = 2.14 diff --git a/dotbot/examples/charging_station_poc.py b/dotbot/examples/charging_station_poc.py new file mode 100644 index 0000000..4424b94 --- /dev/null +++ b/dotbot/examples/charging_station_poc.py @@ -0,0 +1,326 @@ +import asyncio +import math +import os +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, + DotBotRgbLedCommandModel, + DotBotWaypoints, +) +from dotbot.protocol import ApplicationType +from dotbot.rest import RestClient + +THRESHOLD = 30 # Acceptable distance error to consider a waypoint reached +DT = 0.05 # Control loop period (seconds) + +# TODO: Measure these values for real dotbots +BOT_RADIUS = 0.03 # Physical radius of a DotBot (unit), used for collision avoidance +MAX_SPEED = 0.75 # Maximum allowed linear speed of a bot + +(QUEUE_HEAD_X, QUEUE_HEAD_Y) = ( + 0.1, + 0.8, +) # World-frame (X, Y) position of the charging queue head +QUEUE_SPACING = ( + 0.1 # Spacing between consecutive bots in the charging queue (along X axis) +) + +(PARK_X, PARK_Y) = (0.8, 0.1) # World-frame (X, Y) position of the parking area origin +PARK_SPACING = 0.1 # Spacing between parked bots (along Y axis) + + +async def run_charging_station_poc( + params: OrcaParams, + client: RestClient, +) -> None: + dotbots = await client.fetch_active_dotbots() + + # Cosmetic: all bots are red + for dotbot in dotbots: + await client.send_rgb_led_command( + address=dotbot.address, + command=DotBotRgbLedCommandModel(red=255, green=0, blue=0), + ) + + # await set_dotbot_rgb_led( + # client, + # address=dotbot.address, + # application=dotbot.application, + # red=255, + # green=0, + # blue=0, + # ) + + # Phase 1: initial queue + sorted_bots = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) + goals = assign_queue_goals(sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING) + + await send_to_goal(client, goals, params) + + # Phase 2: charging loop + remaining = sorted_bots + total_count = len(dotbots) + # The head of the remaining should park + # Except on the first loop, where it should just queue. + park_dotbot: DotBotModel | None = None + parked_count = total_count - len(remaining) + + while remaining or park_dotbot is not None: + dotbots = await client.fetch_active_dotbots() + + dotbots = [b for b in dotbots if b.address in {r.address for r in remaining}] + remaining = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) + + # Assign charging + shift goals + goals = assign_charge_goals( + remaining, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING + ) + if park_dotbot is not None: + goals[park_dotbot.address] = { + "x": PARK_X, + "y": PARK_Y + parked_count * PARK_SPACING, + } + await send_to_goal(client, goals, params) + + if len(remaining) == 0: + break + + head = remaining[0] + + # Cosmetic: wait for charging... + colors = [ + (255, 255, 0), # yellow + (0, 255, 0), # green + ] + await asyncio.sleep(20 * DT) + + for r, g, b in colors: + await client.send_rgb_led_command( + address=head.address, + command=DotBotRgbLedCommandModel(red=r, green=g, blue=b), + ) + + await asyncio.sleep(20 * DT) + + # Reverse slightly to disengage the robot from the charging station + await disengage_from_charger(client, head) + + parked_count = total_count - len(remaining) + + # send it to park + park_dotbot = remaining[0] + # Remove head from queue + remaining = remaining[1:] + + return None + + +async def disengage_from_charger(client: RestClient, head: DotBotModel): + for _ in range(25): + await client.send_move_raw_command( + address=head.address, + application=head.application, + command=DotBotMoveRawCommandModel( + left_x=0, left_y=-100, right_x=0, right_y=-100 + ), + ) + await asyncio.sleep(DT) + + +async def send_to_goal( + client: RestClient, + goals: Dict[str, dict], + params: OrcaParams, +) -> None: + # Queue + while True: + dotbots = await client.fetch_active_dotbots() + agents: List[Agent] = [] + + for bot in dotbots: + agents.append( + 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, + direction=bot.direction, + max_speed=MAX_SPEED, + preferred_velocity=preferred_vel( + dotbot=bot, goal=goals.get(bot.address) + ), + ) + ) + + queue_ready = all( + a.preferred_velocity.x == 0 and a.preferred_velocity.y == 0 for a in agents + ) + if queue_ready: + break + for agent in agents: + neighbors = [neighbor for neighbor in agents if neighbor.id != agent.id] + + orca_vel = await compute_orca_velocity( + agent, neighbors=neighbors, params=params + ) + STEP_SCALE = 0.1 + step = Vec2(x=orca_vel.x * STEP_SCALE, y=orca_vel.y * STEP_SCALE) + + # ---- 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) + # ------------------------------------ + + waypoints = DotBotWaypoints( + threshold=THRESHOLD, + waypoints=[ + DotBotLH2Position( + x=agent.position.x + step.x, y=agent.position.y + step.y, z=0 + ) + ], + ) + await client.send_waypoint_command( + address=agent.id, + application=ApplicationType.DotBot, + command=waypoints, + ) + await asyncio.sleep(DT) + return None + + +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_queue_goals( + ordered: List[DotBotModel], + head_x: int, + head_y: int, + spacing: int, +) -> Dict[str, dict]: + goals = {} + for i, bot in enumerate(ordered): + goals[bot.address] = { + "x": head_x + i * spacing, + "y": head_y, + } + return goals + + +def assign_charge_goals( + ordered: List[DotBotModel], + base_x: int, + base_y: int, + spacing: int, +) -> Dict[str, dict]: + if len(ordered) == 0: + return {} + + goals = {} + # Send the first one to the charger + head = ordered[0] + goals[head.address] = { + "x": 0.2, + "y": 0.2, + } + + # Remaining bots shift left in the queue + for i, bot in enumerate(ordered[1:]): + goals[bot.address] = { + "x": base_x + i * spacing, + "y": base_y, + } + 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=DT) + url = os.getenv("DOTBOT_CONTROLLER_URL", "localhost") + port = os.getenv("DOTBOT_CONTROLLER_PORT", "8000") + use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) + client = RestClient(url, port, use_https) + + await run_charging_station_poc(params, client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dotbot/examples/dotbot_api.py b/dotbot/examples/dotbot_api.py new file mode 100644 index 0000000..d4c7088 --- /dev/null +++ b/dotbot/examples/dotbot_api.py @@ -0,0 +1,80 @@ +from typing import List + +import httpx + +from dotbot.models import ( + DotBotModel, + DotBotQueryModel, + DotBotWaypoints, +) + + +async def get_dotbots( + client: httpx.AsyncClient, query: DotBotQueryModel +) -> List[DotBotModel]: + resp = await client.get( + "/controller/dotbots", + params={ + "application": query.application.value, + "status": query.status.value, + }, + ) + resp.raise_for_status() + + return [DotBotModel(**d) for d in resp.json()] + + +async def set_dotbot_rgb_led( + client: httpx.AsyncClient, + *, + address: str, + application: int, + red: int, + green: int, + blue: int, +) -> None: + resp = await client.put( + f"/controller/dotbots/{address}/{application}/rgb_led", + json={ + "red": red, + "green": green, + "blue": blue, + }, + ) + resp.raise_for_status() + + +async def move_dotbot_raw( + client: httpx.AsyncClient, + *, + address: str, + application: int, + left_x: int, + right_x: int, + left_y: int, + right_y: int, +) -> None: + resp = await client.put( + f"/controller/dotbots/{address}/{application}/move_raw", + json={ + "left_x": left_x, + "right_x": right_x, + "left_y": left_y, + "right_y": right_y, + }, + ) + resp.raise_for_status() + + +async def set_dotbot_waypoints( + client: httpx.AsyncClient, + *, + address: str, + application: int, + waypoints: DotBotWaypoints, +) -> None: + resp = await client.put( + f"/controller/dotbots/{address}/{application}/waypoints", + json=waypoints.model_dump(), + ) + resp.raise_for_status() diff --git a/dotbot/examples/orca.py b/dotbot/examples/orca.py new file mode 100644 index 0000000..0528b6d --- /dev/null +++ b/dotbot/examples/orca.py @@ -0,0 +1,232 @@ +"""ORCA (Optimal Reciprocal Collision Avoidance) implementation. +Computes collision-free velocities for multiple agents with reciprocal responsibility. +""" + +import math +from dataclasses import dataclass + +from dotbot.examples.vec2 import ( + Vec2, + add, + dot, + length_sq, + mul, + normalize, + perp, + sub, + vec, + vec2_length, +) + +# ========= ORCA LINE ========= + + +@dataclass +class OrcaLine: + point: Vec2 + direction: Vec2 + normal: Vec2 + + +# ========= AGENT ========= + + +@dataclass +class Agent: + id: str + position: Vec2 + velocity: Vec2 + direction: float + radius: float + max_speed: float + preferred_velocity: Vec2 + + +@dataclass +class OrcaParams: + time_horizon: float + + +def cross(a: Vec2, b: Vec2) -> float: + return a.x * b.y - a.y * b.x + + +def compute_orca_lines_for_agent( + agent: Agent, neighbors: list[Agent], params: OrcaParams +) -> list[OrcaLine]: + lines = [] + for other in neighbors: + if other.id == agent.id: + continue + lines.append(compute_orca_line_pair(agent, other, params)) + return lines + + +def compute_orca_line_pair(A: Agent, B: Agent, params: OrcaParams) -> OrcaLine: + + time_horizon = params.time_horizon + + rel_pos = sub(B.position, A.position) + rel_vel = sub(A.velocity, B.velocity) + dist_sq = length_sq(rel_pos) + combined_radius = A.radius + B.radius + combined_radius_sq = combined_radius * combined_radius + + line = OrcaLine(point=vec(0, 0), direction=vec(0, 0), normal=vec(0, 0)) + + # CASE 1: No collision yet + if dist_sq > combined_radius_sq: + inv_th = 1.0 / time_horizon + w = sub(rel_vel, mul(rel_pos, inv_th)) + w_len_sq = length_sq(w) + dot_w_rel = dot(w, rel_pos) + + # Circle projection condition + if dot_w_rel < 0 and dot_w_rel * dot_w_rel > combined_radius_sq * w_len_sq: + w_len = math.sqrt(w_len_sq) + unit_w = mul(w, 1.0 / w_len) + u = mul(unit_w, combined_radius * inv_th - w_len) + + line.point = add(A.velocity, mul(u, 0.5)) + line.normal = unit_w + line.direction = perp(line.normal) + + else: + dist = math.sqrt(dist_sq) + rel_unit = mul(rel_pos, 1.0 / dist) + leg = math.sqrt(dist_sq - combined_radius_sq) + + left_leg = Vec2( + (rel_pos.x * leg - rel_pos.y * combined_radius) / dist_sq, + (rel_pos.x * combined_radius + rel_pos.y * leg) / dist_sq, + ) + + right_leg = Vec2( + (rel_pos.x * leg + rel_pos.y * combined_radius) / dist_sq, + (-rel_pos.x * combined_radius + rel_pos.y * leg) / dist_sq, + ) + + side = math.copysign(1, cross(rel_vel, rel_unit)) + + if side >= 0: + leg_dir = left_leg + else: + leg_dir = right_leg + + proj = dot(rel_vel, leg_dir) + closest_point = mul(leg_dir, proj) + u = sub(closest_point, rel_vel) + + line.point = add(A.velocity, mul(u, 0.5)) + line.direction = leg_dir + line.normal = normalize(perp(line.direction)) + + else: + # CASE 2: Already colliding + dist = math.sqrt(dist_sq) + rel_unit = mul(rel_pos, 1.0 / dist) if dist > 0 else vec(1, 0) + + penetration = combined_radius - dist + u = mul(rel_unit, penetration) + + line.point = add(A.velocity, mul(u, 0.5)) + line.normal = rel_unit + line.direction = perp(line.normal) + + line.direction = normalize(line.direction) + line.normal = normalize(line.normal) + return line + + +def is_feasible(line: OrcaLine, v: Vec2) -> bool: + rel = sub(v, line.point) + return dot(rel, line.normal) >= 0 + + +def project_scalar(v_pref: Vec2, line: OrcaLine) -> float: + rel = sub(v_pref, line.point) + return dot(rel, line.direction) + + +def intersect_lines(a: OrcaLine, b: OrcaLine, max_speed: float, v_pref: Vec2) -> Vec2: + + p = a.point + r = a.direction + q = b.point + s = b.direction + + rxs = cross(r, s) + q_p = sub(q, p) + + if abs(rxs) < 1e-6: + # Parallel + t = project_scalar(v_pref, a) + v = add(p, mul(r, t)) + else: + t = cross(q_p, s) / rxs + v = add(p, mul(r, t)) + + # clamp to circle + if length_sq(v) > max_speed * max_speed: + v = mul(normalize(v), max_speed) + + return v + + +def project_on_line_and_fix( + line: OrcaLine, v_pref: Vec2, max_speed: float, lines: list[OrcaLine], line_no: int +) -> Vec2: + + t = project_scalar(v_pref, line) + v = add(line.point, mul(line.direction, t)) + + if length_sq(v) > max_speed * max_speed: + v = mul(normalize(v), max_speed) + + for i in range(line_no): + prev = lines[i] + if not is_feasible(prev, v): + v = intersect_lines(prev, line, max_speed, v_pref) + + return v + + +def solve_orca_velocity(v_pref: Vec2, max_speed: float, lines: list[OrcaLine]) -> Vec2: + + if length_sq(v_pref) > max_speed * max_speed: + result = mul(normalize(v_pref), max_speed) + else: + result = v_pref + + for i, line in enumerate(lines): + if not is_feasible(line, result): + result = project_on_line_and_fix(line, v_pref, max_speed, lines, i) + + return result + + +# ========= High-level helpers ========= + + +def compute_orca_velocity_for_agent( + agent: Agent, neighbors: list[Agent], params: OrcaParams +) -> Vec2: + lines = compute_orca_lines_for_agent(agent, neighbors, params) + result = solve_orca_velocity(agent.preferred_velocity, agent.max_speed, lines) + return result + + +def compute_orca_velocity_toward_goal( + agent: Agent, neighbors: list[Agent], goal: Vec2, params: OrcaParams +) -> Vec2: + + diff = sub(goal, agent.position) + dist = vec2_length(diff) + + if dist < 1e-6: + preferred = vec(0, 0) + else: + preferred = mul(normalize(diff), agent.max_speed) + + lines = compute_orca_lines_for_agent(agent, neighbors, params) + return solve_orca_velocity(preferred, agent.max_speed, lines) diff --git a/dotbot/examples/vec2.py b/dotbot/examples/vec2.py new file mode 100644 index 0000000..0e9ea11 --- /dev/null +++ b/dotbot/examples/vec2.py @@ -0,0 +1,58 @@ +""" +Minimal 2D vector utilities for geometry and control logic. + +Defines a lightweight `Vec2` type and basic operations (add, scale, normalize, +dot product, perpendicular) used in control and simulation code. +It is intentionally small and dependency-free, suitable for control loops and +simulation code where simplicity and readability matter more than performance +or full linear-algebra coverage. +""" + +import math +from dataclasses import dataclass + + +@dataclass +class Vec2: + x: float + y: float + + +def vec(x: float, y: float) -> Vec2: + return Vec2(x, y) + + +def add(a: Vec2, b: Vec2) -> Vec2: + return Vec2(a.x + b.x, a.y + b.y) + + +def sub(a: Vec2, b: Vec2) -> Vec2: + return Vec2(a.x - b.x, a.y - b.y) + + +def mul(a: Vec2, s: float) -> Vec2: + return Vec2(a.x * s, a.y * s) + + +def dot(a: Vec2, b: Vec2) -> float: + return a.x * b.x + a.y * b.y + + +def length_sq(a: Vec2) -> float: + return dot(a, a) + + +def vec2_length(a: Vec2) -> float: + return math.sqrt(length_sq(a)) + + +def normalize(a: Vec2) -> Vec2: + length = vec2_length(a) + if length == 0: + return Vec2(0.0, 0.0) + return Vec2(a.x / length, a.y / length) + + +def perp(a: Vec2) -> Vec2: + # Left-hand perpendicular + return Vec2(-a.y, a.x) diff --git a/dotbot/rest.py b/dotbot/rest.py index c787da6..89b6ab1 100644 --- a/dotbot/rest.py +++ b/dotbot/rest.py @@ -6,11 +6,12 @@ """Module containing client code to interact with the controller REST API.""" from contextlib import asynccontextmanager +from typing import List import httpx from dotbot.logger import LOGGER, setup_logging -from dotbot.models import DotBotStatus +from dotbot.models import DotBotModel, DotBotStatus from dotbot.protocol import ApplicationType @@ -42,7 +43,7 @@ def base_url(self): async def close(self): await self._client.aclose() - async def fetch_active_dotbots(self): + async def fetch_active_dotbots(self) -> List[DotBotModel]: """Fetch active DotBots.""" try: response = await self._client.get( @@ -60,7 +61,7 @@ async def fetch_active_dotbots(self): ) else: return [ - dotbot + DotBotModel(**dotbot) for dotbot in response.json() if dotbot["status"] == DotBotStatus.ACTIVE.value ] @@ -94,3 +95,7 @@ async def send_move_raw_command(self, address, application, command): async def send_rgb_led_command(self, address, command): """Send an RGB LED command to a DotBot.""" await self._send_command(address, ApplicationType.SailBot, "rgb_led", command) + + async def send_waypoint_command(self, address, application, command): + """Send an waypoint command to a DotBot.""" + await self._send_command(address, application, "waypoints", command) diff --git a/dotbot/tests/test_rest.py b/dotbot/tests/test_rest.py index 3b4c72b..63e67ca 100644 --- a/dotbot/tests/test_rest.py +++ b/dotbot/tests/test_rest.py @@ -3,7 +3,11 @@ import httpx import pytest -from dotbot.models import DotBotMoveRawCommandModel, DotBotRgbLedCommandModel +from dotbot.models import ( + DotBotModel, + DotBotMoveRawCommandModel, + DotBotRgbLedCommandModel, +) from dotbot.protocol import ApplicationType from dotbot.rest import rest_client @@ -16,13 +20,17 @@ pytest.param(httpx.ConnectError, [], id="http error"), pytest.param(httpx.Response(403), [], id="http code error"), pytest.param( - httpx.Response(200, json=[{"address": "test", "status": 1}]), + httpx.Response( + 200, json=[{"address": "test", "status": 1, "last_seen": 0}] + ), [], id="none active", ), pytest.param( - httpx.Response(200, json=[{"address": "test", "status": 0}]), - [{"address": "test", "status": 0}], + httpx.Response( + 200, json=[{"address": "test", "status": 0, "last_seen": 0}] + ), + [DotBotModel(**{"address": "test", "status": 0, "last_seen": 0})], id="found", ), ], From 606a61ebec25846d95cc6f13debaf3c2c6289cd2 Mon Sep 17 00:00:00 2001 From: WilliamTakeshi Date: Fri, 9 Jan 2026 10:22:19 +0100 Subject: [PATCH 2/5] fix: readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cc5b4d..98d7e2e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Use `--config-path` to specify the file: # Use settings from the config file dotbot-controller --config-path config_sample.toml # Use config file but override port and adapter (simulator example) -dotbot-controller --config-path config_sample.toml -p dotbot-simulator -a serial +dotbot-controller --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator ``` Values defined in the config file behave exactly like CLI options. From 7df530c49071687553d745732e92f090d3d41866 Mon Sep 17 00:00:00 2001 From: WilliamTakeshi Date: Fri, 9 Jan 2026 13:09:43 +0100 Subject: [PATCH 3/5] test: add tst case and rename charging_station_poc to charging station --- dotbot/examples/README.md | 10 +-- ...ing_station_poc.py => charging_station.py} | 4 +- .../examples/charging_station_init_state.toml | 3 - dotbot/examples/dotbot_api.py | 80 ------------------- dotbot/tests/test_rest.py | 46 +++++++++++ 5 files changed, 49 insertions(+), 94 deletions(-) rename dotbot/examples/{charging_station_poc.py => charging_station.py} (99%) delete mode 100644 dotbot/examples/dotbot_api.py diff --git a/dotbot/examples/README.md b/dotbot/examples/README.md index b333247..cf14ca4 100644 --- a/dotbot/examples/README.md +++ b/dotbot/examples/README.md @@ -24,13 +24,5 @@ dotbot-controller \ For example, if you want to run the charging station proof-of-concept ```bash -python3 dotbot/examples/charging_station_poc.py -``` - -## 3. API boundary - -All communication with the simulator/controller happens through: - -``` -dotbot/examples/api.py +python3 dotbot/examples/charging_station.py ``` diff --git a/dotbot/examples/charging_station_poc.py b/dotbot/examples/charging_station.py similarity index 99% rename from dotbot/examples/charging_station_poc.py rename to dotbot/examples/charging_station.py index 4424b94..a34118f 100644 --- a/dotbot/examples/charging_station_poc.py +++ b/dotbot/examples/charging_station.py @@ -38,7 +38,7 @@ PARK_SPACING = 0.1 # Spacing between parked bots (along Y axis) -async def run_charging_station_poc( +async def run_charging_station( params: OrcaParams, client: RestClient, ) -> None: @@ -319,7 +319,7 @@ async def main() -> None: use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) client = RestClient(url, port, use_https) - await run_charging_station_poc(params, client) + await run_charging_station(params, client) if __name__ == "__main__": diff --git a/dotbot/examples/charging_station_init_state.toml b/dotbot/examples/charging_station_init_state.toml index ebf3d19..be2b633 100644 --- a/dotbot/examples/charging_station_init_state.toml +++ b/dotbot/examples/charging_station_init_state.toml @@ -29,21 +29,18 @@ pos_x = 10_000 pos_y = 870_000 theta = 3.14 - [[dotbots]] address = "6666666666666666" pos_x = 280_000 pos_y = 880_000 theta = 3.14 - [[dotbots]] address = "7777777777777777" pos_x = 120_000 pos_y = 90_000 theta = 3.14 - [[dotbots]] address = "8888888888888888" pos_x = 800_000 diff --git a/dotbot/examples/dotbot_api.py b/dotbot/examples/dotbot_api.py deleted file mode 100644 index d4c7088..0000000 --- a/dotbot/examples/dotbot_api.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import List - -import httpx - -from dotbot.models import ( - DotBotModel, - DotBotQueryModel, - DotBotWaypoints, -) - - -async def get_dotbots( - client: httpx.AsyncClient, query: DotBotQueryModel -) -> List[DotBotModel]: - resp = await client.get( - "/controller/dotbots", - params={ - "application": query.application.value, - "status": query.status.value, - }, - ) - resp.raise_for_status() - - return [DotBotModel(**d) for d in resp.json()] - - -async def set_dotbot_rgb_led( - client: httpx.AsyncClient, - *, - address: str, - application: int, - red: int, - green: int, - blue: int, -) -> None: - resp = await client.put( - f"/controller/dotbots/{address}/{application}/rgb_led", - json={ - "red": red, - "green": green, - "blue": blue, - }, - ) - resp.raise_for_status() - - -async def move_dotbot_raw( - client: httpx.AsyncClient, - *, - address: str, - application: int, - left_x: int, - right_x: int, - left_y: int, - right_y: int, -) -> None: - resp = await client.put( - f"/controller/dotbots/{address}/{application}/move_raw", - json={ - "left_x": left_x, - "right_x": right_x, - "left_y": left_y, - "right_y": right_y, - }, - ) - resp.raise_for_status() - - -async def set_dotbot_waypoints( - client: httpx.AsyncClient, - *, - address: str, - application: int, - waypoints: DotBotWaypoints, -) -> None: - resp = await client.put( - f"/controller/dotbots/{address}/{application}/waypoints", - json=waypoints.model_dump(), - ) - resp.raise_for_status() diff --git a/dotbot/tests/test_rest.py b/dotbot/tests/test_rest.py index 63e67ca..1e182e1 100644 --- a/dotbot/tests/test_rest.py +++ b/dotbot/tests/test_rest.py @@ -4,9 +4,11 @@ import pytest from dotbot.models import ( + DotBotLH2Position, DotBotModel, DotBotMoveRawCommandModel, DotBotRgbLedCommandModel, + DotBotWaypoints, ) from dotbot.protocol import ApplicationType from dotbot.rest import rest_client @@ -118,3 +120,47 @@ async def test_send_rgb_led_command(put, response, command): async with rest_client("localhost", 1234, False) as client: await client.send_rgb_led_command("test", command) put.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response,application,command", + [ + pytest.param( + httpx.Response(200), + ApplicationType.DotBot, + DotBotWaypoints( + threshold=40, + waypoints=[DotBotLH2Position(x=0, y=0, z=0)], + ), + id="ok", + ), + pytest.param( + httpx.ConnectError, + ApplicationType.DotBot, + DotBotWaypoints( + threshold=40, + waypoints=[DotBotLH2Position(x=0, y=0, z=0)], + ), + id="http error", + ), + pytest.param( + httpx.Response(403), + ApplicationType.DotBot, + DotBotWaypoints( + threshold=40, + waypoints=[DotBotLH2Position(x=0, y=0, z=0)], + ), + id="invalid http code", + ), + ], +) +@mock.patch("httpx.AsyncClient.put") +async def test_send_waypoint_command(put, application, response, command): + if response == httpx.ConnectError: + put.side_effect = response("error") + else: + put.return_value = response + async with rest_client("localhost", 1234, False) as client: + await client.send_waypoint_command("test", application, command) + put.assert_called_once() From e2c14d3fd09c99adac11993ec1a9dc8625c0a0c9 Mon Sep 17 00:00:00 2001 From: WilliamTakeshi Date: Fri, 9 Jan 2026 15:06:44 +0100 Subject: [PATCH 4/5] feat: split the charging experiment into two parts and add tests --- dotbot/examples/charging_station.py | 42 ++-- .../tests/test_experiment_charging_station.py | 213 ++++++++++++++++++ 2 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 dotbot/tests/test_experiment_charging_station.py diff --git a/dotbot/examples/charging_station.py b/dotbot/examples/charging_station.py index a34118f..7d22382 100644 --- a/dotbot/examples/charging_station.py +++ b/dotbot/examples/charging_station.py @@ -51,23 +51,31 @@ async def run_charging_station( command=DotBotRgbLedCommandModel(red=255, green=0, blue=0), ) - # await set_dotbot_rgb_led( - # client, - # address=dotbot.address, - # application=dotbot.application, - # red=255, - # green=0, - # blue=0, - # ) - # Phase 1: initial queue + await queue_robots(client, dotbots, params) + + # Phase 2: charging loop + await charge_robots(client, params) + + return None + + +async def queue_robots( + client: RestClient, + dotbots: List[DotBotModel], + params: OrcaParams, +) -> None: sorted_bots = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) goals = assign_queue_goals(sorted_bots, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING) - await send_to_goal(client, goals, params) - # Phase 2: charging loop - remaining = sorted_bots + +async def charge_robots( + client: RestClient, + params: OrcaParams, +) -> None: + dotbots = await client.fetch_active_dotbots() + remaining = order_bots(dotbots, QUEUE_HEAD_X, QUEUE_HEAD_Y) total_count = len(dotbots) # The head of the remaining should park # Except on the first loop, where it should just queue. @@ -84,6 +92,7 @@ async def run_charging_station( goals = assign_charge_goals( remaining, QUEUE_HEAD_X, QUEUE_HEAD_Y, QUEUE_SPACING ) + if park_dotbot is not None: goals[park_dotbot.address] = { "x": PARK_X, @@ -121,14 +130,12 @@ async def run_charging_station( # Remove head from queue remaining = remaining[1:] - return None - -async def disengage_from_charger(client: RestClient, head: DotBotModel): +async def disengage_from_charger(client: RestClient, dotbot: DotBotModel): for _ in range(25): await client.send_move_raw_command( - address=head.address, - application=head.application, + address=dotbot.address, + application=dotbot.application, command=DotBotMoveRawCommandModel( left_x=0, left_y=-100, right_x=0, right_y=-100 ), @@ -141,7 +148,6 @@ async def send_to_goal( goals: Dict[str, dict], params: OrcaParams, ) -> None: - # Queue while True: dotbots = await client.fetch_active_dotbots() agents: List[Agent] = [] diff --git a/dotbot/tests/test_experiment_charging_station.py b/dotbot/tests/test_experiment_charging_station.py new file mode 100644 index 0000000..f00e2ce --- /dev/null +++ b/dotbot/tests/test_experiment_charging_station.py @@ -0,0 +1,213 @@ +import math +from copy import deepcopy +from typing import Dict, List +from unittest.mock import AsyncMock, patch + +import pytest + +from dotbot.examples.charging_station import ( + DT, + PARK_SPACING, + PARK_X, + PARK_Y, + QUEUE_HEAD_X, + QUEUE_HEAD_Y, + QUEUE_SPACING, + charge_robots, + queue_robots, +) +from dotbot.examples.orca import OrcaParams +from dotbot.models import ( + DotBotLH2Position, + DotBotModel, + DotBotMoveRawCommandModel, + DotBotRgbLedCommandModel, + DotBotStatus, + DotBotWaypoints, +) +from dotbot.protocol import ApplicationType + +MOVE_RAW_SCALE = 0.001 # small, deterministic displacement + + +class FakeRestClient: + """ + Fake RestClient for testing control logic. + + - Stores DotBots in memory + - Teleports bots to waypoints immediately + - Records all commands for assertions + """ + + def __init__(self, dotbots: List[DotBotModel]): + # Store bots by address (copy to avoid mutating test fixtures) + self._dotbots: Dict[str, DotBotModel] = { + b.address: deepcopy(b) for b in dotbots + } + + # Command logs (for assertions) + self.waypoint_commands = [] + self.move_raw_commands = [] + self.rgb_commands = [] + + async def fetch_active_dotbots(self) -> List[DotBotModel]: + return list(self._dotbots.values()) + + async def send_waypoint_command( + self, + *, + address: str, + application: ApplicationType, + command: DotBotWaypoints, + ): + self.waypoint_commands.append((address, command)) + + bot = self._dotbots[address] + wp = command.waypoints[0] + + # Compute displacement + dx = wp.x - bot.lh2_position.x + dy = wp.y - bot.lh2_position.y + + # Update direction if there is movement + if dx != 0 or dy != 0: + # atan2 gives angle from +X axis + angle_rad = math.atan2(dy, dx) + + # Convert back to DotBot direction convention + # Inverse of: rad = (direction + 90) * pi / 180 + direction_deg = math.degrees(angle_rad) - 90 + + # Normalize to [-180, 180] + direction_deg = math.atan2( + math.sin(math.radians(direction_deg)), + math.cos(math.radians(direction_deg)), + ) + direction_deg = math.degrees(direction_deg) + + bot.direction = direction_deg + + # TELEPORT bot to waypoint (instant convergence) + bot.lh2_position = DotBotLH2Position( + x=wp.x, + y=wp.y, + z=wp.z, + ) + + async def send_move_raw_command( + self, + *, + address: str, + application: ApplicationType, + command: DotBotMoveRawCommandModel, + ): + self.move_raw_commands.append((address, command)) + + bot = self._dotbots[address] + + # Average forward/backward command + forward = (command.left_y + command.right_y) / 2.0 + + if forward == 0: + return + + # Convert bot direction (degrees) to radians + theta = math.radians(bot.direction) + + # Move along heading + dx = math.cos(theta) * forward * MOVE_RAW_SCALE + dy = math.sin(theta) * forward * MOVE_RAW_SCALE + + bot.lh2_position.x += dx + bot.lh2_position.y += dy + + async def send_rgb_led_command( + self, + *, + address: str, + command: DotBotRgbLedCommandModel, + ): + self.rgb_commands.append((address, command)) + + +def fake_bot(address: str, x: float, y: float) -> DotBotModel: + return DotBotModel( + address=address, + application=ApplicationType.DotBot, + status=DotBotStatus.ACTIVE, + direction=0, + lh2_position=DotBotLH2Position(x=x, y=y, z=0), + last_seen=0, + ) + + +@pytest.mark.asyncio +@patch("asyncio.sleep", new_callable=AsyncMock) +async def test_queue_robots_converges_to_queue_positions(_): + bots = [ + fake_bot("B", x=0.5, y=0.0), + fake_bot("A", x=0.1, y=0.0), + fake_bot("C", x=0.9, y=0.0), + ] + + client = FakeRestClient(bots) + params = OrcaParams(time_horizon=DT) + + await queue_robots(client, bots, params) + + # Bots should be ordered A, B, C along the queue + expected = { + "A": QUEUE_HEAD_X + 0 * QUEUE_SPACING, + "B": QUEUE_HEAD_X + 1 * QUEUE_SPACING, + "C": QUEUE_HEAD_X + 2 * QUEUE_SPACING, + } + + for address, expected_x in expected.items(): + bot = client._dotbots[address] + + # X, Y coordinate matches queue spacing + assert math.isclose(bot.lh2_position.x, expected_x, abs_tol=0.05) + assert math.isclose(bot.lh2_position.y, QUEUE_HEAD_Y, abs_tol=0.05) + + # Waypoints were actually sent + assert len(client.waypoint_commands) == 39 + + +@pytest.mark.asyncio +@patch("asyncio.sleep", new_callable=AsyncMock) +async def test_charge_robots_moves_all_bots_to_parking(_): + # Start bots already queued + bots = [ + fake_bot("A", x=QUEUE_HEAD_X + 1 * QUEUE_SPACING, y=QUEUE_HEAD_Y), + fake_bot("B", x=QUEUE_HEAD_X + 2 * QUEUE_SPACING, y=QUEUE_HEAD_Y), + fake_bot("C", x=QUEUE_HEAD_X + 3 * QUEUE_SPACING, y=QUEUE_HEAD_Y), + ] + + client = FakeRestClient(bots) + params = OrcaParams(time_horizon=DT) + + await charge_robots(client, params) + + # --- Assertions: all bots parked --- + # Bots should be ordered A, B, C along the park slots + expected = { + "A": PARK_Y + 0 * PARK_SPACING, + "B": PARK_Y + 1 * PARK_SPACING, + "C": PARK_Y + 2 * PARK_SPACING, + } + + for address, expected_y in expected.items(): + bot = client._dotbots[address] + + # X, Y coordinate matches queue spacing + assert math.isclose(bot.lh2_position.x, PARK_X, abs_tol=0.05) + assert math.isclose(bot.lh2_position.y, expected_y, abs_tol=0.05) + + # LEDs were used during charging + assert len(client.rgb_commands) >= 2 * len(bots) + + # Raw moves were issued to disengage bots + assert len(client.move_raw_commands) > 0 + + # Waypoints were issued for charging + parking + assert len(client.waypoint_commands) > 0 From 0acdef255da97d01ce2616bc5f7b6dc8cf7529c9 Mon Sep 17 00:00:00 2001 From: WilliamTakeshi Date: Fri, 16 Jan 2026 15:01:08 +0100 Subject: [PATCH 5/5] fix: readme and remove queue_and_charge function --- README.md | 5 +--- dotbot/examples/README.md | 1 - dotbot/examples/charging_station.py | 39 ++++++++++++----------------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 98d7e2e..fe383d9 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Use `--config-path` to specify the file: # Use settings from the config file dotbot-controller --config-path config_sample.toml # Use config file but override port and adapter (simulator example) -dotbot-controller --config-path config_sample.toml -p dotbot-simulator -a dotbot-simulator +dotbot-controller --config-path config_sample.toml -a dotbot-simulator ``` Values defined in the config file behave exactly like CLI options. @@ -101,7 +101,6 @@ To run the tests, install [tox](https://pypi.org/project/tox/) and use it: tox ``` - [ci-badge]: https://github.com/DotBots/PyDotBot/workflows/CI/badge.svg [ci-link]: https://github.com/DotBots/PyDotBot/actions?query=workflow%3ACI+branch%3Amain [pypi-badge]: https://badge.fury.io/py/pydotbot.svg @@ -110,10 +109,8 @@ tox [doc-link]: https://pydotbot.readthedocs.io/en/latest [license-badge]: https://img.shields.io/pypi/l/pydotbot [license-link]: https://github.com/DotBots/pydotbot/blob/main/LICENSE.txt - [codecov-badge]: https://codecov.io/gh/DotBots/PyDotBot/branch/main/graph/badge.svg [codecov-link]: https://codecov.io/gh/DotBots/PyDotBot - [pydotbot-overview]: https://github.com/DotBots/PyDotBot/blob/main/dotbots.png?raw=True [dotbot-firmware-repo]: https://github.com/DotBots/DotBot-firmware [dotbot-pcb-repo]: https://github.com/DotBots/DotBot-hardware diff --git a/dotbot/examples/README.md b/dotbot/examples/README.md index cf14ca4..f3a3996 100644 --- a/dotbot/examples/README.md +++ b/dotbot/examples/README.md @@ -15,7 +15,6 @@ First, start the DotBot controller in **simulator mode** with the correct config ```bash dotbot-controller \ --config-path config_sample.toml \ - -p dotbot-simulator \ -a dotbot-simulator ``` diff --git a/dotbot/examples/charging_station.py b/dotbot/examples/charging_station.py index 7d22382..a8a4306 100644 --- a/dotbot/examples/charging_station.py +++ b/dotbot/examples/charging_station.py @@ -38,28 +38,6 @@ PARK_SPACING = 0.1 # Spacing between parked bots (along Y axis) -async def run_charging_station( - params: OrcaParams, - client: RestClient, -) -> None: - dotbots = await client.fetch_active_dotbots() - - # Cosmetic: all bots are red - for dotbot in dotbots: - await client.send_rgb_led_command( - address=dotbot.address, - command=DotBotRgbLedCommandModel(red=255, green=0, blue=0), - ) - - # Phase 1: initial queue - await queue_robots(client, dotbots, params) - - # Phase 2: charging loop - await charge_robots(client, params) - - return None - - async def queue_robots( client: RestClient, dotbots: List[DotBotModel], @@ -325,7 +303,22 @@ async def main() -> None: use_https = os.getenv("DOTBOT_CONTROLLER_USE_HTTPS", False) client = RestClient(url, port, use_https) - await run_charging_station(params, client) + dotbots = await client.fetch_active_dotbots() + + # Cosmetic: all bots are red + for dotbot in dotbots: + await client.send_rgb_led_command( + address=dotbot.address, + command=DotBotRgbLedCommandModel(red=255, green=0, blue=0), + ) + + # Phase 1: initial queue + await queue_robots(client, dotbots, params) + + # Phase 2: charging loop + await charge_robots(client, params) + + return None if __name__ == "__main__":