Skip to content

Commit 08b1ead

Browse files
authored
Merge pull request #2 from srobo/comp-matches
Add support for running competition matches
2 parents 7bbcdfe + 809afbf commit 08b1ead

File tree

12 files changed

+1109
-116
lines changed

12 files changed

+1109
-116
lines changed

assets/user_readme.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ The API for the simulator is the same as the API for the physical robot, so you
141141

142142

143143
As well as the logs being displayed in the console, they are also saved to a file.
144-
This file is saved in the `zone_0` folder and has a name in the format `log-<date>.log`.
144+
This file is saved in the `zone_0` folder and has a name in the format `log-zone-<zone>-<date>.log`.
145145
The date is when that simulation was run.
146146

147147
### Simulation of Time

scripts/generate_release.py

+6
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,14 @@
7878
logger.info("Copying helper scripts to temp directory")
7979
shutil.copy(project_root / "scripts/setup.py", temp_dir / "setup.py")
8080
for script in project_root.glob("scripts/run_*.py"):
81+
if "run_comp_" in str(script):
82+
continue
8183
shutil.copy(script, temp_dir)
8284

85+
script_dir = temp_dir / "scripts"
86+
script_dir.mkdir()
87+
shutil.copy(project_root / "scripts/run_comp_match.py", script_dir)
88+
8389
logger.info("Copying example code to temp directory")
8490
shutil.copytree(project_root / "example_robots", temp_dir / "example_robots")
8591

scripts/run_comp_match.py

+298
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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

Comments
 (0)