Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b66accc
fix pytest error swallowing
energy-in-joles Feb 26, 2026
8ca2bee
add exp_ball field
energy-in-joles Feb 26, 2026
cd0b4aa
teleport ball off the field for grsim
energy-in-joles Feb 26, 2026
5586764
add test cases for test_exp_ball.py
energy-in-joles Feb 26, 2026
5de0585
add check to prevent rsim infinite loop when invalid frame.
energy-in-joles Feb 26, 2026
5da7b38
ensure ball is not incorrectly imputed when exp_ball false
energy-in-joles Feb 27, 2026
bc24587
add more test with kalman filtering enabled
energy-in-joles Feb 27, 2026
61ed923
Update utama_core/run/strategy_runner.py
energy-in-joles Feb 27, 2026
3111530
standardised teleport sim controller api for grsim and rsim
energy-in-joles Feb 27, 2026
351d1c9
do exp_ball check on opp strat too
energy-in-joles Feb 27, 2026
4672ecf
Update utama_core/data_processing/refiners/position.py
energy-in-joles Feb 27, 2026
c0d148c
some docstring improvements
energy-in-joles Feb 27, 2026
dc97096
relax exp_ball requirement
energy-in-joles Feb 27, 2026
77cac8b
update tests that don't need balls to not have balls and some fixes
energy-in-joles Feb 27, 2026
f0f02c2
update test docstring
energy-in-joles Feb 27, 2026
6fca5ce
move motion planning strats out of tests
energy-in-joles Feb 27, 2026
9a85155
updates
energy-in-joles Feb 27, 2026
e2eea24
update random test to be harder
energy-in-joles Feb 27, 2026
af07a50
fix according to Fred's comments
energy-in-joles Feb 27, 2026
d712aad
chore: cleanup
energy-in-joles Feb 27, 2026
7dfbf43
add sim controller tests
energy-in-joles Feb 27, 2026
5d0684e
fix remove_ball logic
energy-in-joles Feb 27, 2026
e6a6683
remove unncessary test case
energy-in-joles Feb 27, 2026
9959991
add __init__.py
energy-in-joles Feb 27, 2026
108a7d3
change remove_ball to be fixed offset
energy-in-joles Feb 27, 2026
cf29e2b
chore: clean up exp_ball conditionals in position refiner and sim con…
fred-huang122 Feb 28, 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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,9 @@ renders/
# pixi environments
.pixi/*
!.pixi/config.toml

# RL training artifacts
outputs/
wandb/
mappo_*/
*.gif
4 changes: 2 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from utama_core.strategy.examples import (
DefenceStrategy,
GoToBallExampleStrategy,
PointCycleStrategy,
RandomMovementStrategy,
RobotPlacementStrategy,
StartupStrategy,
TwoRobotPlacementStrategy,
Expand All @@ -18,7 +18,7 @@ def main():
custom_bounds = FieldBounds(top_left=(2.25, 1.5), bottom_right=(4.5, -1.5))

runner = StrategyRunner(
strategy=PointCycleStrategy(n_robots=2, field_bounds=custom_bounds, endpoint_tolerance=0.1, seed=42),
strategy=RandomMovementStrategy(n_robots=2, field_bounds=custom_bounds, endpoint_tolerance=0.1, seed=42),
my_team_is_yellow=True,
my_team_is_right=True,
mode="rsim",
Expand Down
3 changes: 3 additions & 0 deletions utama_core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@

### Refiners ###
BALL_MERGE_THRESHOLD = 0.05 # CameraCombiner: distance threshold to merge balls (m)
VISION_BOUNDS_BUFFER = 1.0 # CameraCombiner: buffer around field bounds to include in vision (m)

OFF_PITCH_OFFSET = VISION_BOUNDS_BUFFER * 5 # distance outside field bounds to consider as off-pitch (m)
49 changes: 31 additions & 18 deletions utama_core/data_processing/refiners/position.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import numpy as np

from utama_core.config.settings import BALL_MERGE_THRESHOLD
from utama_core.config.settings import BALL_MERGE_THRESHOLD, VISION_BOUNDS_BUFFER
from utama_core.data_processing.refiners.base_refiner import BaseRefiner
from utama_core.data_processing.refiners.filters.kalman import (
KalmanFilter,
Expand Down Expand Up @@ -40,19 +40,28 @@ class VisionBounds:


class PositionRefiner(BaseRefiner):
"""
Refiner that combines vision data from multiple cameras, applies bounds filtering,
and optionally applies Kalman filtering for smoothing and imputing vanished robots.

Important:
when exp_ball set to False, the refiner could return either Ball | None type ball value
when exp_ball set to True, the refiner will ALWAYS return a Ball type ball value (will impute missing frames)
"""

def __init__(
self,
field_bounds: FieldBounds,
bounds_buffer: float = 1.0,
filtering: bool = True,
exp_ball: bool = True,
):
# alpha=0 means no change in angle (inf smoothing), alpha=1 means no smoothing
self.angle_smoother = AngleSmoother(alpha=1)
self.vision_bounds = VisionBounds(
x_min=field_bounds.top_left[0] - bounds_buffer, # expand left
x_max=field_bounds.bottom_right[0] + bounds_buffer, # expand right
y_min=field_bounds.bottom_right[1] - bounds_buffer, # expand bottom
y_max=field_bounds.top_left[1] + bounds_buffer, # expand top
x_min=field_bounds.top_left[0] - VISION_BOUNDS_BUFFER, # expand left
x_max=field_bounds.bottom_right[0] + VISION_BOUNDS_BUFFER, # expand right
y_min=field_bounds.bottom_right[1] - VISION_BOUNDS_BUFFER, # expand bottom
y_max=field_bounds.top_left[1] + VISION_BOUNDS_BUFFER, # expand top
)

# For Kalman filtering and imputing vanished values.
Expand All @@ -61,6 +70,8 @@ def __init__(
False # Only start filtering once we have valid data to filter (i.e. after the first valid game frame)
)

self.exp_ball = exp_ball

if self.filtering:
# Instantiate a dedicated Kalman filter for each robot so filtering can be kept independent.
self.kalman_filters_yellow: dict[int, KalmanFilter] = {}
Expand Down Expand Up @@ -138,18 +149,20 @@ def refine(self, game_frame: GameFrame, data: List[RawVisionData]) -> GameFrame:
)

# After the balls have been combined, take the most confident
new_ball: Ball = PositionRefiner._get_most_confident_ball(combined_vision_data.balls)

# For filtering and vanishing
if self.filtering and self._filter_running:
new_ball = self.kalman_filter_ball.filter_data(
new_ball,
game_frame.ball,
time_elapsed,
)
elif new_ball is None:
# If none, take the ball from the last frame of the game
new_ball = game_frame.ball
new_ball = PositionRefiner._get_most_confident_ball(combined_vision_data.balls)

# Skip filtering when there's no ball and we don't expect one
if new_ball is not None or self.exp_ball:
# For filtering and vanishing
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggestion: The pass + else pattern is a bit hard to parse at a glance. Consider inverting the condition:

if new_ball is not None or self.exp_ball:
    # For filtering and vanishing
    if self.filtering and self._filter_running:
        new_ball = self.kalman_filter_ball.filter_data(
            new_ball,
            game_frame.ball,
            time_elapsed,
        )
    elif new_ball is None:
        # If none, take the ball from the last frame of the game
        new_ball = game_frame.ball

This removes the empty pass branch and makes the intent clearer: "run the filter logic unless there's no ball and we don't expect one."

if self.filtering and self._filter_running:
new_ball = self.kalman_filter_ball.filter_data(
new_ball,
game_frame.ball,
time_elapsed,
)
elif new_ball is None:
# If none, take the ball from the last frame of the game
new_ball = game_frame.ball

if game_frame.my_team_is_yellow:
new_game_frame = replace(
Expand Down
17 changes: 17 additions & 0 deletions utama_core/global_utils/math_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ def compute_bounding_zone_from_points(
return FieldBounds(top_left=(min_x, max_y), bottom_right=(max_x, min_y))


def in_field_bounds(point: Tuple[float, float] | Vector2D, bounding_box: FieldBounds) -> bool:
"""Check if a point is within a given bounding box.

Args:
point (tuple or Vector2D): The (x, y) coordinates of the point to check.
bounding_box (FieldBounds): The bounding box defined by its top-left and bottom-right corners.

Returns:
bool: True if the point is within the bounding box, False otherwise.
"""
x, y = point
return (
bounding_box.top_left[0] <= x <= bounding_box.bottom_right[0]
and bounding_box.bottom_right[1] <= y <= bounding_box.top_left[1]
)


def assert_valid_bounding_box(bb: FieldBounds):
"""Asserts that a FieldBounds object is valid, raising an AssertionError if not."""
fx, fy = Field._FULL_FIELD_HALF_LENGTH, Field._FULL_FIELD_HALF_WIDTH
Expand Down
11 changes: 9 additions & 2 deletions utama_core/rsoccer_simulator/src/ssl/envs/standard_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def __init__(
time_step=time_step,
render_mode=render_mode,
)

# Note: observation_space and action_space removed - not needed for non-RL use

# set starting formation style for
Expand Down Expand Up @@ -354,7 +355,7 @@ def _dribbler_release_kick(
def _calculate_reward_and_done(self):
return 1, False

def _get_initial_positions_frame(self):
def _get_initial_positions_frame(self, ball_exists: bool = True) -> Frame:
"""Returns the position of each robot and ball for the initial frame (random placement)"""
pos_frame: Frame = Frame()

Expand All @@ -366,7 +367,13 @@ def _get_initial_positions_frame(self):
x, y, heading = self.yellow_formation[i]
pos_frame.robots_yellow[i] = Robot(id=i, x=x, y=-y, theta=-rad_to_deg(heading))

pos_frame.ball = Ball(x=0, y=0)
if ball_exists:
pos_frame.ball = Ball(x=0, y=0)
else:
pos_frame.ball = Ball(
x=self.OFF_FIELD_OFFSET + self.field.length,
y=self.OFF_FIELD_OFFSET + self.field.width,
)

return pos_frame

Expand Down
26 changes: 19 additions & 7 deletions utama_core/rsoccer_simulator/src/ssl/ssl_gym_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class SSLBaseEnv:
"render.fps": 60,
}
NORM_BOUNDS = 1.2
OFF_FIELD_OFFSET = 100.0 # Meters outside of the field to place ball when not needed.

def __init__(
self,
Expand All @@ -51,6 +52,7 @@ def __init__(
n_robots_yellow=n_robots_yellow,
time_step_ms=int(self.time_step * 1000),
)

self.n_robots_blue: int = n_robots_blue
self.n_robots_yellow: int = n_robots_yellow

Expand Down Expand Up @@ -149,9 +151,14 @@ def close(self):

### CUSTOM FUNCTIONS WE ADDED ###

def teleport_ball(self, x: float, y: float, vx: float = 0, vy: float = 0):
"""Teleport ball to new position in meters.
def step_noop(self):
"""Step the environment without any action."""
observation = self._frame_to_observations()[0]
self.steps += 1
return observation

def teleport_ball(self, x: float, y: float, vx: float = 0, vy: float = 0):
"""Teleport ball to a new position on the field in meters.
Note: this does not create a new frame, but mutates the current frame
"""
ball = Ball(x=x, y=-y, z=self.frame.ball.z, v_x=vx, v_y=-vy)
Expand All @@ -161,13 +168,12 @@ def teleport_ball(self, x: float, y: float, vx: float = 0, vy: float = 0):
def teleport_robot(
self,
is_team_yellow: bool,
robot_id: bool,
robot_id: int,
x: float,
y: float,
theta: float = None,
):
"""Teleport robot to new position in meters, radians.

"""Teleport robot to a new position on the field in meters, radians.
Note: this does not create a new frame, but mutates the current frame
"""
if theta is None:
Expand Down Expand Up @@ -312,8 +318,14 @@ def _calculate_reward_and_done(self):
"""Returns reward value and done flag from type List[Robot] state."""
raise NotImplementedError

def _get_initial_positions_frame(self) -> Frame:
"""Returns frame with robots initial positions."""
def _get_initial_positions_frame(self, ball_exists: bool = True) -> Frame:
"""
Returns frame with robots initial positions.
Args:
ball_exists (bool): Whether the ball should be placed in the center of the field or removed from the field.
Returns:
Frame: frame with initial positions of robots and ball (if ball_exists=True)
"""
raise NotImplementedError

def norm_pos(self, pos):
Expand Down
16 changes: 12 additions & 4 deletions utama_core/run/game_gater.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def wait_until_game_valid(
my_team_is_right: bool,
exp_friendly: int,
exp_enemy: int,
exp_ball: bool,
vision_buffers: List[Deque[RawVisionData]],
position_refiner: PositionRefiner,
is_pvp: bool,
Expand All @@ -30,8 +31,8 @@ def wait_until_game_valid(

def _add_frame(my_game_frame: GameFrame, opp_game_frame: GameFrame) -> Tuple[GameFrame, Optional[GameFrame]]:
if rsim_env:
vision_frames = [rsim_env._frame_to_observations()[0]]
rsim_env.steps += 1 # Increment the step count to simulate time passing in the environment
obs = rsim_env.step_noop() # Step the environment without action to get the latest observation
vision_frames = [obs]
else:
vision_frames = [buffer.popleft() if buffer else None for buffer in vision_buffers]
my_game_frame = position_refiner.refine(my_game_frame, vision_frames)
Expand All @@ -54,14 +55,21 @@ def _add_frame(my_game_frame: GameFrame, opp_game_frame: GameFrame) -> Tuple[Gam
while (
len(my_game_frame.friendly_robots) < exp_friendly
or len(my_game_frame.enemy_robots) < exp_enemy
or my_game_frame.ball is None
or (my_game_frame.ball is None and exp_ball)
):
if time.time() - start_time > wait_before_warn:
start_time = time.time()
print("Waiting for valid game frame...")
print(f"Friendly robots: {len(my_game_frame.friendly_robots)}/{exp_friendly}")
print(f"Enemy robots: {len(my_game_frame.enemy_robots)}/{exp_enemy}")
print(f"Ball present: {my_game_frame.ball is not None}\n")
print(f"Ball present: {my_game_frame.ball is not None} (exp: {exp_ball})\n")

# nothing will change in rsim if we don't step it.
# if no valid frame, likely misconfigured.
if rsim_env:
raise TimeoutError(
f"Rsim environment did not produce a valid game frame after {wait_before_warn} seconds. Check the environment setup and vision data."
)
time.sleep(0.05)
my_game_frame, opp_game_frame = _add_frame(my_game_frame, opp_game_frame)

Expand Down
Loading
Loading