Skip to content

Commit 144aee1

Browse files
committed
Merge branch 'master' of https://github.com/UBC-Thunderbots/Software into robocup_2026
2 parents 478c07c + 8fe47ca commit 144aee1

28 files changed

Lines changed: 1152 additions & 792 deletions

src/software/py_constants.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ PYBIND11_MODULE(py_constants, m)
4141
ACCELERATION_DUE_TO_GRAVITY_METERS_PER_SECOND_SQUARED;
4242
m.attr("ENEMY_BALL_PLACEMENT_DISTANCE_METERS") = ENEMY_BALL_PLACEMENT_DISTANCE_METERS;
4343

44+
m.attr("BALL_TO_FRONT_OF_ROBOT_DISTANCE_WHEN_DRIBBLING") =
45+
BALL_TO_FRONT_OF_ROBOT_DISTANCE_WHEN_DRIBBLING;
46+
4447

4548
m.attr("TACTIC_OVERRIDE_PATH") = TACTIC_OVERRIDE_PATH;
4649
m.attr("PLAY_OVERRIDE_PATH") = PLAY_OVERRIDE_PATH;

src/software/stats/loggers/BUILD

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
py_library(
4+
name = "stats_logger",
5+
srcs = ["stats_logger.py"],
6+
deps = [
7+
"//software/stats/trackers:tracker",
8+
],
9+
)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import os
2+
3+
from software.stats.trackers import (
4+
PossessionTracker,
5+
ShotTracker,
6+
PassTracker,
7+
TrackerBuilder,
8+
RefereeTracker,
9+
GoalieTracker,
10+
)
11+
from software.thunderscope.proto_unix_io import ProtoUnixIO
12+
from software.thunderscope.constants import RuntimeManagerConstants
13+
from software.stats.logs.event_log import EventLog
14+
import logging
15+
from proto.import_all_protos import *
16+
import queue
17+
from proto.ssl_gc_common_pb2 import Team
18+
19+
20+
class StatsLogger:
21+
# From GoalieTacticConfig
22+
INCOMING_SHOT_MIN_VELOCITY = 0.2
23+
24+
EVENT_BUFFER_SIZE = 100
25+
26+
def __init__(
27+
self,
28+
proto_unix_io: ProtoUnixIO,
29+
friendly_colour_yellow: bool,
30+
out_file_name: str | None = None,
31+
buffer_size: int = 5,
32+
record_enemy_stats: bool = False,
33+
):
34+
"""Initializes the FullSystem Stats Tracker
35+
36+
:param friendly_colour_yellow: if the friendly colour is yellow
37+
:param out_file_name: name of file to write stats to.
38+
If None, uses the value from constants
39+
:param buffer_size: the buffer size for protocol buffers
40+
:param record_enemy_stats: if this should record both friendly and enemy stats or just friendly
41+
"""
42+
self.friendly_colour_yellow = friendly_colour_yellow
43+
44+
self.events_file_path = os.path.join(
45+
RuntimeManagerConstants.RUNTIME_EVENTS_DIRECTORY_PATH,
46+
RuntimeManagerConstants.RUNTIME_EVENTS_FILE
47+
if out_file_name is None
48+
else out_file_name,
49+
)
50+
# initialized in setup()
51+
self.events_file_handle = None
52+
53+
self.event_queue = queue.Queue(self.EVENT_BUFFER_SIZE)
54+
55+
# flag to turn off logging stats if needed
56+
self.logging_enabled = True
57+
58+
self.tracker = (
59+
TrackerBuilder(
60+
proto_unix_io=proto_unix_io,
61+
from_team=(Team.YELLOW if self.friendly_colour_yellow else Team.BLUE),
62+
event_queue=self.event_queue,
63+
buffer_size=buffer_size,
64+
)
65+
.add_tracker(PassTracker)
66+
.add_tracker(ShotTracker)
67+
.add_tracker(PossessionTracker)
68+
.add_tracker(
69+
RefereeTracker,
70+
friendly_color_yellow=self.friendly_colour_yellow,
71+
toggle_logging=self._toggle_logging,
72+
)
73+
.add_tracker(GoalieTracker, for_friendly=True)
74+
)
75+
76+
self.record_enemy_stats = record_enemy_stats
77+
if self.record_enemy_stats:
78+
self.enemy_tracker = (
79+
TrackerBuilder(
80+
proto_unix_io=proto_unix_io,
81+
from_team=(
82+
Team.YELLOW if self.friendly_colour_yellow else Team.BLUE
83+
),
84+
for_team=(
85+
Team.BLUE if self.friendly_colour_yellow else Team.YELLOW
86+
),
87+
event_queue=self.event_queue,
88+
buffer_size=buffer_size,
89+
)
90+
.add_tracker(
91+
RefereeTracker,
92+
friendly_color_yellow=(not self.friendly_colour_yellow),
93+
toggle_logging=self._toggle_logging,
94+
)
95+
.add_tracker(GoalieTracker, for_friendly=False)
96+
)
97+
98+
def refresh(self) -> None:
99+
"""Refreshes the events for the game so far"""
100+
self.tracker.refresh()
101+
102+
if not self.events_file_handle:
103+
return
104+
105+
while not self.event_queue.empty():
106+
try:
107+
# Get item without blocking
108+
event = self.event_queue.get_nowait()
109+
110+
self._write_event_to_file(event)
111+
except queue.Empty:
112+
return
113+
114+
def __enter__(self):
115+
"""Sets up the file resources for logging
116+
Creates any missing directories and stores the file handle
117+
"""
118+
# create temp stats directory if it doesn't exist
119+
os.makedirs(os.path.dirname(self.events_file_path), exist_ok=True)
120+
121+
self.events_file_handle = open(self.events_file_path, "a")
122+
123+
return self
124+
125+
def __exit__(self, exc_type, exc_value, traceback):
126+
"""Writes all logs back to file, and cleans up any created file resources after logging"""
127+
if self.events_file_handle:
128+
self.events_file_handle.flush()
129+
self.events_file_handle.close()
130+
131+
def _toggle_logging(self, should_log: bool) -> None:
132+
"""Turns logging off or on based on the given boolean
133+
134+
;param should_log: True if logging should continue, False if not
135+
"""
136+
self.logging_enabled = should_log
137+
138+
def _write_event_to_file(self, event: EventLog) -> None:
139+
"""Write the given stats to the given file
140+
141+
:param event: the event to write
142+
"""
143+
if not self.events_file_handle:
144+
return
145+
146+
try:
147+
csv_row = event.to_csv_row()
148+
self.events_file_handle.write(csv_row + "\n")
149+
self.events_file_handle.flush()
150+
151+
except (IOError, FileNotFoundError, PermissionError):
152+
logging.warning("Failed to write event to file")

src/software/stats/logs/BUILD

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package(default_visibility = ["//visibility:public"])
2+
3+
py_library(
4+
name = "log_interface",
5+
srcs = ["log_interface.py"],
6+
deps = [
7+
"//proto:import_all_protos",
8+
"//software/thunderscope:time_provider",
9+
],
10+
)
11+
12+
py_library(
13+
name = "type_utils",
14+
srcs = ["type_utils.py"],
15+
deps = [],
16+
)
17+
18+
py_library(
19+
name = "world_state_log",
20+
srcs = ["world_state_log.py"],
21+
data = [
22+
"//software:py_constants.so",
23+
],
24+
deps = [
25+
":log_interface",
26+
":type_utils",
27+
],
28+
)
29+
30+
py_library(
31+
name = "event_log",
32+
srcs = ["event_log.py"],
33+
deps = [
34+
":log_interface",
35+
":world_state_log",
36+
],
37+
)
38+
39+
py_library(
40+
name = "logs",
41+
deps = [
42+
":event_log",
43+
":log_interface",
44+
":world_state_log",
45+
],
46+
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
from dataclasses import dataclass
3+
from enum import StrEnum, auto
4+
from proto.import_all_protos import *
5+
from proto.ssl_gc_common_pb2 import Team
6+
from typing import Any, override
7+
from software.stats.logs.log_interface import TimestampedEvalLog
8+
from software.stats.logs.world_state_log import WorldStateLog
9+
10+
11+
class EventType(StrEnum):
12+
"""Enum for the different types of events we want to track"""
13+
14+
PASS = auto()
15+
SHOT_ON_GOAL = auto()
16+
ENEMY_SHOT_ON_GOAL = auto()
17+
SHOT_BLOCKED = auto()
18+
FRIENDLY_POSSESSION_START = auto()
19+
FRIENDLY_POSSESSION_END = auto()
20+
ENEMY_POSSESSION_START = auto()
21+
ENEMY_POSSESSION_END = auto()
22+
GAME_START = auto()
23+
GAME_END = auto()
24+
GOAL_SCORED = auto()
25+
YELLOW_CARD = auto()
26+
RED_CARD = auto()
27+
28+
29+
@dataclass(kw_only=True)
30+
class EventLog(TimestampedEvalLog):
31+
"""Represents a single event being tracked, where and for whom the event is,
32+
and the game state at the time of the event
33+
"""
34+
35+
event_type: EventType
36+
from_team: Team
37+
for_team: Team
38+
world_state_log: WorldStateLog
39+
40+
num_cols = TimestampedEvalLog.get_num_cols() + 3 + WorldStateLog.get_num_cols()
41+
42+
@staticmethod
43+
def from_world(
44+
world_msg: World, event_type: EventType, from_team: Team, for_team: Team
45+
) -> EventLog:
46+
"""Creates an EventLog from a world protobuf message
47+
48+
:param world_msg: the world object containing the state of the game
49+
:param event_type: the type of event being recorded
50+
:param from_team: the team that the event is coming from
51+
:param for_team: the team that the event is for
52+
:return: a fully populated EventLog including world state
53+
"""
54+
world_state_log = WorldStateLog.from_world(world_msg=world_msg)
55+
56+
return EventLog(
57+
event_type=event_type,
58+
from_team=from_team,
59+
for_team=for_team,
60+
world_state_log=world_state_log,
61+
)
62+
63+
@override
64+
@classmethod
65+
def get_num_cols(cls) -> int:
66+
return EventLog.num_cols
67+
68+
@override
69+
def to_array(self) -> list[Any]:
70+
return (
71+
super().to_array()
72+
+ [
73+
self.event_type.value,
74+
self.from_team,
75+
self.for_team,
76+
]
77+
+ self.world_state_log.to_array()
78+
)
79+
80+
@staticmethod
81+
@override
82+
def from_csv_row(row_iter: Iterator[str]) -> EventLog | None:
83+
"""Parses a full CSV row into an EventLog."""
84+
timestamp = float(next(row_iter))
85+
86+
event_type = EventType(next(row_iter))
87+
from_team = int(next(row_iter))
88+
for_team = int(next(row_iter))
89+
90+
world_state = WorldStateLog.from_csv_row(row_iter)
91+
92+
if not world_state:
93+
return None
94+
95+
return EventLog(
96+
timestamp=timestamp,
97+
event_type=event_type,
98+
from_team=from_team,
99+
for_team=for_team,
100+
world_state_log=world_state,
101+
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
from abc import abstractmethod, ABC
3+
from dataclasses import dataclass, field
4+
from proto.import_all_protos import *
5+
from typing import Iterator, Any, override
6+
from software.thunderscope.time_provider import time_provider_instance
7+
8+
9+
class IEvalLog(ABC):
10+
@classmethod
11+
@abstractmethod
12+
def get_num_cols(cls) -> int:
13+
"""Gets the number of columns present in this log"""
14+
raise NotImplementedError("Please use the appropriate subclass of log!")
15+
16+
@abstractmethod
17+
def to_array(self) -> list[Any]:
18+
"""Converts this log to an array of elements"""
19+
raise NotImplementedError("Please use the appropriate subclass of log!")
20+
21+
def to_csv_row(self):
22+
"""Converts this log into a Comma Separated Values string
23+
24+
:return: a string of values separated by commas
25+
"""
26+
row_array = self.to_array()
27+
assert len(row_array) == type(self).get_num_cols()
28+
29+
return ",".join([str(elem) for elem in row_array])
30+
31+
@staticmethod
32+
@abstractmethod
33+
def from_csv_row(row_iter: Iterator[str], **kwargs) -> IEvalLog | None:
34+
"""Converts a CSV row into an instance of this log
35+
36+
:param row_iter: an iterator representing a csv row, which returns elements one by one
37+
:param **kwargs: any extra arguments needed for this log not present in the csv row
38+
"""
39+
raise NotImplementedError("Please use the appropriate subclass of log!")
40+
41+
42+
@dataclass
43+
class TimestampedEvalLog(IEvalLog):
44+
timestamp: float = field(default_factory=time_provider_instance.elapsed_time_ns)
45+
46+
def get_timestamp(self) -> float:
47+
"""Get this log's timestamp"""
48+
return self.timestamp
49+
50+
@classmethod
51+
@override
52+
def get_num_cols(cls) -> int:
53+
return 1
54+
55+
@override
56+
def to_array(self) -> list[Any]:
57+
return [self.timestamp]
58+
59+
@staticmethod
60+
@override
61+
def from_csv_row(row_iter: Iterator[str], **kwargs) -> TimestampedEvalLog | None:
62+
return TimestampedEvalLog(timestamp=float(next(row_iter)))

0 commit comments

Comments
 (0)