hivebr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hivebr/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """hivebr — OpenLab API for single- and multi-robot navigation experiments.
2
+
3
+ The public API is backend-agnostic and intentionally avoids importing the
4
+ pygame-based engine (``hivebr.engine``) at package import time, so the same
5
+ user script can later target the physical ROS 2 arena.
6
+ """
7
+
8
+ from .core import HiveBrAgent, SwarmController, SwarmExperiment
9
+ from .state import Command, FleetState, RobotState
10
+
11
+ __all__ = [
12
+ "HiveBrAgent",
13
+ "SwarmController",
14
+ "SwarmExperiment",
15
+ "Command",
16
+ "FleetState",
17
+ "RobotState",
18
+ ]
19
+
20
+ __version__ = "0.1.0"
hivebr/bridge.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from .kinematics import normalize_command
6
+ from .state import Command, FleetState, RobotState
7
+
8
+
9
+ class Backend(ABC):
10
+ """Execution backend contract shared by the simulator and ROS 2 arena.
11
+
12
+ A backend is the only layer allowed to talk to a concrete engine, so that
13
+ the public API never imports pygame or ROS 2 directly.
14
+ """
15
+
16
+ @abstractmethod
17
+ def reset(self) -> None: ...
18
+
19
+ @abstractmethod
20
+ def get_states(self) -> dict[int, RobotState]: ...
21
+
22
+ @abstractmethod
23
+ def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
24
+ """Return one command per robot id from a frozen fleet snapshot."""
25
+
26
+ @abstractmethod
27
+ def step(self, commands: dict[int, Command], dt: float) -> None: ...
28
+
29
+ @abstractmethod
30
+ def run(self) -> None: ...
31
+
32
+ @abstractmethod
33
+ def close(self) -> None: ...
34
+
35
+
36
+ class NavSim2DBackend(Backend):
37
+ """Backend for local simulation using the hivebr.engine renderer/physics."""
38
+
39
+ def __init__(self, experiment) -> None:
40
+ self.experiment = experiment
41
+ self._sim = None
42
+
43
+ def reset(self) -> None:
44
+ if self._sim is not None:
45
+ self._sim.reset()
46
+
47
+ def get_states(self) -> dict[int, RobotState]:
48
+ if self._sim is None:
49
+ return {}
50
+ robots = getattr(self._sim, "robots", [self._sim.robot])
51
+ states = {}
52
+ for i, robot in enumerate(robots):
53
+ goal = self._sim.robot_goal(i) if hasattr(self._sim, "robot_goal") else None
54
+ states[i] = _state_from_robot(i, robot, goal)
55
+ return states
56
+
57
+ def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
58
+ return self.experiment._dispatch(states, dt)
59
+
60
+ def step(self, commands: dict[int, Command], dt: float) -> None:
61
+ raise NotImplementedError(
62
+ "Headless stepping is not available yet; use run() which drives the "
63
+ "engine's interactive loop through the fleet controller."
64
+ )
65
+
66
+ def run(self) -> None:
67
+ from .engine import Simulator
68
+
69
+ config = self.experiment.build_engine_config()
70
+ self._sim = Simulator(config=config, fleet_controller=self._fleet_controller)
71
+ self._sim.run()
72
+
73
+ def close(self) -> None:
74
+ self._sim = None
75
+
76
+ def _fleet_controller(self, robots, goals, config, dt, lidar_readings=None):
77
+ states = {
78
+ i: _state_from_robot(i, r, goals[i] if goals is not None else None)
79
+ for i, r in enumerate(robots)
80
+ }
81
+ commands = self.experiment._dispatch(states, dt)
82
+ return [_engine_command(r, commands.get(i, Command())) for i, r in enumerate(robots)]
83
+
84
+
85
+ class Ros2Backend(Backend):
86
+ """Placeholder for the physical arena: one ROS 2 node per agent or a single
87
+ overarching node for a SwarmController."""
88
+
89
+ def __init__(self, experiment) -> None:
90
+ self.experiment = experiment
91
+
92
+ def reset(self) -> None:
93
+ raise NotImplementedError("ROS 2 backend not implemented yet.")
94
+
95
+ def get_states(self) -> dict[int, RobotState]:
96
+ raise NotImplementedError("ROS 2 backend not implemented yet.")
97
+
98
+ def compute(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
99
+ return self.experiment._dispatch(states, dt)
100
+
101
+ def step(self, commands: dict[int, Command], dt: float) -> None:
102
+ raise NotImplementedError("ROS 2 backend not implemented yet.")
103
+
104
+ def run(self) -> None:
105
+ raise NotImplementedError("ROS 2 backend not implemented yet.")
106
+
107
+ def close(self) -> None:
108
+ pass
109
+
110
+
111
+ def _state_from_robot(rid: int, robot, goal=None) -> RobotState:
112
+ st = robot.state()
113
+ goal_tuple = tuple(float(v) for v in goal) if goal is not None else None
114
+ return RobotState(
115
+ id=rid,
116
+ x=float(st.get("x", getattr(robot, "x", 0.0))),
117
+ y=float(st.get("y", getattr(robot, "y", 0.0))),
118
+ theta=float(st.get("theta", getattr(robot, "theta", 0.0))),
119
+ v=float(st.get("v", 0.0)),
120
+ omega=float(st.get("omega", 0.0)),
121
+ type=str(st.get("type", "differential")),
122
+ radius=float(getattr(robot, "radius", 0.1375)),
123
+ battery=float(getattr(robot, "battery_percent", 100.0)),
124
+ goal=goal_tuple,
125
+ )
126
+
127
+
128
+ def _engine_command(robot, command: Command) -> dict:
129
+ rtype = str(robot.state().get("type", "differential"))
130
+ if rtype == "holonomic":
131
+ return {"vx": command.v, "vy": command.omega}
132
+ if rtype == "ackermann":
133
+ return {"speed": command.v, "steer": command.omega}
134
+ return {"v": command.v, "omega": command.omega}
135
+
136
+
137
+ def create_backend(name: str, experiment) -> Backend:
138
+ key = str(name).lower()
139
+ if key in ("navsim2d", "nav_sim2d", "local", "sim"):
140
+ return NavSim2DBackend(experiment)
141
+ if key in ("ros2", "ros", "arena"):
142
+ return Ros2Backend(experiment)
143
+ raise ValueError(f"Unknown backend: {name!r} (use 'navsim2d' or 'ros2')")
hivebr/core.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from copy import deepcopy
5
+ import math
6
+
7
+ from .bridge import create_backend
8
+ from .kinematics import normalize_command
9
+ from .state import Command, RobotState
10
+
11
+
12
+ class HiveBrAgent(ABC):
13
+ """Decentralized (agent-centric) behavior for a single B-bot.
14
+
15
+ The engine instantiates this class once per robot and calls ``loop`` with a
16
+ frozen state snapshot plus the states of its neighbors.
17
+ """
18
+
19
+ def __init__(self, robot_id: int | None = None, **params):
20
+ self.id = robot_id
21
+ self.params = params
22
+
23
+ def setup(self) -> None:
24
+ """Optional one-time initialization."""
25
+
26
+ @abstractmethod
27
+ def loop(
28
+ self,
29
+ state: RobotState,
30
+ neighbors: dict[int, RobotState],
31
+ ) -> "Command | tuple[float, float]":
32
+ """Return the desired (v, omega) for this agent."""
33
+
34
+ def reset(self) -> None:
35
+ """Optional per-episode reset."""
36
+
37
+
38
+ class SwarmController(ABC):
39
+ """Centralized (global-centric) control for the whole fleet.
40
+
41
+ The engine instantiates this class exactly once and passes the states of
42
+ all robots, expecting one command per robot id back.
43
+ """
44
+
45
+ def setup(self) -> None:
46
+ """Optional one-time initialization."""
47
+
48
+ @abstractmethod
49
+ def loop(self, states: dict[int, RobotState]) -> dict[int, "Command | tuple[float, float]"]:
50
+ """Return a mapping ``{robot_id: command}``."""
51
+
52
+ def reset(self) -> None:
53
+ """Optional per-episode reset."""
54
+
55
+
56
+ class SwarmExperiment:
57
+ """Entry point that binds user code to a backend (navsim2d or ROS 2)."""
58
+
59
+ def __init__(
60
+ self,
61
+ robots=1,
62
+ backend: str = "navsim2d",
63
+ config=None,
64
+ dt: float = 0.03,
65
+ orientation: bool | None = None,
66
+ goal_tolerance: float | None = None,
67
+ comm_radius: float | None = None,
68
+ ):
69
+ self.dt = float(dt)
70
+ self.backend_name = backend
71
+ self.config = config
72
+ self.orientation = orientation
73
+ self.goal_tolerance = goal_tolerance
74
+ self.comm_radius = comm_radius
75
+ self.agent_classes: dict[int, type[HiveBrAgent]] = {}
76
+ self.agent_params: dict[int, dict] = {}
77
+ self.routes: dict[int, list] = {}
78
+ self.controller: SwarmController | None = None
79
+ self.agents: dict[int, HiveBrAgent] = {}
80
+ self._mode: str | None = None
81
+ self._backend = None
82
+ self._num_robots, self._fleet = _parse_robots(robots)
83
+
84
+ def assign_agent(self, robot_id=None, robot_ids=None, agent_class=None, **params) -> "SwarmExperiment":
85
+ if agent_class is None:
86
+ raise ValueError("assign_agent requires an agent_class.")
87
+ ids = []
88
+ if robot_id is not None:
89
+ ids.append(int(robot_id))
90
+ if robot_ids is not None:
91
+ ids.extend(int(i) for i in robot_ids)
92
+ for rid in ids:
93
+ self.agent_classes[rid] = agent_class
94
+ self.agent_params[rid] = dict(params)
95
+ return self
96
+
97
+ def set_controller(self, controller: SwarmController) -> "SwarmExperiment":
98
+ self.controller = controller
99
+ return self
100
+
101
+ def assign_route(self, robot_id, waypoints) -> "SwarmExperiment":
102
+ """Give a B-bot its waypoint route (each item ``(x, y)`` or ``(x, y, theta)``)."""
103
+ self.routes[int(robot_id)] = [list(wp) for wp in waypoints]
104
+ return self
105
+
106
+ def _build(self) -> None:
107
+ if self.controller is not None:
108
+ self._mode = "centralized"
109
+ self.controller.setup()
110
+ return
111
+
112
+ self._mode = "decentralized"
113
+ self.agents = {
114
+ rid: cls(robot_id=rid, **self.agent_params.get(rid, {}))
115
+ for rid, cls in self.agent_classes.items()
116
+ }
117
+ for agent in self.agents.values():
118
+ agent.setup()
119
+
120
+ def _dispatch(self, states: dict[int, RobotState], dt: float) -> dict[int, Command]:
121
+ if self._mode is None:
122
+ self._build()
123
+
124
+ if self._mode == "centralized":
125
+ raw = self.controller.loop(states)
126
+ return {int(rid): normalize_command(cmd) for rid, cmd in raw.items()}
127
+
128
+ comm_radius = self.comm_radius
129
+ if comm_radius is None and isinstance(self.config, dict):
130
+ comm_radius = self.config.get("interaction", {}).get("comm_radius")
131
+
132
+ neighbors = {}
133
+ for rid, st in states.items():
134
+ visible = {}
135
+ for j, other in states.items():
136
+ if j == rid:
137
+ continue
138
+ if comm_radius is None or math.hypot(st.x - other.x, st.y - other.y) <= comm_radius:
139
+ visible[j] = other
140
+ neighbors[rid] = visible
141
+
142
+ commands: dict[int, Command] = {}
143
+ for rid, st in states.items():
144
+ agent = self.agents.get(rid)
145
+ commands[rid] = normalize_command(agent.loop(st, neighbors[rid])) if agent else Command()
146
+ return commands
147
+
148
+ def reset(self) -> None:
149
+ for agent in self.agents.values():
150
+ agent.reset()
151
+ if self.controller is not None:
152
+ self.controller.reset()
153
+ if self._backend is not None:
154
+ self._backend.reset()
155
+
156
+ def step(self) -> None:
157
+ backend = self._ensure_backend()
158
+ states = backend.get_states()
159
+ commands = self._dispatch(states, self.dt)
160
+ backend.step(commands, self.dt)
161
+
162
+ def run(self) -> None:
163
+ self._build()
164
+ self._ensure_backend().run()
165
+
166
+ def close(self) -> None:
167
+ if self._backend is not None:
168
+ self._backend.close()
169
+ self._backend = None
170
+
171
+ def build_engine_config(self) -> dict:
172
+ cfg = deepcopy(self.config) if self.config is not None else None
173
+ if cfg is None:
174
+ from .engine.simulator import DEFAULT_CONFIG
175
+
176
+ cfg = deepcopy(DEFAULT_CONFIG)
177
+ cfg["robots"] = [deepcopy(meta) for meta in self._fleet]
178
+ for index, meta in enumerate(cfg["robots"]):
179
+ meta.setdefault("initial_pose", list(_spread_pose(index)))
180
+ if index in self.routes:
181
+ meta["waypoints"] = [list(wp) for wp in self.routes[index]]
182
+
183
+ cfg.setdefault("waypoints", {})
184
+ if self.orientation is not None:
185
+ cfg["waypoints"]["define_orientation"] = bool(self.orientation)
186
+ if self.goal_tolerance is not None:
187
+ cfg["waypoints"]["goal_tolerance"] = float(self.goal_tolerance)
188
+ return cfg
189
+
190
+ def _ensure_backend(self):
191
+ if self._backend is None:
192
+ self._backend = create_backend(self.backend_name, self)
193
+ return self._backend
194
+
195
+
196
+ def _spread_pose(index: int, spacing: float = 1.0) -> tuple[float, float, float]:
197
+ """Default spawn layout: a row starting near the world origin."""
198
+ return (1.0 + spacing * index, 1.0, 0.0)
199
+
200
+
201
+ def _parse_robots(robots) -> tuple[int, list[dict]]:
202
+ if isinstance(robots, int):
203
+ return robots, [{"id": i} for i in range(robots)]
204
+ fleet = [dict(meta) for meta in robots]
205
+ for i, meta in enumerate(fleet):
206
+ meta.setdefault("id", i)
207
+ return len(fleet), fleet
@@ -0,0 +1,15 @@
1
+ from .config import load_config
2
+ from .simulator import Simulator
3
+ from .data_logger import DataLogger, SimLog
4
+ from .robots import create_fleet, create_robot
5
+ from . import interaction
6
+
7
+ __all__ = [
8
+ "Simulator",
9
+ "load_config",
10
+ "DataLogger",
11
+ "SimLog",
12
+ "create_fleet",
13
+ "create_robot",
14
+ "interaction",
15
+ ]
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ import numpy as np
4
+
5
+
6
+ @dataclass
7
+ class Camera2D:
8
+ world_width: float
9
+ world_height: float
10
+ viewport_width: int
11
+ viewport_height: int
12
+ initial_zoom: float = 1.0
13
+ min_zoom: float = 0.25
14
+ max_zoom: float = 8.0
15
+ zoom_step: float = 1.1
16
+ margin_x: float = 1.0
17
+ margin_y: float = 1.0
18
+
19
+ def __post_init__(self):
20
+ self.zoom = self.initial_zoom
21
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
22
+ self.compute_scale()
23
+
24
+ def compute_scale(self):
25
+ scale_x = self.viewport_width / self.world_width
26
+ scale_y = self.viewport_height / self.world_height
27
+ self.base_scale = min(scale_x, scale_y)
28
+ self.scale = self.base_scale * self.zoom
29
+
30
+ def resize(self, viewport_width: int, viewport_height: int):
31
+ self.viewport_width = max(1, int(viewport_width))
32
+ self.viewport_height = max(1, int(viewport_height))
33
+ self.compute_scale()
34
+
35
+ def reset(self):
36
+ self.zoom = self.initial_zoom
37
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
38
+ self.compute_scale()
39
+
40
+ def center_on_origin_with_margin(self):
41
+ self.offset = np.array([-self.margin_x, -self.margin_y], dtype=float)
42
+
43
+ def zoom_at(self, mouse_pos, wheel_y: int):
44
+ old_world = self.screen_to_world(*mouse_pos)
45
+ factor = self.zoom_step if wheel_y > 0 else 1.0 / self.zoom_step
46
+ self.zoom = float(np.clip(self.zoom * factor, self.min_zoom, self.max_zoom))
47
+ self.compute_scale()
48
+ new_world = self.screen_to_world(*mouse_pos)
49
+ self.offset += old_world - new_world
50
+
51
+ def pan_pixels(self, dx: float, dy: float):
52
+ self.offset[0] -= dx / self.scale
53
+ self.offset[1] += dy / self.scale
54
+
55
+ def _display_offsets(self):
56
+ ox = (self.viewport_width - self.world_width * self.scale) / 2
57
+ oy = (self.viewport_height - self.world_height * self.scale) / 2
58
+ return ox, oy
59
+
60
+ def world_to_screen(self, px: float, py: float):
61
+ ox, oy = self._display_offsets()
62
+ px_cam = px - self.offset[0]
63
+ py_cam = py - self.offset[1]
64
+ sx = int(px_cam * self.scale + ox)
65
+ sy = int(self.viewport_height - (py_cam * self.scale + oy))
66
+ return sx, sy
67
+
68
+ def screen_to_world(self, sx: float, sy: float):
69
+ ox, oy = self._display_offsets()
70
+ px = (sx - ox) / self.scale + self.offset[0]
71
+ py = ((self.viewport_height - sy) - oy) / self.scale + self.offset[1]
72
+ return np.array([px, py], dtype=float)
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from typing import Any
4
+ import yaml
5
+
6
+
7
+ def load_config(path: str | Path) -> dict[str, Any]:
8
+ path = Path(path)
9
+ with path.open("r", encoding="utf-8") as f:
10
+ return yaml.safe_load(f)