Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
e74145f
Forward correct image topics
ayin21 Jun 19, 2026
5c21291
Make robots black
ayin21 Jun 19, 2026
6cd3000
Add example script for formation calc
ayin21 Jun 19, 2026
1ced7f6
basic creation of positioning_capsule
cleWu03 Jun 21, 2026
61bf6e7
connect positioning capsule with behaviour (example for defender)
cleWu03 Jun 21, 2026
4fa2aed
fix some type errors and run format (now works in simulation for a si…
cleWu03 Jun 22, 2026
eca2b97
test integration of different roles in simulation (only normal behavi…
cleWu03 Jun 22, 2026
775da12
add x any pos slider to rqt data sim
cleWu03 Jun 22, 2026
842adfd
Use events executor for rviz markers
Flova Jun 29, 2026
43c6e31
Isolate placement test script
Flova Jun 29, 2026
49ed7aa
Delete redundant file
Flova Jun 29, 2026
6d616d6
Add freekick mode
Flova Jun 29, 2026
251a18d
Apply PR feedback
Flova Jun 29, 2026
9002998
Add typing
Flova Jun 29, 2026
49d519a
Apply format
Flova Jun 29, 2026
eaaf36d
improved dsd based on comments in PR (not testet for building)
cleWu03 Jun 30, 2026
bfbf3c7
integrate positioning capsule changes in main dsd
cleWu03 Jun 30, 2026
3fc4e2c
rqt add ball pose publish
cleWu03 Jun 30, 2026
28dbf2e
add @ in dsd go tos
cleWu03 Jun 30, 2026
ae03c94
Merge branch 'main' into feature/better_assignment to add double touch
cleWu03 Jun 30, 2026
d058eeb
fix positioning ready
cleWu03 Jun 30, 2026
b884405
fix hysteresis call
cleWu03 Jun 30, 2026
656f27f
fix closest to ball decision
cleWu03 Jun 30, 2026
e2c3c4a
no double toch integration with Strategy passiv
cleWu03 Jul 1, 2026
ab88f08
change freekick param name and add function to set it
cleWu03 Jul 1, 2026
2ef42fe
fixes sim errors
cleWu03 Jul 1, 2026
b9bfea2
this as well
cleWu03 Jul 1, 2026
104cea8
remove wrong log
cleWu03 Jul 1, 2026
b6b222d
add own action to get passive
cleWu03 Jul 1, 2026
ee12f90
some more dsd fixes
cleWu03 Jul 2, 2026
c09ac88
Merge main to get the newest simulator stuff for testing
cleWu03 Jul 2, 2026
1b03218
check for alingnement with goalPose to stop moving on place
cleWu03 Jul 2, 2026
5190a13
fix for communicated actions
cleWu03 Jul 2, 2026
09a1641
quick save continue in formation position
cleWu03 Jul 3, 2026
d86cc96
add reached goal logic to go to formation
cleWu03 Jul 3, 2026
e10883c
Fix small regressions
Jul 3, 2026
fa5e0ce
rewrite action
ayin21 Jul 3, 2026
9ddf72d
Fix various issues with GoToFormationPosition
Jul 3, 2026
bedf8e9
Merge branch 'main' into feature/better_assignment
Jul 3, 2026
5551f44
Remove Init Animation
Jul 3, 2026
94408f2
update passiv action for multiple players (Needs to be tested!)
cleWu03 Jul 3, 2026
85c0aaa
Remove old goto targets
Flova Jul 3, 2026
93cd9c4
Remove hysteresis and add set play formation
Flova Jul 3, 2026
7c664c7
Adapt and fix the behavior
Flova Jul 3, 2026
5cf3bab
Fix set play in gui
Flova Jul 3, 2026
eeed157
Fix seperation during complex / crowded set play situations
Flova Jul 3, 2026
a3f9344
Merge branch 'feature/better_assignment' of github.com:bit-bots/bitbo…
Flova Jul 3, 2026
8b63646
Apply format
Flova Jul 3, 2026
9a3099b
Fix passive robots
Flova Jul 3, 2026
bcfdbb2
Adapt gui to model passive robots during assignment
Flova Jul 3, 2026
426c857
Fix typing for positoning capsule
Jul 3, 2026
a093312
pixi format
Jul 3, 2026
33a0ff2
Fix formatting
Flova Jul 4, 2026
0e885e3
Remove unreachable code
Flova Jul 4, 2026
5904a09
Rework set play situation
Flova Jul 4, 2026
1b38e33
Add method to retrieve robot's own role and clean up role assignment …
Flova Jul 4, 2026
a99dc2c
Merge branch 'main' into feature/better_assignment
Flova Jul 4, 2026
6d3d637
Merge branch 'main' into feature/better_assignment
Jul 4, 2026
6acf08a
Merge remote-tracking branch 'origin/main' into feature/better_assign…
Jul 4, 2026
d35ad73
Correct unit of parameter
Jul 4, 2026
888ea17
remove latch
cleWu03 Jul 4, 2026
1ece6ee
dont reevaluate Forget Ball
cleWu03 Jul 4, 2026
3e11e17
Add deadband filter for with player number prio for striker assignment
Flova Jul 4, 2026
ad9f591
Merge branch 'feature/better_assignment' of github.com:bit-bots/bitbo…
Flova Jul 4, 2026
6e49a6c
Make supporter optional
Flova Jul 4, 2026
6818fa5
Add variable defender gap
Flova Jul 4, 2026
aaed253
Merge branch 'main' of github.com:bit-bots/bitbots_main into feature/…
Flova Jul 4, 2026
463144b
Add better defensive positions
Flova Jul 4, 2026
53874db
Merge origin/main into feature/better_assignment
jaagut Jul 18, 2026
bd8cb9c
publish absolut_ball from slider values to team_data
cleWu03 Jul 30, 2026
4867dce
small fixes from copilot, use rl_kick specific go_to_ball action, co…
cleWu03 Jul 31, 2026
bdae2ba
code style fix
cleWu03 Jul 31, 2026
7d52bbe
use float explicitly for ci
cleWu03 Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,018 changes: 871 additions & 3,147 deletions pixi.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/bitbots_behavior/bitbots_blackboard/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ if(BUILD_TESTING)
ament_mypy(CONFIG_FILE "${CMAKE_CURRENT_LIST_DIR}/mypy.ini")
endif()

install(DIRECTORY scripts/ USE_SOURCE_PERMISSIONS
DESTINATION lib/${PROJECT_NAME})

ament_python_install_package(${PROJECT_NAME})

ament_package()
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from bitbots_blackboard.capsules.kick_capsule import KickCapsule
from bitbots_blackboard.capsules.misc_capsule import MiscCapsule
from bitbots_blackboard.capsules.pathfinding_capsule import PathfindingCapsule
from bitbots_blackboard.capsules.positioning_capsule import PositioningCapsule
from bitbots_blackboard.capsules.team_data_capsule import TeamDataCapsule
from bitbots_blackboard.capsules.world_model_capsule import WorldModelCapsule

Expand All @@ -34,6 +35,7 @@ def __init__(self, node: Node, tf_buffer: tf2.BufferInterface):
self.costmap = CostmapCapsule(self.node, self)
self.pathfinding = PathfindingCapsule(self.node, self)
self.team_data = TeamDataCapsule(self.node, self)
self.positioning = PositioningCapsule(self.node, self)

self.capsules = [
self.misc,
Expand All @@ -44,6 +46,7 @@ def __init__(self, node: Node, tf_buffer: tf2.BufferInterface):
self.costmap,
self.pathfinding,
self.team_data,
self.positioning,
]

def clear_cache(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ def received_gamestate(self) -> bool:
def get_team_id(self) -> int:
return self.team_id

def get_own_id(self) -> int:
return self.own_id

def gamestate_callback(self, gamestate_msg: GameState) -> None:
if self.gamestate.penalized and not gamestate_msg.penalized:
self.unpenalized_time = self._node.get_clock().now().nanoseconds / 1e9
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
from typing import Literal, Optional

import numpy as np
Expand Down Expand Up @@ -187,6 +188,40 @@ def get_active_teammate_poses(self, count_goalies: bool = False) -> list[Pose]:
if self.is_valid(data) and (data.strategy.role != Strategy.ROLE_GOALIE or count_goalies):
poses.append(data.robot_position.pose)
return poses

def get_in_of_passive_player(self) -> int | None:
"""Returns the poses of all playing robots"""
index_list = []
data: TeamData
for data in self.team_data.values():
self._node.getLogger
if self.is_valid(data) and (data.strategy.action is Strategy.ACTION_PASSIVE):
return data.robot_id
if self.strategy.action is Strategy.ACTION_PASSIVE:
return self._blackboard.gamestate.get_own_id()
return None

def quaternion_to_yaw(self, q) -> float:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specify the quaternion notation xyzw vs wxyz and maybe move/check but it's utils for these functions.

"""Extract yaw (theta) from a quaternion."""
return math.atan2(2.0 * (q.w * q.z + q.x * q.y), 1.0 - 2.0 * (q.y * q.y + q.z * q.z))

def get_robot_poses(self, include_own: bool = True) -> dict[int, list[float]]:
"""Returns a mapping of jersey_number -> [x, y, theta] for all active robots."""
robot_poses = {}
data: TeamData
for data in self.team_data.values():
if self.is_valid(data):
pose = data.robot_position.pose
robot_poses[data.robot_id] = [
pose.position.x,
pose.position.y,
self.quaternion_to_yaw(pose.orientation),
]
if include_own:
robot_poses[self._blackboard.gamestate.get_own_id()] = list(
self._blackboard.world_model.get_current_position()
)
return robot_poses

def get_number_of_active_field_players(self, count_goalie: bool = False) -> int:
def is_not_goalie(team_data: TeamData) -> bool:
Expand Down
181 changes: 181 additions & 0 deletions src/bitbots_behavior/bitbots_blackboard/scripts/debug_positioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Debug GUI for InnerPositioningCapsule.

Exercises the exact capsule code without starting the full stack.
Click the field to move the ball; use the sliders to tweak params.
"""

import numpy as np
from bitbots_blackboard.capsules.positioning_capsule import Field, InnerPositioningCapsule, Params

_inner = InnerPositioningCapsule()


def run_gui():
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
from matplotlib.widgets import CheckButtons, Slider

fld = Field()
params = Params()
state = {"ball": np.array([1.0, 0.5]), "n": 5, "prev": None}

colors = {"goalie": "#e6b800", "striker": "#d62728", "supporter": "#2ca02c"}

fig, ax = plt.subplots(figsize=(9, 9))
plt.subplots_adjust(left=0.08, right=0.97, top=0.98, bottom=0.50)

def _ax(b):
return plt.axes([0.18, b, 0.72, 0.015])

s_n = Slider(_ax(0.470), "players", 1, 8, valinit=state["n"], valstep=1)
s_sep = Slider(_ax(0.440), "min_sep", 0.3, 2.0, valinit=params.min_sep)
s_alpha = Slider(_ax(0.410), "push α", 0.1, 0.8, valinit=params.alpha)
s_dbias = Slider(_ax(0.380), "def fwd/back", -2.0, 3.0, valinit=params.depth_bias)
s_dside = Slider(_ax(0.350), "def side", 0.0, 2.0, valinit=params.def_side)
s_gap = Slider(_ax(0.320), "def gap", 0.5, 2.0, valinit=params.gap)
s_f = Slider(_ax(0.290), "supp lead", 0.0, 3.0, valinit=params.f)
s_side = Slider(_ax(0.260), "supp side", 0.0, 2.5, valinit=params.supp_side)
s_smax = Slider(_ax(0.230), "supp max x", 0.0, 4.2, valinit=params.supp_max_x)
s_pmarg = Slider(_ax(0.200), "post margin", 0.0, 1.3, valinit=params.post_margin)
s_back = Slider(_ax(0.170), "back dist", 0.0, 3.0, valinit=params.back_dist)
s_kclr = Slider(_ax(0.140), "kick clear", 0.0, 1.5, valinit=params.kick_clear)
s_gout = Slider(_ax(0.110), "goalie out", 0.2, 2.0, valinit=params.d_g)
check_fk = CheckButtons(plt.axes([0.18, 0.073, 0.20, 0.022]), ["freekick"], [False])
s_fkcl = Slider(_ax(0.050), "fk clearance", 0.1, 2.0, valinit=params.freekick_clearance)

def draw():
ax.clear()
ax.add_patch(
Rectangle((-fld.length / 2, -fld.width / 2), fld.length, fld.width, fill=False, color="white", lw=2)
)
ax.axvline(0, color="white", lw=1)
ax.add_patch(Circle((0, 0), 0.75, fill=False, color="white", lw=1))
for sgn in (-1, 1):
ax.plot(
[sgn * fld.length / 2] * 2,
[-fld.goal_width / 2, fld.goal_width / 2],
color="#4da6ff" if sgn < 0 else "#ff9999",
lw=6,
)
ax.set_facecolor("#2e7d32")

params.min_sep, params.alpha, params.gap, params.f = s_sep.val, s_alpha.val, s_gap.val, s_f.val
params.depth_bias, params.supp_side, params.d_g = s_dbias.val, s_side.val, s_gout.val
params.supp_max_x, params.def_side = s_smax.val, s_dside.val
params.post_margin, params.back_dist = s_pmarg.val, s_back.val
params.kick_clear = s_kclr.val
params.freekick = check_fk.get_status()[0]
params.freekick_clearance = s_fkcl.val
n = int(s_n.val)

if params.freekick:
ax.add_patch(
Circle(
state["ball"],
params.freekick_clearance,
fill=False,
color="yellow",
lw=1.5,
ls="--",
zorder=2,
alpha=0.7,
)
)

form = _inner._compute_formation(state["ball"], fld, n, params)
new_items = list(form.items())

prev = state["prev"]
if prev is not None and len(prev) == len(new_items):
for old_idx, new_pose, role in _inner._match_assignment(prev, new_items, state["ball"]):
old_pose = prev[old_idx]
c = colors.get(role.split("_")[0], "#1f77b4")
ax.plot([old_pose[0], new_pose[0]], [old_pose[1], new_pose[1]], ls=":", color=c, lw=1.5, zorder=3)
ax.add_patch(Circle(old_pose[:2], 0.10, color=c, alpha=0.55, zorder=4))
state["prev"] = [pose for _role, pose in new_items]

ax.add_patch(Circle(state["ball"], 0.12, color="white", ec="black", zorder=5))
if "striker" in form:
th = form["striker"][2]
aim = np.array([np.cos(th), np.sin(th)])
perp = np.array([-aim[1], aim[0]])
b = np.asarray(state["ball"])
far = b + params.kick_range * aim
corner = perp * params.kick_clear
lane = np.array([b + corner, far + corner, far - corner, b - corner])
ax.add_patch(plt.Polygon(lane, closed=True, color="white", alpha=0.10, zorder=1))
ax.arrow(
*state["ball"],
*(1.4 * aim),
color="white",
width=0.02,
head_width=0.18,
length_includes_head=True,
zorder=8,
alpha=0.9,
)
for role, pose in form.items():
p, th = pose[:2], pose[2]
base = role.split("_")[0]
c = colors.get(base, "#1f77b4")
ax.add_patch(Circle(p, params.min_sep / 2, color=c, alpha=0.18, zorder=2))
ax.add_patch(Circle(p, 0.16, color=c, ec="black", zorder=6))
ax.plot(
[p[0], p[0] + 0.45 * np.cos(th)],
[p[1], p[1] + 0.45 * np.sin(th)],
color="black",
lw=2.5,
zorder=7,
solid_capstyle="round",
)
ax.annotate(
role.replace("defender_", "D").replace("supporter", "supp"),
p,
color="white",
fontsize=8,
ha="center",
va="center",
zorder=8,
)

ax.set_xlim(-fld.length / 2 - 0.5, fld.length / 2 + 0.5)
ax.set_ylim(-fld.width / 2 - 0.5, fld.width / 2 + 0.5)
ax.set_aspect("equal")
ax.set_title("click to move the ball · dots = previous assignment (dotted = who moves where)", color="black")
fig.canvas.draw_idle()

def on_click(event):
if event.inaxes is ax and event.xdata is not None:
state["ball"] = np.array([event.xdata, event.ydata])
draw()

for s in (
s_n,
s_sep,
s_alpha,
s_dbias,
s_dside,
s_gap,
s_f,
s_side,
s_smax,
s_pmarg,
s_back,
s_kclr,
s_gout,
s_fkcl,
):
s.on_changed(lambda _v: draw())
check_fk.on_clicked(lambda _label: draw())
fig.canvas.mpl_connect("button_press_event", on_click)
draw()
plt.show()


def main():
run_gui()


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from bitbots_blackboard.capsules.pathfinding_capsule import BallGoalType
from dynamic_stack_decider.abstract_action_element import AbstractActionElement
from geometry_msgs.msg import Vector3
from tf2_geometry_msgs import PoseStamped
from std_msgs.msg import ColorRGBA
from visualization_msgs.msg import Marker

Expand All @@ -25,7 +26,15 @@ def __init__(self, blackboard, dsd, parameters):
self.side_offset = parameters.get("side_offset", 0.00)

def perform(self, reevaluate=False):
pose_msg = self.blackboard.pathfinding.get_ball_goal(self.target, self.distance, self.side_offset)
pose_msg = PoseStamped()
pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg()
pose_msg.header.frame_id = self.blackboard.map_frame
optimal_positioning = self.blackboard.positioning.get_formation_assignment()

@Flova Flova Jun 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the debounced assignment here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean by that?

own_position = optimal_positioning[self.blackboard.gamestate.get_own_id()]
pose = own_position["goal_pose"]
pose_msg.pose.position.x = pose[0]
pose_msg.pose.position.y = pose[1]
pose_msg.pose.orientation.w = pose[2]
self.blackboard.pathfinding.publish(pose_msg)

approach_marker = Marker()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@
from tf2_geometry_msgs import PoseStamped


class GoToGoaliePosition(AbstractActionElement):
blackboard: BodyBlackboard

def __init__(self, blackboard, dsd, parameters):
super().__init__(blackboard, dsd, parameters)

def perform(self, reevaluate=False):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now it treads go to goalie, defense, etc all the same. That might be confusing, because somebody calls go to defense position to go explicitly there and he ends up at the goalie position.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer an explicit go to placement position. We can keep the old supporter position for the forced two robot ball touch, but that works outside of this placement. The PR is still pending, but it essentially removes the robot from the assignment after the ball was touched and goes to an explicit position to wait. While the others play with essentially one robot less. This can be done in the new concept, but we need the explicit support position for this. Other than that all other go to defense, block etc can be removed. Go to role needs to stay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to think about what to do if we don't see a ball. The position assignment should still work based on the lastest ball. I think the striker should perform our normal ball discovery behavior (turn around, go to opponent goal, go to middle line on the side). The rest of the robots should go to their role positions to not interfere with the striker during this procedure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the whole part with coding the positioning goals in the old GoTo decisions was only for testing :) I use GoToFormationPosition now in the most cases, we still need to discuss how to handle the supporter during no double touch, I would prefer a direct integration in the formation capsule

pose_msg = PoseStamped()
pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg()
pose_msg.header.frame_id = self.blackboard.map_frame

optimal_positioning = self.blackboard.positioning.get_formation_assignment()
own_position = optimal_positioning[self.blackboard.gamestate.get_own_id()]
pose = own_position["goal_pose"]
pose_msg.pose.position.x = pose[0]
pose_msg.pose.position.y = pose[1]
pose_msg.pose.orientation.w = pose[2]

self.blackboard.pathfinding.publish(pose_msg)


class GoToBlockPosition(AbstractActionElement):
blackboard: BodyBlackboard

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,11 @@ def perform(self, reevaluate=False):
pose_msg.pose.orientation = Quaternion(x=x, y=y, z=z, w=w)
else:
# center point between ball and own goal
pose_msg.pose.position.x = (goal_position[0] + ball_position[0]) / 2
pose_msg.pose.position.y = ball_position[1] / 2 + self.y_offset
pose_msg.pose.orientation.w = 1.0
optimal_positioning = self.blackboard.positioning.get_formation_assignment()
own_position = optimal_positioning[self.blackboard.gamestate.get_own_id()]
pose = own_position["goal_pose"]
pose_msg.pose.position.x = pose[0]
pose_msg.pose.position.y = pose[1]
pose_msg.pose.orientation.w = pose[2]

self.blackboard.pathfinding.publish(pose_msg)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

from bitbots_blackboard.body_blackboard import BodyBlackboard
from dynamic_stack_decider.abstract_action_element import AbstractActionElement
from tf2_geometry_msgs import PoseStamped


class GoToFormationPosition(AbstractActionElement):
blackboard: BodyBlackboard

def __init__(self, blackboard, dsd, parameters):
super().__init__(blackboard, dsd, parameters)

def perform(self, reevaluate=False):
pose_msg = PoseStamped()
pose_msg.header.stamp = self.blackboard.node.get_clock().now().to_msg()
pose_msg.header.frame_id = self.blackboard.map_frame

optimal_positioning = self.blackboard.positioning.get_formation_assignment()
own_position = optimal_positioning[self.blackboard.gamestate.get_own_id()]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

pose = own_position["goal_pose"]
pose_msg.pose.position.x = pose[0]
pose_msg.pose.position.y = pose[1]
pose_msg.pose.orientation.w = pose[2]

self.blackboard.pathfinding.publish(pose_msg)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should stop if we reached the target pose roughly. Make the threshold and whole feature a parameter, because e.g. the striker will not stop when he wants to strike while the supporter and goaly should. This is fairly important as walking on place for too long might break the localization due to slight drift that is unaccounted for in the linear noise model.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right inside the action? how about a dsd decision either says stands or goes to formation position?

Loading
Loading