-
Notifications
You must be signed in to change notification settings - Fork 0
Parameterisable Full Field Dimensions #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
00bad8a
parameterisable field
energy-in-joles 68e2497
more fixes
energy-in-joles 5431999
fix out of bounds issue by moving game creation to run/run_test
energy-in-joles 26d80c4
remove use of asserts
energy-in-joles 64e48df
Update utama_core/run/strategy_runner.py
energy-in-joles 8e08e73
more fixes
energy-in-joles 55ef31d
changed it so that formations are now dynamically allocated based on …
energy-in-joles 57995e5
add TODO comment about fitting algo
energy-in-joles 3646006
fix rsim hardcoded ball pos. Extensive testing
energy-in-joles 5719111
Update utama_core/strategy/examples/startup_strategy.py
energy-in-joles 92bfe05
add FieldBounds overlay and rendered rsim field based on FieldDimensions
energy-in-joles 75fe177
fix bug with send robot stop command
energy-in-joles 506171b
small fixes
energy-in-joles 58ea162
ensure clear left and right team separation
energy-in-joles 04edfaa
correct logic
energy-in-joles cd15ab6
fix goalkeep constants
energy-in-joles 2b12b61
fixes
energy-in-joles 261fe45
relax vision bounding for position refiner
energy-in-joles 41a159a
improve documentation for position refiner
energy-in-joles File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| from dataclasses import dataclass | ||
| from functools import cached_property | ||
|
|
||
| import numpy as np | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class FieldBounds: | ||
| top_left: tuple[float, float] | ||
| bottom_right: tuple[float, float] | ||
|
|
||
| @property | ||
| def center(self) -> tuple[float, float]: | ||
| """Calculates the geometric center of the field bounds.""" | ||
| cx = (self.top_left[0] + self.bottom_right[0]) / 2.0 | ||
| cy = (self.top_left[1] + self.bottom_right[1]) / 2.0 | ||
| return (cx, cy) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class FieldDimensions: | ||
| """Holds field dimensions and derives all geometric shapes.""" | ||
|
|
||
| full_field_half_length: float | ||
| full_field_half_width: float | ||
| half_defense_area_depth: float | ||
| half_defense_area_width: float | ||
| half_goal_width: float | ||
|
|
||
| # --- Bounds --- | ||
|
|
||
| @cached_property | ||
| def full_field_bounds(self): | ||
| return FieldBounds( | ||
| top_left=(-self.full_field_half_length, self.full_field_half_width), | ||
| bottom_right=(self.full_field_half_length, -self.full_field_half_width), | ||
| ) | ||
|
|
||
| # --- Full field polygon --- | ||
|
|
||
| @cached_property | ||
| def full_field(self) -> np.ndarray: | ||
| L = self.full_field_half_length | ||
| W = self.full_field_half_width | ||
| return np.array( | ||
| [ | ||
| (L, W), | ||
| (L, -W), | ||
| (-L, -W), | ||
| (-L, W), | ||
| ] | ||
| ) | ||
|
|
||
| # --- Goal lines --- | ||
|
|
||
| @cached_property | ||
| def right_goal_line(self) -> np.ndarray: | ||
| L = self.full_field_half_length | ||
| G = self.half_goal_width | ||
| return np.array( | ||
| [ | ||
| (L, G), | ||
| (L, -G), | ||
| ] | ||
| ) | ||
|
|
||
| @cached_property | ||
| def left_goal_line(self) -> np.ndarray: | ||
| L = self.full_field_half_length | ||
| G = self.half_goal_width | ||
| return np.array( | ||
| [ | ||
| (-L, G), | ||
| (-L, -G), | ||
| ] | ||
| ) | ||
|
|
||
| # --- Defense areas --- | ||
|
|
||
| @cached_property | ||
| def right_defense_area(self) -> np.ndarray: | ||
| L = self.full_field_half_length | ||
| D = self.half_defense_area_depth | ||
| W = self.half_defense_area_width | ||
| return np.array( | ||
| [ | ||
| (L, W), | ||
| (L - 2 * D, W), | ||
| (L - 2 * D, -W), | ||
| (L, -W), | ||
| ] | ||
| ) | ||
|
|
||
| @cached_property | ||
| def left_defense_area(self) -> np.ndarray: | ||
| L = self.full_field_half_length | ||
| D = self.half_defense_area_depth | ||
| W = self.half_defense_area_width | ||
| return np.array( | ||
| [ | ||
| (-L, W), | ||
| (-L + 2 * D, W), | ||
| (-L + 2 * D, -W), | ||
| (-L, -W), | ||
| ] | ||
| ) | ||
|
|
||
| def __post_init__(self): | ||
| L = self.full_field_half_length | ||
| W = self.full_field_half_width | ||
| D = self.half_defense_area_depth | ||
| DW = self.half_defense_area_width | ||
| G = self.half_goal_width | ||
|
|
||
| # --- Positivity --- | ||
| if not (L > 0 and W > 0): | ||
| raise ValueError("Field length/width must be positive") | ||
| if not (D > 0 and DW > 0 and G > 0): | ||
| raise ValueError("Goal/defense measurements must be positive") | ||
|
|
||
| # --- Fit constraints --- | ||
| if 2 * D > L: | ||
| raise ValueError(f"Defense depth {2*D} exceeds field length {L}") | ||
| if DW > W: | ||
| raise ValueError(f"Defense width {DW} exceeds field width {W}") | ||
| if G > W: | ||
| raise ValueError(f"Goal width {G} exceeds field width {W}") | ||
|
|
||
| # --- Optional semantic constraint --- | ||
| if G > DW: | ||
| raise ValueError(f"Goal width {G} should not exceed defense width {DW}") | ||
|
|
||
|
|
||
| STANDARD_FIELD_DIMS = FieldDimensions( | ||
| full_field_half_length=4.5, | ||
| full_field_half_width=3.0, | ||
| half_defense_area_depth=0.5, | ||
| half_defense_area_width=1, | ||
| half_goal_width=0.5, | ||
| ) | ||
|
|
||
| GREAT_EXHIBITION_FIELD_DIMS = FieldDimensions( | ||
| full_field_half_length=2.0, | ||
| full_field_half_width=1.5, | ||
| half_defense_area_depth=0.4, | ||
| half_defense_area_width=0.8, | ||
| half_goal_width=0.5, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,177 @@ | ||
| import math | ||
| from enum import Enum | ||
| from typing import NamedTuple | ||
|
|
||
| import numpy as np | ||
|
|
||
| from utama_core.config.field_params import FieldBounds, FieldDimensions | ||
| from utama_core.config.physical_constants import MAX_ROBOTS, ROBOT_RADIUS | ||
| from utama_core.global_utils.math_utils import normalise_heading | ||
|
|
||
|
|
||
| class FormationEntry(NamedTuple): | ||
| x: float | ||
| y: float | ||
| theta: float | ||
|
|
||
|
|
||
| # Starting positions for right team | ||
| RIGHT_START_ONE = [ | ||
| (4.2000, 0.0, np.pi), | ||
| (3.4000, -0.2000, np.pi), | ||
| (3.4000, 0.2000, np.pi), | ||
| (0.7000, 0.0, np.pi), | ||
| (0.7000, 2.2500, np.pi), | ||
| (0.7000, -2.2500, np.pi), | ||
| ] | ||
|
|
||
| # Starting positions for left team | ||
| LEFT_START_ONE = [ | ||
| (-4.2000, 0.0, 0), | ||
| (-3.4000, 0.2000, 0), | ||
| (-3.4000, -0.2000, 0), | ||
| (-0.7000, 0.0, 0), | ||
| (-0.7000, -2.2500, 0), | ||
| (-0.7000, 2.2500, 0), | ||
| ] | ||
|
|
||
| #################### INSERT FORMATIONS HERE ###################### | ||
| # Normalised right team formation that will be scaled, | ||
| # then mirrored for left. | ||
|
|
||
| # Anisotropic normalised scaling to field half-length, half-width | ||
| # e.g | ||
| # 0.75 = 3.375 (actual scale of x-coord) / 4.5 (field half-length) | ||
| # -0.06 = -0.18 (actual scale of y-coord) / 3.0 (field half-width) | ||
| ################################################################## | ||
|
|
||
|
|
||
| class FormationType(Enum): | ||
| START_ONE = "START_ONE" | ||
|
|
||
|
|
||
| FORMATIONS = { | ||
| FormationType.START_ONE: [ | ||
| FormationEntry(0.9, 0.0, np.pi), | ||
| FormationEntry(0.75, -0.2, np.pi), | ||
| FormationEntry(0.75, 0.2, np.pi), | ||
| FormationEntry(0.16, 0.0, np.pi), | ||
| FormationEntry(0.16, 0.75, np.pi), | ||
| FormationEntry(0.16, -0.75, np.pi), | ||
| ], | ||
| } | ||
|
|
||
| ################## END OF FORMATIONS ########################## | ||
|
|
||
|
|
||
| def _mirror(formation: list[FormationEntry], bounds: FieldBounds) -> list[FormationEntry]: | ||
| cx, _ = bounds.center | ||
|
|
||
| return [ | ||
| FormationEntry( | ||
| 2 * cx - entry.x, | ||
| entry.y, | ||
| normalise_heading(np.pi - entry.theta), | ||
| ) | ||
| for entry in formation | ||
| ] | ||
|
|
||
|
|
||
| def _scale(norm_formation, bounds: FieldBounds) -> list[FormationEntry]: | ||
| x_min = bounds.top_left[0] | ||
| x_max = bounds.bottom_right[0] | ||
| y_max = bounds.top_left[1] | ||
| y_min = bounds.bottom_right[1] | ||
|
|
||
| L = (x_max - x_min) / 2 | ||
| W = (y_max - y_min) / 2 | ||
|
|
||
| cx, cy = bounds.center | ||
|
|
||
| return [FormationEntry(cx + x * L, cy + y * W, theta) for x, y, theta in norm_formation] | ||
|
|
||
|
|
||
| def _validate_bounds_and_intra_team_collision( | ||
| formation: list[FormationEntry], | ||
| bounds: FieldBounds, | ||
| ) -> None: | ||
| """ | ||
| Validate that the formation fits inside the bounding box and that robots do not overlap. | ||
| The robot center may touch the edges. | ||
|
|
||
| Raises ValueError if the formation cannot fit. | ||
|
|
||
| Args: | ||
| formation: List of (x, y, theta) tuples for robots. | ||
| bounds: FieldBounds object with top_left and bottom_right coordinates. | ||
| """ | ||
| # --- Bounding box edges (center can touch boundary) --- | ||
| x_min = bounds.top_left[0] | ||
| x_max = bounds.bottom_right[0] | ||
| y_max = bounds.top_left[1] | ||
| y_min = bounds.bottom_right[1] | ||
|
|
||
| # --- Check bounds for each robot --- | ||
| for i, (x, y, _) in enumerate(formation): | ||
| if not (x_min <= x <= x_max): | ||
| raise ValueError(f"Robot {i} x-position out of bounds: {x}, allowed: [{x_min}, {x_max}]") | ||
| if not (y_min <= y <= y_max): | ||
| raise ValueError(f"Robot {i} y-position out of bounds: {y}, allowed: [{y_min}, {y_max}]") | ||
|
|
||
| # --- Check pairwise collisions --- | ||
| n = len(formation) | ||
| for i in range(n): | ||
| x1, y1, _ = formation[i] | ||
| for j in range(i + 1, n): | ||
| x2, y2, _ = formation[j] | ||
| dist = math.hypot(x1 - x2, y1 - y2) | ||
| if dist < 2 * ROBOT_RADIUS: | ||
| raise ValueError( | ||
| f"Could not fit all robots in provided FieldBounds/FieldDimensions. Robots {i} and {j} overlap (distance={dist:.3f})" | ||
| ) | ||
|
|
||
|
|
||
| def _validate_team_separation(left, right): | ||
| """ | ||
| Validate that the left and right teams are sufficiently separated to avoid collisions. | ||
| """ | ||
| max_left_x = -np.inf | ||
| min_right_x = np.inf | ||
| if left: | ||
| max_left_x = max(x for x, _, _ in left) | ||
| if right: | ||
| min_right_x = min(x for x, _, _ in right) | ||
|
|
||
| gap = min_right_x - max_left_x | ||
| required = 2 * ROBOT_RADIUS | ||
|
|
||
| if gap < required: | ||
| raise ValueError(f"Teams not sufficiently separated: gap={gap:.3f}, required={required:.3f}") | ||
|
|
||
|
|
||
| # TODO: can consider a fitting algorithm that can optimise robot placement so that the chance of running out of space is reduced. | ||
|
|
||
|
|
||
| def get_formations( | ||
| bounds: FieldBounds, | ||
| n_left: int, | ||
| n_right: int, | ||
| formation_type: FormationType, | ||
| ) -> tuple[list[FormationEntry], list[FormationEntry]]: | ||
| """ | ||
| Returns the starting formations for both teams based on the provided field dimensions. | ||
| The formations are defined as lists of FormationEntry objects, which contain the x and y coordinates | ||
|
|
||
| Args: | ||
| bounds: FieldBounds object defining the top-left and bottom-right corners of the field. | ||
| n_left: Number of robots on the left team. | ||
| n_right: Number of robots on the right team. | ||
| formation_type: The type of formation to generate (e.g., START_ONE). | ||
|
|
||
| Returns: | ||
| tuple[list[FormationEntry], list[FormationEntry]]: A tuple containing two lists of FormationEntry objects. | ||
| left and right team formations respectively. | ||
| """ | ||
| if n_left > MAX_ROBOTS or n_right > MAX_ROBOTS: | ||
| raise ValueError( | ||
| f"Number of robots per team cannot exceed {MAX_ROBOTS}. Got n_left={n_left}, n_right={n_right}." | ||
| ) | ||
| if formation_type not in FORMATIONS: | ||
| raise ValueError(f"Formation '{formation_type.value}' not found. Available: {list(FORMATIONS.keys())}") | ||
|
|
||
| base = FORMATIONS[formation_type] | ||
| if n_left > len(base) or n_right > len(base): | ||
| raise ValueError( | ||
| f"Formation '{formation_type.value}' only defines {len(base)} positions, but got n_left={n_left}, n_right={n_right}." | ||
| ) | ||
|
|
||
| left = _mirror(_scale(base[:n_left], bounds), bounds) | ||
| right = _scale(base[:n_right], bounds) | ||
|
|
||
| _validate_bounds_and_intra_team_collision(left, bounds) | ||
| _validate_bounds_and_intra_team_collision(right, bounds) | ||
| _validate_team_separation(left, right) | ||
|
|
||
| return left, right |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| ROBOT_RADIUS = 0.09 | ||
| MAX_ROBOTS = 6 | ||
| BALL_RADIUS = 0.0215 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.