299 lines
9.3 KiB
Python
Executable File
299 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""A script to run a competition match."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from zipfile import ZipFile
|
|
|
|
if (Path(__file__).parents[1] / 'simulator/VERSION').exists():
|
|
# Running in release mode, run_simulator will be in folder above
|
|
sys.path.append(str(Path(__file__).parents[1]))
|
|
|
|
from run_simulator import get_webots_parameters
|
|
|
|
NUM_ZONES = 4
|
|
GAME_DURATION_SECONDS = 150
|
|
|
|
|
|
class MatchParams(argparse.Namespace):
|
|
"""Parameters for running a competition match."""
|
|
|
|
archives_dir: Path
|
|
match_num: int
|
|
teams: list[str]
|
|
duration: int
|
|
video_enabled: bool
|
|
video_resolution: tuple[int, int]
|
|
|
|
|
|
def load_team_code(
|
|
usercode_dir: Path,
|
|
arena_root: Path,
|
|
match_parameters: MatchParams,
|
|
) -> None:
|
|
"""Load the team code into the arena root."""
|
|
for zone_id, tla in enumerate(match_parameters.teams):
|
|
zone_path = arena_root / f"zone_{zone_id}"
|
|
|
|
if zone_path.exists():
|
|
shutil.rmtree(zone_path)
|
|
|
|
if tla == '-':
|
|
# no team in this zone
|
|
continue
|
|
|
|
zone_path.mkdir()
|
|
with ZipFile(usercode_dir / f'{tla}.zip') as zipfile:
|
|
zipfile.extractall(zone_path)
|
|
|
|
|
|
def generate_match_file(save_path: Path, match_parameters: MatchParams) -> None:
|
|
"""Write the match file to the arena root."""
|
|
match_file = save_path / 'match.json'
|
|
|
|
# Use a format that is compatible with SRComp
|
|
match_file.write_text(json.dumps(
|
|
{
|
|
'match_number': match_parameters.match_num,
|
|
'arena_id': 'Simulator',
|
|
'teams': {
|
|
tla: {'zone': idx}
|
|
for idx, tla in enumerate(match_parameters.teams)
|
|
if tla != '-'
|
|
},
|
|
'duration': match_parameters.duration,
|
|
'recording_config': {
|
|
'enabled': match_parameters.video_enabled,
|
|
'resolution': list(match_parameters.video_resolution)
|
|
}
|
|
},
|
|
indent=4,
|
|
))
|
|
|
|
|
|
def set_comp_mode(arena_root: Path) -> None:
|
|
"""Write the mode file to indicate that the competition is running."""
|
|
(arena_root / 'mode.txt').write_text('comp')
|
|
|
|
|
|
def archive_zone_files(
|
|
team_archives_dir: Path,
|
|
arena_root: Path,
|
|
zone: int,
|
|
match_id: str,
|
|
) -> None:
|
|
"""Zip the files in the zone directory and save them to the team archives directory."""
|
|
zone_dir = arena_root / f'zone_{zone}'
|
|
|
|
shutil.make_archive(str(team_archives_dir / f'{match_id}-zone-{zone}'), 'zip', zone_dir)
|
|
|
|
|
|
def archive_zone_folders(
|
|
archives_dir: Path,
|
|
arena_root: Path,
|
|
teams: list[str],
|
|
match_id: str,
|
|
) -> None:
|
|
"""Zip the zone folders and save them to the archives directory."""
|
|
for zone_id, tla in enumerate(teams):
|
|
if tla == '-':
|
|
# no team in this zone
|
|
continue
|
|
|
|
tla_dir = archives_dir / tla
|
|
tla_dir.mkdir(exist_ok=True)
|
|
|
|
archive_zone_files(tla_dir, arena_root, zone_id, match_id)
|
|
|
|
|
|
def archive_match_recordings(archives_dir: Path, arena_root: Path, match_id: str) -> None:
|
|
"""Copy the video, animation, and image files to the archives directory."""
|
|
recordings_dir = archives_dir / 'recordings'
|
|
recordings_dir.mkdir(exist_ok=True)
|
|
|
|
match_recordings = arena_root / 'recordings'
|
|
|
|
# Copy the video file
|
|
video_file = match_recordings / f'{match_id}.mp4'
|
|
if video_file.exists():
|
|
shutil.copy(video_file, recordings_dir)
|
|
|
|
# Copy the animation files
|
|
animation_files = [
|
|
match_recordings / f'{match_id}.html',
|
|
match_recordings / f'{match_id}.json',
|
|
match_recordings / f'{match_id}.x3d',
|
|
match_recordings / f'{match_id}.css',
|
|
]
|
|
for animation_file in animation_files:
|
|
shutil.copy(animation_file, recordings_dir)
|
|
|
|
# Copy the animation textures
|
|
# Every match will have the same textures, so we only need one copy of them
|
|
textures_dir = match_recordings / 'textures'
|
|
shutil.copytree(textures_dir, recordings_dir / 'textures', dirs_exist_ok=True)
|
|
|
|
# Copy the image file
|
|
image_file = match_recordings / f'{match_id}.jpg'
|
|
shutil.copy(image_file, recordings_dir)
|
|
|
|
|
|
def archive_match_file(archives_dir: Path, match_file: Path, match_number: int) -> None:
|
|
"""
|
|
Copy the match file (which may contain scoring data) to the archives directory.
|
|
|
|
This also renames the file to be compatible with SRComp.
|
|
"""
|
|
matches_dir = archives_dir / 'matches'
|
|
matches_dir.mkdir(exist_ok=True)
|
|
|
|
# SRComp expects YAML files. JSON is a subset of YAML, so we can just rename the file.
|
|
completed_match_file = matches_dir / f'{match_number:0>3}.yaml'
|
|
|
|
shutil.copy(match_file, completed_match_file)
|
|
|
|
|
|
def archive_supervisor_log(archives_dir: Path, arena_root: Path, match_id: str) -> None:
|
|
"""Archive the supervisor log file."""
|
|
log_archive_dir = archives_dir / 'supervisor_logs'
|
|
log_archive_dir.mkdir(exist_ok=True)
|
|
|
|
log_file = arena_root / f'supervisor-log-{match_id}.txt'
|
|
|
|
shutil.copy(log_file, log_archive_dir)
|
|
|
|
|
|
def execute_match(arena_root: Path) -> None:
|
|
"""Run Webots with the right world."""
|
|
# Webots is only on the PATH on Linux so we have a helper function to find it
|
|
try:
|
|
webots, world_file = get_webots_parameters()
|
|
except RuntimeError as e:
|
|
raise FileNotFoundError(e)
|
|
|
|
sim_env = os.environ.copy()
|
|
sim_env['ARENA_ROOT'] = str(arena_root)
|
|
try:
|
|
subprocess.check_call(
|
|
[
|
|
str(webots),
|
|
'--batch',
|
|
'--stdout',
|
|
'--stderr',
|
|
'--mode=realtime',
|
|
str(world_file),
|
|
],
|
|
env=sim_env,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
# TODO review log output here
|
|
raise RuntimeError(f"Webots failed with return code {e.returncode}") from e
|
|
|
|
|
|
def run_match(match_parameters: MatchParams) -> None:
|
|
"""Run the match in a temporary directory and archive the results."""
|
|
with TemporaryDirectory(suffix=f'match-{match_parameters.match_num}') as temp_folder:
|
|
arena_root = Path(temp_folder)
|
|
match_num = match_parameters.match_num
|
|
match_id = f'match-{match_num}'
|
|
archives_dir = match_parameters.archives_dir
|
|
|
|
# unzip teams code into zone_N folders under this folder
|
|
load_team_code(archives_dir, arena_root, match_parameters)
|
|
# Create info file to tell the comp supervisor what match this is
|
|
# and how to handle recordings
|
|
generate_match_file(arena_root, match_parameters)
|
|
# Set mode file to comp
|
|
set_comp_mode(arena_root)
|
|
|
|
try:
|
|
# Run webots with the right world
|
|
execute_match(arena_root)
|
|
except (FileNotFoundError, RuntimeError) as e:
|
|
print(f"Failed to run match: {e}")
|
|
# Save the supervisor log as it may contain useful information
|
|
archive_supervisor_log(archives_dir, arena_root, match_id)
|
|
raise
|
|
|
|
# Archive the supervisor log first in case any collation fails
|
|
archive_supervisor_log(archives_dir, arena_root, match_id)
|
|
# Zip up and collect all files for each zone
|
|
archive_zone_folders(archives_dir, arena_root, match_parameters.teams, match_id)
|
|
# Collect video, animation & image
|
|
archive_match_recordings(archives_dir, arena_root, match_id)
|
|
# Collect ancillary files
|
|
archive_match_file(archives_dir, arena_root / 'match.json', match_num)
|
|
|
|
|
|
def parse_args() -> MatchParams:
|
|
"""Parse command line arguments."""
|
|
parser = argparse.ArgumentParser(description="Run a competition match.")
|
|
|
|
parser.add_argument(
|
|
'archives_dir',
|
|
help=(
|
|
"The directory containing the teams' robot code, as Zip archives "
|
|
"named for the teams' TLAs. This directory will also be used as the "
|
|
"root for storing the resulting logs and recordings."
|
|
),
|
|
type=Path,
|
|
)
|
|
parser.add_argument(
|
|
'match_num',
|
|
type=int,
|
|
help="The number of the match to run.",
|
|
)
|
|
parser.add_argument(
|
|
'teams',
|
|
nargs=NUM_ZONES,
|
|
help=(
|
|
"TLA of the team in each zone, in order from zone 0 to "
|
|
f"{NUM_ZONES - 1}. Use dash (-) for an empty zone. "
|
|
"Must specify all zones."
|
|
),
|
|
metavar='tla',
|
|
)
|
|
parser.add_argument(
|
|
'--duration',
|
|
help="The duration of the match (in seconds).",
|
|
type=int,
|
|
default=GAME_DURATION_SECONDS,
|
|
)
|
|
parser.add_argument(
|
|
'--no-record',
|
|
help=(
|
|
"Inhibit creation of the MPEG video, the animation is unaffected. "
|
|
"This can greatly increase the execution speed on GPU limited systems "
|
|
"when the video is not required."
|
|
),
|
|
action='store_false',
|
|
dest='video_enabled',
|
|
)
|
|
parser.add_argument(
|
|
'--resolution',
|
|
help="Set the resolution of the produced video.",
|
|
type=int,
|
|
nargs=2,
|
|
default=[1920, 1080],
|
|
metavar=('width', 'height'),
|
|
dest='video_resolution',
|
|
)
|
|
return parser.parse_args(namespace=MatchParams())
|
|
|
|
|
|
def main() -> None:
|
|
"""Run a competition match entrypoint."""
|
|
match_parameters = parse_args()
|
|
run_match(match_parameters)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|