From 8dcd082eb26e28c6e1aeafdf651e73c6fc6f0396 Mon Sep 17 00:00:00 2001 From: StarrryNight Date: Sun, 1 Mar 2026 16:17:38 -0800 Subject: [PATCH 1/4] change record stat flag functionality, add record stat duration flag, and prohibit robot removal in record stat mode --- .../game_controller.py | 6 ++- .../binary_context_managers/tigers_autoref.py | 5 ++- .../thunderscope/thunderscope_main.py | 42 +++++++++++++++---- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/software/thunderscope/binary_context_managers/game_controller.py b/src/software/thunderscope/binary_context_managers/game_controller.py index acd9fd556a..f7e4a03fa4 100644 --- a/src/software/thunderscope/binary_context_managers/game_controller.py +++ b/src/software/thunderscope/binary_context_managers/game_controller.py @@ -37,6 +37,7 @@ def __init__( self, suppress_logs: bool = False, use_conventional_port: bool = False, + is_recording_stats: bool = False ) -> None: """Run Gamecontroller @@ -44,6 +45,7 @@ def __init__( :param use_conventional_port: whether or not to use the conventional port! """ self.suppress_logs = suppress_logs + self.is_recording_stats = is_recording_stats # We default to using a non-conventional port to avoid emitting # on the same port as what other teams may be listening on. @@ -192,8 +194,8 @@ def handle_referee(self, referee: Referee) -> None: block=False, return_cached=True ) - max_allowed_bots_yellow: int = referee.yellow.max_allowed_bots - max_allowed_bots_blue: int = referee.blue.max_allowed_bots + max_allowed_bots_yellow: int = 6 if self.is_recording_stats else referee.yellow.max_allowed_bots + max_allowed_bots_blue: int = 6 if self.is_recording_stats else referee.blue.max_allowed_bots # Ignore if nothing needs to be updated if ( len(self.latest_world.friendly_team.team_robots) == max_allowed_bots_blue diff --git a/src/software/thunderscope/binary_context_managers/tigers_autoref.py b/src/software/thunderscope/binary_context_managers/tigers_autoref.py index 776d9cc960..4c853f90d2 100644 --- a/src/software/thunderscope/binary_context_managers/tigers_autoref.py +++ b/src/software/thunderscope/binary_context_managers/tigers_autoref.py @@ -52,6 +52,7 @@ def __init__( buffer_size: int = 5, suppress_logs: bool = True, show_gui: bool = False, + record_stats: bool = False, ) -> None: """Constructor @@ -62,6 +63,7 @@ def __init__( :param buffer_size: buffer size for the SSL wrapper and referee packets :param suppress_logs: true silences logs from the Autoref binary, otherwise shows them (its very verbose) :param show_gui: true shows the Tigers' autoref GUI, false runs it in headless mode + :param record_stats: true if stats recording mode is enabled """ self.tigers_autoref_proc = None self.auto_ref_proc_thread = None @@ -74,6 +76,7 @@ def __init__( self.suppress_logs = suppress_logs self.tick_rate_ms = tick_rate_ms self.show_gui = show_gui + self.record_stats = record_stats self.current_timestamp = int(time.time_ns()) self.timestamp_mutex = threading.Lock() @@ -236,7 +239,7 @@ def _start_autoref(self) -> None: if not self.show_gui: autoref_cmd += " -hl" - if self.ci_mode: + if self.ci_mode or self.record_stats: autoref_cmd += " --ci" if self.suppress_logs: diff --git a/src/software/thunderscope/thunderscope_main.py b/src/software/thunderscope/thunderscope_main.py index a0230cc6d4..490a8cdaa4 100644 --- a/src/software/thunderscope/thunderscope_main.py +++ b/src/software/thunderscope/thunderscope_main.py @@ -209,6 +209,12 @@ default=False, help="Run unix_full_system under sudo", ) + parser.add_argument( + "--record_stats_duration", + type=int, + default = 60, + help="Run unix_full_system under sudo", + ) estop_group = parser.add_mutually_exclusive_group() estop_group.add_argument( @@ -343,7 +349,7 @@ with ( Gamecontroller( - suppress_logs=(not args.verbose), use_conventional_port=False + suppress_logs=(not args.verbose), use_conventional_port=False, is_recording_stats=args.record_stats ) if args.launch_gc else contextlib.nullcontext() @@ -433,7 +439,7 @@ def __ticker(tick_rate_ms: int) -> None: 0 if args.empty else DIV_B_NUM_ROBOTS, ) - if args.ci_mode: + if args.ci_mode or args.record_stats: async_sim_ticker( tick_rate_ms, tscope.proto_unix_io_map[ProtoUnixIOTypes.BLUE], @@ -459,7 +465,7 @@ def __ticker(tick_rate_ms: int) -> None: friendly_colour_yellow=False, should_restart_on_crash=False, run_sudo=args.sudo, - running_in_realtime=(not args.ci_mode), + running_in_realtime=(not args.ci_mode and not args.record_stats), log_level=args.log_level, ) as blue_fs, FullSystem( path_to_binary=runtime_config.get_yellow_runtime_path(), @@ -468,10 +474,10 @@ def __ticker(tick_rate_ms: int) -> None: friendly_colour_yellow=True, should_restart_on_crash=False, run_sudo=args.sudo, - running_in_realtime=(not args.ci_mode), + running_in_realtime=(not args.ci_mode and not args.record_stats), log_level=args.log_level, ) as yellow_fs, Gamecontroller( - suppress_logs=(not args.verbose), + suppress_logs=(not args.verbose), is_recording_stats=args.record_stats ) as gamecontroller, ( # Here we only initialize autoref if the --enable_autoref flag is requested. # To avoid nested Python withs, the autoref is initialized as None when this flag doesn't exist. @@ -482,8 +488,9 @@ def __ticker(tick_rate_ms: int) -> None: suppress_logs=(not args.verbose), tick_rate_ms=DEFAULT_SIMULATOR_TICK_RATE_MILLISECONDS_PER_TICK, show_gui=args.show_autoref_gui, + record_stats=args.record_stats ) - if args.enable_autoref + if args.enable_autoref or args.record_stats else contextlib.nullcontext() ) as autoref, ( Stats( @@ -527,7 +534,7 @@ def __ticker(tick_rate_ms: int) -> None: autoref_proto_unix_io=autoref_proto_unix_io, simulator_proto_unix_io=tscope.proto_unix_io_map[ProtoUnixIOTypes.SIM], ) - if args.enable_autoref: + if args.enable_autoref or args.record_stats: autoref.setup_ssl_wrapper_packets( autoref_proto_unix_io, ) @@ -559,6 +566,27 @@ def __ticker(tick_rate_ms: int) -> None: exiter_thread.join() sim_ticker_thread.join() + sys.exit(0) + elif args.record_stats: + # In CI mode, we want AI vs AI to end automatically after a given time (CI_DURATION_S). The exiter + # thread is passed an exit handler that will close the Thunderscope window + # This exit handler is necessary because Qt runs on the main thread, so tscope.show() is a blocking + # call so we need to somehow close it before doing our resource cleanup + exiter_thread = threading.Thread( + target=exit_poller, + args=(autoref, args.record_stats_duration, lambda: tscope.close()), + daemon=True, + ) + + exiter_thread.start() # start the exit countdown + sim_ticker_thread.start() # start the simulation ticking + + tscope.show() # blocking! + + # these resource cleanups occur after tscope.close() is called by the exiter_thread + exiter_thread.join() + sim_ticker_thread.join() + sys.exit(0) else: sim_ticker_thread.start() # start the simulation ticking From e6c43ac63ba00fca2c4f4abf8229e7df2fb382e0 Mon Sep 17 00:00:00 2001 From: StarrryNight Date: Sun, 1 Mar 2026 16:39:04 -0800 Subject: [PATCH 2/4] use the UpdateConfig proto to add max number of robots by number of red cards instead --- .../game_controller.py | 32 ++++++++++++++++--- .../thunderscope/thunderscope_main.py | 4 +-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/software/thunderscope/binary_context_managers/game_controller.py b/src/software/thunderscope/binary_context_managers/game_controller.py index f7e4a03fa4..1cbdf83da3 100644 --- a/src/software/thunderscope/binary_context_managers/game_controller.py +++ b/src/software/thunderscope/binary_context_managers/game_controller.py @@ -37,7 +37,7 @@ def __init__( self, suppress_logs: bool = False, use_conventional_port: bool = False, - is_recording_stats: bool = False + record_stats: bool = False ) -> None: """Run Gamecontroller @@ -45,7 +45,7 @@ def __init__( :param use_conventional_port: whether or not to use the conventional port! """ self.suppress_logs = suppress_logs - self.is_recording_stats = is_recording_stats + self.record_stats = record_stats # We default to using a non-conventional port to avoid emitting # on the same port as what other teams may be listening on. @@ -70,6 +70,7 @@ def __init__( self.latest_world = None self.blue_removed_robot_ids = queue.Queue() self.yellow_removed_robot_ids = queue.Queue() + self._max_robots_per_team = 6 # Tracks current max sent to SSL GC (for record_stats red card compensation) def get_referee_port(self) -> int: """Sometimes, the port that we are using changes depending on context. @@ -194,8 +195,17 @@ def handle_referee(self, referee: Referee) -> None: block=False, return_cached=True ) - max_allowed_bots_yellow: int = 6 if self.is_recording_stats else referee.yellow.max_allowed_bots - max_allowed_bots_blue: int = 6 if self.is_recording_stats else referee.blue.max_allowed_bots + max_allowed_bots_yellow: int = 6 if self.record_stats else referee.yellow.max_allowed_bots + max_allowed_bots_blue: int = 6 if self.record_stats else referee.blue.max_allowed_bots + + if self.record_stats: + # The SSL GC computes effective_max = max_robots_per_team - red_cards. + # Compensate so the GC's effective limit stays at 6 and it doesn't force halt. + max_red_cards = max(referee.blue.red_cards, referee.yellow.red_cards) + needed = 6 + max_red_cards + if needed != self._max_robots_per_team: + self._send_max_robots_update(needed) + self._max_robots_per_team = needed # Ignore if nothing needs to be updated if ( len(self.latest_world.friendly_team.team_robots) == max_allowed_bots_blue @@ -318,6 +328,20 @@ def __send_referee_command(data: Referee) -> None: ) self.simulator_proto_unix_io = simulator_proto_unix_io + def _send_max_robots_update(self, max_robots: int) -> None: + """Send UpdateConfig to the SSL GC to set max_robots_per_team. + Used in record_stats mode to compensate for red cards so the GC doesn't force halt. + + :param max_robots: the new max_robots_per_team value to send + """ + ci_input = CiInput(timestamp=int(time.time_ns())) + update_config = Change.UpdateConfig() + update_config.max_robots_per_team.value = max_robots + api_input = Input() + api_input.change.update_config_change.CopyFrom(update_config) + ci_input.api_inputs.append(api_input) + self.send_ci_input(ci_input) + def send_gc_command( self, gc_command: proto.ssl_gc_state_pb2.Command, diff --git a/src/software/thunderscope/thunderscope_main.py b/src/software/thunderscope/thunderscope_main.py index 490a8cdaa4..f20ac6e992 100644 --- a/src/software/thunderscope/thunderscope_main.py +++ b/src/software/thunderscope/thunderscope_main.py @@ -349,7 +349,7 @@ with ( Gamecontroller( - suppress_logs=(not args.verbose), use_conventional_port=False, is_recording_stats=args.record_stats + suppress_logs=(not args.verbose), use_conventional_port=False, record_stats=args.record_stats ) if args.launch_gc else contextlib.nullcontext() @@ -477,7 +477,7 @@ def __ticker(tick_rate_ms: int) -> None: running_in_realtime=(not args.ci_mode and not args.record_stats), log_level=args.log_level, ) as yellow_fs, Gamecontroller( - suppress_logs=(not args.verbose), is_recording_stats=args.record_stats + suppress_logs=(not args.verbose), record_stats=args.record_stats ) as gamecontroller, ( # Here we only initialize autoref if the --enable_autoref flag is requested. # To avoid nested Python withs, the autoref is initialized as None when this flag doesn't exist. From a1f882a46bff0a9d04ea85bff31413ca48e5424b Mon Sep 17 00:00:00 2001 From: StarrryNight Date: Sun, 1 Mar 2026 16:39:04 -0800 Subject: [PATCH 3/4] use the UpdateConfig proto to send max number of robots + red cards to game controller to compensate for the red card --- .../game_controller.py | 32 ++++++++++++++++--- .../thunderscope/thunderscope_main.py | 4 +-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/software/thunderscope/binary_context_managers/game_controller.py b/src/software/thunderscope/binary_context_managers/game_controller.py index f7e4a03fa4..1cbdf83da3 100644 --- a/src/software/thunderscope/binary_context_managers/game_controller.py +++ b/src/software/thunderscope/binary_context_managers/game_controller.py @@ -37,7 +37,7 @@ def __init__( self, suppress_logs: bool = False, use_conventional_port: bool = False, - is_recording_stats: bool = False + record_stats: bool = False ) -> None: """Run Gamecontroller @@ -45,7 +45,7 @@ def __init__( :param use_conventional_port: whether or not to use the conventional port! """ self.suppress_logs = suppress_logs - self.is_recording_stats = is_recording_stats + self.record_stats = record_stats # We default to using a non-conventional port to avoid emitting # on the same port as what other teams may be listening on. @@ -70,6 +70,7 @@ def __init__( self.latest_world = None self.blue_removed_robot_ids = queue.Queue() self.yellow_removed_robot_ids = queue.Queue() + self._max_robots_per_team = 6 # Tracks current max sent to SSL GC (for record_stats red card compensation) def get_referee_port(self) -> int: """Sometimes, the port that we are using changes depending on context. @@ -194,8 +195,17 @@ def handle_referee(self, referee: Referee) -> None: block=False, return_cached=True ) - max_allowed_bots_yellow: int = 6 if self.is_recording_stats else referee.yellow.max_allowed_bots - max_allowed_bots_blue: int = 6 if self.is_recording_stats else referee.blue.max_allowed_bots + max_allowed_bots_yellow: int = 6 if self.record_stats else referee.yellow.max_allowed_bots + max_allowed_bots_blue: int = 6 if self.record_stats else referee.blue.max_allowed_bots + + if self.record_stats: + # The SSL GC computes effective_max = max_robots_per_team - red_cards. + # Compensate so the GC's effective limit stays at 6 and it doesn't force halt. + max_red_cards = max(referee.blue.red_cards, referee.yellow.red_cards) + needed = 6 + max_red_cards + if needed != self._max_robots_per_team: + self._send_max_robots_update(needed) + self._max_robots_per_team = needed # Ignore if nothing needs to be updated if ( len(self.latest_world.friendly_team.team_robots) == max_allowed_bots_blue @@ -318,6 +328,20 @@ def __send_referee_command(data: Referee) -> None: ) self.simulator_proto_unix_io = simulator_proto_unix_io + def _send_max_robots_update(self, max_robots: int) -> None: + """Send UpdateConfig to the SSL GC to set max_robots_per_team. + Used in record_stats mode to compensate for red cards so the GC doesn't force halt. + + :param max_robots: the new max_robots_per_team value to send + """ + ci_input = CiInput(timestamp=int(time.time_ns())) + update_config = Change.UpdateConfig() + update_config.max_robots_per_team.value = max_robots + api_input = Input() + api_input.change.update_config_change.CopyFrom(update_config) + ci_input.api_inputs.append(api_input) + self.send_ci_input(ci_input) + def send_gc_command( self, gc_command: proto.ssl_gc_state_pb2.Command, diff --git a/src/software/thunderscope/thunderscope_main.py b/src/software/thunderscope/thunderscope_main.py index 490a8cdaa4..f20ac6e992 100644 --- a/src/software/thunderscope/thunderscope_main.py +++ b/src/software/thunderscope/thunderscope_main.py @@ -349,7 +349,7 @@ with ( Gamecontroller( - suppress_logs=(not args.verbose), use_conventional_port=False, is_recording_stats=args.record_stats + suppress_logs=(not args.verbose), use_conventional_port=False, record_stats=args.record_stats ) if args.launch_gc else contextlib.nullcontext() @@ -477,7 +477,7 @@ def __ticker(tick_rate_ms: int) -> None: running_in_realtime=(not args.ci_mode and not args.record_stats), log_level=args.log_level, ) as yellow_fs, Gamecontroller( - suppress_logs=(not args.verbose), is_recording_stats=args.record_stats + suppress_logs=(not args.verbose), record_stats=args.record_stats ) as gamecontroller, ( # Here we only initialize autoref if the --enable_autoref flag is requested. # To avoid nested Python withs, the autoref is initialized as None when this flag doesn't exist. From 676a4b833d9e3113d13b78ababb1f990db59edac Mon Sep 17 00:00:00 2001 From: StarrryNight Date: Sun, 1 Mar 2026 17:10:29 -0800 Subject: [PATCH 4/4] add comments and change flag name --- src/software/thunderscope/thunderscope_main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/software/thunderscope/thunderscope_main.py b/src/software/thunderscope/thunderscope_main.py index f20ac6e992..1f034892d7 100644 --- a/src/software/thunderscope/thunderscope_main.py +++ b/src/software/thunderscope/thunderscope_main.py @@ -210,7 +210,7 @@ help="Run unix_full_system under sudo", ) parser.add_argument( - "--record_stats_duration", + "--record_stats_duration_s", type=int, default = 60, help="Run unix_full_system under sudo", @@ -479,7 +479,7 @@ def __ticker(tick_rate_ms: int) -> None: ) as yellow_fs, Gamecontroller( suppress_logs=(not args.verbose), record_stats=args.record_stats ) as gamecontroller, ( - # Here we only initialize autoref if the --enable_autoref flag is requested. + # Here we only initialize autoref if the --enable_autoref flag is requested, or when we are recording stats. # To avoid nested Python withs, the autoref is initialized as None when this flag doesn't exist. # All calls to autoref should be guarded with args.enable_autoref TigersAutoref( @@ -568,13 +568,13 @@ def __ticker(tick_rate_ms: int) -> None: sys.exit(0) elif args.record_stats: - # In CI mode, we want AI vs AI to end automatically after a given time (CI_DURATION_S). The exiter + # In record stats mode mode, we want AI vs AI to end automatically after a given time (args.record_stats_duration_s). The exiter # thread is passed an exit handler that will close the Thunderscope window # This exit handler is necessary because Qt runs on the main thread, so tscope.show() is a blocking # call so we need to somehow close it before doing our resource cleanup exiter_thread = threading.Thread( target=exit_poller, - args=(autoref, args.record_stats_duration, lambda: tscope.close()), + args=(autoref, args.record_stats_duration_s, lambda: tscope.close()), daemon=True, )