|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""A script to run a competition match.""" |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import argparse |
| 6 | +import json |
| 7 | +import os |
| 8 | +import shutil |
| 9 | +import subprocess |
| 10 | +import sys |
| 11 | +from pathlib import Path |
| 12 | +from tempfile import TemporaryDirectory |
| 13 | +from zipfile import ZipFile |
| 14 | + |
| 15 | +if (Path(__file__).parents[1] / 'simulator/VERSION').exists(): |
| 16 | + # Running in release mode, run_simulator will be in folder above |
| 17 | + sys.path.append(str(Path(__file__).parents[1])) |
| 18 | + |
| 19 | +from run_simulator import get_webots_parameters |
| 20 | + |
| 21 | +NUM_ZONES = 4 |
| 22 | +GAME_DURATION_SECONDS = 150 |
| 23 | + |
| 24 | + |
| 25 | +class MatchParams(argparse.Namespace): |
| 26 | + """Parameters for running a competition match.""" |
| 27 | + |
| 28 | + archives_dir: Path |
| 29 | + match_num: int |
| 30 | + teams: list[str] |
| 31 | + duration: int |
| 32 | + video_enabled: bool |
| 33 | + video_resolution: tuple[int, int] |
| 34 | + |
| 35 | + |
| 36 | +def load_team_code( |
| 37 | + usercode_dir: Path, |
| 38 | + arena_root: Path, |
| 39 | + match_parameters: MatchParams, |
| 40 | +) -> None: |
| 41 | + """Load the team code into the arena root.""" |
| 42 | + for zone_id, tla in enumerate(match_parameters.teams): |
| 43 | + zone_path = arena_root / f"zone_{zone_id}" |
| 44 | + |
| 45 | + if zone_path.exists(): |
| 46 | + shutil.rmtree(zone_path) |
| 47 | + |
| 48 | + if tla == '-': |
| 49 | + # no team in this zone |
| 50 | + continue |
| 51 | + |
| 52 | + zone_path.mkdir() |
| 53 | + with ZipFile(usercode_dir / f'{tla}.zip') as zipfile: |
| 54 | + zipfile.extractall(zone_path) |
| 55 | + |
| 56 | + |
| 57 | +def generate_match_file(save_path: Path, match_parameters: MatchParams) -> None: |
| 58 | + """Write the match file to the arena root.""" |
| 59 | + match_file = save_path / 'match.json' |
| 60 | + |
| 61 | + # Use a format that is compatible with SRComp |
| 62 | + match_file.write_text(json.dumps( |
| 63 | + { |
| 64 | + 'match_number': match_parameters.match_num, |
| 65 | + 'arena_id': 'Simulator', |
| 66 | + 'teams': { |
| 67 | + tla: {'zone': idx} |
| 68 | + for idx, tla in enumerate(match_parameters.teams) |
| 69 | + if tla != '-' |
| 70 | + }, |
| 71 | + 'duration': match_parameters.duration, |
| 72 | + 'recording_config': { |
| 73 | + 'enabled': match_parameters.video_enabled, |
| 74 | + 'resolution': list(match_parameters.video_resolution) |
| 75 | + } |
| 76 | + }, |
| 77 | + indent=4, |
| 78 | + )) |
| 79 | + |
| 80 | + |
| 81 | +def set_comp_mode(arena_root: Path) -> None: |
| 82 | + """Write the mode file to indicate that the competition is running.""" |
| 83 | + (arena_root / 'mode.txt').write_text('comp') |
| 84 | + |
| 85 | + |
| 86 | +def archive_zone_files( |
| 87 | + team_archives_dir: Path, |
| 88 | + arena_root: Path, |
| 89 | + zone: int, |
| 90 | + match_id: str, |
| 91 | +) -> None: |
| 92 | + """Zip the files in the zone directory and save them to the team archives directory.""" |
| 93 | + zone_dir = arena_root / f'zone_{zone}' |
| 94 | + |
| 95 | + shutil.make_archive(str(team_archives_dir / f'{match_id}-zone-{zone}'), 'zip', zone_dir) |
| 96 | + |
| 97 | + |
| 98 | +def archive_zone_folders( |
| 99 | + archives_dir: Path, |
| 100 | + arena_root: Path, |
| 101 | + teams: list[str], |
| 102 | + match_id: str, |
| 103 | +) -> None: |
| 104 | + """Zip the zone folders and save them to the archives directory.""" |
| 105 | + for zone_id, tla in enumerate(teams): |
| 106 | + if tla == '-': |
| 107 | + # no team in this zone |
| 108 | + continue |
| 109 | + |
| 110 | + tla_dir = archives_dir / tla |
| 111 | + tla_dir.mkdir(exist_ok=True) |
| 112 | + |
| 113 | + archive_zone_files(tla_dir, arena_root, zone_id, match_id) |
| 114 | + |
| 115 | + |
| 116 | +def archive_match_recordings(archives_dir: Path, arena_root: Path, match_id: str) -> None: |
| 117 | + """Copy the video, animation, and image files to the archives directory.""" |
| 118 | + recordings_dir = archives_dir / 'recordings' |
| 119 | + recordings_dir.mkdir(exist_ok=True) |
| 120 | + |
| 121 | + match_recordings = arena_root / 'recordings' |
| 122 | + |
| 123 | + # Copy the video file |
| 124 | + video_file = match_recordings / f'{match_id}.mp4' |
| 125 | + if video_file.exists(): |
| 126 | + shutil.copy(video_file, recordings_dir) |
| 127 | + |
| 128 | + # Copy the animation files |
| 129 | + animation_files = [ |
| 130 | + match_recordings / f'{match_id}.html', |
| 131 | + match_recordings / f'{match_id}.json', |
| 132 | + match_recordings / f'{match_id}.x3d', |
| 133 | + match_recordings / f'{match_id}.css', |
| 134 | + ] |
| 135 | + for animation_file in animation_files: |
| 136 | + shutil.copy(animation_file, recordings_dir) |
| 137 | + |
| 138 | + # Copy the animation textures |
| 139 | + # Every match will have the same textures, so we only need one copy of them |
| 140 | + textures_dir = match_recordings / 'textures' |
| 141 | + shutil.copytree(textures_dir, recordings_dir / 'textures', dirs_exist_ok=True) |
| 142 | + |
| 143 | + # Copy the image file |
| 144 | + image_file = match_recordings / f'{match_id}.jpg' |
| 145 | + shutil.copy(image_file, recordings_dir) |
| 146 | + |
| 147 | + |
| 148 | +def archive_match_file(archives_dir: Path, match_file: Path, match_number: int) -> None: |
| 149 | + """ |
| 150 | + Copy the match file (which may contain scoring data) to the archives directory. |
| 151 | +
|
| 152 | + This also renames the file to be compatible with SRComp. |
| 153 | + """ |
| 154 | + matches_dir = archives_dir / 'matches' |
| 155 | + matches_dir.mkdir(exist_ok=True) |
| 156 | + |
| 157 | + # SRComp expects YAML files. JSON is a subset of YAML, so we can just rename the file. |
| 158 | + completed_match_file = matches_dir / f'{match_number:0>3}.yaml' |
| 159 | + |
| 160 | + shutil.copy(match_file, completed_match_file) |
| 161 | + |
| 162 | + |
| 163 | +def archive_supervisor_log(archives_dir: Path, arena_root: Path, match_id: str) -> None: |
| 164 | + """Archive the supervisor log file.""" |
| 165 | + log_archive_dir = archives_dir / 'supervisor_logs' |
| 166 | + log_archive_dir.mkdir(exist_ok=True) |
| 167 | + |
| 168 | + log_file = arena_root / f'supervisor-log-{match_id}.txt' |
| 169 | + |
| 170 | + shutil.copy(log_file, log_archive_dir) |
| 171 | + |
| 172 | + |
| 173 | +def execute_match(arena_root: Path) -> None: |
| 174 | + """Run Webots with the right world.""" |
| 175 | + # Webots is only on the PATH on Linux so we have a helper function to find it |
| 176 | + try: |
| 177 | + webots, world_file = get_webots_parameters() |
| 178 | + except RuntimeError: |
| 179 | + raise FileNotFoundError("Webots executable not found.") |
| 180 | + |
| 181 | + sim_env = os.environ.copy() |
| 182 | + sim_env['ARENA_ROOT'] = str(arena_root) |
| 183 | + try: |
| 184 | + subprocess.check_call( |
| 185 | + [ |
| 186 | + str(webots), |
| 187 | + '--batch', |
| 188 | + '--stdout', |
| 189 | + '--stderr', |
| 190 | + '--mode=realtime', |
| 191 | + str(world_file), |
| 192 | + ], |
| 193 | + env=sim_env, |
| 194 | + ) |
| 195 | + except subprocess.CalledProcessError as e: |
| 196 | + # TODO review log output here |
| 197 | + raise RuntimeError(f"Webots failed with return code {e.returncode}") from e |
| 198 | + |
| 199 | + |
| 200 | +def run_match(match_parameters: MatchParams) -> None: |
| 201 | + """Run the match in a temporary directory and archive the results.""" |
| 202 | + with TemporaryDirectory(suffix=f'match-{match_parameters.match_num}') as temp_folder: |
| 203 | + arena_root = Path(temp_folder) |
| 204 | + match_num = match_parameters.match_num |
| 205 | + match_id = f'match-{match_num}' |
| 206 | + archives_dir = match_parameters.archives_dir |
| 207 | + |
| 208 | + # unzip teams code into zone_N folders under this folder |
| 209 | + load_team_code(archives_dir, arena_root, match_parameters) |
| 210 | + # Create info file to tell the comp supervisor what match this is |
| 211 | + # and how to handle recordings |
| 212 | + generate_match_file(arena_root, match_parameters) |
| 213 | + # Set mode file to comp |
| 214 | + set_comp_mode(arena_root) |
| 215 | + |
| 216 | + try: |
| 217 | + # Run webots with the right world |
| 218 | + execute_match(arena_root) |
| 219 | + except (FileNotFoundError, RuntimeError) as e: |
| 220 | + print(f"Failed to run match: {e}") |
| 221 | + # Save the supervisor log as it may contain useful information |
| 222 | + archive_supervisor_log(archives_dir, arena_root, match_id) |
| 223 | + raise |
| 224 | + |
| 225 | + # Archive the supervisor log first in case any collation fails |
| 226 | + archive_supervisor_log(archives_dir, arena_root, match_id) |
| 227 | + # Zip up and collect all files for each zone |
| 228 | + archive_zone_folders(archives_dir, arena_root, match_parameters.teams, match_id) |
| 229 | + # Collect video, animation & image |
| 230 | + archive_match_recordings(archives_dir, arena_root, match_id) |
| 231 | + # Collect ancillary files |
| 232 | + archive_match_file(archives_dir, arena_root / 'match.json', match_num) |
| 233 | + |
| 234 | + |
| 235 | +def parse_args() -> MatchParams: |
| 236 | + """Parse command line arguments.""" |
| 237 | + parser = argparse.ArgumentParser(description="Run a competition match.") |
| 238 | + |
| 239 | + parser.add_argument( |
| 240 | + 'archives_dir', |
| 241 | + help=( |
| 242 | + "The directory containing the teams' robot code, as Zip archives " |
| 243 | + "named for the teams' TLAs. This directory will also be used as the " |
| 244 | + "root for storing the resulting logs and recordings." |
| 245 | + ), |
| 246 | + type=Path, |
| 247 | + ) |
| 248 | + parser.add_argument( |
| 249 | + 'match_num', |
| 250 | + type=int, |
| 251 | + help="The number of the match to run.", |
| 252 | + ) |
| 253 | + parser.add_argument( |
| 254 | + 'teams', |
| 255 | + nargs=NUM_ZONES, |
| 256 | + help=( |
| 257 | + "TLA of the team in each zone, in order from zone 0 to " |
| 258 | + f"{NUM_ZONES - 1}. Use dash (-) for an empty zone. " |
| 259 | + "Must specify all zones." |
| 260 | + ), |
| 261 | + metavar='tla', |
| 262 | + ) |
| 263 | + parser.add_argument( |
| 264 | + '--duration', |
| 265 | + help="The duration of the match (in seconds).", |
| 266 | + type=int, |
| 267 | + default=GAME_DURATION_SECONDS, |
| 268 | + ) |
| 269 | + parser.add_argument( |
| 270 | + '--no-record', |
| 271 | + help=( |
| 272 | + "Inhibit creation of the MPEG video, the animation is unaffected. " |
| 273 | + "This can greatly increase the execution speed on GPU limited systems " |
| 274 | + "when the video is not required." |
| 275 | + ), |
| 276 | + action='store_false', |
| 277 | + dest='video_enabled', |
| 278 | + ) |
| 279 | + parser.add_argument( |
| 280 | + '--resolution', |
| 281 | + help="Set the resolution of the produced video.", |
| 282 | + type=int, |
| 283 | + nargs=2, |
| 284 | + default=[1920, 1080], |
| 285 | + metavar=('width', 'height'), |
| 286 | + dest='video_resolution', |
| 287 | + ) |
| 288 | + return parser.parse_args(namespace=MatchParams()) |
| 289 | + |
| 290 | + |
| 291 | +def main() -> None: |
| 292 | + """Run a competition match entrypoint.""" |
| 293 | + match_parameters = parse_args() |
| 294 | + run_match(match_parameters) |
| 295 | + |
| 296 | + |
| 297 | +if __name__ == '__main__': |
| 298 | + main() |
0 commit comments